diff --git a/.github/workflows/authorship.yml b/.github/workflows/authorship.yml new file mode 100644 index 000000000..97c6637a8 --- /dev/null +++ b/.github/workflows/authorship.yml @@ -0,0 +1,40 @@ +name: Build validation + +on: + pull_request_target: + branches: [main] + types: [opened, synchronize, reopened, edited, ready_for_review] + push: + branches: [main] + workflow_run: + workflows: [Build APK] + types: [completed] + workflow_dispatch: + inputs: + pr_number: + description: Pull request to validate; empty for main + required: false + +permissions: + contents: read + actions: read + pull-requests: read + statuses: write + +jobs: + validate: + name: Build APK + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + fetch-depth: 0 + persist-credentials: false + - name: Validate source and APK compilation + env: + GH_TOKEN: ${{ github.token }} + run: | + gh auth setup-git + python3 tools/release/validate_apk.py diff --git a/.github/workflows/build-desktop-kde.yml b/.github/workflows/build-desktop-kde.yml index 5a9933abc..365e3d4f8 100644 --- a/.github/workflows/build-desktop-kde.yml +++ b/.github/workflows/build-desktop-kde.yml @@ -64,6 +64,7 @@ jobs: KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out cp out/kwin/desktop-kde.kwin.txt out/ echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)" + echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)" - uses: actions/upload-artifact@v4 with: name: desktop-kde @@ -74,6 +75,8 @@ jobs: out/desktop-kde.packages.txt out/desktop-kde.kwin.txt out/desktop-kde.sha256 + out/desktop-kde.i18n.txt + out/desktop-kde-i18n-*.tar.zst - name: Publish if: github.event_name == 'workflow_dispatch' && inputs.tag != '' env: @@ -88,5 +91,6 @@ jobs: fi gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \ --notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \ - out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt + out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \ + out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst gh release edit "$TAG" --latest=false --prerelease=false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ada030b90..0c81773ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,8 +29,65 @@ env: PRELOAD_CACHE_VERSION: v6 jobs: + xbox: + name: Xbox ARM64 backend + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + outputs: + base-sha: ${{ steps.merge.outputs.base-sha }} + tree-sha: ${{ steps.source.outputs.tree-sha }} + 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: Pin and merge the APK source + id: merge + 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" + 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 + id: source + run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v4 + id: xbox-cache + with: + path: | + 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/**') }} + - 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 + - uses: actions/upload-artifact@v4 + with: + name: xbox-arm64 + path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + retention-days: 14 + - uses: actions/upload-artifact@v4 + with: + name: xbox-corresponding-source + path: | + app/build/xbox/xodus-droiddeck-source.tar.xz + app/build/xbox/runtime/*-source.tar.xz + if-no-files-found: error + retention-days: 14 + build: - name: Build APK + needs: xbox + name: Compile and test APK if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 timeout-minutes: 45 @@ -42,18 +99,24 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 submodules: true + persist-credentials: false - - name: Check co-authors - if: github.event_name == 'pull_request' + - name: Check human authorship + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE: ${{ github.event.before }} run: | set -euo pipefail - git fetch --no-tags origin main - base=$(git merge-base origin/main HEAD) - messages=$(git log --format=%B "$base..HEAD") - messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")" - if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then - echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer." - exit 1 + if [ "$EVENT_NAME" = pull_request ]; then + git fetch --no-tags origin main + base=$(git merge-base origin/main HEAD) + checker="$RUNNER_TEMP/droiddeck-check-authorship.py" + git show origin/main:tools/release/check_authorship.py > "$checker" + python3 "$checker" "$base..HEAD" --event "$GITHUB_EVENT_PATH" + elif [ "$EVENT_NAME" = push ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then + python3 tools/release/check_authorship.py "$BEFORE..HEAD" + else + python3 tools/release/check_authorship.py HEAD fi - name: The packages a signed build ships as @@ -87,14 +150,17 @@ jobs: - name: Merge onto main, or say what conflicts if: github.event_name == 'pull_request' + env: + BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }} run: | set -uo pipefail git config user.name "ci" git config user.email "ci@users.noreply.github.com" git fetch --no-tags origin main - BASE=$(git rev-parse --short origin/main) + git fetch --no-tags origin "$BUILD_BASE_SHA" + BASE=$(git rev-parse --short FETCH_HEAD) HEAD=$(git rev-parse --short HEAD) - if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then + if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then { echo "### Mergeable" echo @@ -141,6 +207,11 @@ jobs: echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')" exit 1 + - name: Verify backend and APK source match + env: + XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }} + run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" + - uses: actions/setup-java@v4 with: distribution: temurin @@ -185,15 +256,14 @@ jobs: uses: actions/cache@v4 with: path: app/src/main/assets/linuxfs - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', '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/linuxfs/xclipse-layer/*', '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/**', 'external/MaliBridge/client/**', 'external/MaliBridge/common/**', 'external/MaliBridge/tests/**', 'external/MaliBridge/scripts/build-client-linux.sh', 'tools/venus/glprobe.c', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }} + 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/linuxfs/xclipse-layer/*', '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/**', 'external/MaliBridge/client/**', 'external/MaliBridge/common/**', 'external/MaliBridge/tests/**', 'external/MaliBridge/scripts/build-client-linux.sh', 'tools/venus/glprobe.c', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - sudo apt-get update -qq + 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 libvulkan-dev libvulkan1 mesa-vulkan-drivers >/dev/null D=app/src/main/assets/linuxfs mkdir -p "$D" @@ -237,7 +307,7 @@ jobs: done if [ -f tools/gamescope/release.env ]; then . tools/gamescope/release.env - gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst + 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" @@ -268,6 +338,17 @@ jobs: 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 @@ -340,18 +421,17 @@ jobs: ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386 - name: Bundle the droiddeck-esync packs - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail if [ -f tools/droiddeck-esync/release.env ]; then . tools/droiddeck-esync/release.env - gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst + 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 - gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json + 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 @@ -450,6 +530,8 @@ jobs: /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 @@ -499,6 +581,11 @@ jobs: restore-keys: | cxx-${{ env.NDK_VERSION }}- + - 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: | @@ -506,6 +593,9 @@ jobs: rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh + - name: Build the add-on packs + run: bash tools/packs/build.sh app/build/packs + - name: Build id: build env: @@ -545,6 +635,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: | # The Xclipse layer regression test compiles the layer against the distribution's headers. @@ -608,6 +703,14 @@ jobs: retention-days: 30 compression-level: 0 + - uses: actions/upload-artifact@v4 + with: + name: droiddeck-packs + path: app/build/packs/*.tar.zst + if-no-files-found: error + retention-days: 30 + compression-level: 0 + - name: Report on the pull request if: always() && github.event_name == 'pull_request' continue-on-error: true @@ -710,8 +813,17 @@ jobs: variants: ${{ needs.build.outputs.variants }} secrets: inherit + packs: + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + permissions: + contents: read + actions: read + uses: ./.github/workflows/publish-packs.yml + secrets: inherit + announce: - needs: [build, sign] + needs: [build, sign, packs] if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 10 diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index b55d2a214..dab96e015 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -176,6 +176,27 @@ jobs: pattern: droiddeck-pr-signed-* merge-multiple: true path: signed + - name: The add-on packs main has not published + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ needs.authorize.outputs.run_id }} + run: | + set -euo pipefail + gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs + mkdir -p pr-packs + for file in packs/*.tar.zst; do + name=$(basename "$file" .tar.zst) + sha=$(sha256sum "$file" | cut -d' ' -f1) + tag="pack-$name-${sha:0:16}" + digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } | + jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty') + if [ "$digest" = "sha256:$sha" ]; then + echo "$name: served from DroidDeck-Components $tag" + else + cp "$file" "pr-packs/$tag.tar.zst" + echo "$name: attached to the PR release as $tag.tar.zst" + fi + done - name: Describe the change env: GH_TOKEN: ${{ github.token }} @@ -233,6 +254,7 @@ jobs: first=true fi echo "first=$first" >> "$GITHUB_OUTPUT" + cp pr-packs/* signed/ 2>/dev/null || true gh release upload "$tag" signed/* -R "$repo" --clobber gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease keep=$(cd signed && ls | jq -R . | jq -sc .) diff --git a/.github/workflows/publish-packs.yml b/.github/workflows/publish-packs.yml new file mode 100644 index 000000000..580b1c818 --- /dev/null +++ b/.github/workflows/publish-packs.yml @@ -0,0 +1,83 @@ +name: Publish add-on packs + +# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under +# content-addressed tags (pack--). The APK from that run pins each pack's +# SHA-256 and downloads it from that tag. A tag is created once and never replaced. +# Only main and release builds publish here; a PR test build attaches the packs main has not published +# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes. + +on: + workflow_call: + inputs: + run_id: + description: The Build APK run whose packs to publish; empty for the calling run's own + type: string + default: '' + +permissions: + contents: read + actions: read + +jobs: + publish: + name: publish add-on packs + runs-on: ubuntu-24.04 + timeout-minutes: 15 + concurrency: + group: publish-packs + cancel-in-progress: false + steps: + - name: The packs (this run's build) + if: inputs.run_id == '' + uses: actions/download-artifact@v4 + with: + name: droiddeck-packs + path: packs + + - name: The packs (another Build APK run) + if: inputs.run_id != '' + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ inputs.run_id }} + run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs + + - uses: actions/create-github-app-token@v2 + id: components-token + with: + app-id: ${{ vars.DROIDDECK_CI_APP_ID }} + private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }} + owner: Droid-Deck + repositories: DroidDeck-Components + + - name: Publish + env: + GH_TOKEN: ${{ steps.components-token.outputs.token }} + REPO: Droid-Deck/DroidDeck-Components + BUILD_RUN: ${{ inputs.run_id || github.run_id }} + run: | + set -euo pipefail + published_digest() { + { gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } | + jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty' + } + commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA") + for name in proot directaudio tracking xbox comet; do + file="packs/$name.tar.zst" + [ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; } + sha=$(sha256sum "$file" | cut -d' ' -f1) + tag="pack-$name-${sha:0:16}" + digest=$(published_digest "$tag" "$name.tar.zst") + if [ -z "$digest" ]; then + notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID). + + Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest." + gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \ + || echo "::warning::creating $tag failed; checking whether another run published it" + digest=$(published_digest "$tag" "$name.tar.zst") + fi + if [ "$digest" != "sha256:$sha" ]; then + echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha" + exit 1 + fi + echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8fe92060..a15159268 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,9 +63,19 @@ jobs: artifact: droiddeck-release secrets: inherit + packs: + needs: find + permissions: + contents: read + actions: read + uses: ./.github/workflows/publish-packs.yml + with: + run_id: ${{ needs.find.outputs.run_id }} + secrets: inherit + publish: name: publish the release - needs: [find, sign] + needs: [find, sign, packs] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: diff --git a/README.md b/README.md index 6261459df..0a67b2674 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ On Android 14 and newer, before Steam launches, you must turn on **Disable child ## Smart glasses -Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, and XREAL connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations. +Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, XREAL, and Rokid connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations. ## Community @@ -47,10 +47,12 @@ 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. 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. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). 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. 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. -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). 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 and REDMAGIC are trademarks of their owners; this project is not affiliated with them. +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. Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo. 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. diff --git a/app/build.gradle b/app/build.gradle index 3b19b2169..f5daf8182 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines() .findAll { it.contains('=') && !it.startsWith('#') } .collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] } +def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines() + .findAll { it.contains('=') && !it.startsWith('#') } + .collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] } +def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name -> + def pack = file("build/packs/${name}.tar.zst") + if (!pack.isFile()) { + if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs") + return [(name): [sha256: '', size: 0L]] + } + def digest = java.security.MessageDigest.getInstance('SHA-256') + pack.withInputStream { input -> + byte[] buffer = new byte[1 << 20] + int n + while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n) + } + [(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]] +} def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json')) if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) { throw new GradleException('Desktop removal inventories do not match the pinned archive') @@ -119,6 +136,10 @@ android { buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\"" buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\"" buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L" + addonPacks.each { name, pack -> + buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\"" + buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L" + } ndk { abiFilters 'arm64-v8a' @@ -175,6 +196,11 @@ android { disable 'ExpiredTargetSdkVersion' } + androidResources { + // aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs). + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:_*:!CVS:!thumbs.db:!picasa.ini:!*~:droiddeck-xbox:droiddeck-comet:directaudio' + } + packagingOptions { resources { excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] @@ -182,6 +208,8 @@ android { jniLibs { useLegacyPackaging = true pickFirsts += ['**/libtermux.so'] + // Shipped in the proot and tracking packs (tools/packs) and run or loaded from there. + excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so'] } } @@ -195,6 +223,8 @@ android { unitTests.returnDefaultValues = true unitTests.includeAndroidResources = true unitTests.all { + // 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 if (logTarget != null) systemProperty 'robolectric.logging', logTarget } @@ -316,9 +346,8 @@ def verifyHandModel = tasks.register('verifyHandModel') { } } tasks.named('preBuild').configure { dependsOn(verifyHandModel) } - dependencies { - implementation 'org.opencv:opencv:4.13.0' + implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}" implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49' implementation 'com.github.luben:zstd-jni:1.5.7-4@aar' implementation 'org.apache.commons:commons-compress:1.27.1' @@ -337,7 +366,8 @@ dependencies { implementation 'com.termux.termux-app:terminal-view:0.118.0' implementation 'androidx.camera:camera-camera2:1.4.2' implementation 'androidx.camera:camera-lifecycle:1.4.2' - implementation('com.google.mediapipe:tasks-vision:1.0.0') { + implementation 'androidx.webkit:webkit:1.12.1' + 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 757d21bf8..543994093 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,11 +65,6 @@ android:exported="true" android:screenOrientation="sensorLandscape" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation"> - - - - - @@ -88,6 +83,31 @@ + + + + + + + + + + + + + + + + + + SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc). add_subdirectory(thirdparty/dxbc) -set(WINFG_UPSTREAM "v0.3.0") -set(WINFG_CHAIN_SRC - framegen/winfg/framegen.cpp - framegen/winfg/framegen.hpp - framegen/winfg/record_impl.inc - framegen/winfg/config.hpp - framegen/winfg/embedded_shaders.hpp - framegen/winfg/winfg_engine.cpp +set(DDFG_UPSTREAM "v0.3.0") +set(DDFG_CHAIN_SRC + framegen/ddfg/framegen.cpp + framegen/ddfg/framegen.hpp + framegen/ddfg/record_impl.inc + framegen/ddfg/config.hpp + framegen/ddfg/embedded_shaders.hpp + framegen/ddfg/ddfg_engine.cpp ) -set(WINFG_HASH_INPUT "") -foreach(f ${WINFG_CHAIN_SRC}) +set(DDFG_HASH_INPUT "") +foreach(f ${DDFG_CHAIN_SRC}) file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h) - string(APPEND WINFG_HASH_INPUT "${_h}") + string(APPEND DDFG_HASH_INPUT "${_h}") endforeach() -string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}") -string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH) -message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})") +string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}") +string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH) +message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})") add_subdirectory(waylandcomp) add_subdirectory(questxr) @@ -39,6 +39,10 @@ target_link_libraries(deviceinfo dl) add_library(termux SHARED termux/termux.c) +add_library(ddvenc SHARED venc/venc_server.cpp) +target_compile_features(ddvenc PRIVATE cxx_std_17) +target_link_libraries(ddvenc mediandk log) + # The optional proprietary SDK is supplied locally; its headers/libraries are not vendored. if(DROIDDECK_VITURE_SDK_DIR) add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 486b72076..c446170fe 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -21,6 +21,9 @@ #include #include +#ifdef __GLIBC__ +#include +#endif #include #include #include @@ -1181,8 +1184,9 @@ struct TritonSharedData { uint32_t input_length; uint8_t input[64]; uint64_t output_seq; - uint32_t output_feature; - uint32_t output_length; + uint16_t output_feature; + uint16_t output_length; + uint32_t output_ack; uint8_t output[64]; uint64_t get_request; uint64_t get_reply; @@ -1199,6 +1203,8 @@ static_assert(offsetof(TritonSharedData, report_id) == 16, "Triton report ID off static_assert(offsetof(TritonSharedData, input) == 24, "Triton input offset"); static_assert(offsetof(TritonSharedData, output_seq) == 88, "Triton output sequence offset"); static_assert(offsetof(TritonSharedData, output_feature) == 96, "Triton output feature offset"); +static_assert(offsetof(TritonSharedData, output_length) == 98, "Triton output length offset"); +static_assert(offsetof(TritonSharedData, output_ack) == 100, "Triton output ack offset"); static_assert(offsetof(TritonSharedData, output) == 104, "Triton output offset"); static_assert(offsetof(TritonSharedData, get_request) == 168, "Triton feature request offset"); static_assert(offsetof(TritonSharedData, get_reply) == 176, "Triton feature reply offset"); @@ -1216,6 +1222,8 @@ static constexpr int TRITON_REPORT_ID_BYTES = 1; static constexpr int TRITON_STATE_BYTES = 45; static constexpr int TRITON_REPORT_INTERVAL_US = 4000; static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500; +// The app clears output_length once it has taken a report; a writer waits this long for that before overwriting. +static constexpr int TRITON_OUTPUT_TIMEOUT_MS = 50; static constexpr const char *TRITON_NAME = "Steam Controller"; static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01"; static const uint8_t kTritonReportDescriptor[] = { @@ -2571,11 +2579,21 @@ __attribute__((visibility("hidden"))) static void triton_queue_output(DeckHidraw if (!data || !size || size > 64 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return; auto *shared = reinterpret_cast(reinterpret_cast(triton.ring) + TRITON_SHARED_OFFSET); if (__atomic_load_n(&shared->magic, __ATOMIC_ACQUIRE) != TRITON_SHARED_MAGIC) return; + static std::mutex one_at_a_time; + std::lock_guard guard(one_at_a_time); + for (int waited = 0; + static_cast(__atomic_load_n(&shared->output_seq, __ATOMIC_ACQUIRE)) != + __atomic_load_n(&shared->output_ack, __ATOMIC_ACQUIRE) && + __atomic_load_n(&shared->connected, __ATOMIC_ACQUIRE) != 0 && waited < TRITON_OUTPUT_TIMEOUT_MS * 4; + waited++) { + struct timespec interval = {0, 250 * 1000L}; + nanosleep(&interval, nullptr); + } uint64_t seq = __atomic_load_n(&shared->output_seq, __ATOMIC_RELAXED); if (seq & 1) seq++; __atomic_store_n(&shared->output_seq, seq + 1, __ATOMIC_RELEASE); shared->output_feature = feature ? 1 : 0; - shared->output_length = static_cast(size); + shared->output_length = static_cast(size); memcpy(shared->output, data, size); if (size < sizeof(shared->output)) memset(shared->output + size, 0, sizeof(shared->output) - size); __atomic_thread_fence(__ATOMIC_RELEASE); @@ -2632,6 +2650,38 @@ __attribute__((visibility("hidden"))) static int triton_get_feature(DeckHidraw & static constexpr uint32_t TRITON_HWID = 72; static constexpr uint32_t TRITON_FW_TIMESTAMP = 0x6AC686B3; +// Steam compares the controller's firmware with TRITON_FW_TS in its bundled hardwareupdater.cfg (and blocks +// with a required update below MUST_UPDATE_TRITON_FW_TS), so the emulated pad reports at least the build +// Steam ships. +static uint32_t triton_fw_timestamp() { + static const uint32_t timestamp = [] { + std::vector roots; + char exe[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + if (n > 0) { + std::string path(exe, static_cast(n)); + size_t bin = path.rfind('/'); + size_t root = bin == std::string::npos || bin == 0 ? std::string::npos : path.rfind('/', bin - 1); + if (bin != std::string::npos && bin > 0) roots.push_back(path.substr(0, bin)); + if (root != std::string::npos) roots.push_back(path.substr(0, root)); + } + if (const char *home = getenv("HOME")) roots.push_back(std::string(home) + "/.local/share/Steam"); + uint32_t newest = TRITON_FW_TIMESTAMP; + for (const std::string &root : roots) { + std::ifstream cfg(root + "/bin/hardwareupdater/hardwareupdater.cfg"); + std::string line; + while (std::getline(cfg, line)) { + if (line.rfind("TRITON_FW_TS:", 0) != 0) continue; + uint32_t shipped = static_cast(strtoul(line.c_str() + 13, nullptr, 16)); + if (shipped > newest) newest = shipped; + } + } + if (newest != TRITON_FW_TIMESTAMP) Logger::log("triton: reporting firmware %08x, the build Steam ships\n", newest); + return newest; + }(); + return timestamp; +} + __attribute__((visibility("hidden"))) static int triton_pad_feature(DeckHidraw &triton, uint8_t *buf, size_t size, bool set) { if (!buf || size < 2) { errno = EINVAL; @@ -2652,7 +2702,7 @@ __attribute__((visibility("hidden"))) static int triton_pad_feature(DeckHidraw & switch (triton.pending_feature) { case DECK_GET_ATTRIBUTES_VALUES: { static const uint8_t kIds[] = {0x01, 0x02, 0x04, 0x09, 0x0a, 0x0b}; - const uint32_t values[] = {static_cast(TRITON_WIRED_PRODUCT), 0, TRITON_FW_TIMESTAMP, TRITON_HWID, 0, 4000}; + const uint32_t values[] = {static_cast(TRITON_WIRED_PRODUCT), 0, triton_fw_timestamp(), TRITON_HWID, 0, 4000}; reply[2] = sizeof(kIds) * 5; for (size_t i = 0; i < sizeof(kIds); i++) { reply[3 + i * 5] = kIds[i]; @@ -3577,7 +3627,21 @@ EXPORT int close(int fd) { static constexpr int kMaxRingWaitMs = 2; static constexpr int kLowLatencyRingWaitUs = 250; -EXPORT ssize_t read(int fd, void *buf, size_t count) { +static ssize_t copy_input_events(void *buf, const struct input_event *events, size_t count) { + size_t bytes = count * sizeof(struct input_event); + struct iovec local = {const_cast(events), bytes}; + struct iovec remote = {buf, bytes}; + ssize_t copied = syscall(SYS_process_vm_writev, getpid(), &local, 1, &remote, 1, 0); + if (copied < 0) return -1; + if (copied != static_cast(bytes)) { + errno = EFAULT; + copied -= copied % sizeof(struct input_event); + if (!copied) return -1; + } + return copied; +} + +static ssize_t read_input(int fd, void *buf, size_t count) { if (!watched(fd)) return syscall(SYS_read, fd, buf, count); std::unique_lock guard(controller_mutex()); auto controller = controller_map().find(fd); @@ -3622,42 +3686,44 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { if (events == 0) break; const uint8_t *ring_events = reinterpret_cast(fake.ring) + FAKE_INPUT_RING_HEADER_SIZE; + std::vector input_events(events); for (size_t i = 0; i < events; ++i) { size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY; RingEvent stored; memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE); - struct input_event ev = {}; + struct input_event &ev = input_events[i]; ev.time.tv_sec = stored.sec; ev.time.tv_usec = stored.usec; ev.type = stored.type; ev.code = stored.code; ev.value = stored.value; if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); - memcpy(static_cast(buf) + i * sizeof(ev), &ev, sizeof(ev)); } __atomic_thread_fence(__ATOMIC_ACQUIRE); if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED)) continue; - fake.read_seq += events; - return static_cast(events * sizeof(struct input_event)); + ssize_t copied = copy_input_events(buf, input_events.data(), events); + if (copied > 0) fake.read_seq += copied / sizeof(struct input_event); + return copied; } } size_t events = std::min(requested, fake.keyframe_remaining); + struct input_event input_events[kNeutralEventCount] = {}; struct timeval now = {}; gettimeofday(&now, nullptr); for (size_t i = 0; i < events; ++i) { - size_t index = kNeutralEventCount - fake.keyframe_remaining; - struct input_event ev = {}; + size_t index = kNeutralEventCount - fake.keyframe_remaining + i; + struct input_event &ev = input_events[i]; ev.time = now; ev.type = kNeutralEvents[index].type; ev.code = kNeutralEvents[index].code; ev.value = keyframe_value(fake, ev.type, ev.code); if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); - memcpy(static_cast(buf) + i * sizeof(ev), &ev, sizeof(ev)); - --fake.keyframe_remaining; } - return static_cast(events * sizeof(struct input_event)); + ssize_t copied = copy_input_events(buf, input_events, events); + if (copied > 0) fake.keyframe_remaining -= copied / sizeof(struct input_event); + return copied; } if (nonblock) { errno = EAGAIN; @@ -3673,6 +3739,52 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { } } +EXPORT ssize_t read(int fd, void *buf, size_t count) { + return read_input(fd, buf, count); +} + +EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) { + if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt); + if (iovcnt < 0 || iovcnt > IOV_MAX) { + errno = EINVAL; + return -1; + } + if (iovcnt && !iov) { + errno = EFAULT; + return -1; + } + std::vector copied_iov(static_cast(iovcnt)); + if (iovcnt) { + size_t bytes = copied_iov.size() * sizeof(struct iovec); + struct iovec local = {copied_iov.data(), bytes}; + struct iovec remote = {const_cast(iov), bytes}; + ssize_t copied = syscall(SYS_process_vm_readv, getpid(), &local, 1, &remote, 1, 0); + if (copied != static_cast(bytes)) { + errno = EFAULT; + return -1; + } + } + iov = copied_iov.data(); + size_t size = 0; + for (int i = 0; i < iovcnt; ++i) { + if (iov[i].iov_len > static_cast(SSIZE_MAX) - size) { + errno = EINVAL; + return -1; + } + size += iov[i].iov_len; + } + + ssize_t total = 0; + for (int i = 0; i < iovcnt; ++i) { + if (!iov[i].iov_len) continue; + ssize_t got = read_input(fd, iov[i].iov_base, iov[i].iov_len); + if (got < 0) return total ? total : -1; + total += got; + if (static_cast(got) != iov[i].iov_len) break; + } + return total; +} + EXPORT ssize_t write(int fd, const void *buf, size_t count) { static auto my_write = reinterpret_cast(dlsym(RTLD_NEXT, "write")); @@ -4103,4 +4215,92 @@ EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) { if (!real) return fstat64(fd, buf); return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); }); } + +#if defined(__x86_64__) || defined(__i386__) +using dlsym_fn = void *(*)(void *, const char *); + +static const char *const interposed_names[] = { + "open", "open64", "openat", "openat64", "close", "read", "readv", "write", "writev", "ioctl", + "poll", "ppoll", "select", "stat", "stat64", "fstat", "fstat64", "__xstat", "__xstat64", + "__fxstat", "__fxstat64", "statx", "statfs", "statfs64", "fstatfs", "fstatfs64", "access", + "faccessat", "opendir", "scandir", "inotify_add_watch", +}; + +static dlsym_fn next_dlsym() { + static const dlsym_fn fn = [] { +#ifdef __x86_64__ + const char *base = "GLIBC_2.2.5"; +#else + const char *base = "GLIBC_2.0"; +#endif + for (const char *version : {base, "GLIBC_2.34"}) { + if (auto found = reinterpret_cast(dlvsym(RTLD_NEXT, "dlsym", version))) return found; + } + return dlsym_fn{}; + }(); + return fn; +} + +static void *global_dlsym(void *, const char *name) { return next_dlsym()(RTLD_DEFAULT, name); } + +static void *missing_dlsym(void *, const char *) { return nullptr; } + +static bool interposed(const char *name) { + for (const char *interposed_name : interposed_names) { + if (strcmp(interposed_name, name) == 0) return true; + } + return false; +} + +extern "C" __attribute__((visibility("hidden"), used)) void *fakeinput_dlsym_target(void *handle, const char *name) { + dlsym_fn next = next_dlsym(); + if (!next) return reinterpret_cast(missing_dlsym); + if (handle == RTLD_DEFAULT || handle == RTLD_NEXT || name == nullptr) return reinterpret_cast(next); + static void *const libc = dlopen(LIBC_SO, RTLD_LAZY | RTLD_NOLOAD); + if (handle != libc || !interposed(name)) return reinterpret_cast(next); + return reinterpret_cast(global_dlsym); +} + +#ifdef __x86_64__ +__asm__(".text\n" + ".globl dlsym\n" + ".type dlsym, @function\n" + "dlsym:\n" + ".cfi_startproc\n" + "pushq %rdi\n" + ".cfi_adjust_cfa_offset 8\n" + "pushq %rsi\n" + ".cfi_adjust_cfa_offset 8\n" + "subq $8, %rsp\n" + ".cfi_adjust_cfa_offset 8\n" + "call fakeinput_dlsym_target\n" + "addq $8, %rsp\n" + ".cfi_adjust_cfa_offset -8\n" + "popq %rsi\n" + ".cfi_adjust_cfa_offset -8\n" + "popq %rdi\n" + ".cfi_adjust_cfa_offset -8\n" + "jmp *%rax\n" + ".cfi_endproc\n" + ".size dlsym, .-dlsym\n"); +#else +__asm__(".text\n" + ".globl dlsym\n" + ".type dlsym, @function\n" + "dlsym:\n" + ".cfi_startproc\n" + "subl $12, %esp\n" + ".cfi_adjust_cfa_offset 12\n" + "movl 20(%esp), %eax\n" + "movl %eax, 4(%esp)\n" + "movl 16(%esp), %eax\n" + "movl %eax, (%esp)\n" + "call fakeinput_dlsym_target\n" + "addl $12, %esp\n" + ".cfi_adjust_cfa_offset -12\n" + "jmp *%eax\n" + ".cfi_endproc\n" + ".size dlsym, .-dlsym\n"); +#endif +#endif #endif diff --git a/app/src/main/cpp/framegen/VulkanRendererContext.h b/app/src/main/cpp/framegen/VulkanRendererContext.h index efbc00c68..7cbbcdf42 100644 --- a/app/src/main/cpp/framegen/VulkanRendererContext.h +++ b/app/src/main/cpp/framegen/VulkanRendererContext.h @@ -131,7 +131,7 @@ struct VkTable { #include namespace lsfg { class Engine; } -namespace winfg { class Engine; } +namespace ddfg { class Engine; } static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2; @@ -280,7 +280,7 @@ public: static constexpr int32_t kFgCaptureGame = -1; void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0); void setFrameGenEngine(int kind); - void setWinFgTuning(int model, int perfPreset); + void setDdFgTuning(int model, int perfPreset); private: struct WinTex { @@ -468,12 +468,12 @@ private: uint64_t fgSourceFrames_ = 0; std::string lsfgCachePath_; bool lsfgEngineTried_ = false; - std::unique_ptr winfgEngine_; - bool winfgEngineTried_ = false; + std::unique_ptr ddfgEngine_; + bool ddfgEngineTried_ = false; std::atomic fgEngineKind_{0}; std::atomic fgModel_{4}; std::atomic fgPerfPreset_{1}; - bool ensureWinFgEngine(); + bool ensureDdFgEngine(); bool fgCapsOk() const; uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; } diff --git a/app/src/main/cpp/framegen/winfg/LICENSE.win-fg b/app/src/main/cpp/framegen/ddfg/LICENSE.win-fg similarity index 100% rename from app/src/main/cpp/framegen/winfg/LICENSE.win-fg rename to app/src/main/cpp/framegen/ddfg/LICENSE.win-fg diff --git a/app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md b/app/src/main/cpp/framegen/ddfg/NOTICE_FIDELITYFX_OPTICALFLOW.md similarity index 100% rename from app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md rename to app/src/main/cpp/framegen/ddfg/NOTICE_FIDELITYFX_OPTICALFLOW.md diff --git a/app/src/main/cpp/framegen/winfg/config.hpp b/app/src/main/cpp/framegen/ddfg/config.hpp similarity index 78% rename from app/src/main/cpp/framegen/winfg/config.hpp rename to app/src/main/cpp/framegen/ddfg/config.hpp index a3bf09a5e..b488711b5 100644 --- a/app/src/main/cpp/framegen/winfg/config.hpp +++ b/app/src/main/cpp/framegen/ddfg/config.hpp @@ -6,7 +6,7 @@ #include #include -namespace winfg { +namespace ddfg { struct Config { bool enabled = false; @@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) { } static inline std::string conf_path() { - if (const char* p = std::getenv("WIN_FG_CONF")) return p; + if (const char* p = std::getenv("DD_FG_CONF")) return p; const char* home = std::getenv("HOME"); - return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string(); + return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string(); } static inline std::string capture_root(const Config& c) { if (!c.capDir.empty()) return c.capDir; - if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture"; - return std::string("/tmp/winfg-capture"); + if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture"; + return std::string("/tmp/ddfg-capture"); } static inline Config load_config() { Config c; - c.enabled = envi("WIN_FG_ENABLE", 0) != 0; - if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug); - if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing); - c.model = envi("WIN_FG_MODEL", c.model); - c.multiplier = envi("WIN_FG_MULT", c.multiplier); - c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages); - c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset); - if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode); - if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode); - c.frIters = envi("WIN_FG_FR_ITERS", c.frIters); - c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda); - c.frDt = envf("WIN_FG_FR_DT", c.frDt); - c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge); - c.frEps = envf("WIN_FG_FR_EPS", c.frEps); - c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale); - c.beta = envf("WIN_FG_BETA", c.beta); - c.lambda = envf("WIN_FG_LAMBDA", c.lambda); - c.epsilon = envf("WIN_FG_EPSILON", c.epsilon); - c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale); - if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture); - c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW); - c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH); - if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode); - c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches); - c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize); - c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion); - c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB); - if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd; - if (const char* h = std::getenv("WIN_FG_HUD_RECT")) { + c.enabled = envi("DD_FG_ENABLE", 0) != 0; + if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug); + if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing); + c.model = envi("DD_FG_MODEL", c.model); + c.multiplier = envi("DD_FG_MULT", c.multiplier); + c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages); + c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset); + if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode); + if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode); + c.frIters = envi("DD_FG_FR_ITERS", c.frIters); + c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda); + c.frDt = envf("DD_FG_FR_DT", c.frDt); + c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge); + c.frEps = envf("DD_FG_FR_EPS", c.frEps); + c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale); + c.beta = envf("DD_FG_BETA", c.beta); + c.lambda = envf("DD_FG_LAMBDA", c.lambda); + c.epsilon = envf("DD_FG_EPSILON", c.epsilon); + c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale); + if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture); + c.captureW = envi("DD_FG_CAPTURE_W", c.captureW); + c.captureH = envi("DD_FG_CAPTURE_H", c.captureH); + if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode); + c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches); + c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize); + c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion); + c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB); + if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd; + if (const char* h = std::getenv("DD_FG_HUD_RECT")) { float r[4] = {0,0,0,0}; int n = 0; std::string s = h; size_t start = 0; for (; n < 4; ++n) { @@ -266,8 +266,8 @@ static inline Config load_config() { c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3]; } const char* home = std::getenv("HOME"); - if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml"); - if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p); + if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml"); + if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p); c.sanitize(); return c; } diff --git a/app/src/main/cpp/framegen/winfg/winfg_engine.cpp b/app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp similarity index 91% rename from app/src/main/cpp/framegen/winfg/winfg_engine.cpp rename to app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp index 965e15a15..6e87890b3 100644 --- a/app/src/main/cpp/framegen/winfg/winfg_engine.cpp +++ b/app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp @@ -1,10 +1,10 @@ -#include "winfg_engine.h" -#include "winfg_vkd.h" +#include "ddfg_engine.h" +#include "ddfg_vkd.h" #include "log.hpp" #include #include -namespace winfg { +namespace ddfg { namespace { float alphaFor(uint32_t k, uint32_t n) { @@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); } bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev, uint32_t queueFamily, VkQueue queue) { - if (!winfgVkdInit(table, dd_, id_)) { - WFG_LOGE("native: dispatch incomplete; frame generation unavailable"); + if (!ddfgVkdInit(table, dd_, id_)) { + DDFG_LOGE("native: dispatch incomplete; frame generation unavailable"); return false; } phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue; id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_); if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) { - WFG_LOGE("native: FrameGen::init failed"); + DDFG_LOGE("native: FrameGen::init failed"); unavailable_ = true; return false; } - WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)"); + DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)"); return true; } @@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) { if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) { - WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h); + DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h); unavailable_ = true; return false; } @@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) { } builtW_ = w; builtH_ = h; builtFmt_ = fmt; history_ = 0; curr_ = 0; frames_ = 0; - WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f", + DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f", w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale); return true; } +static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot"); + uint32_t Engine::plan(uint32_t capacity) const { if (!valid() || history_ < 2) return 0; const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0); @@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite); - fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_); + fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_, + generation, generation > 0); if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots; (void)w; (void)h; diff --git a/app/src/main/cpp/framegen/winfg/winfg_engine.h b/app/src/main/cpp/framegen/ddfg/ddfg_engine.h similarity index 99% rename from app/src/main/cpp/framegen/winfg/winfg_engine.h rename to app/src/main/cpp/framegen/ddfg/ddfg_engine.h index 9a607f531..55c1ef8fa 100644 --- a/app/src/main/cpp/framegen/winfg/winfg_engine.h +++ b/app/src/main/cpp/framegen/ddfg/ddfg_engine.h @@ -6,7 +6,7 @@ struct VkTable; -namespace winfg { +namespace ddfg { constexpr uint32_t kMaxGenerations = 3; diff --git a/app/src/main/cpp/framegen/winfg/winfg_vkd.cpp b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp similarity index 90% rename from app/src/main/cpp/framegen/winfg/winfg_vkd.cpp rename to app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp index a0c2dd441..ba8a7b3ad 100644 --- a/app/src/main/cpp/framegen/winfg/winfg_vkd.cpp +++ b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp @@ -1,14 +1,14 @@ -#include "winfg_vkd.h" +#include "ddfg_vkd.h" #include "../VulkanRendererContext.h" #include -bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) { +bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) { bool ok = true; #define COPY(dst, fn) \ do { \ dst.fn = table.fn; \ if (!dst.fn) { \ - __android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \ + __android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \ "entry point vk" #fn " did not resolve"); \ ok = false; \ } \ diff --git a/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h new file mode 100644 index 000000000..8c5b69404 --- /dev/null +++ b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h @@ -0,0 +1,4 @@ +#pragma once +#include "vk_dispatch.hpp" +struct VkTable; +bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id); diff --git a/app/src/main/cpp/framegen/winfg/embedded_shaders.hpp b/app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp similarity index 82% rename from app/src/main/cpp/framegen/winfg/embedded_shaders.hpp rename to app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp index 20dd4477c..61f5c154c 100644 --- a/app/src/main/cpp/framegen/winfg/embedded_shaders.hpp +++ b/app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp @@ -1,7 +1,7 @@ #pragma once #include #include -namespace winfg { namespace embedded { +namespace ddfg { namespace embedded { static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x00000035u,0x00000037u,0x0003003eu,0x00000030u,0x00000038u,0x0004003du,0x0000003du,0x00000040u,0x0000003fu,0x0004003du,0x0000002eu,0x00000041u,0x00000030u,0x00070058u,0x00000043u,0x00000044u,0x00000040u,0x00000041u,0x00000002u,0x00000042u,0x0008004fu,0x00000039u,0x00000045u,0x00000044u,0x00000044u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000003bu,0x00000045u,0x0004003du,0x00000039u,0x00000048u,0x0000003bu,0x00050094u,0x00000013u,0x0000004du,0x00000048u,0x0000004cu,0x0003003eu,0x00000047u,0x0000004du,0x0004003du,0x00000014u,0x0000004eu,0x00000016u,0x0004003du,0x00000007u,0x0000004fu,0x00000009u,0x0004003du,0x00000013u,0x00000050u,0x00000047u,0x00070050u,0x00000043u,0x00000051u,0x00000050u,0x00000042u,0x00000042u,0x00000042u,0x00040063u,0x0000004eu,0x0000004fu,0x00000051u,0x000100fdu,0x00010038u}; static const size_t OF3_LUMA_LEN = 481; static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x00050067u,0x00000007u,0x00000038u,0x00000037u,0x00000036u,0x0004006fu,0x0000002eu,0x00000039u,0x00000038u,0x0003003eu,0x00000030u,0x00000039u,0x0004003du,0x00000007u,0x0000003bu,0x00000009u,0x0004006fu,0x0000002eu,0x0000003cu,0x0000003bu,0x0005008eu,0x0000002eu,0x0000003eu,0x0000003cu,0x0000003du,0x00050050u,0x0000002eu,0x00000040u,0x0000003fu,0x0000003fu,0x00050081u,0x0000002eu,0x00000041u,0x0000003eu,0x00000040u,0x0004003du,0x0000002eu,0x00000042u,0x00000030u,0x00050088u,0x0000002eu,0x00000043u,0x00000041u,0x00000042u,0x0003003eu,0x0000003au,0x00000043u,0x0004003du,0x0000002eu,0x00000046u,0x00000030u,0x00050050u,0x0000002eu,0x00000047u,0x00000045u,0x00000045u,0x00050088u,0x0000002eu,0x00000048u,0x00000047u,0x00000046u,0x0003003eu,0x00000044u,0x00000048u,0x0004003du,0x00000032u,0x0000004bu,0x00000034u,0x0004003du,0x0000002eu,0x0000004cu,0x0000003au,0x00050041u,0x00000049u,0x0000004du,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x0000004eu,0x0000004du,0x0004007fu,0x00000013u,0x0000004fu,0x0000004eu,0x00050041u,0x00000049u,0x00000050u,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x00000051u,0x00000050u,0x0004007fu,0x00000013u,0x00000052u,0x00000051u,0x00050050u,0x0000002eu,0x00000053u,0x0000004fu,0x00000052u,0x00050081u,0x0000002eu,0x00000054u,0x0000004cu,0x00000053u,0x00070058u,0x00000056u,0x00000057u,0x0000004bu,0x00000054u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000058u,0x00000057u,0x00000000u,0x0003003eu,0x0000004au,0x00000058u,0x0004003du,0x00000032u,0x0000005au,0x00000034u,0x0004003du,0x0000002eu,0x0000005bu,0x0000003au,0x00050041u,0x00000049u,0x0000005cu,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x0000005du,0x0000005cu,0x00050041u,0x00000049u,0x0000005eu,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x0000005fu,0x0000005eu,0x0004007fu,0x00000013u,0x00000060u,0x0000005fu,0x00050050u,0x0000002eu,0x00000061u,0x0000005du,0x00000060u,0x00050081u,0x0000002eu,0x00000062u,0x0000005bu,0x00000061u,0x00070058u,0x00000056u,0x00000063u,0x0000005au,0x00000062u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000064u,0x00000063u,0x00000000u,0x0003003eu,0x00000059u,0x00000064u,0x0004003du,0x00000032u,0x00000066u,0x00000034u,0x0004003du,0x0000002eu,0x00000067u,0x0000003au,0x00050041u,0x00000049u,0x00000068u,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x00000069u,0x00000068u,0x0004007fu,0x00000013u,0x0000006au,0x00000069u,0x00050041u,0x00000049u,0x0000006bu,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x0000006cu,0x0000006bu,0x00050050u,0x0000002eu,0x0000006du,0x0000006au,0x0000006cu,0x00050081u,0x0000002eu,0x0000006eu,0x00000067u,0x0000006du,0x00070058u,0x00000056u,0x0000006fu,0x00000066u,0x0000006eu,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000070u,0x0000006fu,0x00000000u,0x0003003eu,0x00000065u,0x00000070u,0x0004003du,0x00000032u,0x00000072u,0x00000034u,0x0004003du,0x0000002eu,0x00000073u,0x0000003au,0x00050041u,0x00000049u,0x00000074u,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x00000075u,0x00000074u,0x00050041u,0x00000049u,0x00000076u,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x00000077u,0x00000076u,0x00050050u,0x0000002eu,0x00000078u,0x00000075u,0x00000077u,0x00050081u,0x0000002eu,0x00000079u,0x00000073u,0x00000078u,0x00070058u,0x00000056u,0x0000007au,0x00000072u,0x00000079u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x0000007bu,0x0000007au,0x00000000u,0x0003003eu,0x00000071u,0x0000007bu,0x0004003du,0x00000014u,0x0000007cu,0x00000016u,0x0004003du,0x00000007u,0x0000007du,0x00000009u,0x0004003du,0x00000013u,0x0000007eu,0x0000004au,0x0004003du,0x00000013u,0x0000007fu,0x00000059u,0x00050081u,0x00000013u,0x00000080u,0x0000007eu,0x0000007fu,0x0004003du,0x00000013u,0x00000081u,0x00000065u,0x00050081u,0x00000013u,0x00000082u,0x00000080u,0x00000081u,0x0004003du,0x00000013u,0x00000083u,0x00000071u,0x00050081u,0x00000013u,0x00000084u,0x00000082u,0x00000083u,0x00050085u,0x00000013u,0x00000086u,0x00000084u,0x00000085u,0x00070050u,0x00000056u,0x00000087u,0x00000086u,0x00000055u,0x00000055u,0x00000055u,0x00040063u,0x0000007cu,0x0000007du,0x00000087u,0x000100fdu,0x00010038u}; @@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b static const size_t OF3_GM_PREWARP_LEN = 706; static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,0x00040015u,0x0000002fu,0x00000020u,0x00000000u,0x0004002bu,0x0000002fu,0x00000030u,0x00000000u,0x00040017u,0x00000035u,0x0000002fu,0x00000003u,0x00040020u,0x00000036u,0x00000001u,0x00000035u,0x0004003bu,0x00000036u,0x00000037u,0x00000001u,0x00040017u,0x00000038u,0x0000002fu,0x00000002u,0x00020014u,0x00000040u,0x00040020u,0x00000041u,0x00000007u,0x00000006u,0x0004002bu,0x0000002fu,0x0000004au,0x00000001u,0x00040020u,0x00000054u,0x00000007u,0x00000009u,0x0003001eu,0x00000056u,0x0000000au,0x00040020u,0x00000057u,0x00000002u,0x00000056u,0x0004003bu,0x00000057u,0x00000058u,0x00000002u,0x00040020u,0x00000059u,0x00000002u,0x00000009u,0x0004002bu,0x0000002fu,0x00000060u,0x00000002u,0x0004002bu,0x0000002fu,0x00000064u,0x00000003u,0x00040020u,0x00000067u,0x00000007u,0x0000000au,0x0004002bu,0x00000006u,0x00000070u,0xffffffffu,0x0005002cu,0x00000007u,0x00000071u,0x00000070u,0x0000001bu,0x0005002cu,0x00000007u,0x00000079u,0x0000001eu,0x0000001bu,0x0005002cu,0x00000007u,0x00000081u,0x0000001bu,0x00000070u,0x0005002cu,0x00000007u,0x00000089u,0x0000001bu,0x0000001eu,0x0004002bu,0x00000009u,0x000000b0u,0x3f000000u,0x00040017u,0x000000b7u,0x00000009u,0x00000002u,0x0004002bu,0x00000009u,0x000000c1u,0x3d4ccccdu,0x0004002bu,0x00000009u,0x000000c2u,0x3f800000u,0x0004003bu,0x00000017u,0x00000121u,0x00000000u,0x00090019u,0x0000013cu,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000002u,0x00040020u,0x0000013du,0x00000000u,0x0000013cu,0x0004003bu,0x0000013du,0x0000013eu,0x00000000u,0x0004002bu,0x0000002fu,0x00000142u,0x00000010u,0x0006002cu,0x00000035u,0x00000143u,0x00000142u,0x00000142u,0x0000004au,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000034u,0x00000007u,0x0004003bu,0x00000008u,0x0000003cu,0x00000007u,0x0004003bu,0x00000054u,0x00000055u,0x00000007u,0x0004003bu,0x00000054u,0x0000005cu,0x00000007u,0x0004003bu,0x00000054u,0x0000005fu,0x00000007u,0x0004003bu,0x00000054u,0x00000063u,0x00000007u,0x0004003bu,0x00000067u,0x00000068u,0x00000007u,0x0004003bu,0x00000008u,0x00000069u,0x00000007u,0x0004003bu,0x00000008u,0x0000006bu,0x00000007u,0x0004003bu,0x00000067u,0x0000006eu,0x00000007u,0x0004003bu,0x00000008u,0x00000073u,0x00000007u,0x0004003bu,0x00000008u,0x00000074u,0x00000007u,0x0004003bu,0x00000067u,0x00000077u,0x00000007u,0x0004003bu,0x00000008u,0x0000007bu,0x00000007u,0x0004003bu,0x00000008u,0x0000007cu,0x00000007u,0x0004003bu,0x00000067u,0x0000007fu,0x00000007u,0x0004003bu,0x00000008u,0x00000083u,0x00000007u,0x0004003bu,0x00000008u,0x00000084u,0x00000007u,0x0004003bu,0x00000067u,0x00000087u,0x00000007u,0x0004003bu,0x00000008u,0x0000008bu,0x00000007u,0x0004003bu,0x00000008u,0x0000008cu,0x00000007u,0x0004003bu,0x00000008u,0x0000008fu,0x00000007u,0x0004003bu,0x00000054u,0x00000093u,0x00000007u,0x0004003bu,0x00000008u,0x00000096u,0x00000007u,0x0004003bu,0x00000008u,0x00000097u,0x00000007u,0x0004003bu,0x00000054u,0x0000009au,0x00000007u,0x0004003bu,0x00000008u,0x0000009du,0x00000007u,0x0004003bu,0x00000008u,0x0000009eu,0x00000007u,0x0004003bu,0x00000054u,0x000000a1u,0x00000007u,0x0004003bu,0x00000008u,0x000000a4u,0x00000007u,0x0004003bu,0x00000008u,0x000000a5u,0x00000007u,0x0004003bu,0x00000054u,0x000000a8u,0x00000007u,0x0004003bu,0x00000008u,0x000000abu,0x00000007u,0x0004003bu,0x00000008u,0x000000acu,0x00000007u,0x0004003bu,0x00000054u,0x000000afu,0x00000007u,0x0004003bu,0x00000054u,0x000000bbu,0x00000007u,0x0004003bu,0x00000067u,0x000000c4u,0x00000007u,0x0004003bu,0x00000067u,0x000000c8u,0x00000007u,0x0004003bu,0x00000067u,0x000000ccu,0x00000007u,0x0004003bu,0x00000067u,0x000000d0u,0x00000007u,0x0004003bu,0x00000054u,0x000000d4u,0x00000007u,0x0004003bu,0x00000054u,0x000000d8u,0x00000007u,0x0004003bu,0x00000054u,0x000000e1u,0x00000007u,0x0004003bu,0x00000054u,0x000000eau,0x00000007u,0x0004003bu,0x00000054u,0x000000f3u,0x00000007u,0x0004003bu,0x00000054u,0x000000fcu,0x00000007u,0x0004003bu,0x00000067u,0x00000104u,0x00000007u,0x0004003bu,0x00000067u,0x00000114u,0x00000007u,0x0004003bu,0x00000067u,0x00000120u,0x00000007u,0x0004003bu,0x00000054u,0x0000012au,0x00000007u,0x0004003bu,0x00000067u,0x0000012eu,0x00000007u,0x0004003bu,0x00000067u,0x00000132u,0x00000007u,0x0004003du,0x00000035u,0x00000039u,0x00000037u,0x0007004fu,0x00000038u,0x0000003au,0x00000039u,0x00000039u,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x0000003bu,0x0000003au,0x0003003eu,0x00000034u,0x0000003bu,0x0004003du,0x00000016u,0x0000003du,0x00000018u,0x00040064u,0x00000015u,0x0000003eu,0x0000003du,0x00050067u,0x00000007u,0x0000003fu,0x0000003eu,0x0000001bu,0x0003003eu,0x0000003cu,0x0000003fu,0x00050041u,0x00000041u,0x00000042u,0x00000034u,0x00000030u,0x0004003du,0x00000006u,0x00000043u,0x00000042u,0x00050041u,0x00000041u,0x00000044u,0x0000003cu,0x00000030u,0x0004003du,0x00000006u,0x00000045u,0x00000044u,0x000500afu,0x00000040u,0x00000046u,0x00000043u,0x00000045u,0x000400a8u,0x00000040u,0x00000047u,0x00000046u,0x000300f7u,0x00000049u,0x00000000u,0x000400fau,0x00000047u,0x00000048u,0x00000049u,0x000200f8u,0x00000048u,0x00050041u,0x00000041u,0x0000004bu,0x00000034u,0x0000004au,0x0004003du,0x00000006u,0x0000004cu,0x0000004bu,0x00050041u,0x00000041u,0x0000004du,0x0000003cu,0x0000004au,0x0004003du,0x00000006u,0x0000004eu,0x0000004du,0x000500afu,0x00000040u,0x0000004fu,0x0000004cu,0x0000004eu,0x000200f9u,0x00000049u,0x000200f8u,0x00000049u,0x000700f5u,0x00000040u,0x00000050u,0x00000046u,0x00000005u,0x0000004fu,0x00000048u,0x000300f7u,0x00000052u,0x00000000u,0x000400fau,0x00000050u,0x00000051u,0x00000052u,0x000200f8u,0x00000051u,0x000100fdu,0x000200f8u,0x00000052u,0x00060041u,0x00000059u,0x0000005au,0x00000058u,0x0000001bu,0x00000030u,0x0004003du,0x00000009u,0x0000005bu,0x0000005au,0x0003003eu,0x00000055u,0x0000005bu,0x00060041u,0x00000059u,0x0000005du,0x00000058u,0x0000001bu,0x0000004au,0x0004003du,0x00000009u,0x0000005eu,0x0000005du,0x0003003eu,0x0000005cu,0x0000005eu,0x00060041u,0x00000059u,0x00000061u,0x00000058u,0x0000001bu,0x00000060u,0x0004003du,0x00000009u,0x00000062u,0x00000061u,0x0003003eu,0x0000005fu,0x00000062u,0x00060041u,0x00000059u,0x00000065u,0x00000058u,0x0000001bu,0x00000064u,0x0004003du,0x00000009u,0x00000066u,0x00000065u,0x0003003eu,0x00000063u,0x00000066u,0x0004003du,0x00000007u,0x0000006au,0x00000034u,0x0003003eu,0x00000069u,0x0000006au,0x0004003du,0x00000007u,0x0000006cu,0x0000003cu,0x0003003eu,0x0000006bu,0x0000006cu,0x00060039u,0x0000000au,0x0000006du,0x0000000eu,0x00000069u,0x0000006bu,0x0003003eu,0x00000068u,0x0000006du,0x0004003du,0x00000007u,0x0000006fu,0x00000034u,0x00050080u,0x00000007u,0x00000072u,0x0000006fu,0x00000071u,0x0003003eu,0x00000073u,0x00000072u,0x0004003du,0x00000007u,0x00000075u,0x0000003cu,0x0003003eu,0x00000074u,0x00000075u,0x00060039u,0x0000000au,0x00000076u,0x0000000eu,0x00000073u,0x00000074u,0x0003003eu,0x0000006eu,0x00000076u,0x0004003du,0x00000007u,0x00000078u,0x00000034u,0x00050080u,0x00000007u,0x0000007au,0x00000078u,0x00000079u,0x0003003eu,0x0000007bu,0x0000007au,0x0004003du,0x00000007u,0x0000007du,0x0000003cu,0x0003003eu,0x0000007cu,0x0000007du,0x00060039u,0x0000000au,0x0000007eu,0x0000000eu,0x0000007bu,0x0000007cu,0x0003003eu,0x00000077u,0x0000007eu,0x0004003du,0x00000007u,0x00000080u,0x00000034u,0x00050080u,0x00000007u,0x00000082u,0x00000080u,0x00000081u,0x0003003eu,0x00000083u,0x00000082u,0x0004003du,0x00000007u,0x00000085u,0x0000003cu,0x0003003eu,0x00000084u,0x00000085u,0x00060039u,0x0000000au,0x00000086u,0x0000000eu,0x00000083u,0x00000084u,0x0003003eu,0x0000007fu,0x00000086u,0x0004003du,0x00000007u,0x00000088u,0x00000034u,0x00050080u,0x00000007u,0x0000008au,0x00000088u,0x00000089u,0x0003003eu,0x0000008bu,0x0000008au,0x0004003du,0x00000007u,0x0000008du,0x0000003cu,0x0003003eu,0x0000008cu,0x0000008du,0x00060039u,0x0000000au,0x0000008eu,0x0000000eu,0x0000008bu,0x0000008cu,0x0003003eu,0x00000087u,0x0000008eu,0x0004003du,0x00000016u,0x00000090u,0x00000026u,0x00040064u,0x00000015u,0x00000091u,0x00000090u,0x00050067u,0x00000007u,0x00000092u,0x00000091u,0x0000001bu,0x0003003eu,0x0000008fu,0x00000092u,0x0004003du,0x00000007u,0x00000094u,0x00000034u,0x00050080u,0x00000007u,0x00000095u,0x00000094u,0x00000071u,0x0003003eu,0x00000096u,0x00000095u,0x0004003du,0x00000007u,0x00000098u,0x0000008fu,0x0003003eu,0x00000097u,0x00000098u,0x00060039u,0x00000009u,0x00000099u,0x00000013u,0x00000096u,0x00000097u,0x0003003eu,0x00000093u,0x00000099u,0x0004003du,0x00000007u,0x0000009bu,0x00000034u,0x00050080u,0x00000007u,0x0000009cu,0x0000009bu,0x00000079u,0x0003003eu,0x0000009du,0x0000009cu,0x0004003du,0x00000007u,0x0000009fu,0x0000008fu,0x0003003eu,0x0000009eu,0x0000009fu,0x00060039u,0x00000009u,0x000000a0u,0x00000013u,0x0000009du,0x0000009eu,0x0003003eu,0x0000009au,0x000000a0u,0x0004003du,0x00000007u,0x000000a2u,0x00000034u,0x00050080u,0x00000007u,0x000000a3u,0x000000a2u,0x00000081u,0x0003003eu,0x000000a4u,0x000000a3u,0x0004003du,0x00000007u,0x000000a6u,0x0000008fu,0x0003003eu,0x000000a5u,0x000000a6u,0x00060039u,0x00000009u,0x000000a7u,0x00000013u,0x000000a4u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a7u,0x0004003du,0x00000007u,0x000000a9u,0x00000034u,0x00050080u,0x00000007u,0x000000aau,0x000000a9u,0x00000089u,0x0003003eu,0x000000abu,0x000000aau,0x0004003du,0x00000007u,0x000000adu,0x0000008fu,0x0003003eu,0x000000acu,0x000000adu,0x00060039u,0x00000009u,0x000000aeu,0x00000013u,0x000000abu,0x000000acu,0x0003003eu,0x000000a8u,0x000000aeu,0x0004003du,0x00000009u,0x000000b1u,0x0000009au,0x0004003du,0x00000009u,0x000000b2u,0x00000093u,0x00050083u,0x00000009u,0x000000b3u,0x000000b1u,0x000000b2u,0x0004003du,0x00000009u,0x000000b4u,0x000000a8u,0x0004003du,0x00000009u,0x000000b5u,0x000000a1u,0x00050083u,0x00000009u,0x000000b6u,0x000000b4u,0x000000b5u,0x00050050u,0x000000b7u,0x000000b8u,0x000000b3u,0x000000b6u,0x0006000cu,0x00000009u,0x000000b9u,0x00000001u,0x00000042u,0x000000b8u,0x00050085u,0x00000009u,0x000000bau,0x000000b0u,0x000000b9u,0x0003003eu,0x000000afu,0x000000bau,0x0004003du,0x00000009u,0x000000bcu,0x0000005fu,0x0004007fu,0x00000009u,0x000000bdu,0x000000bcu,0x0004003du,0x00000009u,0x000000beu,0x000000afu,0x00050085u,0x00000009u,0x000000bfu,0x000000bdu,0x000000beu,0x0006000cu,0x00000009u,0x000000c0u,0x00000001u,0x0000001bu,0x000000bfu,0x0008000cu,0x00000009u,0x000000c3u,0x00000001u,0x0000002bu,0x000000c0u,0x000000c1u,0x000000c2u,0x0003003eu,0x000000bbu,0x000000c3u,0x0004003du,0x0000000au,0x000000c5u,0x0000006eu,0x0004003du,0x0000000au,0x000000c6u,0x00000068u,0x00050083u,0x0000000au,0x000000c7u,0x000000c5u,0x000000c6u,0x0003003eu,0x000000c4u,0x000000c7u,0x0004003du,0x0000000au,0x000000c9u,0x00000077u,0x0004003du,0x0000000au,0x000000cau,0x00000068u,0x00050083u,0x0000000au,0x000000cbu,0x000000c9u,0x000000cau,0x0003003eu,0x000000c8u,0x000000cbu,0x0004003du,0x0000000au,0x000000cdu,0x0000007fu,0x0004003du,0x0000000au,0x000000ceu,0x00000068u,0x00050083u,0x0000000au,0x000000cfu,0x000000cdu,0x000000ceu,0x0003003eu,0x000000ccu,0x000000cfu,0x0004003du,0x0000000au,0x000000d1u,0x00000087u,0x0004003du,0x0000000au,0x000000d2u,0x00000068u,0x00050083u,0x0000000au,0x000000d3u,0x000000d1u,0x000000d2u,0x0003003eu,0x000000d0u,0x000000d3u,0x0004003du,0x00000009u,0x000000d5u,0x00000063u,0x0004003du,0x00000009u,0x000000d6u,0x00000063u,0x00050085u,0x00000009u,0x000000d7u,0x000000d5u,0x000000d6u,0x0003003eu,0x000000d4u,0x000000d7u,0x0004003du,0x00000009u,0x000000d9u,0x000000bbu,0x0004003du,0x0000000au,0x000000dau,0x000000c4u,0x0004003du,0x0000000au,0x000000dbu,0x000000c4u,0x00050094u,0x00000009u,0x000000dcu,0x000000dau,0x000000dbu,0x0004003du,0x00000009u,0x000000ddu,0x000000d4u,0x00050081u,0x00000009u,0x000000deu,0x000000dcu,0x000000ddu,0x0006000cu,0x00000009u,0x000000dfu,0x00000001u,0x00000020u,0x000000deu,0x00050085u,0x00000009u,0x000000e0u,0x000000d9u,0x000000dfu,0x0003003eu,0x000000d8u,0x000000e0u,0x0004003du,0x00000009u,0x000000e2u,0x000000bbu,0x0004003du,0x0000000au,0x000000e3u,0x000000c8u,0x0004003du,0x0000000au,0x000000e4u,0x000000c8u,0x00050094u,0x00000009u,0x000000e5u,0x000000e3u,0x000000e4u,0x0004003du,0x00000009u,0x000000e6u,0x000000d4u,0x00050081u,0x00000009u,0x000000e7u,0x000000e5u,0x000000e6u,0x0006000cu,0x00000009u,0x000000e8u,0x00000001u,0x00000020u,0x000000e7u,0x00050085u,0x00000009u,0x000000e9u,0x000000e2u,0x000000e8u,0x0003003eu,0x000000e1u,0x000000e9u,0x0004003du,0x00000009u,0x000000ebu,0x000000bbu,0x0004003du,0x0000000au,0x000000ecu,0x000000ccu,0x0004003du,0x0000000au,0x000000edu,0x000000ccu,0x00050094u,0x00000009u,0x000000eeu,0x000000ecu,0x000000edu,0x0004003du,0x00000009u,0x000000efu,0x000000d4u,0x00050081u,0x00000009u,0x000000f0u,0x000000eeu,0x000000efu,0x0006000cu,0x00000009u,0x000000f1u,0x00000001u,0x00000020u,0x000000f0u,0x00050085u,0x00000009u,0x000000f2u,0x000000ebu,0x000000f1u,0x0003003eu,0x000000eau,0x000000f2u,0x0004003du,0x00000009u,0x000000f4u,0x000000bbu,0x0004003du,0x0000000au,0x000000f5u,0x000000d0u,0x0004003du,0x0000000au,0x000000f6u,0x000000d0u,0x00050094u,0x00000009u,0x000000f7u,0x000000f5u,0x000000f6u,0x0004003du,0x00000009u,0x000000f8u,0x000000d4u,0x00050081u,0x00000009u,0x000000f9u,0x000000f7u,0x000000f8u,0x0006000cu,0x00000009u,0x000000fau,0x00000001u,0x00000020u,0x000000f9u,0x00050085u,0x00000009u,0x000000fbu,0x000000f4u,0x000000fau,0x0003003eu,0x000000f3u,0x000000fbu,0x0004003du,0x00000009u,0x000000fdu,0x000000d8u,0x0004003du,0x00000009u,0x000000feu,0x000000e1u,0x00050081u,0x00000009u,0x000000ffu,0x000000fdu,0x000000feu,0x0004003du,0x00000009u,0x00000100u,0x000000eau,0x00050081u,0x00000009u,0x00000101u,0x000000ffu,0x00000100u,0x0004003du,0x00000009u,0x00000102u,0x000000f3u,0x00050081u,0x00000009u,0x00000103u,0x00000101u,0x00000102u,0x0003003eu,0x000000fcu,0x00000103u,0x0004003du,0x00000009u,0x00000105u,0x000000d8u,0x0004003du,0x0000000au,0x00000106u,0x0000006eu,0x0005008eu,0x0000000au,0x00000107u,0x00000106u,0x00000105u,0x0004003du,0x00000009u,0x00000108u,0x000000e1u,0x0004003du,0x0000000au,0x00000109u,0x00000077u,0x0005008eu,0x0000000au,0x0000010au,0x00000109u,0x00000108u,0x00050081u,0x0000000au,0x0000010bu,0x00000107u,0x0000010au,0x0004003du,0x00000009u,0x0000010cu,0x000000eau,0x0004003du,0x0000000au,0x0000010du,0x0000007fu,0x0005008eu,0x0000000au,0x0000010eu,0x0000010du,0x0000010cu,0x00050081u,0x0000000au,0x0000010fu,0x0000010bu,0x0000010eu,0x0004003du,0x00000009u,0x00000110u,0x000000f3u,0x0004003du,0x0000000au,0x00000111u,0x00000087u,0x0005008eu,0x0000000au,0x00000112u,0x00000111u,0x00000110u,0x00050081u,0x0000000au,0x00000113u,0x0000010fu,0x00000112u,0x0003003eu,0x00000104u,0x00000113u,0x0004003du,0x0000000au,0x00000115u,0x00000068u,0x0004003du,0x00000009u,0x00000116u,0x00000055u,0x0004003du,0x0000000au,0x00000117u,0x00000104u,0x0005008eu,0x0000000au,0x00000118u,0x00000117u,0x00000116u,0x00050081u,0x0000000au,0x00000119u,0x00000115u,0x00000118u,0x0004003du,0x00000009u,0x0000011au,0x00000055u,0x0004003du,0x00000009u,0x0000011bu,0x000000fcu,0x00050085u,0x00000009u,0x0000011cu,0x0000011au,0x0000011bu,0x00050081u,0x00000009u,0x0000011du,0x000000c2u,0x0000011cu,0x00070050u,0x0000000au,0x0000011eu,0x0000011du,0x0000011du,0x0000011du,0x0000011du,0x00050088u,0x0000000au,0x0000011fu,0x00000119u,0x0000011eu,0x0003003eu,0x00000114u,0x0000011fu,0x0004003du,0x00000016u,0x00000122u,0x00000121u,0x0004003du,0x00000007u,0x00000123u,0x00000034u,0x0004003du,0x00000007u,0x00000124u,0x0000003cu,0x00050050u,0x00000007u,0x00000125u,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x00000126u,0x00000124u,0x00000125u,0x0008000cu,0x00000007u,0x00000127u,0x00000001u,0x0000002du,0x00000123u,0x0000001cu,0x00000126u,0x00040064u,0x00000015u,0x00000128u,0x00000122u,0x0007005fu,0x0000000au,0x00000129u,0x00000128u,0x00000127u,0x00000002u,0x0000001bu,0x0003003eu,0x00000120u,0x00000129u,0x0004003du,0x00000009u,0x0000012bu,0x00000055u,0x0004003du,0x00000009u,0x0000012cu,0x0000005cu,0x00050085u,0x00000009u,0x0000012du,0x0000012bu,0x0000012cu,0x0003003eu,0x0000012au,0x0000012du,0x0004003du,0x0000000au,0x0000012fu,0x00000114u,0x0004003du,0x0000000au,0x00000130u,0x00000120u,0x00050083u,0x0000000au,0x00000131u,0x0000012fu,0x00000130u,0x0003003eu,0x0000012eu,0x00000131u,0x0004003du,0x0000000au,0x00000133u,0x00000114u,0x0004003du,0x0000000au,0x00000134u,0x0000012eu,0x0004003du,0x00000009u,0x00000135u,0x0000012au,0x0004007fu,0x00000009u,0x00000136u,0x00000135u,0x00070050u,0x0000000au,0x00000137u,0x00000136u,0x00000136u,0x00000136u,0x00000136u,0x0004003du,0x00000009u,0x00000138u,0x0000012au,0x00070050u,0x0000000au,0x00000139u,0x00000138u,0x00000138u,0x00000138u,0x00000138u,0x0008000cu,0x0000000au,0x0000013au,0x00000001u,0x0000002bu,0x00000134u,0x00000137u,0x00000139u,0x00050083u,0x0000000au,0x0000013bu,0x00000133u,0x0000013au,0x0003003eu,0x00000132u,0x0000013bu,0x0004003du,0x0000013cu,0x0000013fu,0x0000013eu,0x0004003du,0x00000007u,0x00000140u,0x00000034u,0x0004003du,0x0000000au,0x00000141u,0x00000132u,0x00040063u,0x0000013fu,0x00000140u,0x00000141u,0x000100fdu,0x00010038u,0x00050036u,0x0000000au,0x0000000eu,0x00000000u,0x0000000bu,0x00030037u,0x00000008u,0x0000000cu,0x00030037u,0x00000008u,0x0000000du,0x000200f8u,0x0000000fu,0x0004003du,0x00000016u,0x00000019u,0x00000018u,0x0004003du,0x00000007u,0x0000001au,0x0000000cu,0x0004003du,0x00000007u,0x0000001du,0x0000000du,0x00050050u,0x00000007u,0x0000001fu,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x00000020u,0x0000001du,0x0000001fu,0x0008000cu,0x00000007u,0x00000021u,0x00000001u,0x0000002du,0x0000001au,0x0000001cu,0x00000020u,0x00040064u,0x00000015u,0x00000022u,0x00000019u,0x0007005fu,0x0000000au,0x00000023u,0x00000022u,0x00000021u,0x00000002u,0x0000001bu,0x000200feu,0x00000023u,0x00010038u,0x00050036u,0x00000009u,0x00000013u,0x00000000u,0x00000010u,0x00030037u,0x00000008u,0x00000011u,0x00030037u,0x00000008u,0x00000012u,0x000200f8u,0x00000014u,0x0004003du,0x00000016u,0x00000027u,0x00000026u,0x0004003du,0x00000007u,0x00000028u,0x00000011u,0x0004003du,0x00000007u,0x00000029u,0x00000012u,0x00050050u,0x00000007u,0x0000002au,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x0000002bu,0x00000029u,0x0000002au,0x0008000cu,0x00000007u,0x0000002cu,0x00000001u,0x0000002du,0x00000028u,0x0000001cu,0x0000002bu,0x00040064u,0x00000015u,0x0000002du,0x00000027u,0x0007005fu,0x0000000au,0x0000002eu,0x0000002du,0x0000002cu,0x00000002u,0x0000001bu,0x00050051u,0x00000009u,0x00000031u,0x0000002eu,0x00000000u,0x000200feu,0x00000031u,0x00010038u}; static const size_t OF3_FLOWREG_LEN = 1936; -static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0x00000022u,0x00000000u,0x00040047u,0x00000207u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00030016u,0x00000006u,0x00000020u,0x00040017u,0x00000007u,0x00000006u,0x00000003u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040021u,0x00000009u,0x00000006u,0x00000008u,0x0004002bu,0x00000006u,0x0000000eu,0x3e59b3d0u,0x0004002bu,0x00000006u,0x0000000fu,0x3f371759u,0x0004002bu,0x00000006u,0x00000010u,0x3d93dd98u,0x0006002cu,0x00000007u,0x00000011u,0x0000000eu,0x0000000fu,0x00000010u,0x00040015u,0x00000015u,0x00000020u,0x00000001u,0x00040017u,0x00000016u,0x00000015u,0x00000002u,0x00040020u,0x00000017u,0x00000007u,0x00000016u,0x00040015u,0x00000019u,0x00000020u,0x00000000u,0x00040017u,0x0000001au,0x00000019u,0x00000003u,0x00040020u,0x0000001bu,0x00000001u,0x0000001au,0x0004003bu,0x0000001bu,0x0000001cu,0x00000001u,0x00040017u,0x0000001du,0x00000019u,0x00000002u,0x00090019u,0x00000022u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000004u,0x00040020u,0x00000023u,0x00000000u,0x00000022u,0x0004003bu,0x00000023u,0x00000024u,0x00000000u,0x00020014u,0x00000027u,0x0004002bu,0x00000019u,0x00000028u,0x00000000u,0x00040020u,0x00000029u,0x00000007u,0x00000015u,0x0004002bu,0x00000019u,0x00000032u,0x00000001u,0x00040017u,0x0000003cu,0x00000006u,0x00000002u,0x00040020u,0x0000003du,0x00000007u,0x0000003cu,0x0004002bu,0x00000006u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000006u,0x00000047u,0x3f000000u,0x00040017u,0x0000004cu,0x00000006u,0x00000004u,0x000b001eu,0x0000004du,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x0000004cu,0x00040020u,0x0000004eu,0x00000002u,0x0000004du,0x0004003bu,0x0000004eu,0x0000004fu,0x00000002u,0x0004002bu,0x00000015u,0x00000050u,0x00000008u,0x00040020u,0x00000051u,0x00000002u,0x00000006u,0x0004002bu,0x00000019u,0x00000054u,0x00000002u,0x0004002bu,0x00000019u,0x0000005cu,0x00000003u,0x00090019u,0x00000089u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000008au,0x00000089u,0x00040020u,0x0000008bu,0x00000000u,0x0000008au,0x0004003bu,0x0000008bu,0x0000008cu,0x00000000u,0x0004002bu,0x00000006u,0x0000008fu,0x00000000u,0x00040020u,0x00000097u,0x00000007u,0x00000027u,0x0004002bu,0x00000015u,0x00000099u,0x00000000u,0x0004002bu,0x00000006u,0x0000009cu,0x40600000u,0x00040020u,0x0000009eu,0x00000007u,0x00000006u,0x00040020u,0x000000a8u,0x00000007u,0x0000004cu,0x0004003bu,0x0000008bu,0x000000aau,0x00000000u,0x0004003bu,0x0000008bu,0x000000afu,0x00000000u,0x0004002bu,0x00000006u,0x000000cdu,0x3dcccccdu,0x0004002bu,0x00000015u,0x000000e2u,0x00000001u,0x0004003bu,0x0000008bu,0x000000f1u,0x00000000u,0x0004002bu,0x00000015u,0x0000014au,0x00000005u,0x0004002bu,0x00000015u,0x0000014fu,0x00000003u,0x0004002bu,0x00000015u,0x00000170u,0x00000002u,0x0004002bu,0x00000006u,0x00000189u,0x322bcc77u,0x0004002bu,0x00000006u,0x000001b2u,0x3d23d70au,0x0004002bu,0x00000006u,0x000001b3u,0x3e3851ecu,0x0004002bu,0x00000015u,0x000001bcu,0x00000004u,0x0004002bu,0x00000006u,0x000001d3u,0x3e800000u,0x0004002bu,0x00000006u,0x000001d8u,0x3e4ccccdu,0x0004002bu,0x00000006u,0x000001d9u,0x3ecccccdu,0x0004002bu,0x00000019u,0x00000206u,0x00000010u,0x0006002cu,0x0000001au,0x00000207u,0x00000206u,0x00000206u,0x00000032u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000017u,0x00000018u,0x00000007u,0x0004003bu,0x00000017u,0x00000021u,0x00000007u,0x0004003bu,0x0000003du,0x0000003eu,0x00000007u,0x0004003bu,0x0000003du,0x00000044u,0x00000007u,0x0004003bu,0x00000097u,0x00000098u,0x00000007u,0x0004003bu,0x0000009eu,0x0000009fu,0x00000007u,0x0004003bu,0x0000009eu,0x000000a1u,0x00000007u,0x0004003bu,0x000000a8u,0x000000a9u,0x00000007u,0x0004003bu,0x000000a8u,0x000000aeu,0x00000007u,0x0004003bu,0x0000003du,0x000000b3u,0x00000007u,0x0004003bu,0x0000003du,0x000000bau,0x00000007u,0x0004003bu,0x0000009eu,0x000000c1u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c5u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c9u,0x00000007u,0x0004003bu,0x0000009eu,0x000000d5u,0x00000007u,0x0004003bu,0x0000003du,0x000000e0u,0x00000007u,0x0004003bu,0x0000003du,0x000000e8u,0x00000007u,0x0004003bu,0x00000008u,0x000000f0u,0x00000007u,0x0004003bu,0x00000008u,0x000000f6u,0x00000007u,0x0004003bu,0x0000009eu,0x000000fbu,0x00000007u,0x0004003bu,0x00000008u,0x000000fcu,0x00000007u,0x0004003bu,0x00000008u,0x0000010cu,0x00000007u,0x0004003bu,0x0000009eu,0x00000111u,0x00000007u,0x0004003bu,0x00000008u,0x00000112u,0x00000007u,0x0004003bu,0x00000008u,0x00000122u,0x00000007u,0x0004003bu,0x0000003du,0x00000127u,0x00000007u,0x0004003bu,0x0000009eu,0x0000012bu,0x00000007u,0x0004003bu,0x0000009eu,0x00000138u,0x00000007u,0x0004003bu,0x0000009eu,0x00000149u,0x00000007u,0x0004003bu,0x0000009eu,0x00000157u,0x00000007u,0x0004003bu,0x0000009eu,0x00000163u,0x00000007u,0x0004003bu,0x0000009eu,0x00000167u,0x00000007u,0x0004003bu,0x0000009eu,0x0000016au,0x00000007u,0x0004003bu,0x0000009eu,0x0000016eu,0x00000007u,0x0004003bu,0x0000009eu,0x00000179u,0x00000007u,0x0004003bu,0x0000009eu,0x00000183u,0x00000007u,0x0004003bu,0x00000008u,0x00000187u,0x00000007u,0x0004003bu,0x00000008u,0x0000018bu,0x00000007u,0x0004003bu,0x00000008u,0x000001a0u,0x00000007u,0x0004003bu,0x0000009eu,0x000001adu,0x00000007u,0x0004003bu,0x0000009eu,0x000001b1u,0x00000007u,0x0004003bu,0x0000009eu,0x000001b7u,0x00000007u,0x0004003bu,0x00000008u,0x000001c3u,0x00000007u,0x0004003bu,0x00000008u,0x000001c8u,0x00000007u,0x0004003bu,0x0000009eu,0x000001cdu,0x00000007u,0x0004003bu,0x0000009eu,0x000001d2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001d7u,0x00000007u,0x0004003bu,0x0000009eu,0x000001dcu,0x00000007u,0x0004003bu,0x00000008u,0x000001e5u,0x00000007u,0x0004003bu,0x0000009eu,0x000001eeu,0x00000007u,0x0004003bu,0x0000009eu,0x000001f2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001f6u,0x00000007u,0x0004003du,0x0000001au,0x0000001eu,0x0000001cu,0x0007004fu,0x0000001du,0x0000001fu,0x0000001eu,0x0000001eu,0x00000000u,0x00000001u,0x0004007cu,0x00000016u,0x00000020u,0x0000001fu,0x0003003eu,0x00000018u,0x00000020u,0x0004003du,0x00000022u,0x00000025u,0x00000024u,0x00040068u,0x00000016u,0x00000026u,0x00000025u,0x0003003eu,0x00000021u,0x00000026u,0x00050041u,0x00000029u,0x0000002au,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000002bu,0x0000002au,0x00050041u,0x00000029u,0x0000002cu,0x00000021u,0x00000028u,0x0004003du,0x00000015u,0x0000002du,0x0000002cu,0x000500afu,0x00000027u,0x0000002eu,0x0000002bu,0x0000002du,0x000400a8u,0x00000027u,0x0000002fu,0x0000002eu,0x000300f7u,0x00000031u,0x00000000u,0x000400fau,0x0000002fu,0x00000030u,0x00000031u,0x000200f8u,0x00000030u,0x00050041u,0x00000029u,0x00000033u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000034u,0x00000033u,0x00050041u,0x00000029u,0x00000035u,0x00000021u,0x00000032u,0x0004003du,0x00000015u,0x00000036u,0x00000035u,0x000500afu,0x00000027u,0x00000037u,0x00000034u,0x00000036u,0x000200f9u,0x00000031u,0x000200f8u,0x00000031u,0x000700f5u,0x00000027u,0x00000038u,0x0000002eu,0x00000005u,0x00000037u,0x00000030u,0x000300f7u,0x0000003au,0x00000000u,0x000400fau,0x00000038u,0x00000039u,0x0000003au,0x000200f8u,0x00000039u,0x000100fdu,0x000200f8u,0x0000003au,0x0004003du,0x00000016u,0x00000040u,0x00000021u,0x0004006fu,0x0000003cu,0x00000041u,0x00000040u,0x00050050u,0x0000003cu,0x00000042u,0x0000003fu,0x0000003fu,0x00050088u,0x0000003cu,0x00000043u,0x00000042u,0x00000041u,0x0003003eu,0x0000003eu,0x00000043u,0x0004003du,0x00000016u,0x00000045u,0x00000018u,0x0004006fu,0x0000003cu,0x00000046u,0x00000045u,0x00050050u,0x0000003cu,0x00000048u,0x00000047u,0x00000047u,0x00050081u,0x0000003cu,0x00000049u,0x00000046u,0x00000048u,0x0004003du,0x0000003cu,0x0000004au,0x0000003eu,0x00050085u,0x0000003cu,0x0000004bu,0x00000049u,0x0000004au,0x0003003eu,0x00000044u,0x0000004bu,0x00060041u,0x00000051u,0x00000052u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000053u,0x00000052u,0x00060041u,0x00000051u,0x00000055u,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000056u,0x00000055u,0x000500b8u,0x00000027u,0x00000057u,0x00000053u,0x00000056u,0x000300f7u,0x00000059u,0x00000000u,0x000400fau,0x00000057u,0x00000058u,0x00000059u,0x000200f8u,0x00000058u,0x00060041u,0x00000051u,0x0000005au,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x0000005bu,0x0000005au,0x00060041u,0x00000051u,0x0000005du,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x0000005eu,0x0000005du,0x000500b8u,0x00000027u,0x0000005fu,0x0000005bu,0x0000005eu,0x000200f9u,0x00000059u,0x000200f8u,0x00000059u,0x000700f5u,0x00000027u,0x00000060u,0x00000057u,0x0000003au,0x0000005fu,0x00000058u,0x000300f7u,0x00000062u,0x00000000u,0x000400fau,0x00000060u,0x00000061u,0x00000062u,0x000200f8u,0x00000061u,0x00050041u,0x00000029u,0x00000063u,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x00000064u,0x00000063u,0x0004006fu,0x00000006u,0x00000065u,0x00000064u,0x00060041u,0x00000051u,0x00000066u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000067u,0x00000066u,0x000500beu,0x00000027u,0x00000068u,0x00000065u,0x00000067u,0x000200f9u,0x00000062u,0x000200f8u,0x00000062u,0x000700f5u,0x00000027u,0x00000069u,0x00000060u,0x00000059u,0x00000068u,0x00000061u,0x000300f7u,0x0000006bu,0x00000000u,0x000400fau,0x00000069u,0x0000006au,0x0000006bu,0x000200f8u,0x0000006au,0x00050041u,0x00000029u,0x0000006cu,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000006du,0x0000006cu,0x0004006fu,0x00000006u,0x0000006eu,0x0000006du,0x00060041u,0x00000051u,0x0000006fu,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000070u,0x0000006fu,0x000500b8u,0x00000027u,0x00000071u,0x0000006eu,0x00000070u,0x000200f9u,0x0000006bu,0x000200f8u,0x0000006bu,0x000700f5u,0x00000027u,0x00000072u,0x00000069u,0x00000062u,0x00000071u,0x0000006au,0x000300f7u,0x00000074u,0x00000000u,0x000400fau,0x00000072u,0x00000073u,0x00000074u,0x000200f8u,0x00000073u,0x00050041u,0x00000029u,0x00000075u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000076u,0x00000075u,0x0004006fu,0x00000006u,0x00000077u,0x00000076u,0x00060041u,0x00000051u,0x00000078u,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x00000079u,0x00000078u,0x000500beu,0x00000027u,0x0000007au,0x00000077u,0x00000079u,0x000200f9u,0x00000074u,0x000200f8u,0x00000074u,0x000700f5u,0x00000027u,0x0000007bu,0x00000072u,0x0000006bu,0x0000007au,0x00000073u,0x000300f7u,0x0000007du,0x00000000u,0x000400fau,0x0000007bu,0x0000007cu,0x0000007du,0x000200f8u,0x0000007cu,0x00050041u,0x00000029u,0x0000007eu,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x0000007fu,0x0000007eu,0x0004006fu,0x00000006u,0x00000080u,0x0000007fu,0x00060041u,0x00000051u,0x00000081u,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x00000082u,0x00000081u,0x000500b8u,0x00000027u,0x00000083u,0x00000080u,0x00000082u,0x000200f9u,0x0000007du,0x000200f8u,0x0000007du,0x000700f5u,0x00000027u,0x00000084u,0x0000007bu,0x00000074u,0x00000083u,0x0000007cu,0x000300f7u,0x00000086u,0x00000000u,0x000400fau,0x00000084u,0x00000085u,0x00000086u,0x000200f8u,0x00000085u,0x0004003du,0x00000022u,0x00000087u,0x00000024u,0x0004003du,0x00000016u,0x00000088u,0x00000018u,0x0004003du,0x0000008au,0x0000008du,0x0000008cu,0x0004003du,0x0000003cu,0x0000008eu,0x00000044u,0x00070058u,0x0000004cu,0x00000090u,0x0000008du,0x0000008eu,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000091u,0x00000090u,0x00000090u,0x00000000u,0x00000001u,0x00000002u,0x00050051u,0x00000006u,0x00000092u,0x00000091u,0x00000000u,0x00050051u,0x00000006u,0x00000093u,0x00000091u,0x00000001u,0x00050051u,0x00000006u,0x00000094u,0x00000091u,0x00000002u,0x00070050u,0x0000004cu,0x00000095u,0x00000092u,0x00000093u,0x00000094u,0x0000003fu,0x00040063u,0x00000087u,0x00000088u,0x00000095u,0x000100fdu,0x000200f8u,0x00000086u,0x00050041u,0x00000051u,0x0000009au,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000009bu,0x0000009au,0x000500bau,0x00000027u,0x0000009du,0x0000009bu,0x0000009cu,0x0003003eu,0x00000098u,0x0000009du,0x0004003du,0x00000027u,0x000000a0u,0x00000098u,0x000300f7u,0x000000a3u,0x00000000u,0x000400fau,0x000000a0u,0x000000a2u,0x000000a4u,0x000200f8u,0x000000a2u,0x0003003eu,0x000000a1u,0x0000003fu,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a4u,0x00050041u,0x00000051u,0x000000a5u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x000000a6u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a6u,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a3u,0x0004003du,0x00000006u,0x000000a7u,0x000000a1u,0x0003003eu,0x0000009fu,0x000000a7u,0x0004003du,0x0000008au,0x000000abu,0x000000aau,0x0004003du,0x0000003cu,0x000000acu,0x00000044u,0x00070058u,0x0000004cu,0x000000adu,0x000000abu,0x000000acu,0x00000002u,0x0000008fu,0x0003003eu,0x000000a9u,0x000000adu,0x0004003du,0x0000008au,0x000000b0u,0x000000afu,0x0004003du,0x0000003cu,0x000000b1u,0x00000044u,0x00070058u,0x0000004cu,0x000000b2u,0x000000b0u,0x000000b1u,0x00000002u,0x0000008fu,0x0003003eu,0x000000aeu,0x000000b2u,0x0004003du,0x0000004cu,0x000000b4u,0x000000a9u,0x0007004fu,0x0000003cu,0x000000b5u,0x000000b4u,0x000000b4u,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000b6u,0x0000009fu,0x0005008eu,0x0000003cu,0x000000b7u,0x000000b5u,0x000000b6u,0x0004003du,0x0000003cu,0x000000b8u,0x0000003eu,0x00050085u,0x0000003cu,0x000000b9u,0x000000b7u,0x000000b8u,0x0003003eu,0x000000b3u,0x000000b9u,0x0004003du,0x0000004cu,0x000000bbu,0x000000aeu,0x0007004fu,0x0000003cu,0x000000bcu,0x000000bbu,0x000000bbu,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000bdu,0x0000009fu,0x0005008eu,0x0000003cu,0x000000beu,0x000000bcu,0x000000bdu,0x0004003du,0x0000003cu,0x000000bfu,0x0000003eu,0x00050085u,0x0000003cu,0x000000c0u,0x000000beu,0x000000bfu,0x0003003eu,0x000000bau,0x000000c0u,0x00050041u,0x0000009eu,0x000000c2u,0x000000a9u,0x0000005cu,0x0004003du,0x00000006u,0x000000c3u,0x000000c2u,0x0008000cu,0x00000006u,0x000000c4u,0x00000001u,0x0000002bu,0x000000c3u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c1u,0x000000c4u,0x00050041u,0x0000009eu,0x000000c6u,0x000000aeu,0x0000005cu,0x0004003du,0x00000006u,0x000000c7u,0x000000c6u,0x0008000cu,0x00000006u,0x000000c8u,0x00000001u,0x0000002bu,0x000000c7u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c5u,0x000000c8u,0x0004003du,0x0000003cu,0x000000cau,0x000000b3u,0x0006000cu,0x00000006u,0x000000cbu,0x00000001u,0x00000042u,0x000000cau,0x0003003eu,0x000000c9u,0x000000cbu,0x0004003du,0x00000006u,0x000000ccu,0x000000c9u,0x000500bau,0x00000027u,0x000000ceu,0x000000ccu,0x000000cdu,0x000300f7u,0x000000d0u,0x00000000u,0x000400fau,0x000000ceu,0x000000cfu,0x000000d0u,0x000200f8u,0x000000cfu,0x0004003du,0x00000006u,0x000000d1u,0x000000c9u,0x00050088u,0x00000006u,0x000000d2u,0x000000cdu,0x000000d1u,0x0004003du,0x0000003cu,0x000000d3u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000d4u,0x000000d3u,0x000000d2u,0x0003003eu,0x000000b3u,0x000000d4u,0x000200f9u,0x000000d0u,0x000200f8u,0x000000d0u,0x0004003du,0x0000003cu,0x000000d6u,0x000000bau,0x0006000cu,0x00000006u,0x000000d7u,0x00000001u,0x00000042u,0x000000d6u,0x0003003eu,0x000000d5u,0x000000d7u,0x0004003du,0x00000006u,0x000000d8u,0x000000d5u,0x000500bau,0x00000027u,0x000000d9u,0x000000d8u,0x000000cdu,0x000300f7u,0x000000dbu,0x00000000u,0x000400fau,0x000000d9u,0x000000dau,0x000000dbu,0x000200f8u,0x000000dau,0x0004003du,0x00000006u,0x000000dcu,0x000000d5u,0x00050088u,0x00000006u,0x000000ddu,0x000000cdu,0x000000dcu,0x0004003du,0x0000003cu,0x000000deu,0x000000bau,0x0005008eu,0x0000003cu,0x000000dfu,0x000000deu,0x000000ddu,0x0003003eu,0x000000bau,0x000000dfu,0x000200f9u,0x000000dbu,0x000200f8u,0x000000dbu,0x0004003du,0x0000003cu,0x000000e1u,0x00000044u,0x00050041u,0x00000051u,0x000000e3u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000e4u,0x000000e3u,0x0004003du,0x0000003cu,0x000000e5u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000e6u,0x000000e5u,0x000000e4u,0x00050083u,0x0000003cu,0x000000e7u,0x000000e1u,0x000000e6u,0x0003003eu,0x000000e0u,0x000000e7u,0x0004003du,0x0000003cu,0x000000e9u,0x00000044u,0x00050041u,0x00000051u,0x000000eau,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000ebu,0x000000eau,0x00050083u,0x00000006u,0x000000ecu,0x0000003fu,0x000000ebu,0x0004003du,0x0000003cu,0x000000edu,0x000000bau,0x0005008eu,0x0000003cu,0x000000eeu,0x000000edu,0x000000ecu,0x00050083u,0x0000003cu,0x000000efu,0x000000e9u,0x000000eeu,0x0003003eu,0x000000e8u,0x000000efu,0x0004003du,0x0000008au,0x000000f2u,0x000000f1u,0x0004003du,0x0000003cu,0x000000f3u,0x000000e0u,0x00070058u,0x0000004cu,0x000000f4u,0x000000f2u,0x000000f3u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000f5u,0x000000f4u,0x000000f4u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f0u,0x000000f5u,0x0004003du,0x0000008au,0x000000f7u,0x0000008cu,0x0004003du,0x0000003cu,0x000000f8u,0x000000e8u,0x00070058u,0x0000004cu,0x000000f9u,0x000000f7u,0x000000f8u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000fau,0x000000f9u,0x000000f9u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f6u,0x000000fau,0x0004003du,0x00000007u,0x000000fdu,0x000000f0u,0x0003003eu,0x000000fcu,0x000000fdu,0x00050039u,0x00000006u,0x000000feu,0x0000000bu,0x000000fcu,0x0004003du,0x0000008au,0x000000ffu,0x0000008cu,0x0004003du,0x0000003cu,0x00000100u,0x000000e0u,0x0004003du,0x0000008au,0x00000101u,0x000000aau,0x0004003du,0x0000003cu,0x00000102u,0x000000e0u,0x00070058u,0x0000004cu,0x00000103u,0x00000101u,0x00000102u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000104u,0x00000103u,0x00000103u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000105u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000106u,0x00000105u,0x0005008eu,0x0000003cu,0x00000107u,0x00000104u,0x00000106u,0x0004003du,0x0000003cu,0x00000108u,0x0000003eu,0x00050085u,0x0000003cu,0x00000109u,0x00000107u,0x00000108u,0x00050081u,0x0000003cu,0x0000010au,0x00000100u,0x00000109u,0x00070058u,0x0000004cu,0x0000010bu,0x000000ffu,0x0000010au,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x0000010du,0x0000010bu,0x0000010bu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000010cu,0x0000010du,0x00050039u,0x00000006u,0x0000010eu,0x0000000bu,0x0000010cu,0x00050083u,0x00000006u,0x0000010fu,0x000000feu,0x0000010eu,0x0006000cu,0x00000006u,0x00000110u,0x00000001u,0x00000004u,0x0000010fu,0x0003003eu,0x000000fbu,0x00000110u,0x0004003du,0x00000007u,0x00000113u,0x000000f6u,0x0003003eu,0x00000112u,0x00000113u,0x00050039u,0x00000006u,0x00000114u,0x0000000bu,0x00000112u,0x0004003du,0x0000008au,0x00000115u,0x000000f1u,0x0004003du,0x0000003cu,0x00000116u,0x000000e8u,0x0004003du,0x0000008au,0x00000117u,0x000000afu,0x0004003du,0x0000003cu,0x00000118u,0x000000e8u,0x00070058u,0x0000004cu,0x00000119u,0x00000117u,0x00000118u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x0000011au,0x00000119u,0x00000119u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x0000011bu,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000011cu,0x0000011bu,0x0005008eu,0x0000003cu,0x0000011du,0x0000011au,0x0000011cu,0x0004003du,0x0000003cu,0x0000011eu,0x0000003eu,0x00050085u,0x0000003cu,0x0000011fu,0x0000011du,0x0000011eu,0x00050081u,0x0000003cu,0x00000120u,0x00000116u,0x0000011fu,0x00070058u,0x0000004cu,0x00000121u,0x00000115u,0x00000120u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000123u,0x00000121u,0x00000121u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x00000122u,0x00000123u,0x00050039u,0x00000006u,0x00000124u,0x0000000bu,0x00000122u,0x00050083u,0x00000006u,0x00000125u,0x00000114u,0x00000124u,0x0006000cu,0x00000006u,0x00000126u,0x00000001u,0x00000004u,0x00000125u,0x0003003eu,0x00000111u,0x00000126u,0x0004003du,0x0000003cu,0x00000128u,0x00000044u,0x0004003du,0x0000003cu,0x00000129u,0x000000b3u,0x00050081u,0x0000003cu,0x0000012au,0x00000128u,0x00000129u,0x0003003eu,0x00000127u,0x0000012au,0x0004003du,0x0000003cu,0x0000012cu,0x000000b3u,0x0004003du,0x0000008au,0x0000012du,0x000000afu,0x0004003du,0x0000003cu,0x0000012eu,0x00000127u,0x00070058u,0x0000004cu,0x0000012fu,0x0000012du,0x0000012eu,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000130u,0x0000012fu,0x0000012fu,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000131u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000132u,0x00000131u,0x0005008eu,0x0000003cu,0x00000133u,0x00000130u,0x00000132u,0x0004003du,0x0000003cu,0x00000134u,0x0000003eu,0x00050085u,0x0000003cu,0x00000135u,0x00000133u,0x00000134u,0x00050081u,0x0000003cu,0x00000136u,0x0000012cu,0x00000135u,0x0006000cu,0x00000006u,0x00000137u,0x00000001u,0x00000042u,0x00000136u,0x0003003eu,0x0000012bu,0x00000137u,0x0004003du,0x00000006u,0x00000139u,0x000000c1u,0x0004003du,0x00000006u,0x0000013au,0x000000c5u,0x0007000cu,0x00000006u,0x0000013bu,0x00000001u,0x00000025u,0x00000139u,0x0000013au,0x0004003du,0x00000006u,0x0000013cu,0x0000012bu,0x0004007fu,0x00000006u,0x0000013du,0x0000013cu,0x0004003du,0x0000003cu,0x0000013eu,0x000000b3u,0x0006000cu,0x00000006u,0x0000013fu,0x00000001u,0x00000042u,0x0000013eu,0x0004003du,0x0000003cu,0x00000140u,0x000000bau,0x0006000cu,0x00000006u,0x00000141u,0x00000001u,0x00000042u,0x00000140u,0x00050081u,0x00000006u,0x00000142u,0x0000013fu,0x00000141u,0x00050041u,0x0000009eu,0x00000143u,0x0000003eu,0x00000028u,0x0004003du,0x00000006u,0x00000144u,0x00000143u,0x00050081u,0x00000006u,0x00000145u,0x00000142u,0x00000144u,0x00050088u,0x00000006u,0x00000146u,0x0000013du,0x00000145u,0x0006000cu,0x00000006u,0x00000147u,0x00000001u,0x0000001bu,0x00000146u,0x00050085u,0x00000006u,0x00000148u,0x0000013bu,0x00000147u,0x0003003eu,0x00000138u,0x00000148u,0x00050041u,0x00000051u,0x0000014bu,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x0000014cu,0x0000014bu,0x0004003du,0x00000006u,0x0000014du,0x000000fbu,0x00050085u,0x00000006u,0x0000014eu,0x0000014cu,0x0000014du,0x00050041u,0x00000051u,0x00000150u,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x00000151u,0x00000150u,0x0004003du,0x00000006u,0x00000152u,0x00000138u,0x00050083u,0x00000006u,0x00000153u,0x0000003fu,0x00000152u,0x00050085u,0x00000006u,0x00000154u,0x00000151u,0x00000153u,0x00050081u,0x00000006u,0x00000155u,0x0000014eu,0x00000154u,0x0004007fu,0x00000006u,0x00000156u,0x00000155u,0x0003003eu,0x00000149u,0x00000156u,0x00050041u,0x00000051u,0x00000158u,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x00000159u,0x00000158u,0x0004003du,0x00000006u,0x0000015au,0x00000111u,0x00050085u,0x00000006u,0x0000015bu,0x00000159u,0x0000015au,0x00050041u,0x00000051u,0x0000015cu,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x0000015du,0x0000015cu,0x0004003du,0x00000006u,0x0000015eu,0x00000138u,0x00050083u,0x00000006u,0x0000015fu,0x0000003fu,0x0000015eu,0x00050085u,0x00000006u,0x00000160u,0x0000015du,0x0000015fu,0x00050081u,0x00000006u,0x00000161u,0x0000015bu,0x00000160u,0x0004007fu,0x00000006u,0x00000162u,0x00000161u,0x0003003eu,0x00000157u,0x00000162u,0x00050041u,0x00000051u,0x00000164u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000165u,0x00000164u,0x00050083u,0x00000006u,0x00000166u,0x0000003fu,0x00000165u,0x0003003eu,0x00000163u,0x00000166u,0x00050041u,0x00000051u,0x00000168u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000169u,0x00000168u,0x0003003eu,0x00000167u,0x00000169u,0x0004003du,0x00000006u,0x0000016bu,0x00000149u,0x0004003du,0x00000006u,0x0000016cu,0x00000157u,0x0007000cu,0x00000006u,0x0000016du,0x00000001u,0x00000028u,0x0000016bu,0x0000016cu,0x0003003eu,0x0000016au,0x0000016du,0x0004003du,0x00000006u,0x0000016fu,0x00000163u,0x00050041u,0x00000051u,0x00000171u,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x00000172u,0x00000171u,0x0004003du,0x00000006u,0x00000173u,0x00000149u,0x0004003du,0x00000006u,0x00000174u,0x0000016au,0x00050083u,0x00000006u,0x00000175u,0x00000173u,0x00000174u,0x00050085u,0x00000006u,0x00000176u,0x00000172u,0x00000175u,0x0006000cu,0x00000006u,0x00000177u,0x00000001u,0x0000001bu,0x00000176u,0x00050085u,0x00000006u,0x00000178u,0x0000016fu,0x00000177u,0x0003003eu,0x0000016eu,0x00000178u,0x0004003du,0x00000006u,0x0000017au,0x00000167u,0x00050041u,0x00000051u,0x0000017bu,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x0000017cu,0x0000017bu,0x0004003du,0x00000006u,0x0000017du,0x00000157u,0x0004003du,0x00000006u,0x0000017eu,0x0000016au,0x00050083u,0x00000006u,0x0000017fu,0x0000017du,0x0000017eu,0x00050085u,0x00000006u,0x00000180u,0x0000017cu,0x0000017fu,0x0006000cu,0x00000006u,0x00000181u,0x00000001u,0x0000001bu,0x00000180u,0x00050085u,0x00000006u,0x00000182u,0x0000017au,0x00000181u,0x0003003eu,0x00000179u,0x00000182u,0x0004003du,0x00000006u,0x00000184u,0x0000016eu,0x0004003du,0x00000006u,0x00000185u,0x00000179u,0x00050081u,0x00000006u,0x00000186u,0x00000184u,0x00000185u,0x0003003eu,0x00000183u,0x00000186u,0x0004003du,0x00000006u,0x00000188u,0x00000183u,0x000500bau,0x00000027u,0x0000018au,0x00000188u,0x00000189u,0x000300f7u,0x0000018du,0x00000000u,0x000400fau,0x0000018au,0x0000018cu,0x00000198u,0x000200f8u,0x0000018cu,0x0004003du,0x00000006u,0x0000018eu,0x0000016eu,0x0004003du,0x00000007u,0x0000018fu,0x000000f0u,0x0005008eu,0x00000007u,0x00000190u,0x0000018fu,0x0000018eu,0x0004003du,0x00000006u,0x00000191u,0x00000179u,0x0004003du,0x00000007u,0x00000192u,0x000000f6u,0x0005008eu,0x00000007u,0x00000193u,0x00000192u,0x00000191u,0x00050081u,0x00000007u,0x00000194u,0x00000190u,0x00000193u,0x0004003du,0x00000006u,0x00000195u,0x00000183u,0x00060050u,0x00000007u,0x00000196u,0x00000195u,0x00000195u,0x00000195u,0x00050088u,0x00000007u,0x00000197u,0x00000194u,0x00000196u,0x0003003eu,0x0000018bu,0x00000197u,0x000200f9u,0x0000018du,0x000200f8u,0x00000198u,0x0004003du,0x00000007u,0x00000199u,0x000000f0u,0x0004003du,0x00000007u,0x0000019au,0x000000f6u,0x00050041u,0x00000051u,0x0000019bu,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x0000019cu,0x0000019bu,0x00060050u,0x00000007u,0x0000019du,0x0000019cu,0x0000019cu,0x0000019cu,0x0008000cu,0x00000007u,0x0000019eu,0x00000001u,0x0000002eu,0x00000199u,0x0000019au,0x0000019du,0x0003003eu,0x0000018bu,0x0000019eu,0x000200f9u,0x0000018du,0x000200f8u,0x0000018du,0x0004003du,0x00000007u,0x0000019fu,0x0000018bu,0x0003003eu,0x00000187u,0x0000019fu,0x0004003du,0x0000008au,0x000001a1u,0x000000f1u,0x0004003du,0x0000003cu,0x000001a2u,0x00000044u,0x00070058u,0x0000004cu,0x000001a3u,0x000001a1u,0x000001a2u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a4u,0x000001a3u,0x000001a3u,0x00000000u,0x00000001u,0x00000002u,0x0004003du,0x0000008au,0x000001a5u,0x0000008cu,0x0004003du,0x0000003cu,0x000001a6u,0x00000044u,0x00070058u,0x0000004cu,0x000001a7u,0x000001a5u,0x000001a6u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a8u,0x000001a7u,0x000001a7u,0x00000000u,0x00000001u,0x00000002u,0x00050041u,0x00000051u,0x000001a9u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000001aau,0x000001a9u,0x00060050u,0x00000007u,0x000001abu,0x000001aau,0x000001aau,0x000001aau,0x0008000cu,0x00000007u,0x000001acu,0x00000001u,0x0000002eu,0x000001a4u,0x000001a8u,0x000001abu,0x0003003eu,0x000001a0u,0x000001acu,0x0004003du,0x00000006u,0x000001aeu,0x000000fbu,0x0004003du,0x00000006u,0x000001afu,0x00000111u,0x0007000cu,0x00000006u,0x000001b0u,0x00000001u,0x00000025u,0x000001aeu,0x000001afu,0x0003003eu,0x000001adu,0x000001b0u,0x0004003du,0x00000006u,0x000001b4u,0x000001adu,0x0008000cu,0x00000006u,0x000001b5u,0x00000001u,0x00000031u,0x000001b2u,0x000001b3u,0x000001b4u,0x00050083u,0x00000006u,0x000001b6u,0x0000003fu,0x000001b5u,0x0003003eu,0x000001b1u,0x000001b6u,0x0004003du,0x00000006u,0x000001b8u,0x00000138u,0x0008000cu,0x00000006u,0x000001b9u,0x00000001u,0x0000002bu,0x000001b8u,0x0000008fu,0x0000003fu,0x0004003du,0x00000006u,0x000001bau,0x000001b1u,0x00050085u,0x00000006u,0x000001bbu,0x000001b9u,0x000001bau,0x0003003eu,0x000001b7u,0x000001bbu,0x00050041u,0x00000051u,0x000001bdu,0x0000004fu,0x000001bcu,0x0004003du,0x00000006u,0x000001beu,0x000001bdu,0x0004003du,0x00000006u,0x000001bfu,0x00000183u,0x0008000cu,0x00000006u,0x000001c0u,0x00000001u,0x00000031u,0x0000008fu,0x000001beu,0x000001bfu,0x0004003du,0x00000006u,0x000001c1u,0x000001b7u,0x00050085u,0x00000006u,0x000001c2u,0x000001c1u,0x000001c0u,0x0003003eu,0x000001b7u,0x000001c2u,0x0004003du,0x0000008au,0x000001c4u,0x000000f1u,0x0004003du,0x0000003cu,0x000001c5u,0x00000044u,0x00070058u,0x0000004cu,0x000001c6u,0x000001c4u,0x000001c5u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001c7u,0x000001c6u,0x000001c6u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c3u,0x000001c7u,0x0004003du,0x0000008au,0x000001c9u,0x0000008cu,0x0004003du,0x0000003cu,0x000001cau,0x00000044u,0x00070058u,0x0000004cu,0x000001cbu,0x000001c9u,0x000001cau,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001ccu,0x000001cbu,0x000001cbu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c8u,0x000001ccu,0x0004003du,0x00000007u,0x000001ceu,0x000001c8u,0x0004003du,0x00000007u,0x000001cfu,0x000001c3u,0x00050083u,0x00000007u,0x000001d0u,0x000001ceu,0x000001cfu,0x0006000cu,0x00000006u,0x000001d1u,0x00000001u,0x00000042u,0x000001d0u,0x0003003eu,0x000001cdu,0x000001d1u,0x0004003du,0x00000006u,0x000001d4u,0x000001b7u,0x0008000cu,0x00000006u,0x000001d5u,0x00000001u,0x00000031u,0x000000cdu,0x000001d3u,0x000001d4u,0x00050083u,0x00000006u,0x000001d6u,0x0000003fu,0x000001d5u,0x0003003eu,0x000001d2u,0x000001d6u,0x0004003du,0x00000006u,0x000001dau,0x000001cdu,0x0008000cu,0x00000006u,0x000001dbu,0x00000001u,0x00000031u,0x000001d8u,0x000001d9u,0x000001dau,0x0003003eu,0x000001d7u,0x000001dbu,0x0004003du,0x00000006u,0x000001ddu,0x000001d2u,0x0004003du,0x00000006u,0x000001deu,0x000001d7u,0x00050085u,0x00000006u,0x000001dfu,0x000001ddu,0x000001deu,0x0003003eu,0x000001dcu,0x000001dfu,0x0004003du,0x00000007u,0x000001e0u,0x000001a0u,0x0004003du,0x00000007u,0x000001e1u,0x000001c8u,0x0004003du,0x00000006u,0x000001e2u,0x000001dcu,0x00060050u,0x00000007u,0x000001e3u,0x000001e2u,0x000001e2u,0x000001e2u,0x0008000cu,0x00000007u,0x000001e4u,0x00000001u,0x0000002eu,0x000001e0u,0x000001e1u,0x000001e3u,0x0003003eu,0x000001a0u,0x000001e4u,0x0004003du,0x00000007u,0x000001e6u,0x000001a0u,0x0004003du,0x00000007u,0x000001e7u,0x00000187u,0x0004003du,0x00000006u,0x000001e8u,0x000001b7u,0x00060050u,0x00000007u,0x000001e9u,0x000001e8u,0x000001e8u,0x000001e8u,0x0008000cu,0x00000007u,0x000001eau,0x00000001u,0x0000002eu,0x000001e6u,0x000001e7u,0x000001e9u,0x0003003eu,0x000001e5u,0x000001eau,0x0004003du,0x00000027u,0x000001ebu,0x00000098u,0x000300f7u,0x000001edu,0x00000000u,0x000400fau,0x000001ebu,0x000001ecu,0x000001edu,0x000200f8u,0x000001ecu,0x0004003du,0x00000006u,0x000001efu,0x000000c9u,0x0004003du,0x00000006u,0x000001f0u,0x000000d5u,0x0007000cu,0x00000006u,0x000001f1u,0x00000001u,0x00000028u,0x000001efu,0x000001f0u,0x0003003eu,0x000001eeu,0x000001f1u,0x0004003du,0x00000006u,0x000001f3u,0x000001eeu,0x00050088u,0x00000006u,0x000001f4u,0x000001f3u,0x000000cdu,0x0008000cu,0x00000006u,0x000001f5u,0x00000001u,0x0000002bu,0x000001f4u,0x0000008fu,0x0000003fu,0x0003003eu,0x000001f2u,0x000001f5u,0x0004003du,0x00000006u,0x000001f7u,0x000001eeu,0x0007000cu,0x00000006u,0x000001f8u,0x00000001u,0x00000030u,0x000000cdu,0x000001f7u,0x0003003eu,0x000001f6u,0x000001f8u,0x0004003du,0x00000022u,0x000001f9u,0x00000024u,0x0004003du,0x00000016u,0x000001fau,0x00000018u,0x0004003du,0x00000006u,0x000001fbu,0x000001f6u,0x0004003du,0x00000006u,0x000001fcu,0x000001f2u,0x00070050u,0x0000004cu,0x000001fdu,0x000001fbu,0x000001fcu,0x0000008fu,0x0000003fu,0x00040063u,0x000001f9u,0x000001fau,0x000001fdu,0x000100fdu,0x000200f8u,0x000001edu,0x0004003du,0x00000022u,0x000001ffu,0x00000024u,0x0004003du,0x00000016u,0x00000200u,0x00000018u,0x0004003du,0x00000007u,0x00000201u,0x000001e5u,0x00050051u,0x00000006u,0x00000202u,0x00000201u,0x00000000u,0x00050051u,0x00000006u,0x00000203u,0x00000201u,0x00000001u,0x00050051u,0x00000006u,0x00000204u,0x00000201u,0x00000002u,0x00070050u,0x0000004cu,0x00000205u,0x00000202u,0x00000203u,0x00000204u,0x0000003fu,0x00040063u,0x000001ffu,0x00000200u,0x00000205u,0x000100fdu,0x00010038u,0x00050036u,0x00000006u,0x0000000bu,0x00000000u,0x00000009u,0x00030037u,0x00000008u,0x0000000au,0x000200f8u,0x0000000cu,0x0004003du,0x00000007u,0x0000000du,0x0000000au,0x00050094u,0x00000006u,0x00000012u,0x0000000du,0x00000011u,0x000200feu,0x00000012u,0x00010038u}; -static const size_t WFG_SYNTH_LEN = 3082; +static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0x00000022u,0x00000000u,0x00040047u,0x00000207u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00030016u,0x00000006u,0x00000020u,0x00040017u,0x00000007u,0x00000006u,0x00000003u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040021u,0x00000009u,0x00000006u,0x00000008u,0x0004002bu,0x00000006u,0x0000000eu,0x3e59b3d0u,0x0004002bu,0x00000006u,0x0000000fu,0x3f371759u,0x0004002bu,0x00000006u,0x00000010u,0x3d93dd98u,0x0006002cu,0x00000007u,0x00000011u,0x0000000eu,0x0000000fu,0x00000010u,0x00040015u,0x00000015u,0x00000020u,0x00000001u,0x00040017u,0x00000016u,0x00000015u,0x00000002u,0x00040020u,0x00000017u,0x00000007u,0x00000016u,0x00040015u,0x00000019u,0x00000020u,0x00000000u,0x00040017u,0x0000001au,0x00000019u,0x00000003u,0x00040020u,0x0000001bu,0x00000001u,0x0000001au,0x0004003bu,0x0000001bu,0x0000001cu,0x00000001u,0x00040017u,0x0000001du,0x00000019u,0x00000002u,0x00090019u,0x00000022u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000004u,0x00040020u,0x00000023u,0x00000000u,0x00000022u,0x0004003bu,0x00000023u,0x00000024u,0x00000000u,0x00020014u,0x00000027u,0x0004002bu,0x00000019u,0x00000028u,0x00000000u,0x00040020u,0x00000029u,0x00000007u,0x00000015u,0x0004002bu,0x00000019u,0x00000032u,0x00000001u,0x00040017u,0x0000003cu,0x00000006u,0x00000002u,0x00040020u,0x0000003du,0x00000007u,0x0000003cu,0x0004002bu,0x00000006u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000006u,0x00000047u,0x3f000000u,0x00040017u,0x0000004cu,0x00000006u,0x00000004u,0x000b001eu,0x0000004du,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x0000004cu,0x00040020u,0x0000004eu,0x00000002u,0x0000004du,0x0004003bu,0x0000004eu,0x0000004fu,0x00000002u,0x0004002bu,0x00000015u,0x00000050u,0x00000008u,0x00040020u,0x00000051u,0x00000002u,0x00000006u,0x0004002bu,0x00000019u,0x00000054u,0x00000002u,0x0004002bu,0x00000019u,0x0000005cu,0x00000003u,0x00090019u,0x00000089u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000008au,0x00000089u,0x00040020u,0x0000008bu,0x00000000u,0x0000008au,0x0004003bu,0x0000008bu,0x0000008cu,0x00000000u,0x0004002bu,0x00000006u,0x0000008fu,0x00000000u,0x00040020u,0x00000097u,0x00000007u,0x00000027u,0x0004002bu,0x00000015u,0x00000099u,0x00000000u,0x0004002bu,0x00000006u,0x0000009cu,0x40600000u,0x00040020u,0x0000009eu,0x00000007u,0x00000006u,0x00040020u,0x000000a8u,0x00000007u,0x0000004cu,0x0004003bu,0x0000008bu,0x000000aau,0x00000000u,0x0004003bu,0x0000008bu,0x000000afu,0x00000000u,0x0004002bu,0x00000006u,0x000000cdu,0x3dcccccdu,0x0004002bu,0x00000015u,0x000000e2u,0x00000001u,0x0004003bu,0x0000008bu,0x000000f1u,0x00000000u,0x0004002bu,0x00000015u,0x0000014au,0x00000005u,0x0004002bu,0x00000015u,0x0000014fu,0x00000003u,0x0004002bu,0x00000015u,0x00000170u,0x00000002u,0x0004002bu,0x00000006u,0x00000189u,0x322bcc77u,0x0004002bu,0x00000006u,0x000001b2u,0x3d23d70au,0x0004002bu,0x00000006u,0x000001b3u,0x3e3851ecu,0x0004002bu,0x00000015u,0x000001bcu,0x00000004u,0x0004002bu,0x00000006u,0x000001d3u,0x3e800000u,0x0004002bu,0x00000006u,0x000001d8u,0x3e4ccccdu,0x0004002bu,0x00000006u,0x000001d9u,0x3ecccccdu,0x0004002bu,0x00000019u,0x00000206u,0x00000010u,0x0006002cu,0x0000001au,0x00000207u,0x00000206u,0x00000206u,0x00000032u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000017u,0x00000018u,0x00000007u,0x0004003bu,0x00000017u,0x00000021u,0x00000007u,0x0004003bu,0x0000003du,0x0000003eu,0x00000007u,0x0004003bu,0x0000003du,0x00000044u,0x00000007u,0x0004003bu,0x00000097u,0x00000098u,0x00000007u,0x0004003bu,0x0000009eu,0x0000009fu,0x00000007u,0x0004003bu,0x0000009eu,0x000000a1u,0x00000007u,0x0004003bu,0x000000a8u,0x000000a9u,0x00000007u,0x0004003bu,0x000000a8u,0x000000aeu,0x00000007u,0x0004003bu,0x0000003du,0x000000b3u,0x00000007u,0x0004003bu,0x0000003du,0x000000bau,0x00000007u,0x0004003bu,0x0000009eu,0x000000c1u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c5u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c9u,0x00000007u,0x0004003bu,0x0000009eu,0x000000d5u,0x00000007u,0x0004003bu,0x0000003du,0x000000e0u,0x00000007u,0x0004003bu,0x0000003du,0x000000e8u,0x00000007u,0x0004003bu,0x00000008u,0x000000f0u,0x00000007u,0x0004003bu,0x00000008u,0x000000f6u,0x00000007u,0x0004003bu,0x0000009eu,0x000000fbu,0x00000007u,0x0004003bu,0x00000008u,0x000000fcu,0x00000007u,0x0004003bu,0x00000008u,0x0000010cu,0x00000007u,0x0004003bu,0x0000009eu,0x00000111u,0x00000007u,0x0004003bu,0x00000008u,0x00000112u,0x00000007u,0x0004003bu,0x00000008u,0x00000122u,0x00000007u,0x0004003bu,0x0000003du,0x00000127u,0x00000007u,0x0004003bu,0x0000009eu,0x0000012bu,0x00000007u,0x0004003bu,0x0000009eu,0x00000138u,0x00000007u,0x0004003bu,0x0000009eu,0x00000149u,0x00000007u,0x0004003bu,0x0000009eu,0x00000157u,0x00000007u,0x0004003bu,0x0000009eu,0x00000163u,0x00000007u,0x0004003bu,0x0000009eu,0x00000167u,0x00000007u,0x0004003bu,0x0000009eu,0x0000016au,0x00000007u,0x0004003bu,0x0000009eu,0x0000016eu,0x00000007u,0x0004003bu,0x0000009eu,0x00000179u,0x00000007u,0x0004003bu,0x0000009eu,0x00000183u,0x00000007u,0x0004003bu,0x00000008u,0x00000187u,0x00000007u,0x0004003bu,0x00000008u,0x0000018bu,0x00000007u,0x0004003bu,0x00000008u,0x000001a0u,0x00000007u,0x0004003bu,0x0000009eu,0x000001adu,0x00000007u,0x0004003bu,0x0000009eu,0x000001b1u,0x00000007u,0x0004003bu,0x0000009eu,0x000001b7u,0x00000007u,0x0004003bu,0x00000008u,0x000001c3u,0x00000007u,0x0004003bu,0x00000008u,0x000001c8u,0x00000007u,0x0004003bu,0x0000009eu,0x000001cdu,0x00000007u,0x0004003bu,0x0000009eu,0x000001d2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001d7u,0x00000007u,0x0004003bu,0x0000009eu,0x000001dcu,0x00000007u,0x0004003bu,0x00000008u,0x000001e5u,0x00000007u,0x0004003bu,0x0000009eu,0x000001eeu,0x00000007u,0x0004003bu,0x0000009eu,0x000001f2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001f6u,0x00000007u,0x0004003du,0x0000001au,0x0000001eu,0x0000001cu,0x0007004fu,0x0000001du,0x0000001fu,0x0000001eu,0x0000001eu,0x00000000u,0x00000001u,0x0004007cu,0x00000016u,0x00000020u,0x0000001fu,0x0003003eu,0x00000018u,0x00000020u,0x0004003du,0x00000022u,0x00000025u,0x00000024u,0x00040068u,0x00000016u,0x00000026u,0x00000025u,0x0003003eu,0x00000021u,0x00000026u,0x00050041u,0x00000029u,0x0000002au,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000002bu,0x0000002au,0x00050041u,0x00000029u,0x0000002cu,0x00000021u,0x00000028u,0x0004003du,0x00000015u,0x0000002du,0x0000002cu,0x000500afu,0x00000027u,0x0000002eu,0x0000002bu,0x0000002du,0x000400a8u,0x00000027u,0x0000002fu,0x0000002eu,0x000300f7u,0x00000031u,0x00000000u,0x000400fau,0x0000002fu,0x00000030u,0x00000031u,0x000200f8u,0x00000030u,0x00050041u,0x00000029u,0x00000033u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000034u,0x00000033u,0x00050041u,0x00000029u,0x00000035u,0x00000021u,0x00000032u,0x0004003du,0x00000015u,0x00000036u,0x00000035u,0x000500afu,0x00000027u,0x00000037u,0x00000034u,0x00000036u,0x000200f9u,0x00000031u,0x000200f8u,0x00000031u,0x000700f5u,0x00000027u,0x00000038u,0x0000002eu,0x00000005u,0x00000037u,0x00000030u,0x000300f7u,0x0000003au,0x00000000u,0x000400fau,0x00000038u,0x00000039u,0x0000003au,0x000200f8u,0x00000039u,0x000100fdu,0x000200f8u,0x0000003au,0x0004003du,0x00000016u,0x00000040u,0x00000021u,0x0004006fu,0x0000003cu,0x00000041u,0x00000040u,0x00050050u,0x0000003cu,0x00000042u,0x0000003fu,0x0000003fu,0x00050088u,0x0000003cu,0x00000043u,0x00000042u,0x00000041u,0x0003003eu,0x0000003eu,0x00000043u,0x0004003du,0x00000016u,0x00000045u,0x00000018u,0x0004006fu,0x0000003cu,0x00000046u,0x00000045u,0x00050050u,0x0000003cu,0x00000048u,0x00000047u,0x00000047u,0x00050081u,0x0000003cu,0x00000049u,0x00000046u,0x00000048u,0x0004003du,0x0000003cu,0x0000004au,0x0000003eu,0x00050085u,0x0000003cu,0x0000004bu,0x00000049u,0x0000004au,0x0003003eu,0x00000044u,0x0000004bu,0x00060041u,0x00000051u,0x00000052u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000053u,0x00000052u,0x00060041u,0x00000051u,0x00000055u,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000056u,0x00000055u,0x000500b8u,0x00000027u,0x00000057u,0x00000053u,0x00000056u,0x000300f7u,0x00000059u,0x00000000u,0x000400fau,0x00000057u,0x00000058u,0x00000059u,0x000200f8u,0x00000058u,0x00060041u,0x00000051u,0x0000005au,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x0000005bu,0x0000005au,0x00060041u,0x00000051u,0x0000005du,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x0000005eu,0x0000005du,0x000500b8u,0x00000027u,0x0000005fu,0x0000005bu,0x0000005eu,0x000200f9u,0x00000059u,0x000200f8u,0x00000059u,0x000700f5u,0x00000027u,0x00000060u,0x00000057u,0x0000003au,0x0000005fu,0x00000058u,0x000300f7u,0x00000062u,0x00000000u,0x000400fau,0x00000060u,0x00000061u,0x00000062u,0x000200f8u,0x00000061u,0x00050041u,0x00000029u,0x00000063u,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x00000064u,0x00000063u,0x0004006fu,0x00000006u,0x00000065u,0x00000064u,0x00060041u,0x00000051u,0x00000066u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000067u,0x00000066u,0x000500beu,0x00000027u,0x00000068u,0x00000065u,0x00000067u,0x000200f9u,0x00000062u,0x000200f8u,0x00000062u,0x000700f5u,0x00000027u,0x00000069u,0x00000060u,0x00000059u,0x00000068u,0x00000061u,0x000300f7u,0x0000006bu,0x00000000u,0x000400fau,0x00000069u,0x0000006au,0x0000006bu,0x000200f8u,0x0000006au,0x00050041u,0x00000029u,0x0000006cu,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000006du,0x0000006cu,0x0004006fu,0x00000006u,0x0000006eu,0x0000006du,0x00060041u,0x00000051u,0x0000006fu,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000070u,0x0000006fu,0x000500b8u,0x00000027u,0x00000071u,0x0000006eu,0x00000070u,0x000200f9u,0x0000006bu,0x000200f8u,0x0000006bu,0x000700f5u,0x00000027u,0x00000072u,0x00000069u,0x00000062u,0x00000071u,0x0000006au,0x000300f7u,0x00000074u,0x00000000u,0x000400fau,0x00000072u,0x00000073u,0x00000074u,0x000200f8u,0x00000073u,0x00050041u,0x00000029u,0x00000075u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000076u,0x00000075u,0x0004006fu,0x00000006u,0x00000077u,0x00000076u,0x00060041u,0x00000051u,0x00000078u,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x00000079u,0x00000078u,0x000500beu,0x00000027u,0x0000007au,0x00000077u,0x00000079u,0x000200f9u,0x00000074u,0x000200f8u,0x00000074u,0x000700f5u,0x00000027u,0x0000007bu,0x00000072u,0x0000006bu,0x0000007au,0x00000073u,0x000300f7u,0x0000007du,0x00000000u,0x000400fau,0x0000007bu,0x0000007cu,0x0000007du,0x000200f8u,0x0000007cu,0x00050041u,0x00000029u,0x0000007eu,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x0000007fu,0x0000007eu,0x0004006fu,0x00000006u,0x00000080u,0x0000007fu,0x00060041u,0x00000051u,0x00000081u,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x00000082u,0x00000081u,0x000500b8u,0x00000027u,0x00000083u,0x00000080u,0x00000082u,0x000200f9u,0x0000007du,0x000200f8u,0x0000007du,0x000700f5u,0x00000027u,0x00000084u,0x0000007bu,0x00000074u,0x00000083u,0x0000007cu,0x000300f7u,0x00000086u,0x00000000u,0x000400fau,0x00000084u,0x00000085u,0x00000086u,0x000200f8u,0x00000085u,0x0004003du,0x00000022u,0x00000087u,0x00000024u,0x0004003du,0x00000016u,0x00000088u,0x00000018u,0x0004003du,0x0000008au,0x0000008du,0x0000008cu,0x0004003du,0x0000003cu,0x0000008eu,0x00000044u,0x00070058u,0x0000004cu,0x00000090u,0x0000008du,0x0000008eu,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000091u,0x00000090u,0x00000090u,0x00000000u,0x00000001u,0x00000002u,0x00050051u,0x00000006u,0x00000092u,0x00000091u,0x00000000u,0x00050051u,0x00000006u,0x00000093u,0x00000091u,0x00000001u,0x00050051u,0x00000006u,0x00000094u,0x00000091u,0x00000002u,0x00070050u,0x0000004cu,0x00000095u,0x00000092u,0x00000093u,0x00000094u,0x0000003fu,0x00040063u,0x00000087u,0x00000088u,0x00000095u,0x000100fdu,0x000200f8u,0x00000086u,0x00050041u,0x00000051u,0x0000009au,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000009bu,0x0000009au,0x000500bau,0x00000027u,0x0000009du,0x0000009bu,0x0000009cu,0x0003003eu,0x00000098u,0x0000009du,0x0004003du,0x00000027u,0x000000a0u,0x00000098u,0x000300f7u,0x000000a3u,0x00000000u,0x000400fau,0x000000a0u,0x000000a2u,0x000000a4u,0x000200f8u,0x000000a2u,0x0003003eu,0x000000a1u,0x0000003fu,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a4u,0x00050041u,0x00000051u,0x000000a5u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x000000a6u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a6u,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a3u,0x0004003du,0x00000006u,0x000000a7u,0x000000a1u,0x0003003eu,0x0000009fu,0x000000a7u,0x0004003du,0x0000008au,0x000000abu,0x000000aau,0x0004003du,0x0000003cu,0x000000acu,0x00000044u,0x00070058u,0x0000004cu,0x000000adu,0x000000abu,0x000000acu,0x00000002u,0x0000008fu,0x0003003eu,0x000000a9u,0x000000adu,0x0004003du,0x0000008au,0x000000b0u,0x000000afu,0x0004003du,0x0000003cu,0x000000b1u,0x00000044u,0x00070058u,0x0000004cu,0x000000b2u,0x000000b0u,0x000000b1u,0x00000002u,0x0000008fu,0x0003003eu,0x000000aeu,0x000000b2u,0x0004003du,0x0000004cu,0x000000b4u,0x000000a9u,0x0007004fu,0x0000003cu,0x000000b5u,0x000000b4u,0x000000b4u,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000b6u,0x0000009fu,0x0005008eu,0x0000003cu,0x000000b7u,0x000000b5u,0x000000b6u,0x0004003du,0x0000003cu,0x000000b8u,0x0000003eu,0x00050085u,0x0000003cu,0x000000b9u,0x000000b7u,0x000000b8u,0x0003003eu,0x000000b3u,0x000000b9u,0x0004003du,0x0000004cu,0x000000bbu,0x000000aeu,0x0007004fu,0x0000003cu,0x000000bcu,0x000000bbu,0x000000bbu,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000bdu,0x0000009fu,0x0005008eu,0x0000003cu,0x000000beu,0x000000bcu,0x000000bdu,0x0004003du,0x0000003cu,0x000000bfu,0x0000003eu,0x00050085u,0x0000003cu,0x000000c0u,0x000000beu,0x000000bfu,0x0003003eu,0x000000bau,0x000000c0u,0x00050041u,0x0000009eu,0x000000c2u,0x000000a9u,0x0000005cu,0x0004003du,0x00000006u,0x000000c3u,0x000000c2u,0x0008000cu,0x00000006u,0x000000c4u,0x00000001u,0x0000002bu,0x000000c3u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c1u,0x000000c4u,0x00050041u,0x0000009eu,0x000000c6u,0x000000aeu,0x0000005cu,0x0004003du,0x00000006u,0x000000c7u,0x000000c6u,0x0008000cu,0x00000006u,0x000000c8u,0x00000001u,0x0000002bu,0x000000c7u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c5u,0x000000c8u,0x0004003du,0x0000003cu,0x000000cau,0x000000b3u,0x0006000cu,0x00000006u,0x000000cbu,0x00000001u,0x00000042u,0x000000cau,0x0003003eu,0x000000c9u,0x000000cbu,0x0004003du,0x00000006u,0x000000ccu,0x000000c9u,0x000500bau,0x00000027u,0x000000ceu,0x000000ccu,0x000000cdu,0x000300f7u,0x000000d0u,0x00000000u,0x000400fau,0x000000ceu,0x000000cfu,0x000000d0u,0x000200f8u,0x000000cfu,0x0004003du,0x00000006u,0x000000d1u,0x000000c9u,0x00050088u,0x00000006u,0x000000d2u,0x000000cdu,0x000000d1u,0x0004003du,0x0000003cu,0x000000d3u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000d4u,0x000000d3u,0x000000d2u,0x0003003eu,0x000000b3u,0x000000d4u,0x000200f9u,0x000000d0u,0x000200f8u,0x000000d0u,0x0004003du,0x0000003cu,0x000000d6u,0x000000bau,0x0006000cu,0x00000006u,0x000000d7u,0x00000001u,0x00000042u,0x000000d6u,0x0003003eu,0x000000d5u,0x000000d7u,0x0004003du,0x00000006u,0x000000d8u,0x000000d5u,0x000500bau,0x00000027u,0x000000d9u,0x000000d8u,0x000000cdu,0x000300f7u,0x000000dbu,0x00000000u,0x000400fau,0x000000d9u,0x000000dau,0x000000dbu,0x000200f8u,0x000000dau,0x0004003du,0x00000006u,0x000000dcu,0x000000d5u,0x00050088u,0x00000006u,0x000000ddu,0x000000cdu,0x000000dcu,0x0004003du,0x0000003cu,0x000000deu,0x000000bau,0x0005008eu,0x0000003cu,0x000000dfu,0x000000deu,0x000000ddu,0x0003003eu,0x000000bau,0x000000dfu,0x000200f9u,0x000000dbu,0x000200f8u,0x000000dbu,0x0004003du,0x0000003cu,0x000000e1u,0x00000044u,0x00050041u,0x00000051u,0x000000e3u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000e4u,0x000000e3u,0x0004003du,0x0000003cu,0x000000e5u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000e6u,0x000000e5u,0x000000e4u,0x00050083u,0x0000003cu,0x000000e7u,0x000000e1u,0x000000e6u,0x0003003eu,0x000000e0u,0x000000e7u,0x0004003du,0x0000003cu,0x000000e9u,0x00000044u,0x00050041u,0x00000051u,0x000000eau,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000ebu,0x000000eau,0x00050083u,0x00000006u,0x000000ecu,0x0000003fu,0x000000ebu,0x0004003du,0x0000003cu,0x000000edu,0x000000bau,0x0005008eu,0x0000003cu,0x000000eeu,0x000000edu,0x000000ecu,0x00050083u,0x0000003cu,0x000000efu,0x000000e9u,0x000000eeu,0x0003003eu,0x000000e8u,0x000000efu,0x0004003du,0x0000008au,0x000000f2u,0x000000f1u,0x0004003du,0x0000003cu,0x000000f3u,0x000000e0u,0x00070058u,0x0000004cu,0x000000f4u,0x000000f2u,0x000000f3u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000f5u,0x000000f4u,0x000000f4u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f0u,0x000000f5u,0x0004003du,0x0000008au,0x000000f7u,0x0000008cu,0x0004003du,0x0000003cu,0x000000f8u,0x000000e8u,0x00070058u,0x0000004cu,0x000000f9u,0x000000f7u,0x000000f8u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000fau,0x000000f9u,0x000000f9u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f6u,0x000000fau,0x0004003du,0x00000007u,0x000000fdu,0x000000f0u,0x0003003eu,0x000000fcu,0x000000fdu,0x00050039u,0x00000006u,0x000000feu,0x0000000bu,0x000000fcu,0x0004003du,0x0000008au,0x000000ffu,0x0000008cu,0x0004003du,0x0000003cu,0x00000100u,0x000000e0u,0x0004003du,0x0000008au,0x00000101u,0x000000aau,0x0004003du,0x0000003cu,0x00000102u,0x000000e0u,0x00070058u,0x0000004cu,0x00000103u,0x00000101u,0x00000102u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000104u,0x00000103u,0x00000103u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000105u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000106u,0x00000105u,0x0005008eu,0x0000003cu,0x00000107u,0x00000104u,0x00000106u,0x0004003du,0x0000003cu,0x00000108u,0x0000003eu,0x00050085u,0x0000003cu,0x00000109u,0x00000107u,0x00000108u,0x00050081u,0x0000003cu,0x0000010au,0x00000100u,0x00000109u,0x00070058u,0x0000004cu,0x0000010bu,0x000000ffu,0x0000010au,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x0000010du,0x0000010bu,0x0000010bu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000010cu,0x0000010du,0x00050039u,0x00000006u,0x0000010eu,0x0000000bu,0x0000010cu,0x00050083u,0x00000006u,0x0000010fu,0x000000feu,0x0000010eu,0x0006000cu,0x00000006u,0x00000110u,0x00000001u,0x00000004u,0x0000010fu,0x0003003eu,0x000000fbu,0x00000110u,0x0004003du,0x00000007u,0x00000113u,0x000000f6u,0x0003003eu,0x00000112u,0x00000113u,0x00050039u,0x00000006u,0x00000114u,0x0000000bu,0x00000112u,0x0004003du,0x0000008au,0x00000115u,0x000000f1u,0x0004003du,0x0000003cu,0x00000116u,0x000000e8u,0x0004003du,0x0000008au,0x00000117u,0x000000afu,0x0004003du,0x0000003cu,0x00000118u,0x000000e8u,0x00070058u,0x0000004cu,0x00000119u,0x00000117u,0x00000118u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x0000011au,0x00000119u,0x00000119u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x0000011bu,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000011cu,0x0000011bu,0x0005008eu,0x0000003cu,0x0000011du,0x0000011au,0x0000011cu,0x0004003du,0x0000003cu,0x0000011eu,0x0000003eu,0x00050085u,0x0000003cu,0x0000011fu,0x0000011du,0x0000011eu,0x00050081u,0x0000003cu,0x00000120u,0x00000116u,0x0000011fu,0x00070058u,0x0000004cu,0x00000121u,0x00000115u,0x00000120u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000123u,0x00000121u,0x00000121u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x00000122u,0x00000123u,0x00050039u,0x00000006u,0x00000124u,0x0000000bu,0x00000122u,0x00050083u,0x00000006u,0x00000125u,0x00000114u,0x00000124u,0x0006000cu,0x00000006u,0x00000126u,0x00000001u,0x00000004u,0x00000125u,0x0003003eu,0x00000111u,0x00000126u,0x0004003du,0x0000003cu,0x00000128u,0x00000044u,0x0004003du,0x0000003cu,0x00000129u,0x000000b3u,0x00050081u,0x0000003cu,0x0000012au,0x00000128u,0x00000129u,0x0003003eu,0x00000127u,0x0000012au,0x0004003du,0x0000003cu,0x0000012cu,0x000000b3u,0x0004003du,0x0000008au,0x0000012du,0x000000afu,0x0004003du,0x0000003cu,0x0000012eu,0x00000127u,0x00070058u,0x0000004cu,0x0000012fu,0x0000012du,0x0000012eu,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000130u,0x0000012fu,0x0000012fu,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000131u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000132u,0x00000131u,0x0005008eu,0x0000003cu,0x00000133u,0x00000130u,0x00000132u,0x0004003du,0x0000003cu,0x00000134u,0x0000003eu,0x00050085u,0x0000003cu,0x00000135u,0x00000133u,0x00000134u,0x00050081u,0x0000003cu,0x00000136u,0x0000012cu,0x00000135u,0x0006000cu,0x00000006u,0x00000137u,0x00000001u,0x00000042u,0x00000136u,0x0003003eu,0x0000012bu,0x00000137u,0x0004003du,0x00000006u,0x00000139u,0x000000c1u,0x0004003du,0x00000006u,0x0000013au,0x000000c5u,0x0007000cu,0x00000006u,0x0000013bu,0x00000001u,0x00000025u,0x00000139u,0x0000013au,0x0004003du,0x00000006u,0x0000013cu,0x0000012bu,0x0004007fu,0x00000006u,0x0000013du,0x0000013cu,0x0004003du,0x0000003cu,0x0000013eu,0x000000b3u,0x0006000cu,0x00000006u,0x0000013fu,0x00000001u,0x00000042u,0x0000013eu,0x0004003du,0x0000003cu,0x00000140u,0x000000bau,0x0006000cu,0x00000006u,0x00000141u,0x00000001u,0x00000042u,0x00000140u,0x00050081u,0x00000006u,0x00000142u,0x0000013fu,0x00000141u,0x00050041u,0x0000009eu,0x00000143u,0x0000003eu,0x00000028u,0x0004003du,0x00000006u,0x00000144u,0x00000143u,0x00050081u,0x00000006u,0x00000145u,0x00000142u,0x00000144u,0x00050088u,0x00000006u,0x00000146u,0x0000013du,0x00000145u,0x0006000cu,0x00000006u,0x00000147u,0x00000001u,0x0000001bu,0x00000146u,0x00050085u,0x00000006u,0x00000148u,0x0000013bu,0x00000147u,0x0003003eu,0x00000138u,0x00000148u,0x00050041u,0x00000051u,0x0000014bu,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x0000014cu,0x0000014bu,0x0004003du,0x00000006u,0x0000014du,0x000000fbu,0x00050085u,0x00000006u,0x0000014eu,0x0000014cu,0x0000014du,0x00050041u,0x00000051u,0x00000150u,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x00000151u,0x00000150u,0x0004003du,0x00000006u,0x00000152u,0x00000138u,0x00050083u,0x00000006u,0x00000153u,0x0000003fu,0x00000152u,0x00050085u,0x00000006u,0x00000154u,0x00000151u,0x00000153u,0x00050081u,0x00000006u,0x00000155u,0x0000014eu,0x00000154u,0x0004007fu,0x00000006u,0x00000156u,0x00000155u,0x0003003eu,0x00000149u,0x00000156u,0x00050041u,0x00000051u,0x00000158u,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x00000159u,0x00000158u,0x0004003du,0x00000006u,0x0000015au,0x00000111u,0x00050085u,0x00000006u,0x0000015bu,0x00000159u,0x0000015au,0x00050041u,0x00000051u,0x0000015cu,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x0000015du,0x0000015cu,0x0004003du,0x00000006u,0x0000015eu,0x00000138u,0x00050083u,0x00000006u,0x0000015fu,0x0000003fu,0x0000015eu,0x00050085u,0x00000006u,0x00000160u,0x0000015du,0x0000015fu,0x00050081u,0x00000006u,0x00000161u,0x0000015bu,0x00000160u,0x0004007fu,0x00000006u,0x00000162u,0x00000161u,0x0003003eu,0x00000157u,0x00000162u,0x00050041u,0x00000051u,0x00000164u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000165u,0x00000164u,0x00050083u,0x00000006u,0x00000166u,0x0000003fu,0x00000165u,0x0003003eu,0x00000163u,0x00000166u,0x00050041u,0x00000051u,0x00000168u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000169u,0x00000168u,0x0003003eu,0x00000167u,0x00000169u,0x0004003du,0x00000006u,0x0000016bu,0x00000149u,0x0004003du,0x00000006u,0x0000016cu,0x00000157u,0x0007000cu,0x00000006u,0x0000016du,0x00000001u,0x00000028u,0x0000016bu,0x0000016cu,0x0003003eu,0x0000016au,0x0000016du,0x0004003du,0x00000006u,0x0000016fu,0x00000163u,0x00050041u,0x00000051u,0x00000171u,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x00000172u,0x00000171u,0x0004003du,0x00000006u,0x00000173u,0x00000149u,0x0004003du,0x00000006u,0x00000174u,0x0000016au,0x00050083u,0x00000006u,0x00000175u,0x00000173u,0x00000174u,0x00050085u,0x00000006u,0x00000176u,0x00000172u,0x00000175u,0x0006000cu,0x00000006u,0x00000177u,0x00000001u,0x0000001bu,0x00000176u,0x00050085u,0x00000006u,0x00000178u,0x0000016fu,0x00000177u,0x0003003eu,0x0000016eu,0x00000178u,0x0004003du,0x00000006u,0x0000017au,0x00000167u,0x00050041u,0x00000051u,0x0000017bu,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x0000017cu,0x0000017bu,0x0004003du,0x00000006u,0x0000017du,0x00000157u,0x0004003du,0x00000006u,0x0000017eu,0x0000016au,0x00050083u,0x00000006u,0x0000017fu,0x0000017du,0x0000017eu,0x00050085u,0x00000006u,0x00000180u,0x0000017cu,0x0000017fu,0x0006000cu,0x00000006u,0x00000181u,0x00000001u,0x0000001bu,0x00000180u,0x00050085u,0x00000006u,0x00000182u,0x0000017au,0x00000181u,0x0003003eu,0x00000179u,0x00000182u,0x0004003du,0x00000006u,0x00000184u,0x0000016eu,0x0004003du,0x00000006u,0x00000185u,0x00000179u,0x00050081u,0x00000006u,0x00000186u,0x00000184u,0x00000185u,0x0003003eu,0x00000183u,0x00000186u,0x0004003du,0x00000006u,0x00000188u,0x00000183u,0x000500bau,0x00000027u,0x0000018au,0x00000188u,0x00000189u,0x000300f7u,0x0000018du,0x00000000u,0x000400fau,0x0000018au,0x0000018cu,0x00000198u,0x000200f8u,0x0000018cu,0x0004003du,0x00000006u,0x0000018eu,0x0000016eu,0x0004003du,0x00000007u,0x0000018fu,0x000000f0u,0x0005008eu,0x00000007u,0x00000190u,0x0000018fu,0x0000018eu,0x0004003du,0x00000006u,0x00000191u,0x00000179u,0x0004003du,0x00000007u,0x00000192u,0x000000f6u,0x0005008eu,0x00000007u,0x00000193u,0x00000192u,0x00000191u,0x00050081u,0x00000007u,0x00000194u,0x00000190u,0x00000193u,0x0004003du,0x00000006u,0x00000195u,0x00000183u,0x00060050u,0x00000007u,0x00000196u,0x00000195u,0x00000195u,0x00000195u,0x00050088u,0x00000007u,0x00000197u,0x00000194u,0x00000196u,0x0003003eu,0x0000018bu,0x00000197u,0x000200f9u,0x0000018du,0x000200f8u,0x00000198u,0x0004003du,0x00000007u,0x00000199u,0x000000f0u,0x0004003du,0x00000007u,0x0000019au,0x000000f6u,0x00050041u,0x00000051u,0x0000019bu,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x0000019cu,0x0000019bu,0x00060050u,0x00000007u,0x0000019du,0x0000019cu,0x0000019cu,0x0000019cu,0x0008000cu,0x00000007u,0x0000019eu,0x00000001u,0x0000002eu,0x00000199u,0x0000019au,0x0000019du,0x0003003eu,0x0000018bu,0x0000019eu,0x000200f9u,0x0000018du,0x000200f8u,0x0000018du,0x0004003du,0x00000007u,0x0000019fu,0x0000018bu,0x0003003eu,0x00000187u,0x0000019fu,0x0004003du,0x0000008au,0x000001a1u,0x000000f1u,0x0004003du,0x0000003cu,0x000001a2u,0x00000044u,0x00070058u,0x0000004cu,0x000001a3u,0x000001a1u,0x000001a2u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a4u,0x000001a3u,0x000001a3u,0x00000000u,0x00000001u,0x00000002u,0x0004003du,0x0000008au,0x000001a5u,0x0000008cu,0x0004003du,0x0000003cu,0x000001a6u,0x00000044u,0x00070058u,0x0000004cu,0x000001a7u,0x000001a5u,0x000001a6u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a8u,0x000001a7u,0x000001a7u,0x00000000u,0x00000001u,0x00000002u,0x00050041u,0x00000051u,0x000001a9u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000001aau,0x000001a9u,0x00060050u,0x00000007u,0x000001abu,0x000001aau,0x000001aau,0x000001aau,0x0008000cu,0x00000007u,0x000001acu,0x00000001u,0x0000002eu,0x000001a4u,0x000001a8u,0x000001abu,0x0003003eu,0x000001a0u,0x000001acu,0x0004003du,0x00000006u,0x000001aeu,0x000000fbu,0x0004003du,0x00000006u,0x000001afu,0x00000111u,0x0007000cu,0x00000006u,0x000001b0u,0x00000001u,0x00000025u,0x000001aeu,0x000001afu,0x0003003eu,0x000001adu,0x000001b0u,0x0004003du,0x00000006u,0x000001b4u,0x000001adu,0x0008000cu,0x00000006u,0x000001b5u,0x00000001u,0x00000031u,0x000001b2u,0x000001b3u,0x000001b4u,0x00050083u,0x00000006u,0x000001b6u,0x0000003fu,0x000001b5u,0x0003003eu,0x000001b1u,0x000001b6u,0x0004003du,0x00000006u,0x000001b8u,0x00000138u,0x0008000cu,0x00000006u,0x000001b9u,0x00000001u,0x0000002bu,0x000001b8u,0x0000008fu,0x0000003fu,0x0004003du,0x00000006u,0x000001bau,0x000001b1u,0x00050085u,0x00000006u,0x000001bbu,0x000001b9u,0x000001bau,0x0003003eu,0x000001b7u,0x000001bbu,0x00050041u,0x00000051u,0x000001bdu,0x0000004fu,0x000001bcu,0x0004003du,0x00000006u,0x000001beu,0x000001bdu,0x0004003du,0x00000006u,0x000001bfu,0x00000183u,0x0008000cu,0x00000006u,0x000001c0u,0x00000001u,0x00000031u,0x0000008fu,0x000001beu,0x000001bfu,0x0004003du,0x00000006u,0x000001c1u,0x000001b7u,0x00050085u,0x00000006u,0x000001c2u,0x000001c1u,0x000001c0u,0x0003003eu,0x000001b7u,0x000001c2u,0x0004003du,0x0000008au,0x000001c4u,0x000000f1u,0x0004003du,0x0000003cu,0x000001c5u,0x00000044u,0x00070058u,0x0000004cu,0x000001c6u,0x000001c4u,0x000001c5u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001c7u,0x000001c6u,0x000001c6u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c3u,0x000001c7u,0x0004003du,0x0000008au,0x000001c9u,0x0000008cu,0x0004003du,0x0000003cu,0x000001cau,0x00000044u,0x00070058u,0x0000004cu,0x000001cbu,0x000001c9u,0x000001cau,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001ccu,0x000001cbu,0x000001cbu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c8u,0x000001ccu,0x0004003du,0x00000007u,0x000001ceu,0x000001c8u,0x0004003du,0x00000007u,0x000001cfu,0x000001c3u,0x00050083u,0x00000007u,0x000001d0u,0x000001ceu,0x000001cfu,0x0006000cu,0x00000006u,0x000001d1u,0x00000001u,0x00000042u,0x000001d0u,0x0003003eu,0x000001cdu,0x000001d1u,0x0004003du,0x00000006u,0x000001d4u,0x000001b7u,0x0008000cu,0x00000006u,0x000001d5u,0x00000001u,0x00000031u,0x000000cdu,0x000001d3u,0x000001d4u,0x00050083u,0x00000006u,0x000001d6u,0x0000003fu,0x000001d5u,0x0003003eu,0x000001d2u,0x000001d6u,0x0004003du,0x00000006u,0x000001dau,0x000001cdu,0x0008000cu,0x00000006u,0x000001dbu,0x00000001u,0x00000031u,0x000001d8u,0x000001d9u,0x000001dau,0x0003003eu,0x000001d7u,0x000001dbu,0x0004003du,0x00000006u,0x000001ddu,0x000001d2u,0x0004003du,0x00000006u,0x000001deu,0x000001d7u,0x00050085u,0x00000006u,0x000001dfu,0x000001ddu,0x000001deu,0x0003003eu,0x000001dcu,0x000001dfu,0x0004003du,0x00000007u,0x000001e0u,0x000001a0u,0x0004003du,0x00000007u,0x000001e1u,0x000001c8u,0x0004003du,0x00000006u,0x000001e2u,0x000001dcu,0x00060050u,0x00000007u,0x000001e3u,0x000001e2u,0x000001e2u,0x000001e2u,0x0008000cu,0x00000007u,0x000001e4u,0x00000001u,0x0000002eu,0x000001e0u,0x000001e1u,0x000001e3u,0x0003003eu,0x000001a0u,0x000001e4u,0x0004003du,0x00000007u,0x000001e6u,0x000001a0u,0x0004003du,0x00000007u,0x000001e7u,0x00000187u,0x0004003du,0x00000006u,0x000001e8u,0x000001b7u,0x00060050u,0x00000007u,0x000001e9u,0x000001e8u,0x000001e8u,0x000001e8u,0x0008000cu,0x00000007u,0x000001eau,0x00000001u,0x0000002eu,0x000001e6u,0x000001e7u,0x000001e9u,0x0003003eu,0x000001e5u,0x000001eau,0x0004003du,0x00000027u,0x000001ebu,0x00000098u,0x000300f7u,0x000001edu,0x00000000u,0x000400fau,0x000001ebu,0x000001ecu,0x000001edu,0x000200f8u,0x000001ecu,0x0004003du,0x00000006u,0x000001efu,0x000000c9u,0x0004003du,0x00000006u,0x000001f0u,0x000000d5u,0x0007000cu,0x00000006u,0x000001f1u,0x00000001u,0x00000028u,0x000001efu,0x000001f0u,0x0003003eu,0x000001eeu,0x000001f1u,0x0004003du,0x00000006u,0x000001f3u,0x000001eeu,0x00050088u,0x00000006u,0x000001f4u,0x000001f3u,0x000000cdu,0x0008000cu,0x00000006u,0x000001f5u,0x00000001u,0x0000002bu,0x000001f4u,0x0000008fu,0x0000003fu,0x0003003eu,0x000001f2u,0x000001f5u,0x0004003du,0x00000006u,0x000001f7u,0x000001eeu,0x0007000cu,0x00000006u,0x000001f8u,0x00000001u,0x00000030u,0x000000cdu,0x000001f7u,0x0003003eu,0x000001f6u,0x000001f8u,0x0004003du,0x00000022u,0x000001f9u,0x00000024u,0x0004003du,0x00000016u,0x000001fau,0x00000018u,0x0004003du,0x00000006u,0x000001fbu,0x000001f6u,0x0004003du,0x00000006u,0x000001fcu,0x000001f2u,0x00070050u,0x0000004cu,0x000001fdu,0x000001fbu,0x000001fcu,0x0000008fu,0x0000003fu,0x00040063u,0x000001f9u,0x000001fau,0x000001fdu,0x000100fdu,0x000200f8u,0x000001edu,0x0004003du,0x00000022u,0x000001ffu,0x00000024u,0x0004003du,0x00000016u,0x00000200u,0x00000018u,0x0004003du,0x00000007u,0x00000201u,0x000001e5u,0x00050051u,0x00000006u,0x00000202u,0x00000201u,0x00000000u,0x00050051u,0x00000006u,0x00000203u,0x00000201u,0x00000001u,0x00050051u,0x00000006u,0x00000204u,0x00000201u,0x00000002u,0x00070050u,0x0000004cu,0x00000205u,0x00000202u,0x00000203u,0x00000204u,0x0000003fu,0x00040063u,0x000001ffu,0x00000200u,0x00000205u,0x000100fdu,0x00010038u,0x00050036u,0x00000006u,0x0000000bu,0x00000000u,0x00000009u,0x00030037u,0x00000008u,0x0000000au,0x000200f8u,0x0000000cu,0x0004003du,0x00000007u,0x0000000du,0x0000000au,0x00050094u,0x00000006u,0x00000012u,0x0000000du,0x00000011u,0x000200feu,0x00000012u,0x00010038u}; +static const size_t DDFG_SYNTH_LEN = 3082; struct Blob { const uint32_t* code; size_t words; }; -enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT }; +enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT }; static inline Blob blob(Shader s) { switch (s) { case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN }; @@ -35,7 +35,7 @@ static inline Blob blob(Shader s) { case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN }; case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN }; case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN }; - case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN }; + case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN }; default: return { nullptr, 0 }; } } diff --git a/app/src/main/cpp/framegen/winfg/framegen.cpp b/app/src/main/cpp/framegen/ddfg/framegen.cpp similarity index 96% rename from app/src/main/cpp/framegen/winfg/framegen.cpp rename to app/src/main/cpp/framegen/ddfg/framegen.cpp index 4ddd12021..82d0a764b 100644 --- a/app/src/main/cpp/framegen/winfg/framegen.cpp +++ b/app/src/main/cpp/framegen/ddfg/framegen.cpp @@ -4,9 +4,9 @@ #include #include -namespace winfg { +namespace ddfg { -#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0) +#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0) uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const { for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i) @@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id, if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false; if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false; if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false; - if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false; - if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); } - WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)", + if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false; + if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); } + DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)", queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters); return true; } @@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) { if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false; if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false; ready_ = true; - WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)", + DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)", extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset); return true; } diff --git a/app/src/main/cpp/framegen/winfg/framegen.hpp b/app/src/main/cpp/framegen/ddfg/framegen.hpp similarity index 96% rename from app/src/main/cpp/framegen/winfg/framegen.hpp rename to app/src/main/cpp/framegen/ddfg/framegen.hpp index 8b32fd0c0..70719e796 100644 --- a/app/src/main/cpp/framegen/winfg/framegen.hpp +++ b/app/src/main/cpp/framegen/ddfg/framegen.hpp @@ -3,7 +3,7 @@ #include "config.hpp" #include -namespace winfg { +namespace ddfg { class FrameGen { public: @@ -12,7 +12,8 @@ public: void configure(const Config& c); bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false); void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView, - VkImageView outView, float alpha, uint32_t gmSlot = 0); + VkImageView outView, float alpha, uint32_t gmSlot = 0, + uint32_t synthSlot = 0, bool reuseFlow = false); void destroy(); bool valid() const { return device_ != VK_NULL_HANDLE && ready_; } const Config& config() const { return cfg_; } @@ -25,6 +26,7 @@ public: static constexpr int kGmSlots = 3; + static constexpr int kSynthSlots = 3; static constexpr int kGmLevel = 3; static constexpr int kGmThreads = 512; static constexpr int kGmStride = 32; diff --git a/app/src/main/cpp/framegen/ddfg/log.hpp b/app/src/main/cpp/framegen/ddfg/log.hpp new file mode 100644 index 000000000..b4c6c5a8d --- /dev/null +++ b/app/src/main/cpp/framegen/ddfg/log.hpp @@ -0,0 +1,12 @@ +#pragma once +#if defined(__ANDROID__) + #include + #define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__) + #define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__) +#else + #include + #define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) + #define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) +#endif + +#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0) diff --git a/app/src/main/cpp/framegen/winfg/record_impl.inc b/app/src/main/cpp/framegen/ddfg/record_impl.inc similarity index 65% rename from app/src/main/cpp/framegen/winfg/record_impl.inc rename to app/src/main/cpp/framegen/ddfg/record_impl.inc index 2e8fba009..554c05c7f 100644 --- a/app/src/main/cpp/framegen/winfg/record_impl.inc +++ b/app/src/main/cpp/framegen/ddfg/record_impl.inc @@ -1,31 +1,34 @@ namespace { struct ScratchState { - VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr; + VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{}; + VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{}; + void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{}; std::vector flowUbo; std::vector flowUboMem; std::vector flowUboPtr; VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr; VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr; - VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{}; - VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{}; - void* gmUboPtr[winfg::FrameGen::kGmSlots]{}; + VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{}; + VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{}; + void* gmUboPtr[ddfg::FrameGen::kGmSlots]{}; VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE; std::vector sDownA, sDownB, sFlow; - VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{}; + VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{}; VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE; - VkDescriptorSet sSynth = VK_NULL_HANDLE; - VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{}; - std::vector sGmPrewarp[winfg::FrameGen::kGmSlots]; + VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{}; + VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{}; + std::vector sGmPrewarp[ddfg::FrameGen::kGmSlots]; VkExtent2D builtFor{0,0}; bool built = false; bool flowInited = false; + bool flowReady = false; }; ScratchState g_scratch; } -static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev, +static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, const VkPhysicalDeviceMemoryProperties& mp, VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) { VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; @@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev, return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS; } -static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev, +static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorPool pool, VkDescriptorSetLayout layout) { VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout; VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s; } -static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, +static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) { VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout; VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = ⅈ dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr); } -static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, +static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, VkBuffer buf, VkDeviceSize range) { VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range; VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; @@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi; dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr); } -static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, +static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, VkBuffer buf, VkDeviceSize range) { VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range; VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; @@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3]) out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0; } -namespace winfg { +namespace ddfg { static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool, @@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr; }; auto& S = g_scratch; - freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr); + for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]); freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr); freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr); for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]); @@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) { int want = flowFinestForPreset(cfg_.perfPreset); const int maxFinest = kLevels - 2; if (want > maxFinest) { - WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist " + DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist " "(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest); want = maxFinest; } @@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) { const int prevFinest = flowFinest_; flowFinest_ = want; const bool live = (flowFinest_ != prevFinest) && ready_; - WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d", + DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d", cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0); if (!live) return; @@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) { dd_->DeviceWaitIdle(device_); destroyScratch(dd_, device_, descPool_); if (!onResize(extent_, VK_FORMAT_UNDEFINED, true)) - WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize", + DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize", extent_.width, extent_.height); for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0; for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; } gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f; - WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_); + DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_); } bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) { @@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) { } void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView, - VkImageView outView, float alpha, uint32_t gmSlot) { + VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) { if (!valid()) return; if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0; + if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0; const bool m4 = (cfg_.model == 4); const Pipe& flowP = m4 ? pFlowM4_ : pFlow_; const Pipe& expandP = m4 ? pExpandM4_ : pExpand_; @@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_); } - bool gmEngaged = updateGlobalMotion(gmSlot, extent_); - if (g_scratch.gmUboPtr[gmSlot]) { - GMUBO g{}; - g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3]; - g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f; - if (gmEngaged) { - g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3]; - g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5]; + // Every generation of one real frame is recorded into the same command buffer, so the flow + // between prev and curr is computed once and each generation gets its own synth UBO and set. + if (reuseFlow && !g_scratch.flowReady) return; + if (!reuseFlow) { + bool gmEngaged = updateGlobalMotion(gmSlot, extent_); + if (g_scratch.gmUboPtr[gmSlot]) { + GMUBO g{}; + g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3]; + g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f; + if (gmEngaged) { + g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3]; + g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5]; + } + std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g)); } - std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g)); - } - { - static int lastEng = -1; - if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) { - WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)", - gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model); - lastEng = gmEngaged ? 1 : 0; + { + static int lastEng = -1; + if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) { + DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)", + gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model); + lastEng = gmEngaged ? 1 : 0; + } } + + wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); } SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0, cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1}; - if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su)); + if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su)); - wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL); + const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot]; + wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL); auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) { dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline); @@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur }; auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); }; - for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - { - VkClearColorValue zero{}; - VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + if (!reuseFlow) { + for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + { + VkClearColorValue zero{}; + VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + for (int l = flowFinest_; l < kLevels; ++l) { + VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + b.image = flowLvl_[l].image; b.subresourceRange = rng; + b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); + dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng); + b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL; + b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); + } + } + + disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0)); + disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0)); + bar(pyrA_[0]); bar(pyrB_[0]); + for (int l = 1; l < kLevels; ++l) { + disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l)); + disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l)); + bar(pyrA_[l]); bar(pyrB_[l]); + } + if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) { + dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline); + dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr); + dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1); + VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT, + 0, 1, &mb, 0, nullptr, 0, nullptr); + } for (int l = flowFinest_; l < kLevels; ++l) { - VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; - b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; - b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - b.image = flowLvl_[l].image; b.subresourceRange = rng; - b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); - dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng); - b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL; - b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); + disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l)); + bar(pyrAs_[l]); + } + for (int l = kLevels - 1; l >= flowFinest_; --l) { + disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l)); + bar(flowLvl_[l]); } - } - disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0)); - disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0)); - bar(pyrA_[0]); bar(pyrB_[0]); - for (int l = 1; l < kLevels; ++l) { - disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l)); - disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l)); - bar(pyrA_[l]); bar(pyrB_[l]); - } - if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) { - dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline); - dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr); - dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1); - VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; - mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT, - 0, 1, &mb, 0, nullptr, 0, nullptr); - } - for (int l = flowFinest_; l < kLevels; ++l) { - disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l)); - bar(pyrAs_[l]); - } - for (int l = kLevels - 1; l >= flowFinest_; --l) { - disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l)); - bar(flowLvl_[l]); - } + const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0); + if (frEngaged) { + const int F = flowFinest_; + const VkExtent2D fe = levelExtent(extent_, F); + if (g_scratch.flowRegUboPtr) { + FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps}; + std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu)); + } + FrameGen::Img* last = nullptr; + for (int it = 0; it < cfg_.frIters; ++it) { + VkDescriptorSet set; FrameGen::Img* outImg; + if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; } + else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; } + else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; } + dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline); + dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr); + dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1); + bar(*outImg); + last = outImg; + } + VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; + mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 1, &mb, 0, nullptr, 0, nullptr); + VkImageCopy region{}; + region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.extent = {fe.width, fe.height, 1}; + dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL, + flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion); + VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + 0, 1, &mb2, 0, nullptr, 0, nullptr); + } + { + static int lastFr = -1; + int fe = frEngaged ? 1 : 0; + if (fe != lastFr || (gmFrames_ % 300ull) == 0) { + DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)", + fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model); + lastFr = fe; + } + } - const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0); - if (frEngaged) { - const int F = flowFinest_; - const VkExtent2D fe = levelExtent(extent_, F); - if (g_scratch.flowRegUboPtr) { - FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps}; - std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu)); - } - FrameGen::Img* last = nullptr; - for (int it = 0; it < cfg_.frIters; ++it) { - VkDescriptorSet set; FrameGen::Img* outImg; - if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; } - else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; } - else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; } - dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline); - dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr); - dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1); - bar(*outImg); - last = outImg; - } - VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; - mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; - mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, - 0, 1, &mb, 0, nullptr, 0, nullptr); - VkImageCopy region{}; - region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; - region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; - region.extent = {fe.width, fe.height, 1}; - dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL, - flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion); - VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; - mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, - 0, 1, &mb2, 0, nullptr, 0, nullptr); + disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0)); + bar(flowExpA_); bar(flowExpB_); + g_scratch.flowReady = true; } - { - static int lastFr = -1; - int fe = frEngaged ? 1 : 0; - if (fe != lastFr || (gmFrames_ % 300ull) == 0) { - WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)", - fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model); - lastFr = fe; - } - } - - disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0)); - bar(flowExpA_); bar(flowExpB_); - disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0)); + disp(pSynth_, synthSet, levelExtent(extent_, 0)); } static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, @@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, S = ScratchState{}; const auto GEN = VK_IMAGE_LAYOUT_GENERAL; - makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr); + for (int s = 0; s < FrameGen::kSynthSlots; ++s) + makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]); makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr); if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); } makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr); @@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN); } - S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout); - wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO)); - wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN); - wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN); + for (int s = 0; s < FrameGen::kSynthSlots; ++s) { + S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout); + wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO)); + wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN); + wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN); + } S.builtFor = ext; S.built = true; return true; diff --git a/app/src/main/cpp/framegen/winfg/vk_dispatch.hpp b/app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp similarity index 99% rename from app/src/main/cpp/framegen/winfg/vk_dispatch.hpp rename to app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp index ef259250b..7d61ba165 100644 --- a/app/src/main/cpp/framegen/winfg/vk_dispatch.hpp +++ b/app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp @@ -3,7 +3,7 @@ #include #include -namespace winfg { +namespace ddfg { static inline void* dispatch_key(void* handle) { return *reinterpret_cast(handle); } diff --git a/app/src/main/cpp/framegen/winfg/log.hpp b/app/src/main/cpp/framegen/winfg/log.hpp deleted file mode 100644 index 056f136f4..000000000 --- a/app/src/main/cpp/framegen/winfg/log.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#if defined(__ANDROID__) - #include - #define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__) - #define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__) -#else - #include - #define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) - #define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) -#endif - -#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0) diff --git a/app/src/main/cpp/framegen/winfg/winfg_vkd.h b/app/src/main/cpp/framegen/winfg/winfg_vkd.h deleted file mode 100644 index 8faffc826..000000000 --- a/app/src/main/cpp/framegen/winfg/winfg_vkd.h +++ /dev/null @@ -1,4 +0,0 @@ -#pragma once -#include "vk_dispatch.hpp" -struct VkTable; -bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id); diff --git a/app/src/main/cpp/sdnative/sdnative.c b/app/src/main/cpp/sdnative/sdnative.c index c7ec580ab..85e0793b1 100644 --- a/app/src/main/cpp/sdnative/sdnative.c +++ b/app/src/main/cpp/sdnative/sdnative.c @@ -1,5 +1,8 @@ +#include #include #include +#include +#include JNIEXPORT void JNICALL Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) { @@ -7,3 +10,10 @@ Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, (void)clazz; atomic_thread_fence(memory_order_release); } + +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_session_ThreadSignals_tgkill(JNIEnv *env, jclass clazz, jint tgid, jint tid, jint signal) { + (void)env; + (void)clazz; + return syscall(SYS_tgkill, tgid, tid, signal) == 0 ? 0 : errno; +} diff --git a/app/src/main/cpp/venc/venc_proto.h b/app/src/main/cpp/venc/venc_proto.h new file mode 100644 index 000000000..962257711 --- /dev/null +++ b/app/src/main/cpp/venc/venc_proto.h @@ -0,0 +1,55 @@ +#ifndef DROIDDECK_VENC_PROTO_H +#define DROIDDECK_VENC_PROTO_H + +#include + +/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and + * MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS; + * every frame gets one reply, touches get none. */ + +#define VENC_MAX_BUFFERS 16 + +enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5 }; +enum venc_touch_action { VENC_TOUCH_DOWN = 0, VENC_TOUCH_MOVE = 1, VENC_TOUCH_UP = 2 }; +enum venc_codec { VENC_H264 = 0, VENC_HEVC = 1 }; + +struct venc_config { + uint32_t type; + uint32_t codec; + uint32_t width, height, stride, rows; + uint32_t fps_num, fps_den; + uint32_t bitrate, peak_bitrate, vbr; + uint32_t gop; + int32_t profile, level; + uint32_t inputs, outputs; + uint32_t input_size, output_size; +}; + +struct venc_frame { + uint32_t type; + uint32_t input, output; + uint32_t bytes; + uint32_t keyframe; + int64_t pts_us; +}; + +struct venc_done { + uint32_t type; + int32_t status; + uint32_t bytes; + uint32_t keyframe; +}; + +struct venc_bitrate { + uint32_t type; + uint32_t bitrate; +}; + +/* Remote Play's touch-as-mouse turned back into a touch; x and y are fractions of the screen in 1/65535. */ +struct venc_touch { + uint32_t type; + uint32_t action; + uint32_t x, y; +}; + +#endif diff --git a/app/src/main/cpp/venc/venc_server.cpp b/app/src/main/cpp/venc/venc_server.cpp new file mode 100644 index 000000000..be484c898 --- /dev/null +++ b/app/src/main/cpp/venc/venc_server.cpp @@ -0,0 +1,346 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "venc_proto.h" + +#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckVenc", __VA_ARGS__) + +namespace { + +struct Mapping { + uint8_t *data = nullptr; + size_t size = 0; +}; + +constexpr int32_t kColorFormatYuv420SemiPlanar = 21; +constexpr uint32_t kBufferFlagKeyFrame = 1; +constexpr uint32_t kBufferFlagCodecConfig = 2; +constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt +constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32) + +int32_t avc_profile(int32_t v4l2) { + switch (v4l2) { + case 0: return 1; + case 1: return 0x10000; + case 2: return 2; + case 3: return 4; + case 4: return 8; + default: return 0; + } +} + +class Session { +public: + explicit Session(int fd) : fd_(fd) {} + ~Session() { + release(); + close(fd_); + } + + void run() { + for (;;) { + uint8_t buf[256]; + int fds[VENC_MAX_BUFFERS * 2]; + int nfds = 0; + ssize_t n = receive(buf, sizeof buf, fds, &nfds); + if (n <= 0) return; + uint32_t type; + memcpy(&type, buf, sizeof type); + if (type == VENC_CONFIG && n == (ssize_t)sizeof(venc_config)) { + venc_config c; + memcpy(&c, buf, sizeof c); + bool ok = configure(c, fds, nfds); + for (int i = 0; i < nfds; i++) close(fds[i]); + reply(ok ? 0 : -1, 0, false); + } else if (type == VENC_FRAME && n == (ssize_t)sizeof(venc_frame)) { + venc_frame f; + memcpy(&f, buf, sizeof f); + uint32_t bytes = 0; + bool key = false; + bool ok = encode(f, &bytes, &key); + reply(ok ? 0 : -1, bytes, key); + } else if (type == VENC_TOUCH && n == (ssize_t)sizeof(venc_touch)) { + venc_touch t; + memcpy(&t, buf, sizeof t); + touch(t); + for (int i = 0; i < nfds; i++) close(fds[i]); + } else if (type == VENC_BITRATE && n == (ssize_t)sizeof(venc_bitrate)) { + venc_bitrate b; + memcpy(&b, buf, sizeof b); + set_bitrate(b.bitrate); + for (int i = 0; i < nfds; i++) close(fds[i]); + } else { + LOG("unexpected message %u of %zd bytes; closing", type, n); + for (int i = 0; i < nfds; i++) close(fds[i]); + return; + } + } + } + +private: + ssize_t receive(uint8_t *buf, size_t len, int *fds, int *nfds) { + char control[CMSG_SPACE(sizeof(int) * VENC_MAX_BUFFERS * 2)]; + iovec iov = {buf, len}; + msghdr m = {}; + m.msg_iov = &iov; + m.msg_iovlen = 1; + m.msg_control = control; + m.msg_controllen = sizeof control; + ssize_t n = recvmsg(fd_, &m, MSG_CMSG_CLOEXEC); + for (cmsghdr *c = CMSG_FIRSTHDR(&m); n > 0 && c; c = CMSG_NXTHDR(&m, c)) { + if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) continue; + int count = (int)((c->cmsg_len - CMSG_LEN(0)) / sizeof(int)); + memcpy(fds, CMSG_DATA(c), sizeof(int) * std::min(count, VENC_MAX_BUFFERS * 2)); + *nfds = std::min(count, VENC_MAX_BUFFERS * 2); + } + return n; + } + + void reply(int32_t status, uint32_t bytes, bool key) { + venc_done d = {VENC_DONE, status, bytes, key ? 1u : 0u}; + send(fd_, &d, sizeof d, MSG_NOSIGNAL); + } + + void release() { + if (codec_) { + AMediaCodec_stop(codec_); + AMediaCodec_delete(codec_); + codec_ = nullptr; + } + for (auto &m : inputs_) munmap(m.data, m.size); + for (auto &m : outputs_) munmap(m.data, m.size); + inputs_.clear(); + outputs_.clear(); + header_.clear(); + } + + AMediaCodec *create(const venc_config &c, bool with_profile) { + const char *mime = c.codec == VENC_HEVC ? "video/hevc" : "video/avc"; + AMediaCodec *codec = AMediaCodec_createEncoderByType(mime); + if (!codec) return nullptr; + float fps = c.fps_num ? (float)c.fps_den / (float)c.fps_num : 60.0f; + AMediaFormat *f = AMediaFormat_new(); + AMediaFormat_setString(f, AMEDIAFORMAT_KEY_MIME, mime); + AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_WIDTH, (int32_t)c.width); + AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_HEIGHT, (int32_t)c.height); + AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_COLOR_FORMAT, kColorFormatYuv420SemiPlanar); + AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_BIT_RATE, (int32_t)std::max(c.bitrate, 500000u)); + AMediaFormat_setInt32(f, "bitrate-mode", c.vbr ? 1 : 2); + AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_FRAME_RATE, fps); + AMediaFormat_setFloat(f, "operating-rate", fps); + AMediaFormat_setInt32(f, "priority", 0); + AMediaFormat_setInt32(f, "latency", 1); + AMediaFormat_setInt32(f, "max-bframes", 0); + AMediaFormat_setInt32(f, "prepend-sps-pps-to-idr-frames", 1); + float gop_seconds = c.gop && c.gop < 0xffff ? (float)c.gop / fps : 3600.0f; + AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, std::max(gop_seconds, 0.1f)); + if (with_profile) { + int32_t profile = c.codec == VENC_HEVC ? (c.profile == 2 ? 2 : 1) : avc_profile(c.profile); + if (profile) AMediaFormat_setInt32(f, "profile", profile); + } + media_status_t s = AMediaCodec_configure(codec, f, nullptr, nullptr, AMEDIACODEC_CONFIGURE_FLAG_ENCODE); + AMediaFormat_delete(f); + if (s == AMEDIA_OK) s = AMediaCodec_start(codec); + if (s != AMEDIA_OK) { + AMediaCodec_delete(codec); + return nullptr; + } + return codec; + } + + bool configure(const venc_config &c, const int *fds, int nfds) { + release(); + if (c.inputs + c.outputs != (uint32_t)nfds || c.width < 64 || c.height < 64 || c.stride < c.width || + c.rows < c.height || (uint64_t)c.stride * c.rows * 3 / 2 > c.input_size) { + LOG("bad config: %u+%u buffers for %d fds, %ux%u stride %u", c.inputs, c.outputs, nfds, c.width, c.height, + c.stride); + return false; + } + for (int i = 0; i < nfds; i++) { + bool input = (uint32_t)i < c.inputs; + size_t size = input ? c.input_size : c.output_size; + void *p = mmap(nullptr, size, input ? PROT_READ : PROT_READ | PROT_WRITE, MAP_SHARED, fds[i], 0); + if (p == MAP_FAILED) { + LOG("could not map buffer %d: %s", i, strerror(errno)); + release(); + return false; + } + (input ? inputs_ : outputs_).push_back({(uint8_t *)p, size}); + } + codec_ = create(c, true); + if (!codec_) codec_ = create(c, false); + if (!codec_) { + LOG("no %s encoder for %ux%u", c.codec == VENC_HEVC ? "HEVC" : "H.264", c.width, c.height); + release(); + return false; + } + config_ = c; + dst_stride_ = (int32_t)c.width; + dst_rows_ = (int32_t)c.height; + using InputFormat = AMediaFormat *(*)(AMediaCodec *); + static auto input_format = (InputFormat)dlsym(RTLD_DEFAULT, "AMediaCodec_getInputFormat"); + if (AMediaFormat *in = input_format ? input_format(codec_) : nullptr) { + int32_t v; + if (AMediaFormat_getInt32(in, "stride", &v) && v >= (int32_t)c.width) dst_stride_ = v; + if (AMediaFormat_getInt32(in, "slice-height", &v) && v >= (int32_t)c.height) dst_rows_ = v; + AMediaFormat_delete(in); + } + LOG("%s %ux%u at %.2f fps, %u kbit/s %s, input stride %d rows %d", c.codec == VENC_HEVC ? "HEVC" : "H.264", + c.width, c.height, c.fps_num ? (double)c.fps_den / c.fps_num : 0.0, c.bitrate / 1000, + c.vbr ? "VBR" : "CBR", dst_stride_, dst_rows_); + return true; + } + + static void touch(const venc_touch &t) { + using SendTouch = void (*)(int, int, int, int); + static SendTouch send_touch = [] { + void *wayland = dlopen("libdroiddeckwayland.so", RTLD_NOW | RTLD_NOLOAD); + auto fn = wayland ? (SendTouch)dlsym(wayland, "droiddeck_wayland_send_touch") : nullptr; + if (!fn) LOG("remote touch unavailable: %s", dlerror()); + return fn; + }(); + if (!send_touch || t.action > VENC_TOUCH_UP) return; + int x = (int)((uint64_t)std::min(t.x, 65535u) * (kTouchWidth - 1) / 65535); + int y = (int)((uint64_t)std::min(t.y, 65535u) * (kTouchHeight - 1) / 65535); + send_touch((int)t.action, kRemoteTouchId, x, y); + } + + void set_bitrate(uint32_t bitrate) { + if (!codec_) return; + AMediaFormat *p = AMediaFormat_new(); + AMediaFormat_setInt32(p, "video-bitrate", (int32_t)std::max(bitrate, 500000u)); + AMediaCodec_setParameters(codec_, p); + AMediaFormat_delete(p); + } + + bool encode(const venc_frame &f, uint32_t *bytes, bool *key) { + if (!codec_ || f.input >= inputs_.size() || f.output >= outputs_.size()) return false; + if (f.keyframe) { + AMediaFormat *p = AMediaFormat_new(); + AMediaFormat_setInt32(p, "request-sync", 0); + AMediaCodec_setParameters(codec_, p); + AMediaFormat_delete(p); + } + ssize_t in = AMediaCodec_dequeueInputBuffer(codec_, 100000); + if (in < 0) return false; + size_t capacity = 0; + uint8_t *dst = AMediaCodec_getInputBuffer(codec_, (size_t)in, &capacity); + size_t need = (size_t)dst_stride_ * dst_rows_ + (size_t)dst_stride_ * ((config_.height + 1) / 2); + if (!dst || capacity < need) return false; + const uint8_t *src = inputs_[f.input].data; + for (uint32_t y = 0; y < config_.height; y++) + memcpy(dst + (size_t)y * dst_stride_, src + (size_t)y * config_.stride, config_.width); + const uint8_t *src_uv = src + (size_t)config_.stride * config_.rows; + uint8_t *dst_uv = dst + (size_t)dst_stride_ * dst_rows_; + for (uint32_t y = 0; y < (config_.height + 1) / 2; y++) + memcpy(dst_uv + (size_t)y * dst_stride_, src_uv + (size_t)y * config_.stride, config_.width); + if (AMediaCodec_queueInputBuffer(codec_, (size_t)in, 0, need, (uint64_t)std::max(f.pts_us, 0), 0) != + AMEDIA_OK) + return false; + Mapping &out = outputs_[f.output]; + for (int tries = 0; tries < 20; tries++) { + AMediaCodecBufferInfo info; + ssize_t o = AMediaCodec_dequeueOutputBuffer(codec_, &info, 20000); + if (o == AMEDIACODEC_INFO_TRY_AGAIN_LATER || o == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED || + o == AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED) + continue; + if (o < 0) return false; + size_t size = 0; + uint8_t *data = AMediaCodec_getOutputBuffer(codec_, (size_t)o, &size); + const uint8_t *payload = data ? data + info.offset : nullptr; + size_t length = data ? (size_t)info.size : 0; + if (info.flags & kBufferFlagCodecConfig) { + header_.assign(payload, payload + length); + AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false); + continue; + } + *key = (info.flags & kBufferFlagKeyFrame) != 0; + bool prepend = *key && !header_.empty() && + (length < header_.size() || memcmp(payload, header_.data(), header_.size()) != 0); + size_t total = length + (prepend ? header_.size() : 0); + bool fits = payload && total <= out.size; + if (fits) { + if (prepend) memcpy(out.data, header_.data(), header_.size()); + memcpy(out.data + (prepend ? header_.size() : 0), payload, length); + *bytes = (uint32_t)total; + } + AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false); + return fits; + } + return false; + } + + int fd_; + AMediaCodec *codec_ = nullptr; + venc_config config_ = {}; + int32_t dst_stride_ = 0, dst_rows_ = 0; + std::vector inputs_, outputs_; + std::vector header_; +}; + +std::atomic g_listener{-1}; + +void serve(int listener) { + for (;;) { + int fd = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC); + if (fd < 0) { + if (errno == EINTR) continue; + return; + } + std::thread([fd] { + Session s(fd); + s.run(); + }).detach(); + } +} + +} // namespace + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) { + if (g_listener.load() >= 0) return JNI_TRUE; + const char *path = env->GetStringUTFChars(jpath, nullptr); + sockaddr_un addr = {}; + addr.sun_family = AF_UNIX; + bool fits = strlen(path) < sizeof addr.sun_path; + if (fits) strcpy(addr.sun_path, path); + int fd = fits ? socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0) : -1; + if (fd >= 0) { + unlink(path); + if (bind(fd, (sockaddr *)&addr, sizeof addr) != 0 || listen(fd, 4) != 0) { + LOG("could not listen on %s: %s", path, strerror(errno)); + close(fd); + fd = -1; + } + } + env->ReleaseStringUTFChars(jpath, path); + if (fd < 0) return JNI_FALSE; + g_listener.store(fd); + std::thread(serve, fd).detach(); + return JNI_TRUE; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStop(JNIEnv *, jobject) { + int fd = g_listener.exchange(-1); + if (fd >= 0) { + shutdown(fd, SHUT_RDWR); + close(fd); + } +} diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index 47ab1c552..16c707d63 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -45,10 +45,10 @@ target_include_directories(droiddeckwayland PRIVATE ) set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg) -set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg) +set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg) if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp - AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp) - message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)") + AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp) + message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)") target_sources(droiddeckwayland PRIVATE src/framegen_engine.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp @@ -68,9 +68,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp - ${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp - ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp - ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp + ${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp + ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp + ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp ) target_include_directories(droiddeckwayland PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/.. @@ -79,8 +79,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp target_compile_options(droiddeckwayland PRIVATE $<$:-std=c++17 -fvisibility=hidden>) target_compile_definitions(droiddeckwayland PRIVATE - WINFG_UPSTREAM="${WINFG_UPSTREAM}" - WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}" + DDFG_UPSTREAM="${DDFG_UPSTREAM}" + DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}" ) target_link_libraries(droiddeckwayland dxbc atomic) else() diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 983d8abb8..4a281e185 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -228,6 +228,8 @@ struct surface { enum surface_role role; struct wl_resource *idle_buffer; struct wl_listener idle_buffer_destroy; + uint32_t *idle_px; + int idle_w, idle_h; struct wl_resource *xdg_surface, *xdg_toplevel; struct wl_resource *viewport; @@ -979,8 +981,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) g_cursor_shown = s; } +static void surface_forget_idle_px(struct surface *s) { + free(s->idle_px); + s->idle_px = NULL; + s->idle_w = s->idle_h = 0; +} + static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) { struct surface *s = wl_container_of(l, s, idle_buffer_destroy); + struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer); + if (shm) { + int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm); + int32_t stride = wl_shm_buffer_get_stride(shm); + uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4 + ? realloc(s->idle_px, (size_t)w * h * 4) : NULL; + if (px) { + wl_shm_buffer_begin_access(shm); + const uint8_t *src = wl_shm_buffer_get_data(shm); + for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4); + wl_shm_buffer_end_access(shm); + s->idle_px = px; + s->idle_w = w; + s->idle_h = h; + } + } wl_list_remove(&s->idle_buffer_destroy.link); wl_list_init(&s->idle_buffer_destroy.link); s->idle_buffer = NULL; @@ -1007,6 +1031,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) { } static void surface_drop_idle(struct surface *s) { + surface_forget_idle_px(s); struct wl_resource *buffer = surface_take_idle(s); if (buffer) send_buffer_release(buffer); } @@ -1136,6 +1161,7 @@ static void surface_resource_destroy(struct wl_resource *r) { if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); } if (g_cursor_shown == s) g_cursor_shown = NULL; surface_take_idle(s); + surface_forget_idle_px(s); if (g_grab == s) g_grab = NULL; if (g_key_target == s) g_key_target = NULL; if (g_ime_click == s) g_ime_click = NULL; @@ -2123,6 +2149,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3 send_buffer_release(buffer); } else if (g_cursor_shown == s) { cursor_publish_shown(); + } else if (s->idle_px) { + cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy); + g_cursor_shown = s; } } static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } @@ -2674,7 +2703,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int uint32_t t = now_ms(); pointer_focus(target->resource, fx, fy); - if (!(k && k->is_lock)) + // A button-only input must not replay a cached absolute position. A game + // may have warped its cursor since the last motion (before taking lock, + // or while releasing it), so that replay can become an unintended pan. + // Absolute touch events still carry their position, including on clicks. + if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0)) for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy); send_relative_motion(client, dx, dy); for_each_pointer_of(client, sp) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 379087045..94bb581a0 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -86,7 +86,7 @@ static int64_t now_ns(void) { void vkp_framegen_set_engine(int kind) { - if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG; + if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG; atomic_store(&g_kind, kind); atomic_store(&g_failed_kind, -1); atomic_store(&g_cfg_dirty, 1); @@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) { atomic_store(&g_cfg_dirty, 1); } -void vkp_framegen_set_winfg_tuning(int model, int perf_preset) { +void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) { atomic_store(&g_model, model == 3 ? 3 : 4); atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset); atomic_store(&g_cfg_dirty, 1); @@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops); fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled); g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier; - FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s", + FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s", fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(), - fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable"); + fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable"); } void vkp_framegen_device_lost(void) { g_dev_lost = 1; } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h index 5e27db40d..573d6fd3c 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h @@ -6,14 +6,14 @@ #endif #include -enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 }; +enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 }; #define VKP_FG_MAX_GENERATIONS 3 void vkp_framegen_set_engine(int kind); void vkp_framegen_set_armed(int armed, int multiplier, int target_fps); void vkp_framegen_set_lsfg_cache_path(const char *path); void vkp_framegen_set_tuning(float flow_scale, float refresh_hz); -void vkp_framegen_set_winfg_tuning(int model, int perf_preset); +void vkp_framegen_set_ddfg_tuning(int model, int perf_preset); int vkp_framegen_problem(void); const char *vkp_framegen_caps_reason(void); void vkp_framegen_stats(float out[6]); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp index 43edb1240..8558ae3d3 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp @@ -12,7 +12,7 @@ #include "lsfg/lsfg_engine.h" #include "lsfg/lsfg_probe.h" #include "lsfg/lsfg_vkd.h" -#include "winfg/winfg_engine.h" +#include "ddfg/ddfg_engine.h" #define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__) #define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__) @@ -30,7 +30,7 @@ uint32_t g_qfam = 0; bool g_probed = false, g_ready = false; std::unique_ptr g_lsfg; -std::unique_ptr g_winfg; +std::unique_ptr g_ddfg; int g_kind = -1; char g_build[96] = ""; @@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) { fge_stop(); if (!g_ready) return -1; if (kind == 1) { - auto e = std::make_unique(); + auto e = std::make_unique(); if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) { - ELOGE("winfg-native: engine init failed"); + ELOGE("ddfg-native: engine init failed"); return -1; } - g_winfg = std::move(e); + g_ddfg = std::move(e); g_kind = 1; -#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH) - std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH); +#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH) + std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH); #else std::snprintf(g_build, sizeof(g_build), "embedded chain"); #endif @@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) { } void fge_stop(void) { - if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev); + if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev); g_lsfg.reset(); - g_winfg.reset(); + g_ddfg.reset(); g_kind = -1; } int fge_unavailable(void) { - if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0; + if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0; return g_lsfg && g_lsfg->unavailable() ? 1 : 0; } void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model, int perf_preset) { if (multiplier < 2) multiplier = 2; - if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale); + if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale); else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz); } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { - if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0; + if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0; if (!g_lsfg) return 0; g_lsfg->setGuestExtent(w, h); return g_lsfg->prepare(w, h, fmt) ? 1 : 0; } uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) { - if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0; + if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0; if (!g_lsfg) return 0; g_lsfg->setPresentedRate(presented_rate); return g_lsfg->plan(capacity, source_frames); } void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) { - if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; } + if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; } if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens); } void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view, uint32_t w, uint32_t h) { - if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; } + if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; } if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h); } @@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) { } } -const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; } +const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; } const char *fge_build_info(void) { return g_build; } } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c index b9c4dff36..310e10c34 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c @@ -34,5 +34,5 @@ void fge_forget_targets(void) {} void fge_telemetry(float *accepted, float *source_rate, float *thermal) { *accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f; } -const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; } +const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; } const char *fge_build_info(void) { return "stub"; } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_jni.c b/app/src/main/cpp/waylandcomp/src/framegen_jni.c index ea5ce0396..35c5a3de4 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_jni.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_jni.c @@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN } JNIEXPORT void JNICALL -Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz, +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz, jint model, jint perfPreset) { (void)env; (void)clazz; - vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset); + vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset); } JNIEXPORT jint JNICALL diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index e0abe387d..3fc438377 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -16,16 +16,22 @@ class App : Application() { override fun onCreate() { super.onCreate() + com.droiddeck.launcher.ui.Themes.loadGradient(this) // Helper processes must not initialize the main USB provider or guest graphics context. val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName() else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager) .runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName + if (process == "$packageName:xboxlogin") { + if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox") + 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.settleGpuDriverMode(this) com.droiddeck.launcher.update.AppUpdates.init(this) com.droiddeck.launcher.core.DownloadSource.init(this) + prefetchAddonPacks() GpuClockPin.clearLeftover(this) com.droiddeck.launcher.glasses.Glasses.init(this) com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this) @@ -35,5 +41,24 @@ class App : Application() { com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend) + com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend) + com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend) + } + + /** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */ + private fun prefetchAddonPacks() { + val app = this + Thread({ + runCatching { + if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) { + com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app) + com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app) + } + if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app) + if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null) + com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app) + com.droiddeck.launcher.handtracking.HandTracking.load(app) + }.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) } + }, "addon-packs").start() } } diff --git a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt index 9f8a27f9f..240202dae 100644 --- a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt @@ -11,29 +11,46 @@ import java.io.File internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) { var deckyInstalled by mutableStateOf(null) - var deckyReleases by mutableStateOf>(emptyList()) + var deckyLatest by mutableStateOf(null) + var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE) + private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList()) var deckyChecking by mutableStateOf(false) var deckyStage by mutableStateOf(null) var deckyPercent by mutableIntStateOf(-1) var deckyDownloadingBinary by mutableStateOf(false) var deckySupervisor by mutableStateOf(false) + var deckyPendingZip by mutableStateOf(null) var deckyReleaseRequest = 0 fun refreshDecky() { val request = ++deckyReleaseRequest + deckyBranch = DeckyManager.branch(activity) deckyChecking = true Thread({ val channels = runCatching { DeckyManager.releaseChannels(activity) } .getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) } ui.post { if (request == deckyReleaseRequest) { - deckyReleases = channels.stable.ifEmpty { channels.prerelease } + deckyChannels = channels + deckyLatest = DeckyManager.latest(channels, deckyBranch) deckyChecking = false } } }, "decky-releases").start() } + fun switchDeckyBranch(branch: Int) { + if (branch == deckyBranch || deckyStage != null || SessionState.running) return + val saved = runCatching { DeckyManager.setBranch(activity, branch) } + if (saved.isFailure) { + android.widget.Toast.makeText(activity, R.string.decky_channel_failed, android.widget.Toast.LENGTH_LONG).show() + return + } + deckyBranch = branch + deckyLatest = DeckyManager.latest(deckyChannels, branch) + refreshDecky() + } + fun installDecky(release: DeckyManager.Release) { if (deckyStage != null || SessionState.running) return deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1 @@ -49,6 +66,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val } fun importPluginZip(archive: File) { + if (deckyStage != null || SessionState.running || deckyInstalled == null) return + deckyPendingZip = archive + } + + fun cancelPluginZip() { + deckyPendingZip = null + } + + fun confirmPluginZip() { + val archive = deckyPendingZip ?: return + deckyPendingZip = null if (deckyStage != null || SessionState.running || deckyInstalled == null) return deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1 Thread({ diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index 5f9a111fd..b609379e3 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableIntStateOf import com.droiddeck.launcher.gpu.AutoDriverTrial +import com.droiddeck.launcher.gpu.AutoPicks import com.droiddeck.launcher.gpu.DriverBundle import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.gpu.DriverPairs @@ -184,7 +185,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl val lm = LinuxVulkanDriverManager(activity) val td = TurnipDriver(activity) val pairs = DriverPairs.from(TurnipReleases.cached(activity)) - val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs) + val recommended = if (QuestExperimental.isSupported()) null else recommendedKey(pairs) pairRows = pairs.map { p -> val nad = DriverPairs.isNonAdreno(p.key) val bundle = p.bundle?.let { if (nad) installedBundleAnyBuild(it) else installedBundle(it) } @@ -212,6 +213,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl }.sortedByDescending { it.recommended } } + private fun recommendedKey(pairs: List) = DriverPairs.recommendedKey( + gpu, pairs, TurnipReleases.autoPicks(activity), AutoDriverTrial.bad(activity), + blob = AutoPicks.blobVersion(com.droiddeck.launcher.gpu.VulkanInfo.cachedOrNull()?.get("driver_version")), + ) + fun chooseMode(next: String) { pairModeOpen = next == GPU_MODE_PAIR setMode(auto = next == GPU_MODE_AUTO, mix = next == GPU_MODE_MIX) @@ -314,12 +320,13 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl Thread({ val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity) + runCatching { com.droiddeck.launcher.gpu.VulkanInfo.query() } ui.post { releaseChecking = false refreshReleaseRows() refreshPairs() val pairs = DriverPairs.from(TurnipReleases.cached(activity)) - val key = DriverPairs.recommendedKey(gpu, pairs) + val key = recommendedKey(pairs) val pair = pairs.firstOrNull { it.key == key } val row = pairRows.firstOrNull { it.key == key } when { diff --git a/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt b/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt new file mode 100644 index 000000000..9183df76e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager + +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") + + val options: List> = listOf( + DARK to R.string.setup_launcher_icon_dark, + LIGHT to R.string.setup_launcher_icon_light, + ) + + private fun component(context: Context, style: String) = + ComponentName(context, aliases.getValue(style)) + + private fun enabled(context: Context, style: String): Boolean = + when (context.packageManager.getComponentEnabledSetting(component(context, style))) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> style == DARK + else -> false + } + + fun current(context: Context): String = aliases.keys.firstOrNull { enabled(context, it) } ?: DARK + + fun activity(context: Context): ComponentName = component(context, current(context)) + + fun set(context: Context, style: String) { + if (style !in aliases || style == current(context)) return + val pm = context.packageManager + pm.setComponentEnabledSetting(component(context, style), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP) + aliases.keys.filter { it != style }.forEach { + pm.setComponentEnabledSetting(component(context, it), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e5ebeef88..946507d83 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() { private var frameGenLabel by mutableStateOf("") private var lossless by mutableStateOf(Lossless.State.NONE) private var showRemove by mutableStateOf(false) + private var showRuntimeActions by mutableStateOf(null) private var pendingSwitch by mutableStateOf(null) private var switchingTo: Intent? = null private var switchPoll: Runnable? = null @@ -118,11 +119,13 @@ class MainActivity : ComponentActivity() { private var showNonAdreno by mutableStateOf(null) private var glThread by mutableStateOf(true) private var noGlError by mutableStateOf(true) + private var returnAfterGame by mutableStateOf(true) private var steamRepairQueued by mutableStateOf(false) private var mangoapp by mutableStateOf(true) private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) + private var componentsGameKey by mutableStateOf(null) private var showMapping by mutableStateOf(false) private var saveBusy: String? = null private var onSavePicked: ((File) -> Unit)? = null @@ -163,12 +166,13 @@ 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_SENSOR_LANDSCAPE) + private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_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 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) @@ -493,6 +497,20 @@ class MainActivity : ComponentActivity() { pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder)) } + private fun switchLauncherIcon(style: String): Boolean { + val before = LauncherIcon.current(this) + LauncherIcon.set(this, style) + launcherIcon = LauncherIcon.current(this) + if (launcherIcon == before) return false + // Android finishes activities started through the alias that was just disabled. + startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + .setComponent(LauncherIcon.activity(this)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + .putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher")) + finishAndRemoveTask() + return true + } + private fun readGameIntent(request: Intent) { request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) } shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT @@ -748,6 +766,7 @@ class MainActivity : ComponentActivity() { downloadPrefix = DownloadSource.custom(this) DownloadSource.probe() animationsEnabled = SessionPrefs.animationsEnabled(this) + launcherIcon = LauncherIcon.current(this) gameStoresEnabled = SessionPrefs.gameStoresEnabled(this) gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this) storesOpenTab = SessionPrefs.storesOpenTab(this) @@ -759,6 +778,9 @@ class MainActivity : ComponentActivity() { setContent { DroidDeckTheme(theme, appScale = appScale) { com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) { + if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog( + onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() }, + ) val page: (@Composable () -> Unit)? = when { showPerformance -> { { PerformanceHost() } } showComponents -> { { ComponentsHost() } } @@ -810,6 +832,7 @@ class MainActivity : ComponentActivity() { downloadMirror = downloadMirror, downloadPrefix = downloadPrefix, animationsEnabled = animationsEnabled, + launcherIcon = launcherIcon, gameStoresEnabled = gameStoresEnabled, gameStoresSpeedTier = gameStoresSpeedTier, storesOpenTab = storesOpenTab, @@ -853,6 +876,7 @@ class MainActivity : ComponentActivity() { }, onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) }, onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) }, + onGameRemoved = { g -> removeGame(g) }, onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() }, onSgdbKey = { key -> Thread({ @@ -925,6 +949,7 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, + onGameComponents = { game -> openComponents(gameKey = game.profileKey) }, onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) }, onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } }, onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } }, @@ -982,6 +1007,7 @@ class MainActivity : ComponentActivity() { onPageBack = { showPerformance = false; showComponents = false; showMapping = false }, onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, + onLauncherIcon = { style -> switchLauncherIcon(style) }, onLanguage = { tag -> if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) { com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag) @@ -1136,6 +1162,13 @@ class MainActivity : ComponentActivity() { onDismiss = { pendingSwitch = null }, ) } + showRuntimeActions?.let { release -> + com.droiddeck.launcher.ui.RuntimeActionsDialog( + onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) }, + onRemove = { showRuntimeActions = null; showRemove = true }, + onDismiss = { showRuntimeActions = null }, + ) + } if (showRemove) ConfirmDialog( title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title), text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text), @@ -1295,6 +1328,7 @@ class MainActivity : ComponentActivity() { syncLossless() updates.onResume() drivers.checkOnOpen() + resumeInterruptedPackages() protons.checkUpdates() if (!SessionState.running) Thread({ val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList()) @@ -1439,8 +1473,9 @@ class MainActivity : ComponentActivity() { ) } - private fun openComponents(focusContent: Boolean = true, tab: String? = null) { + private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) { focusComponentsContent = focusContent + componentsGameKey = gameKey if (tab != null) components.compComp = tab showPerformance = false showMapping = false @@ -1489,6 +1524,7 @@ class MainActivity : ComponentActivity() { onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) }, onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false }, games = steamGames, + initialGameKey = componentsGameKey, generalFexPreset = fexPreset, onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id }, gpu = drivers.state(), @@ -1549,6 +1585,27 @@ class MainActivity : ComponentActivity() { .putExtra(SessionService.EXTRA_PROGRAM, path)) } + private fun resumeInterruptedPackages() { + if (runtimeChangesBlocked()) return + val first = DesktopCatalog.interrupted(this).firstOrNull() + if (first == null) { + Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start() + return + } + pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1 + Thread({ + val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent -> + ui.post { pkgStage = stage; pkgPercent = percent } + } + ui.post { + pkgStage = null; pkgId = null + if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show() + refreshPackages() + refresh() + } + }, "resume-pkg").start() + } + private fun installPackage(id: String) { val entry = catalog?.firstOrNull { it.id == id } ?: return if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return @@ -1599,6 +1656,7 @@ class MainActivity : ComponentActivity() { pipAutoEnter = pipAutoEnter, unfoldedControls = if (mode == SessionService.MODE_STEAM && com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null, + unfoldedControlsWithController = unfoldedControlsWithController, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, backActionsInverted = backActionsInverted, directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, @@ -1612,6 +1670,7 @@ class MainActivity : ComponentActivity() { forceSsbs = forceSsbs, 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, steamRepairQueued = steamRepairQueued, mangoapp = mangoapp, steamController = if (mode == SessionService.MODE_STEAM) steamController else null, @@ -1627,7 +1686,8 @@ class MainActivity : ComponentActivity() { wifiDiscoveryAsked = wifiDiscoveryAsked, wifiDiscoveryBlocked = wifiDiscoveryBlocked, deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null, - deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null, + deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null, + deckyBranch = decky.deckyBranch, deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent, deckyDownloadingBinary = decky.deckyDownloadingBinary, deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running, @@ -1663,6 +1723,9 @@ class MainActivity : ComponentActivity() { 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 -> + SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on + }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this, inverted) @@ -1697,19 +1760,21 @@ class MainActivity : ComponentActivity() { onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) }, onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() }, onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on }, + onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on }, onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id }, onRunSteamAtStartup = { on -> SessionPrefs.setRunSteamAtStartup(this, on) runSteamAtStartup = on }, - onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() }, + onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() }, onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on }, onImportBootMovie = { pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick))) }, - onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() }, + onBootMovieChanged = { loadBootMovie() }, onDeckyInstall = { release -> decky.installDecky(release) }, onDeckyCheck = { decky.refreshDecky() }, + onDeckyBranch = { branch -> decky.switchDeckyBranch(branch) }, onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled }, onDeckyUninstall = { DeckyManager.uninstall(this, wipeData = false) @@ -1792,6 +1857,30 @@ class MainActivity : ComponentActivity() { }, "steam-art-update").start() } + private fun removeGame(g: Library.SteamGame) { + val folder = g.gameFiles ?: return + when (com.droiddeck.launcher.frontend.GameRemoval.kind(g)) { + com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER -> Unit + com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT -> removeAddedGame(folder.path, g.gameId, g.name) + com.droiddeck.launcher.frontend.GameRemoval.Kind.STORE -> { + val installed = com.droiddeck.launcher.frontend.GameRemoval.storeGame(g) + if (installed != null) { + com.droiddeck.launcher.stores.StoresState.uninstall(this, installed) { refreshAddedGames(); refresh() } + return + } + if (g.library == Library.ADDED) { + removeAddedGame(folder.path, g.gameId, g.name) + return + } + val target = com.droiddeck.launcher.frontend.GameRemoval.steamFiles(g) ?: return + Thread({ + com.droiddeck.launcher.frontend.GameRemoval.delete(target) + ui.post { refreshAddedGames(); refresh() } + }, "game-remove").start() + } + } + } + private fun removeAddedGame(folder: String, appId: Long?, name: String) { steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder } Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start() @@ -1825,10 +1914,12 @@ class MainActivity : ComponentActivity() { private var pipAutoEnter by mutableStateOf(false) private var unfoldedControls by mutableStateOf(false) + private var unfoldedControlsWithController by mutableStateOf(false) private fun loadModeSettings(mode: String) { pipAutoEnter = SessionPrefs.pipAutoEnter(this) unfoldedControls = SessionPrefs.unfoldedControls(this) + unfoldedControlsWithController = SessionPrefs.unfoldedControlsWithController(this) resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) fexPreset = SessionPrefs.fexPreset(this) forceSsbs = SessionPrefs.forceSsbs(this) @@ -1838,6 +1929,7 @@ class MainActivity : ComponentActivity() { steamClientArch = SessionPrefs.steamClientArch(this) steamRepairQueued = SteamRepair.queued(this) mangoapp = SessionPrefs.mangoapp(this) + returnAfterGame = SessionPrefs.returnAfterGame(this) steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) loadBootMovie() @@ -1920,7 +2012,7 @@ class MainActivity : ComponentActivity() { val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64" if (listX64 != steamGamesX64) { steamGamesX64 = listX64 - steamGames = steamGames.filterNot(Library::fromSteamClient) + steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) } } if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) } if (showComponents) drivers.refreshDrivers() @@ -1962,12 +2054,13 @@ class MainActivity : ComponentActivity() { ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus } if (ready) { val quick = runCatching { Library.steamGames(this) }.getOrNull() + val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty() if (quick != null) ui.post { if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) { val before = steamGames.map { it.gameId }.toSet() val fresh = quick.filter { it.gameId !in before } if (fresh.isNotEmpty()) { - steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId } + steamGames = (quick + steamGames.filter { it.library == Library.ADDED && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId } fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) } } } @@ -2006,10 +2099,14 @@ class MainActivity : ComponentActivity() { private fun onRuntimeButton() { if (runtimeChangesBlocked()) return val release = available - if (removalPending || installed != null && release?.version == installed) { + if (removalPending) { showRemove = true return } + if (installed != null && release?.version == installed) { + showRuntimeActions = release + return + } if (release == null) { Thread({ checkCatalog() }, "catalog").start() return @@ -2093,7 +2190,7 @@ class MainActivity : ComponentActivity() { com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM) if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show() com.droiddeck.launcher.ui.Motion.refresh(this) - recreate() + if (!switchLauncherIcon(LauncherIcon.DARK)) recreate() } }, "reset-settings").start() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 370784b1f..baaa8a6ea 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -29,6 +29,7 @@ import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -75,11 +76,13 @@ import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PadMouse import com.droiddeck.launcher.input.PadState import com.droiddeck.launcher.input.SteamControllerBle +import com.droiddeck.launcher.input.DirectTouchHold import com.droiddeck.launcher.input.PointerGestures import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenDisplays import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures +import com.droiddeck.launcher.input.TouchscreenGestures import com.droiddeck.launcher.input.ScrollTouchGestures import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.LoadingState @@ -131,6 +134,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var pipUi by mutableStateOf(false) private var pipAutoEnter by mutableStateOf(false) private var unfoldedControls by mutableStateOf(false) + private var unfoldedControlsWithController by mutableStateOf(false) private var picturePosition by mutableStateOf("center") private fun applyGameAlignment() { @@ -188,7 +192,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private var watching = true private lateinit var touchpad: TouchpadGestures + private lateinit var directTouchHold: DirectTouchHold private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap + private lateinit var touchscreenGestures: TouchscreenGestures + private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig() + private var gestureGame: String? = null + private var gestureSettingsOpen by mutableStateOf(false) + private var recognizedGesture by mutableStateOf(null) + private var gesturePointerX = 0f + private var gesturePointerY = 0f + private var gesturePointerReady = false + private val clearRecognizedGesture = Runnable { recognizedGesture = null } + private val gestureKeysDown = mutableSetOf() private lateinit var scrollTouch: ScrollTouchGestures private lateinit var padMouse: PadMouse @Volatile private var padMouseOn = false @@ -209,6 +224,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ -> uiHandler.post { if (pointerLocked == locked) return@post + gesturePointerReady = false pointerLocked = locked Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released") padMouseCheckedAt = 0L @@ -292,6 +308,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var achievementsInFront by mutableStateOf(null) private var achievementsOpen by mutableStateOf(false) private var hudOn by mutableStateOf(true) + private var hudFps by mutableStateOf(true) + private var hudBattery by mutableStateOf(true) + private var hudGraph by mutableStateOf(true) + private var hudColor by mutableStateOf(SessionPrefs.HUD_COLOR_MINT) + private var hudOpacity by mutableStateOf(SessionPrefs.HUD_OPACITY_DEFAULT) private var upscaler by mutableStateOf(0) private var upscaleSharpness by mutableStateOf(75) private var effects by mutableStateOf(ScreenEffects.OFF) @@ -346,17 +367,46 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var secondScreenDisplays by mutableStateOf>(emptyList()) private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay) private var started = false + private var sessionActivityDisplayId = Display.DEFAULT_DISPLAY private lateinit var displayManager: DisplayManager private var secondScreenPresentation: SecondScreenPresentation? = null private val displayListener = object : DisplayManager.DisplayListener { - override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays() - override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays() + override fun onDisplayAdded(displayId: Int) { + notifyActivityDisplayStateChanged(displayId) + refreshSecondScreenDisplays() + } + override fun onDisplayRemoved(displayId: Int) { + if (refreshActivityDisplayId() && started) { + SessionService.setActivityDisplay(this@SessionActivity, sessionActivityDisplayId) + } + refreshSecondScreenDisplays() + } override fun onDisplayChanged(displayId: Int) { + notifyActivityDisplayStateChanged(displayId) followPanelRefresh(displayId) refreshSecondScreenDisplays() } } + @Suppress("DEPRECATION") + private fun currentActivityDisplayId(): Int = windowManager.defaultDisplay.displayId + + private fun refreshActivityDisplayId(): Boolean { + val current = if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: currentActivityDisplayId() + else currentActivityDisplayId() + val changed = sessionActivityDisplayId != current + sessionActivityDisplayId = current + SessionState.activityDisplayId = current + return changed + } + + private fun notifyActivityDisplayStateChanged(changedDisplayId: Int) { + val changed = refreshActivityDisplayId() + if (started && (changed || changedDisplayId == sessionActivityDisplayId)) { + SessionService.setActivityDisplay(this, sessionActivityDisplayId) + } + } + private fun followPanelRefresh(displayId: Int) { if (!CompositorHost.isStarted) return val output = glassesScreen?.display @@ -455,6 +505,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input")) bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam) + bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this)) padBridge = bridge SessionState.padBridge = bridge if (Build.VERSION.SDK_INT >= 31) { @@ -483,7 +534,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) } keyboard = KeyboardHost(this).also { root.addView(it) } touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) + directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) { + WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + } threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2) + touchscreenGestures = TouchscreenGestures( + ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput, + ) + refreshGestureConfig() + // A real touch target lets FrameLayout split fingers between the + // game surface and on-screen buttons, in either finger-down order. + root.isMotionEventSplittingEnabled = true + surfaceView.setOnTouchListener { _, event -> + routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event) + } padMouse = PadMouse(padMouseListener) bridge.setTouchRoute(object : PadBridge.TouchRoute { override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn @@ -500,12 +564,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM) if (!SessionState.running) { - val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null + val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false) + intent.removeExtra(EXTRA_REPAIR_RUNTIME) + val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null + val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty() val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP && - !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this) + (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) - if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton) + if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume) } hud = PerfHud(this) hud.onPresentingWindowChanged = { @@ -593,6 +660,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onDispose { com.droiddeck.launcher.glasses.GlassesMenuMirror.setSessionMenuVisible(this@SessionActivity, false) } } androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() } + androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() } androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() } DroidDeckTheme { if (pipUi) { @@ -605,10 +673,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } else { com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { - CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, + CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) - if (hud.text.isNotEmpty()) HudText(hud.text) - if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model) + if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, 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) { androidx.compose.ui.viewinterop.AndroidView( factory = { com.droiddeck.launcher.ui.TrackingRecoveryView(it) }, @@ -616,14 +685,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { update = { it.update(trackingRecovery, !glassesOutputActive && glasses.usable && glasses.sbs) }, ) } - if (GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) && + if (!phonePanel && GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) && headTracking && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesRecenterButton( controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100, onRecenterJoyCons = if (steamVrStatus == SteamVr.Status.RUNNING && glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking) joyConVr?.let { { it.recenter() } } else null, ) { headTracker?.recenter() } - if (glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot, + if (!phonePanel && glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot, controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100) { drawerPage = com.droiddeck.launcher.ui.SessionDrawerPage.GLASSES drawerOpen = true @@ -635,6 +704,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onCancel = { SessionService.stop(this@SessionActivity); finish() }, endedDetail = loading.endedDetail, onRetry = { retrySession() }, + onRepair = if (repairOffered) ({ repairRuntime() }) else null, onShareLogs = { shareCurrentSessionLogs() }, onClose = { finish() }, flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) }, @@ -643,6 +713,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)) }, ) + LaunchedEffect(AgentGuest.focusRevision) { + val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" } + else { + val snapshot = AgentGuest.focus(this@SessionActivity) + val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L } + val windows = snapshot?.optJSONArray("focusableApps") + focused?.toString() ?: windows?.let { values -> + (0 until values.length()).map { values.optLong(it) } + .filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString() + } + } + } + if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() } + } + LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) { + if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) { + cancelTouchGestures() + } + } + com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture) + if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog( + game = gestureGame, + onDismiss = { gestureSettingsOpen = false }, + onSaved = { gestureSettingsOpen = false; refreshGestureConfig() }, + ) androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) { syncLossless() @@ -678,7 +774,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { achievementsInFront = found if (found == null) achievementsOpen = false } - androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() } + androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads(); updateOnScreenControls() } LaunchedEffect(pcKeyboardOpen, drawerOpen, achievementsOpen) { if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset() } @@ -696,6 +792,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isHomeApp = isHomeApp, androidApps = androidApps, hudOn = hudOn, + hudFps = hudFps, + hudBattery = hudBattery, + hudGraph = hudGraph, + hudColor = hudColor, + hudOpacity = hudOpacity, upscaler = upscaler, upscaleSharpness = upscaleSharpness, effects = effects, cardboard = cardboard, @@ -723,6 +824,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { secondScreenDisplays = secondScreenDisplays, selectedSecondScreenDisplay = selectedSecondScreenDisplay, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, + onHudFps = { on -> SessionPrefs.setHudShowFps(this@SessionActivity, on); hudFps = on; hud.refresh() }, + onHudBattery = { on -> SessionPrefs.setHudShowBattery(this@SessionActivity, on); hudBattery = on; hud.refresh() }, + onHudGraph = { on -> SessionPrefs.setHudShowGraph(this@SessionActivity, on); hudGraph = on; hud.refresh() }, + onHudColor = { color -> SessionPrefs.setHudColor(this@SessionActivity, color); hudColor = color }, + onHudOpacity = { percent -> SessionPrefs.setHudOpacity(this@SessionActivity, percent); hudOpacity = percent }, onUpscaler = { m -> SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m WaylandCompositor.nativeSetUpscaler(m) @@ -779,6 +885,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionPrefs.setSteamController(this@SessionActivity, id) com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id)) com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id)) + com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id)) padType = id }, backActionsInverted = backActionsInverted, @@ -820,6 +927,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionService.suspendPolicyChanged(this@SessionActivity) }, onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, + onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true }, onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, onSecondScreenMode = ::selectSecondScreenMode, onSecondScreenDisplay = ::selectSecondScreenDisplay, @@ -847,6 +955,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null, onUnfoldedControls = ::chooseUnfoldedControls, + unfoldedControlsWithController = unfoldedControlsWithController, + onUnfoldedControlsWithController = ::chooseUnfoldedControlsWithController, onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) }, onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() }, onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() }, @@ -940,6 +1050,54 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, 90) } + @androidx.compose.runtime.Composable + private fun GlassesPhonePanel(glasses: com.droiddeck.launcher.glasses.GlassesState, vr: Boolean, headTracking: Boolean) { + val battery by androidx.compose.runtime.produceState(null) { + while (true) { + val b = registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED)) + val level = b?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = b?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, 100) ?: 100 + val tenths = b?.getIntExtra(android.os.BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1 + value = if (level < 0) null else getString(R.string.glasses_phone_battery, level * 100 / scale.coerceAtLeast(1), tenths / 10) + kotlinx.coroutines.delay(30_000) + } + } + val pads = com.droiddeck.launcher.input.VrControllers.settings.collectAsState().value + com.droiddeck.launcher.ui.GlassesPhonePanel(glasses.model, battery, com.droiddeck.launcher.ui.GlassesPhoneActions( + onRecenter = if (headTracking) ({ headTracker?.recenter() }) else null, + onRecenterJoyCons = if (vr && glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking) + joyConVr?.let { { it.recenter() } } else null, + onSteamVrMenu = if (vr && SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, + onSwitchSticks = if (vr && pads.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ({ switchVrSticks() }) else null, + controllers = getString(when (pads.emulate) { + com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_off + com.droiddeck.launcher.input.VrControllers.Emulate.INDEX -> R.string.vr_controllers_index + com.droiddeck.launcher.input.VrControllers.Emulate.TOUCH -> R.string.vr_controllers_touch + com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_vive + }), + onNextControllers = if (vr) ({ + val all = com.droiddeck.launcher.input.VrControllers.Emulate.entries + com.droiddeck.launcher.input.VrControllers.setEmulate(this, all[(pads.emulate.ordinal + 1) % all.size]) + }) else null, + onKeyboard = { keyboard?.toggle() }, + onSessionMenu = { drawerOpen = true }, + onStopSteamVr = if (vr) ({ steamVrAction("stop") }) else null, + brightness = glasses.brightness, + brightnessMax = glasses.brightnessMax, + onBrightness = if (glasses.canControl && glasses.hardwareControls) Glasses::setBrightness else null, + )) + } + + /** The SteamVR driver switches pad sticks between hands and game on both stick clicks together. */ + private fun switchVrSticks() { + val chord = { down: Boolean -> padBridge?.applyTouch { state -> + state.press(com.droiddeck.launcher.input.PadState.L3, down) + state.press(com.droiddeck.launcher.input.PadState.R3, down) + } } + chord(true) + uiHandler.postDelayed({ chord(false) }, 120) + } + private fun triggerSteamQam() { drawerOpen = false padBridge?.triggerQam() @@ -1283,8 +1441,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun readPrefs() { pipAutoEnter = SessionPrefs.pipAutoEnter(this) unfoldedControls = SessionPrefs.unfoldedControls(this) + unfoldedControlsWithController = SessionPrefs.unfoldedControlsWithController(this) picturePosition = SessionPrefs.picturePosition(this) hudOn = SessionPrefs.hudEnabled(this) + hudFps = SessionPrefs.hudShowFps(this) + hudBattery = SessionPrefs.hudShowBattery(this) + hudGraph = SessionPrefs.hudShowGraph(this) + hudColor = SessionPrefs.hudColor(this) + hudOpacity = SessionPrefs.hudOpacity(this) upscaler = SessionPrefs.upscaler(this) upscaleSharpness = SessionPrefs.upscaleSharpness(this) effects = SessionPrefs.screenEffects(this) @@ -1298,6 +1462,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val nextTouchMode = SessionPrefs.touchMode(this) if (touchMode != nextTouchMode) { if (::touchpad.isInitialized) touchpad.cancel() + if (::directTouchHold.isInitialized) directTouchHold.cancel() if (::scrollTouch.isInitialized) scrollTouch.cancel() if (CompositorHost.isStarted) { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) @@ -1322,17 +1487,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { @Volatile private var installingRuntime = false + private var repairOffered by mutableStateOf(false) - private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) { + private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) { if (SessionState.stopRequested) return installingRuntime = true - SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton" + SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton" SessionEvents.transition( SessionPhase.INSTALLING_RUNTIME, "${SessionState.installing}.installing", mapOf("component" to SessionState.installing), ) - val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES + val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES loading.say(getString(first.downloading), first.topic, readable = true) loading.percent = -1 Thread({ @@ -1342,6 +1508,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { problem = installRuntime() if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime" } + if (problem == null && resume && !SessionState.stopRequested) { + SessionState.installing = "package" + SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package")) + problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs()) + ?.let { getString(R.string.session_package_resume_failed, it) } + if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package" + } if (problem == null && desktop && !SessionState.stopRequested) { SessionState.installing = "desktop" SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop")) @@ -1366,7 +1539,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return@post } if (problem != null) { - val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED" + val code = when (failedComponent) { + "runtime" -> "RUNTIME_INSTALL_FAILED" + "package" -> "PACKAGE_RESUME_FAILED" + else -> "DESKTOP_INSTALL_FAILED" + } SessionEvents.fail(code, problem) collectStartArtifacts("session start failed") loading.showEnded(problem) @@ -1414,9 +1591,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent -> + uiHandler.post { + loading.percent = percent + loading.say(stage, LoadingState.Topic.RUNTIME, readable = true) + } + } + private fun installRuntime(): String? { val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease() ?: return getString(R.string.session_runtime_catalog_unreachable) + runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) } val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release, progressFor(RUNTIME_LINES, release.size / 1_000_000)) return if (ok) null else getString(R.string.session_runtime_install_failed) @@ -1446,10 +1631,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() } FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb")) + com.droiddeck.launcher.input.VrControllers.publish(this) val turnip = TurnipDriver(this) com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "surface.driver-setup") - if (!CompositorHost.isStarted) CompositorHost.displayChoice = SessionPrefs.androidDriver(this) + if (!CompositorHost.isStarted) { + CompositorHost.displayChoice = SessionPrefs.androidDriver(this) + com.droiddeck.launcher.gpu.AutoDriverTrial.displayStarting(this, SessionPrefs.androidDriver(this)) + } val driverId = if (CompositorHost.isStarted) null else turnip.install() val previous = SessionState.outputSize @@ -1524,6 +1713,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { intent.getStringExtra(SessionService.EXTRA_STEAM_UI), intent.getStringExtra(SessionService.EXTRA_STEAM_URL), intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS), + sessionActivityDisplayId, ) } applyFrameGen() @@ -1539,6 +1729,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { surfaceW = width surfaceH = height if (resized) { + cancelTouchGestures() Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor") CompositorHost.resize(holder.surface) { applyFrameGen() } followWindow(width, height) @@ -1557,7 +1748,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun followChoice(): Boolean { - val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = displayMode() return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) == com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN } @@ -1573,6 +1764,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) + if (refreshActivityDisplayId() && started) { + SessionService.setActivityDisplay(this, sessionActivityDisplayId) + } + cancelTouchGestures() applyFollowOrientation(newConfig) applyUnfoldedControls() } @@ -1581,12 +1776,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Hiding the phone surface after a handoff delivers a late destruction // callback. It must not reset the new output's size or attachment. if (holder !== outputHolder()) return + cancelTouchGestures() surfaceW = 0 surfaceH = 0 glassesOutputActive = false CompositorHost.detach(holder.surface) } + private fun cancelTouchGestures() { + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + if (::touchpad.isInitialized) touchpad.cancel() + if (::scrollTouch.isInitialized) scrollTouch.cancel() + scrollTouchGesture = false + gesturePointerReady = false + } + private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) { windowManager.maximumWindowMetrics.bounds } else { @@ -1596,8 +1800,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels) } + private fun displayMode(): String = SessionPrefs.displayMode(this, + intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM)) + private fun outputSize(): Pair { - val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = displayMode() val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this) val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN && @@ -1611,8 +1818,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { glassesScreen?.hz?.takeIf { it > 1f }?.let { return it } val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay val wanted = window.attributes.preferredDisplayModeId - val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate - ?: display?.refreshRate ?: 60f + val requested = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate + val running = display?.refreshRate?.takeIf { it > 1f } + val hz = listOfNotNull(requested, running).minOrNull() ?: 60f val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap) return if (capped > 1f) capped else 60f @@ -1716,7 +1924,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return } Thread({ - val hint = sessionEndHint(SessionState.logFile) + val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile) runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread showEnded(status, hint) @@ -1735,6 +1943,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop) else -> getString(R.string.session_ended_named, "Steam") } + 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)), @@ -1753,6 +1962,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { uiHandler.post { sessionOverlay.requestFocus() } } + private fun repairRuntime() { + val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true) + closeAtOnce = true + finish() + startActivity(again) + } + private fun retrySession() { val again = Intent(intent) closeAtOnce = true @@ -1760,7 +1976,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { startActivity(again) } - private class EndHint(val text: String, val evenOnSuccess: Boolean = false) + private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false) + + private fun runtimeDamageHint(): EndHint? { + val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList()) + if (broken.isEmpty()) return null + Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}") + return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true) + } private fun presentationFault(): String? { com.droiddeck.launcher.wayland.WaylandCompositor.compositorFault().takeIf { it.isNotEmpty() }?.let { return it } @@ -1791,17 +2014,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { var enosys = 0 var fexMissing = false val size = log.length() - java.io.RandomAccessFile(log, "r").use { f -> + val text = java.io.RandomAccessFile(log, "r").use { f -> val start = maxOf(0L, size - 512L * 1024) f.seek(start) val bytes = ByteArray((size - start).toInt()) f.readFully(bytes) - String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line -> - if (line.contains("Function not implemented")) enosys++ - if (line.contains(FEX_MISSING)) fexMissing = true - } + String(bytes, Charsets.ISO_8859_1) } - if (fexMissing) { + text.lineSequence().forEach { line -> + if (line.contains("Function not implemented")) enosys++ + if (line.contains(FEX_MISSING)) fexMissing = true + } + val steamSetup = com.droiddeck.launcher.session.SteamClientSetupFailure.find(text.lineSequence()) + if (steamSetup != null) { + val message = getString(if (steamSetup.download) R.string.session_end_steam_download else R.string.session_end_steam_setup) + EndHint(listOfNotNull(message, steamSetup.curlError).joinToString("\n")) + } else if (fexMissing) { val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program) EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true) } else if (enosys >= 8) { @@ -1844,7 +2072,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { 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) { - drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty(), glasses.present) + drawerPage = sessionDrawerPages(secondScreenDisplays.isNotEmpty(), glasses.present) .step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1) releaseDrawerDirection() } @@ -2007,6 +2235,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.dispatchKeyEvent(event) } + private fun refreshGestureConfig() { + gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame) + if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig) + gesturePointerReady = false + recognizedGesture = null + } + + private val gestureOutput = object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) { + val rect = drawnRect() ?: return + if (pointerLocked) { + if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true) + } else movePointer(x, y) + gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right) + gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom) + gesturePointerReady = true + cursorVisible = false + } + override fun moveBy(dx: Float, dy: Float, relative: Boolean) { + val rect = drawnRect() ?: return + if (relative || pointerLocked) { + val scale = SessionState.outputSize.first / rect.width() + WaylandCompositor.sendPointerDelta(dx * scale, dy * scale) + } else moveTo(gesturePointerX + dx, gesturePointerY + dy) + } + override fun button(code: Int, down: Boolean) { + WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0) + } + override fun prepareRelativePan() { + if (pointerLocked) return + val rect = drawnRect() ?: return + // Gamescope retains the last absolute position while a game can warp + // its own cursor to the center. Its first unlocked motion must start + // from that same origin, before the game switches to pointer lock. + moveTo(rect.centerX(), rect.centerY()) + } + override fun key(code: Int, down: Boolean) { + if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code) + if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) + } + override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) } + override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } } + override fun gesture(gesture: TouchscreenGestures.Gesture) { + recognizedGesture = gesture + uiHandler.removeCallbacks(clearRecognizedGesture) + uiHandler.postDelayed(clearRecognizedGesture, 1200) + } + } + private val pointerListener = object : PointerGestures.Listener { override fun onMove(x: Float, y: Float) { if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y) @@ -2141,9 +2418,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return true } + private val shownOnDisplayId: Int get() = + if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId + else windowManager.defaultDisplay.displayId + private fun refreshSecondScreenDisplays() { if (!::displayManager.isInitialized) return - val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId + val primaryId = shownOnDisplayId val candidates = SecondScreenDisplays.available(displayManager, primaryId) .filter { glassesScreen == null || it.id != glasses.displayId } val oldIds = secondScreenDisplays.map { it.id }.toSet() @@ -2235,6 +2516,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { applyUnfoldedControls() } + private fun chooseUnfoldedControlsWithController(on: Boolean) { + SessionPrefs.setUnfoldedControlsWithController(this, on) + unfoldedControlsWithController = on + applyUnfoldedControls() + } + private fun refreshControls() { applyUnfoldedControls() updateOnScreenControls() @@ -2244,11 +2531,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!::sessionRoot.isInitialized) return val width = sessionRoot.width val height = sessionRoot.height - val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) && - !PadBridge.anyControllerConnected() && !SteamControllerBle.connected && - SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad && - !pipUi && !isInPictureInPictureMode && width > 0 && height > 0 && - resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP + val wanted = com.droiddeck.launcher.session.SessionDisplay.unfoldedControlsWanted( + enabled = unfoldedControls, + foldable = com.droiddeck.launcher.session.SessionDisplay.foldable(this), + controllerConnected = PadBridge.anyControllerConnected() || SteamControllerBle.connected, + keepWithController = unfoldedControlsWithController, + steamDeckPad = SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad, + pip = pipUi || isInPictureInPictureMode, + width = width, + height = height, + smallestWidthDp = resources.configuration.smallestScreenWidthDp, + ) val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height) else FrameLayout.LayoutParams.MATCH_PARENT val params = surfaceView.layoutParams as FrameLayout.LayoutParams @@ -2297,6 +2590,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this) return when { options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF + SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD else -> SessionPrefs.TOUCH_DIRECT } @@ -2309,7 +2603,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { touchpad.sensitivity = 1.15f * speed } if (next == routedTouchMode) return + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + gesturePointerReady = false if (::touchpad.isInitialized) touchpad.cancel() + if (::directTouchHold.isInitialized) directTouchHold.cancel() if (::scrollTouch.isInitialized) scrollTouch.cancel() if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0) if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false @@ -2344,6 +2641,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onTouchEvent(event: MotionEvent): Boolean { if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event) + if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true + if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false + return touchscreenGestures.onTouch(event) + } if (event.actionMasked == MotionEvent.ACTION_DOWN) { scrollTouchGesture = scrollTouchMode() scrollRestX = 0f @@ -2377,6 +2679,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false return true } + if (directTouchHold.onTouch(event)) return true fun sendTouch(action: Int, index: Int) { val x = event.getX(index).coerceIn(rect.left, rect.right) val y = event.getY(index).coerceIn(rect.top, rect.bottom) @@ -2520,12 +2823,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ?.let { FileUtils.readString(it)?.trim()?.lowercase() } ?: SessionPrefs.oscMode(this) val controls = onScreenControls ?: return - if (pipUi || unfoldedPanel != null) { + if (pipUi || unfoldedPanel != null || pcKeyboardOpen || + (SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) { onScreenButtonsVisible = false controls.releaseAll() controls.visibility = View.GONE steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE } com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false + applyTouchRouting() return } val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM @@ -2538,6 +2843,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (steam == null) { steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE } com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false + applyTouchRouting() } controls.setButtonsOnly(buttonsOnly) val show = when (forced) { @@ -2550,29 +2856,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionState.mode != SessionService.MODE_DESKTOP } onScreenButtonsVisible = show + val drawn = show && !keyboardCoversPad() if (steam != null) { if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE } - if (!show) steam.releaseAll() - steam.visibility = if (show) View.VISIBLE else View.GONE + if (!drawn) steam.releaseAll() + steam.visibility = if (drawn) View.VISIBLE else View.GONE com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show applyTouchRouting() - Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden") + Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden") return } - if (show == (controls.visibility == View.VISIBLE)) return - if (!show) controls.releaseAll() - controls.visibility = if (show) View.VISIBLE else View.GONE + if (drawn == (controls.visibility == View.VISIBLE)) return + if (!drawn) controls.releaseAll() + controls.visibility = if (drawn) View.VISIBLE else View.GONE Log.i(TAG, "on-screen controls " + when { - !show -> "hidden" + !drawn -> "hidden" buttonsOnly -> "Steam + QAM" else -> "full pad" }) } + private fun keyboardCoversPad() = + SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard + private fun sendGuestKey(code: Int, down: Boolean) { if (down && pipUi) return if (down) guestKeysDown.add(code) else guestKeysDown.remove(code) - WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) + if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) } private fun pipBounds(): android.graphics.Rect { @@ -2600,6 +2910,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { releasePads() touchpad.cancel() scrollTouch.cancel() + directTouchHold.cancel() guestKeysDown.toList().forEach { sendGuestKey(it, false) } keyboard?.hide() sessionClipboard.stop() @@ -2649,11 +2960,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.onStart() if (runtimeRemovalBlocked) return started = true + refreshActivityDisplayId() displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) refreshSecondScreenDisplays() if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) startedScreens++ - SessionService.setActivityVisible(this, true) + SessionService.setActivityVisible(this, true, sessionActivityDisplayId) Glasses.rescan() updateGlassesScreen() updateHandCamera() @@ -2667,7 +2979,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { closeSecondScreen(reset = false) startedScreens = (startedScreens - 1).coerceAtLeast(0) updateGlassesScreen() - if (startedScreens == 0) SessionService.setActivityVisible(this, false) + if (startedScreens == 0) SessionService.setActivityVisible(this, false, sessionActivityDisplayId) super.onStop() } @@ -2698,6 +3010,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onResume() { super.onResume() + if (refreshActivityDisplayId() && started) { + SessionService.setActivityDisplay(this, sessionActivityDisplayId) + } applyFollowOrientation(resources.configuration) if (runtimeRemovalBlocked) return com.droiddeck.launcher.ui.Motion.refresh(this) @@ -2726,6 +3041,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onPause() { if (runtimeRemovalBlocked) { super.onPause(); return } sessionClipboard.stop() + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + if (::touchpad.isInitialized) touchpad.cancel() + if (::directTouchHold.isInitialized) directTouchHold.cancel() + uiHandler.removeCallbacks(clearRecognizedGesture) + gestureSettingsOpen = false drawerOpen = false pcKeyboardOpen = false achievementsOpen = false @@ -2975,7 +3295,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun glassesOutputSize(): Pair? { if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height - val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = displayMode() val display = com.droiddeck.launcher.session.SessionDisplay val choice = SessionPrefs.resolutionChoice(this, mode, panel) val size = display.resolveChoice(panel, choice) @@ -3101,6 +3421,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.onWindowFocusChanged(hasFocus) if (!hasFocus && ::padMouse.isInitialized) padMouse.reset() if (runtimeRemovalBlocked) return + if (!hasFocus) { + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + if (::touchpad.isInitialized) touchpad.cancel() + } if (hasFocus && !pipUi) { goFullscreen() refreshClipboard() @@ -3170,7 +3494,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun finishFlooded() { if (isFinishing) return - val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal + val signal = com.droiddeck.launcher.ui.Themes.current(this).signal com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb()) window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb())) quitFlooded = true @@ -3201,7 +3525,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun completeLeave() { if (isFinishing) return - val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal + val signal = com.droiddeck.launcher.ui.Themes.current(this).signal com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb()) window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb())) quitFlooded = true @@ -3211,6 +3535,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { companion object { const val EXTRA_RETURN_HOME = "returnHome" const val EXTRA_STEAMVR = "steamVr" + const val EXTRA_REPAIR_RUNTIME = "repairRuntime" private const val LEAVE_TIMEOUT_MS = 1_500L private const val TAG = "SessionActivity" private var startedScreens = 0 diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt index 2295c407e..82572ab19 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import android.os.FileObserver import android.util.Log @@ -19,6 +20,8 @@ object AgentGuest { private val main by lazy { Handler(Looper.getMainLooper()) } var focusRevision by mutableIntStateOf(0) private set + var steamKeyboard by mutableStateOf(false) + private set private const val TAG = "AgentGuest" private const val POLL_MS = 50L private val lock = Any() @@ -32,6 +35,7 @@ object AgentGuest { observer?.stopWatching() observer = null lastFocusKey = null + main.post { steamKeyboard = false } val dir = dir(context) dir.deleteRecursively() dir.mkdirs() @@ -43,6 +47,7 @@ object AgentGuest { SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus"))) } "focus.json" -> readJson(File(dir, path))?.let(::recordFocus) + "keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) } } } }.also { it.startWatching() } @@ -53,9 +58,19 @@ object AgentGuest { synchronized(lock) { observer?.stopWatching() observer = null + main.post { steamKeyboard = false } } } + private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) { + val showing = snapshot.optBoolean("steamKeyboard") + synchronized(lock) { + if (source !== observer) return + main.post { steamKeyboard = showing } + } + SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq"))) + } + private fun recordFocus(snapshot: JSONObject) { val fields = linkedMapOf( "focusedApp" to snapshot.opt("focusedApp"), diff --git a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt new file mode 100644 index 000000000..d54a5fff5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt @@ -0,0 +1,150 @@ +package com.droiddeck.launcher.core + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.BuildConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileInputStream + +/** + * A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to + * DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there + * yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build + * was made with install, whichever host serves them. + */ +internal class AddonPack( + val name: String, private val sha256: String, val size: Long, private val required: List, + private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD, +) { + sealed interface State { + data object Missing : State + data class Downloading(val progress: Float) : State + data object Failed : State + data object Ready : State + } + + private val mutableState = MutableStateFlow(State.Missing) + val state: StateFlow = mutableState.asStateFlow() + private val lock = Any() + + val available: Boolean get() = sha256.isNotEmpty() + private val tag get() = "pack-$name-${sha256.take(16)}" + private val urls get() = listOfNotNull( + "https://github.com/$REPO/releases/download/$tag/$name.tar.zst", + BuildConfig.PR_NUMBER.takeIf { it > 0 }?.let { "https://github.com/$PR_REPO/releases/download/pr-$it/$tag.tar.zst" }, + ) + + private fun root(context: Context) = File(context.filesDir, "addons/$name") + /** App-private and written only after the archive matched the pinned digest. */ + fun dir(context: Context) = File(root(context), version(context)) + fun file(context: Context, relative: String) = File(dir(context), relative) + + fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile + + // A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed. + private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty() + + private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty() + .filter { dir -> + dir.isDirectory && runCatching { File(dir, COMPLETE).readText().trim() }.getOrNull() == dir.name && + required.all { File(dir, it).isFile } + } + .maxByOrNull { it.lastModified() }?.name + + fun refresh(context: Context): State { + val now = mutableState.value + if (now is State.Downloading) return now + return (if (installed(context)) State.Ready else if (now == State.Failed) State.Failed else State.Missing) + .also { mutableState.value = it } + } + + /** Starts the download in the background unless the pack is already present or on its way. */ + fun ensure(context: Context, onReady: (Context) -> Unit = {}) { + val app = context.applicationContext + synchronized(lock) { + if (installed(app)) { mutableState.value = State.Ready; return } + if (mutableState.value is State.Downloading) return + mutableState.value = State.Downloading(0f) + } + scope.launch { + val ok = runCatching { install(app) }.onFailure { Log.w(TAG, "$name: install failed", it) }.getOrDefault(false) + mutableState.value = if (ok) State.Ready else State.Failed + if (ok) onReady(app) + } + } + + /** Blocks a worker thread until the pack is installed, starting the download if needed. */ + @JvmOverloads + fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean { + if (installed(context)) return true + ensure(context) + while (running()) { + val now = state.value + onState(now) + when (now) { + State.Ready -> return installed(context) + State.Failed -> return false + else -> try { Thread.sleep(250) } catch (e: InterruptedException) { return false } + } + } + return false + } + + private fun install(context: Context): Boolean { + if (!available) { + Log.w(TAG, if (reuseDeviceCopy) "$name: this build has no pack and none is installed" else "$name: this build has no pack") + return false + } + val root = root(context).apply { mkdirs() } + val archive = File(root, "$sha256.tar.zst") + val downloaded = urls.any { url -> + Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) } + .also { if (!it) archive.delete() } + } + if (!downloaded) return false + val staging = File(root, "$sha256.partial") + staging.deleteRecursively() + try { + if (!FileInputStream(archive).use { TarZst.extract(it, staging) }) return false + check(required.all { File(staging, it).isFile }) { "$name pack is incomplete" } + File(staging, COMPLETE).writeText(sha256) + val target = dir(context) + target.deleteRecursively() + check(staging.renameTo(target)) { "could not move the $name pack into place" } + } finally { + staging.deleteRecursively() + archive.delete() + } + root.listFiles().orEmpty().filter { it.name != sha256 }.forEach { it.deleteRecursively() } + return true + } + + companion object { + private const val TAG = "AddonPack" + private const val REPO = "Droid-Deck/DroidDeck-Components" + private const val PR_REPO = "Droid-Deck/DroidDeck-CI" + const val COMPLETE = ".complete" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @JvmField + val PROOT = AddonPack("proot", BuildConfig.PACK_PROOT_SHA256, BuildConfig.PACK_PROOT_SIZE, + listOf("libproot.so", "libproot-loader.so")) + @JvmField + val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE, + listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt")) + val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf( + "hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task", + "libopencv_java4.so", "libmediapipe_tasks_jni.so")) + val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE, + listOf("version", "xodus-cli", "xodus-service")) + val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE, + listOf("version", "comet")) + } +} 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 fedf9d64f..a0f37a071 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -207,9 +207,11 @@ object DeviceReport { h("Drivers") val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect() k("GPU", "${gpu.name} · ${gpu.family.label(gpu.name)} · ${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") 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)))?.let { " (recommended pair: $it)" } ?: "")) + com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context), + com.droiddeck.launcher.gpu.AutoDriverTrial.bad(context))?.let { " (recommended pair: $it)" } ?: "")) val turnip = TurnipDriver(context) val androidChoice = SessionPrefs.androidDriver(context) val displayId = androidChoice.ifEmpty { turnip.autoId() } @@ -236,6 +238,9 @@ object DeviceReport { k("Installed version", LinuxRuntimeInstaller.installedVersion(context)) k("Runtime ready", LinuxRuntime.isInstalled(context)) k("Desktop installed", DesktopCatalog.desktopInstalled(context)) + k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" }) + val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList()) + k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}") k("Runtime root", LinuxRuntime.rootDir(context).path) h("Settings in effect") @@ -260,6 +265,7 @@ object DeviceReport { k("Guest host name", SessionPrefs.guestHostname(context)) k("DirectAudio for games", SessionPrefs.directAudio(context)) k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context)) + k("Return to launcher after a game", SessionPrefs.returnAfterGame(context)) k("Microphone", SessionPrefs.micEnabled(context)) k("On-screen controls", SessionPrefs.oscMode(context)) k("Touch mode", SessionPrefs.touchMode(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 a613bf614..870e8ed42 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt @@ -38,6 +38,7 @@ object DownloadSource { private val CATALOGS = listOf( "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-CI/", "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/", + "https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/", ) data class Mirror(val host: String, val api: Boolean) 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 2527c57e8..183076188 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -54,7 +54,12 @@ public final class Downloader { java.util.function.Consumer progress) { if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}")) throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters"); - return downloadCandidates(url, DownloadSource.INSTANCE.candidates(url), destination, resume, expectedSha256, progress); + return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, progress); + } + + /** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */ + static List sources(String url, String expectedSha256) { + return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url); } // Package-private for the local multi-source HTTP test. diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt index c8341afc2..4672cc651 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt @@ -29,6 +29,7 @@ object GameEnvironment { fun defaults(preset: String, forceSsbs: Boolean = false): Map = linkedMapOf( "MESA_SHADER_CACHE_DISABLE" to "false", + "DROIDDECK_ANTICHEAT_OFFLINE" to "1", "VKD3D_FEATURE_LEVEL" to "12_2", "VKD3D_SHADER_MODEL" to "6_9", "DOTNET_GCRegionRange" to "800000000", 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 078ae8165..3ad45c1fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt @@ -32,6 +32,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_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), Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")), 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 d2128476a..491101dba 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -2,14 +2,51 @@ package com.droiddeck.launcher.core object LogRedactor { private val EMAIL = Regex("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}") + private val HIDL_SHARED_LIBRARY = Regex( + "(?:android\\.(?:hardware|frameworks|hidl|system)|vendor\\.[A-Za-z0-9_]+\\.hardware)" + + "(?:\\.[A-Za-z0-9_]+)+@[0-9]+\\.[0-9]+(?:-[A-Za-z0-9_]+)*\\.so" + ) + private val hidlMatcher = object : ThreadLocal() { + override fun initialValue(): java.util.regex.Matcher = HIDL_SHARED_LIBRARY.toPattern().matcher("") + } + + private fun isHidlLoaderDiagnostic(input: String, match: MatchResult): Boolean { + val matcher = hidlMatcher.get()!! + val isLibrary = try { matcher.reset(match.value).matches() } finally { matcher.reset("") } + if (!isLibrary) return false + val start = match.range.first + val end = match.range.last + 1 + val prefix = "library \"" + val suffix = "\" not found" + val earliestQuote = (start - 4096).coerceAtLeast(0) + var quote = start - 1 + while (quote >= earliestQuote && input[quote] != '"') { + val char = input[quote] + if (!char.isLetterOrDigit() && char !in "/._-") return false + quote-- + } + if (quote < earliestQuote) return false + val pathStart = quote + 1 + if (pathStart < start && (input[start - 1] != '/' || + !(input.regionMatches(pathStart, "/vendor/", 0, 8) || + input.regionMatches(pathStart, "/system/", 0, 8)))) return false + val prefixStart = quote - prefix.length + 1 + val suffixEnd = end + suffix.length + return prefixStart >= 0 && suffixEnd <= input.length && + input.regionMatches(prefixStart, prefix, 0, prefix.length, ignoreCase = true) && + input.regionMatches(end, suffix, 0, suffix.length, ignoreCase = true) && + (prefixStart == 0 || !(input[prefixStart - 1].isLetterOrDigit() || input[prefixStart - 1] == '_')) && + (suffixEnd == input.length || !(input[suffixEnd].isLetterOrDigit() || input[suffixEnd] == '_')) + } + private val JWT_LABELLED = Regex("(?i)(\\bJWT[\\s=:]+)(\\d{8,})") private val JWT_BASE64 = Regex("ey[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}") private val GUID = Regex("\\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\\b") private val SECRET_KV = Regex( - "(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" + + "(?i)(\\b(?:access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" + "auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" + - "machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" + - "(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})" + "machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)[\"']?\\s*[=:]\\s*)" + + "(?:([\"'])((?:\\\\.|(?!\\2)[^\\\\\\r\\n]){4,})\\2|([^\\s\"'<>&;,]{4,}))" ) private val GUARD_CODE = Regex( "(?i)((?:steam\\s*)?guard\\s*code[\\s:=]*|two[\\s-]?factor[\\s:=]*|2fa[\\s:=]*)([A-Za-z0-9]{5})" @@ -146,16 +183,21 @@ object LogRedactor { return out } - fun isText(file: java.io.File): Boolean = try { - file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input -> + /** The at-rest rewrite reads a file into memory twice; larger logs are only scrubbed on the way out. */ + const val IN_MEMORY_SCRUB_MAX = 64L * 1024 * 1024 + + fun isBinary(file: java.io.File): Boolean = try { + file.inputStream().use { input -> val buf = ByteArray(4096) val n = input.read(buf) - n <= 0 || (0 until n).none { buf[it].toInt() == 0 } + n > 0 && (0 until n).any { buf[it].toInt() == 0 } } } catch (e: Exception) { - false + true } + fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file) + const val RULES_VERSION = 3 private val LAN_IPV4 = Regex( @@ -176,8 +218,62 @@ object LogRedactor { } } + private const val SHARE_LINE_MAX = 64 * 1024 + fun scrubForShare(src: java.io.File, out: java.io.Writer) { - src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") } + src.inputStream().buffered().use { input -> + input.mark(2) + val first = input.read() + val second = input.read() + input.reset() + if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) { + out.write("\n") + return + } + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + java.io.InputStreamReader(input, decoder).use { reader -> + val buffer = CharArray(8192) + val line = StringBuilder() + var withheld = false + var afterCr = false + fun emit() { + out.write(if (withheld) "" else redactForShare(line.toString())) + out.write("\n") + line.setLength(0) + withheld = false + } + try { + while (true) { + val size = reader.read(buffer) + if (size < 0) break + for (i in 0 until size) { + val ch = buffer[i] + if (afterCr) { + afterCr = false + if (ch == '\n') continue + } + if (ch == '\r' || ch == '\n') { + emit() + afterCr = ch == '\r' + } else if (!withheld) { + // Splitting a line can separate a credential from its label. + if (ch == '\u0000' || line.length == SHARE_LINE_MAX) { + withheld = true + line.setLength(0) + } else { + line.append(ch) + } + } + } + } + if (withheld || line.isNotEmpty()) emit() + } catch (_: java.nio.charset.CharacterCodingException) { + out.write("\n") + } + } + } } private fun quoted(value: String): String { @@ -232,7 +328,7 @@ object LogRedactor { if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}" }) if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "")) if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) { - out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}" }) + out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${it.groupValues[2]}" }) } if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}" }) if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "")) @@ -250,7 +346,9 @@ object LogRedactor { } if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }) for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "")) - if ('@' in out.text) out.set(EMAIL.replace(out.text, "")) + if ('@' in out.text) out.set(EMAIL.replace(out.text) { m -> + m.value.takeIf { isHidlLoaderDiagnostic(out.text, m) } ?: "" + }) if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) { out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=" }) } diff --git a/app/src/main/java/com/droiddeck/launcher/files/AppFilesProvider.kt b/app/src/main/java/com/droiddeck/launcher/files/AppFilesProvider.kt index 0f794ce0d..9f534b39b 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/AppFilesProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/AppFilesProvider.kt @@ -85,7 +85,7 @@ class AppFilesProvider : DocumentsProvider() { override fun deleteDocument(documentId: String) { val file = fileFor(documentId) - if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted") + if (file.canonicalPath == baseCanonical) throw FileNotFoundException("the app folder itself is not deleted") try { deleteTree(file.toPath()) } catch (e: IOException) { @@ -154,7 +154,7 @@ class AppFilesProvider : DocumentsProvider() { private fun fileFor(documentId: String): File { val f = File(documentId) - if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files") + if (!f.isAbsolute || f.normalize().path != documentId || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files") if (!f.exists()) throw FileNotFoundException("$documentId not found") return f } diff --git a/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt b/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt index 665d063fd..bcba0189b 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -249,11 +250,11 @@ fun CollapsibleRail( if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), shape, ) - .clickable(onClick = item.onClick) + .clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = item.onClick) .padding(horizontal = if (collapsed) 2.dp else 11.dp, vertical = if (collapsed) 6.dp else 9.dp) } else { base - .clickable(onClick = item.onClick) + .clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = item.onClick) .background(if (active) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) else Color.Transparent) .padding(horizontal = if (collapsed) 2.dp else 13.dp, vertical = if (collapsed) 7.dp else 11.dp) } @@ -337,7 +338,7 @@ fun RailTopTabs( .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)), ) links.forEach { link -> - TopTabCell(link.icon, link.label, selected = false, onClick = link.onClick, modifier = Modifier.weight(1f)) + TopTabCell(link.icon, link.label, selected = false, onClick = link.onClick, isTab = false, modifier = Modifier.weight(1f)) } } } @@ -352,6 +353,7 @@ private fun TopTabCell( selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + isTab: Boolean = true, ) { val tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant Column( @@ -359,7 +361,13 @@ private fun TopTabCell( modifier = modifier .clip(RoundedCornerShape(9.dp)) .background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) else Color.Transparent) - .clickable(onClick = onClick) + .then( + if (isTab) { + Modifier.selectable(selected = selected, role = androidx.compose.ui.semantics.Role.Tab, onClick = onClick) + } else { + Modifier.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onClick) + }, + ) .padding(vertical = 6.dp, horizontal = 1.dp), ) { Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(20.dp)) diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt index 45c8a92c8..e6289a3b9 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt @@ -193,7 +193,7 @@ internal fun FileItemRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 3.dp) - .combinedClickable( + .combinedClickable(role = androidx.compose.ui.semantics.Role.Button, onClick = { if (selectionMode) onToggleSelect() else onTap() }, onLongClick = onLongPress, ), @@ -302,7 +302,7 @@ internal fun ParentFolderRow(compact: Boolean = false, onTap: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 3.dp) - .clickable(onClick = onTap), + .clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onTap), shape = RoundedCornerShape(10.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), @@ -422,7 +422,7 @@ private fun FavoriteCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 3.dp) - .clickable(onClick = onJump), + .clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onJump), shape = RoundedCornerShape(10.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), @@ -526,7 +526,7 @@ internal fun FileGridTile( Card( modifier = Modifier .padding(4.dp) - .combinedClickable( + .combinedClickable(role = androidx.compose.ui.semantics.Role.Button, onClick = { if (selectionMode) onToggleSelect() else onTap() }, onLongClick = onLongPress, ), 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 9fbd251ff..0843057f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -90,6 +90,7 @@ 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.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -151,8 +152,11 @@ fun FileManagerScreen( 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 pendingBulkDelete by remember { mutableStateOf>(emptyList()) } var searchQuery by remember { mutableStateOf("") } + val visibleEntries = if (searchQuery.isBlank()) entries + else entries.filter { it.name.contains(searchQuery, ignoreCase = true) } var showSearch by remember { mutableStateOf(false) } var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") } var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) } @@ -174,16 +178,20 @@ fun FileManagerScreen( var showFavorites by remember { mutableStateOf(false) } var favTick by remember { mutableIntStateOf(0) } + var listingJob by remember { mutableStateOf(null) } + fun loadDirectory(dir: File, resetScroll: Boolean = true) { currentDir = dir if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply() - scope.launch { + listingJob?.cancel() + listingJob = scope.launch { val list = withContext(Dispatchers.IO) { dir.listFiles()?.toList() ?.filter { if (pickDirMode) it.isDirectory else !pickMode || it.isDirectory || matchesPickExt(it) } ?.filter { showHidden || !it.name.startsWith(".") } ?.sortedWith(comparatorFor(sortBy, sortDesc)) ?: emptyList() } + if (currentDir != dir) return@launch entries = list if (resetScroll) listState.scrollToItem(0) } @@ -220,7 +228,7 @@ fun FileManagerScreen( fun uniqueDestination(dir: File, name: String): File { var candidate = File(dir, name) - if (!candidate.exists()) return candidate + if (!java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return candidate val dot = name.lastIndexOf('.') val base = if (dot > 0) name.substring(0, dot) else name val ext = if (dot > 0) name.substring(dot) else "" @@ -228,7 +236,7 @@ fun FileManagerScreen( do { candidate = File(dir, "$base ($i)$ext") i++ - } while (candidate.exists()) + } while (java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return candidate } @@ -252,7 +260,7 @@ fun FileManagerScreen( fun performPaste() { val sources = clipboardFiles - if (sources.isEmpty()) return + if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return val dstDir = currentDir val cut = isCutOperation @@ -261,6 +269,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] var applyToAll: ConflictChoice? = null var failed = 0 @@ -278,7 +287,7 @@ fun FileManagerScreen( } var dst = File(dstDir, src.name) - if (dst.exists()) { + if (java.nio.file.Files.exists(dst.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { val choice = applyToAll ?: askConflict(src)?.also { if (conflictApplyToAll) applyToAll = it } ?: run { skipped++; null } ?: continue @@ -296,6 +305,7 @@ fun FileManagerScreen( } var lastPct = -1 val onProgress = FileOps.ProgressCallback { copied, total -> + job?.ensureActive() val pct = if (total > 0) ((copied * 100) / total).toInt() else 100 if (pct != lastPct) { lastPct = pct @@ -304,8 +314,8 @@ fun FileManagerScreen( } val target = dst val ok = withContext(Dispatchers.IO) { - if (cut) FileOps.moveWithProgress(src, target, onProgress) - else FileOps.copyWithProgress(src, target, onProgress) + if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false } + else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false } } if (ok) done++ else failed++ } @@ -791,19 +801,21 @@ fun FileManagerScreen( fontSize = 13.sp, modifier = Modifier.weight(1f), ) + val allVisibleSelected = visibleEntries.isNotEmpty() && visibleEntries.all { it.absolutePath in selectedPaths } + val anyVisibleSelected = visibleEntries.any { it.absolutePath in selectedPaths } val selBarPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp) OutlinedButton( onClick = { - selectedPaths = if (selectedPaths.size == entries.size) emptySet() - else entries.map { it.absolutePath }.toSet() + val shown = visibleEntries.map { it.absolutePath } + selectedPaths = if (allVisibleSelected) selectedPaths - shown.toSet() else selectedPaths + shown }, contentPadding = selBarPadding, - ) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) } + ) { Text(if (allVisibleSelected) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) } Spacer(Modifier.width(4.dp)) OutlinedButton( - enabled = selectedPaths.isNotEmpty(), + enabled = anyVisibleSelected, onClick = { - clipboardFiles = entries.filter { it.absolutePath in selectedPaths } + clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths } isCutOperation = false selectionMode = false selectedPaths = emptySet() @@ -812,9 +824,9 @@ fun FileManagerScreen( ) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) } Spacer(Modifier.width(4.dp)) OutlinedButton( - enabled = selectedPaths.isNotEmpty(), + enabled = anyVisibleSelected, onClick = { - clipboardFiles = entries.filter { it.absolutePath in selectedPaths } + clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths } isCutOperation = true selectionMode = false selectedPaths = emptySet() @@ -823,8 +835,8 @@ fun FileManagerScreen( ) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) } Spacer(Modifier.width(4.dp)) OutlinedButton( - enabled = selectedPaths.isNotEmpty(), - onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } }, + enabled = anyVisibleSelected, + onClick = { pendingBulkDelete = visibleEntries.filter { it.absolutePath in selectedPaths } }, contentPadding = selBarPadding, border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), ) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) } @@ -842,7 +854,7 @@ fun FileManagerScreen( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)) - .clickable { performPaste() } + .clickable(enabled = !isOperationRunning) { performPaste() } .padding(horizontal = 16.dp, vertical = 8.dp), ) { Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp)) @@ -878,6 +890,7 @@ fun FileManagerScreen( ) if (operationJob != null) { TextButton(onClick = { + cancelledJob = operationJob operationJob?.cancel() operationJob = null isOperationRunning = false @@ -964,8 +977,7 @@ fun FileManagerScreen( .fillMaxSize() .nestedScroll(pullState.nestedScrollConnection), ) { - val shownEntries = if (searchQuery.isBlank()) entries - else entries.filter { it.name.contains(searchQuery, ignoreCase = true) } + val shownEntries = visibleEntries if (showGrid && entries.isNotEmpty()) { LazyVerticalGrid( 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 04b83abaa..29df5ff6d 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt @@ -45,23 +45,26 @@ object FileOps { return f.length() } - fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean { + fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean { if (sameFile(src, dst)) return true + if (isCancelled()) return false if (!dst.exists() && src.renameTo(dst)) { progress?.onProgress(1, 1) return true } - if (!copyWithProgress(src, dst, progress)) return false + if (!copyWithProgress(src, dst, progress, isCancelled)) return false + if (isCancelled()) return false return delete(src) } - fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean { + fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean { val total = maxOf(totalSize(src), 1L) - return copyWithProgress(src, dst, longArrayOf(0), total, progress) + return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled) } - private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean { - if (isSymlink(src)) return true + private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean { + if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src") + if (isSymlink(src)) return copyLink(src, dst) if (sameFile(src, dst)) return true if (src.isDirectory && isWithin(dst, src)) { Log.e(TAG, "Refusing to copy directory into itself: $src -> $dst") @@ -71,7 +74,7 @@ object FileOps { 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)) { + if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) { Log.e(TAG, "Failed to copy: ${File(src, name)}") allOk = false } @@ -81,13 +84,41 @@ object FileOps { val parent = dst.parentFile if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false val chunk = 4L * 1024 * 1024 + var staging: File? = null + var created = false + var output: java.nio.channels.FileChannel? = null try { + output = try { + java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW) + .also { created = true } + } catch (e: java.nio.file.FileAlreadyExistsException) { + null + } + val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst + var permissions: Set? = null + if (output == null) { + if (isSymlink(target) || target.parentFile?.isDirectory != true) { + throw IOException("Destination symlink target is not a writable directory entry: $dst") + } + if (target.isDirectory) throw IOException("Destination is a directory: $target") + permissions = try { + Files.getPosixFilePermissions(target.toPath()) + } catch (e: java.nio.file.NoSuchFileException) { + null + } catch (e: UnsupportedOperationException) { + null + } + if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target") + val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-") + staging = directory.toFile() + output = FileOutputStream(File(staging, "file")).channel + } FileInputStream(src).channel.use { input -> - FileOutputStream(dst).channel.use { output -> + checkNotNull(output).use { channel -> val size = input.size() var position = 0L while (position < size) { - val transferred = input.transferTo(position, minOf(chunk, size - position), output) + val transferred = input.transferTo(position, minOf(chunk, size - position), channel) if (transferred <= 0) break position += transferred done[0] += transferred @@ -96,11 +127,54 @@ object FileOps { if (position < size) throw IOException("Incomplete copy: $position/$size bytes") } } + staging?.let { directory -> + val copied = File(directory, "file").toPath() + if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) { + Files.setPosixFilePermissions(copied, permissions) + } + if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src") + Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE) + } return true + } catch (e: java.util.concurrent.CancellationException) { + if (created) dst.delete() + throw e } catch (e: IOException) { Log.e(TAG, "Failed to copy file: $src to $dst", e) - dst.delete() + if (created) dst.delete() return false + } finally { + output?.close() + staging?.let { directory -> + File(directory, "file").delete() + directory.delete() + } + } + } + + private fun copyLink(src: File, dst: File): Boolean { + if (src.absoluteFile == dst.absoluteFile) return true + return try { + val target = Files.readSymbolicLink(src.toPath()) + val parent = dst.parentFile + if (parent != null && !parent.exists() && !parent.mkdirs()) return false + try { + Files.createSymbolicLink(dst.toPath(), target) + } catch (e: java.nio.file.FileAlreadyExistsException) { + val staging = Files.createTempDirectory(checkNotNull(dst.absoluteFile.parentFile).toPath(), ".droiddeck-link-") + val tmp = staging.resolve("link") + try { + Files.createSymbolicLink(tmp, target) + Files.move(tmp, dst.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE) + } finally { + Files.deleteIfExists(tmp) + Files.deleteIfExists(staging) + } + } + true + } catch (e: Exception) { + Log.e(TAG, "Failed to copy link: $src to $dst", e) + false } } 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 b3bdeb968..62d04d247 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt @@ -323,8 +323,8 @@ object AddedGameArt { if (dst.isFile) continue val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue val tmp = File(cache, "$localName.tmp") - tmp.writeBytes(bytes) - if (tmp.renameTo(dst)) { got = true; File(cache, "$localName.src").writeText(STEAM) } + if (runCatching { tmp.writeBytes(bytes) }.isFailure) { tmp.delete(); continue } + if (tmp.renameTo(dst)) { got = true; runCatching { File(cache, "$localName.src").writeText(STEAM) } } } return got } 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 f0808edab..5de835f6e 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -41,10 +41,20 @@ object AddedGames { return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) } } - private val SKIP = Regex( - "(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" + - "|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$", + private val SKIP_PREFIX = Regex("(?i)^(unins|setup|vc_?redist|dxsetup|dxwebsetup|dotnet|directx|easyanticheat|battleye).*") + private val SKIP_PART = Regex("(?i)redist|prereq|installer|uninstall|crashreport|crashhandler|crashpad|crashsender|launchhelper") + private val SKIP_WORDS = setOf( + "eac", "report", "reporter", "editor", "server", "benchmark", "helper", + "update", "updater", "config", "configure", "configurator", "configuration", "settings", ) + private val WORD = Regex("[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+") + + internal fun skipExe(name: String): Boolean { + if (!name.endsWith(".exe", ignoreCase = true)) return false + val base = name.dropLast(4) + if (SKIP_PREFIX.matches(base) || SKIP_PART.containsMatchIn(base)) return true + return WORD.findAll(base).any { it.value.lowercase() in SKIP_WORDS } + } const val GUEST_DIR = "/root/Games" @@ -75,7 +85,7 @@ object AddedGames { val key = "${folder.path}|${folder.lastModified()}" val now = System.currentTimeMillis() ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second } - val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) } + val guessed = GameExePicker.rank(folder) { !skipExe(it.name) } val declared = declaredExes(folder) val preferred = declared.map { it.path }.toSet() val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking( @@ -128,7 +138,7 @@ object AddedGames { val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList() return names.asSequence() .map { File(folder, it.trim().replace('\\', '/').trimStart('/')) } - .filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile } + .filter { it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) && it.isFile } .filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) } .distinctBy { it.path } .toList() @@ -204,8 +214,10 @@ object AddedGames { GamesFolderMigration.run(context) val path = canonical(exe) fun holds(f: File) = inside(path, canonical(f)) - AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it } - SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it } + val entries = AddedExes.list(context) + fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe) + entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it } + SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it } StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it } val dirs = listOfNotNull( GameStorage.effective(context)?.let { File(it.path, "steamapps/common") }, @@ -220,6 +232,24 @@ object AddedGames { return null } + private fun currentExe(context: Context, entries: List, folder: File): File? = + SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File) + ?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) } + ?: rank(folder).exes.firstOrNull() + + private fun sameGame(folder: File, current: File?, pick: File): Boolean { + val base = canonical(folder) + if (current == null || pick.parentFile?.let(::canonical) == base) return true + val known = canonical(current) + if (!inside(known, base) || known.substringBeforeLast('/') == base) return true + val picked = canonical(pick) + fun top(p: String) = p.removePrefix("$base/").substringBefore('/') + fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() } + return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) } + } + + private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$") + fun addExe(context: Context, exe: File): AddResult { val app = context.applicationContext if (!exe.isFile) return AddResult.NotFound @@ -228,7 +258,10 @@ object AddedGames { if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) { single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) } } - if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable + if (guestPath(app, exe) == null) { + (owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) } + if (guestPath(app, exe) == null) return AddResult.Unreachable + } val folder = owner ?: exe.parentFile ?: return AddResult.NotFound SessionPrefs.setAddedGameRemoved(app, folder.path, false) SessionPrefs.setAddedGameExe(app, folder.path, exe.path) @@ -243,6 +276,8 @@ object AddedGames { fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): 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) val removed = SessionPrefs.removedAddedGames(app).toSet() val listed = listedFolders(app, removed) var already = 0 @@ -266,6 +301,20 @@ object AddedGames { return FolderResult(added, already) } + @Synchronized + internal fun bindFolder(context: Context, dir: File) { + if (!dir.isDirectory || !dir.canRead()) return + val binds = SessionPrefs.gameFolderBinds(context) + if (binds.any { it.first == dir.path }) return + val taken = binds.map { it.second }.toSet() + val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest + var guest = base + var n = 2 + while (guest in taken) guest = "$base-${n++}" + SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest)) + Log.i(TAG, "games: bound $dir at $guest") + } + private fun listedFolders(context: Context, removed: Set): List { val out = ArrayList() GamesFolderMigration.run(context) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt index 5f30796e4..db982a0f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt @@ -63,7 +63,8 @@ object CoverArt { missFile(context, rom).delete() found = true } else { - missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") } + runCatching { missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") } } + .onFailure { Log.w(TAG, "${rom.name}: miss marker not written: $it") } } } return found diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameFiles.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameFiles.kt index 8e0b55def..63b5f6b4d 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameFiles.kt @@ -14,6 +14,7 @@ import java.io.InputStream object GameFiles { const val EXTENSION = "steam" private const val MAX_BYTES = 128 + private const val MAX_NAME_BYTES = 200 private const val INVENTORY = ".droiddeck-sync.json" private const val FORMAT = "droiddeck-game-files-v1" @@ -50,8 +51,15 @@ object GameFiles { fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String { val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_") .trim().trim('.').ifBlank { "Game" } - val points = sanitized.codePoints().limit(40).toArray() - val title = String(points, 0, points.size) + val points = sanitized.codePoints().toArray() + var end = 0 + var bytes = 0 + while (end < points.size) { + bytes += String(points, end, 1).toByteArray().size + if (bytes > MAX_NAME_BYTES) break + end++ + } + val title = String(points, 0, end).trimEnd { it.isWhitespace() || it == '.' }.ifBlank { "Game" } val suffix = if (disambiguate) " (${game.gameIdString})" else "" return "$title$suffix.$EXTENSION" } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameIdentifier.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameIdentifier.kt index 4b385d896..c8faf20e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameIdentifier.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameIdentifier.kt @@ -185,7 +185,7 @@ object GameIdentifier { } private val JUNK_NAME_RE = Regex( - """(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$""" + """(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*|bootstrappackagedgame)$""" ) fun isJunkPeName(name: String): Boolean { diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt new file mode 100644 index 000000000..ab997f027 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt @@ -0,0 +1,52 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.StoresState +import java.io.File + +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.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT + else -> Kind.STORE + } + + class SteamFiles(val dir: File, val manifest: File, val partial: File?) + + fun steamFiles(game: Library.SteamGame): SteamFiles? { + val dir = game.gameFiles ?: return null + val common = dir.parentFile ?: return null + if (common.name != "common") return null + val steamapps = common.parentFile ?: return null + if (steamapps.name != "steamapps") return null + val manifest = File(steamapps, "appmanifest_${game.appId}.acf") + if (!manifest.isFile) return null + if (!declares(manifest, game.appId)) return null + return SteamFiles(dir, manifest, File(steamapps, "downloading/${game.appId}")) + } + + fun delete(target: SteamFiles): Boolean { + StoreInstalls.deleteTree(target.dir) + if (target.dir.exists()) return false + target.partial?.let { StoreInstalls.deleteTree(it) } + target.manifest.delete() + return !target.manifest.exists() + } + + fun storeGame(game: Library.SteamGame): InstalledStoreGame? { + val folder = game.gameFiles ?: return null + val store = Store.byId(game.source) + val id = game.storeId + if (store != null && id != null) StoresState.installedGame(store, id)?.let { return it } + val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled } ?: return null + return InstalledStoreGame(sidecar, folder) + } + + private fun declares(manifest: File, appId: Int): Boolean = + runCatching { manifest.readText() }.getOrNull()?.let { Regex("\"appid\"\\s+\"$appId\"").containsMatchIn(it) } == true +} 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 d5ca08be2..80cdd93c1 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt @@ -10,6 +10,7 @@ import android.graphics.BitmapFactory 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.R import java.io.File @@ -23,7 +24,7 @@ object GameShortcuts { val name = game.name.ifBlank { context.getString(R.string.app_name) } val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher) return ShortcutInfo.Builder(context, "game:${game.gameIdString}") - .setActivity(android.content.ComponentName(context, MainActivity::class.java)) + .setActivity(LauncherIcon.activity(context)) .setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon) .setIntent(launchIntent(context, game.gameIdString)).build() } 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 8fbb19b7c..1242d9add 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -72,6 +72,7 @@ object Library { private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE) private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) + private val PRIVATE_APPS = Regex("\"PrivateApps_\\d+\"\\s*\"\\[([^\\]]*)\\]\"") private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE) internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List> { @@ -137,13 +138,35 @@ object Library { } fun steamLibraryArt(context: Context): List> { - val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache") + val root = SessionPrefs.activeSteamRoot(context) + val cache = File(root, "appcache/librarycache") + val concealed = concealedSteamApps(root) return cache.listFiles().orEmpty().mapNotNull { dir -> - val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null + val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES && it.toLong() !in concealed } ?: return@mapNotNull null steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it } } } + /** Apps the user hid (the "hidden" collection) or marked private in Steam. */ + internal fun concealedSteamApps(root: File): Set { + val config = AddedGames.accountConfig(root) ?: return emptySet() + val hidden = runCatching { + val entries = org.json.JSONArray(File(config, "cloudstorage/cloud-storage-namespace-1.json").readText()) + val entry = (0 until entries.length()).map { entries.getJSONArray(it) } + .firstOrNull { it.optString(0) == "user-collections.hidden" }?.optJSONObject(1) + if (entry == null || entry.optBoolean("is_deleted")) emptySet() else { + val collection = org.json.JSONObject(entry.optString("value")) + fun ids(key: String) = collection.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optLong(it) } }.orEmpty() + ids("added").toSet() - ids("removed").toSet() + } + }.getOrDefault(emptySet()) + val private = runCatching { + PRIVATE_APPS.find(File(config, "localconfig.vdf").readText())?.groupValues?.get(1) + ?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet() + }.getOrNull().orEmpty() + return hidden + private + } + fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM fun launchableGames( @@ -155,9 +178,23 @@ object Library { var steam = steamGames(context, strictRead, arch) val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) } if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch) - return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId } + val claimed = claimedFolders(steam) + val own = list.filter { g -> + g.steamAppId != null || claimed.isEmpty() || + (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 } } + fun claimedFolders(steam: List): Set = steam.filter(::fromSteamClient).flatMap { g -> + val dir = g.gameFiles ?: return@flatMap emptyList() + listOfNotNull( + runCatching { dir.canonicalPath }.getOrNull(), + runCatching { java.nio.file.Files.readSymbolicLink(dir.toPath()).toString() }.getOrNull(), + ) + }.toSet() + fun addedGame(context: Context, g: AddedGames.Game, roots: List = steamLibraries(context).map { it.first }): SteamGame = AddedGameArt.resolve(context, g).let { art -> SteamGame( diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt index d43c4bafb..ba1a42420 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt @@ -153,7 +153,7 @@ object SteamAppInfo { return String(bytes, Charsets.UTF_8) } - private fun readMap(b: ByteBuffer, strings: List?): Map { + internal fun readMap(b: ByteBuffer, strings: List?): Map { val out = LinkedHashMap() while (b.hasRemaining()) { val type = b.get().toInt() and 0xFF diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt index 89d5fcbde..fe09a7809 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt @@ -43,6 +43,7 @@ data class GlassesState( val volumeMax: Int = 8, val sunblock: Boolean = false, val hardwareControls: Boolean = true, + val supportsDisplayModeControl: Boolean = true, val hasSunblock: Boolean = true, val controlError: String? = null, val tracking: Boolean = false, @@ -50,6 +51,7 @@ data class GlassesState( val noSignal: Boolean = false, val sbsSupport: Int = SBS_UNKNOWN, val sbs90: Boolean = false, + val rates2d: List = listOf(60, 90, 120), val experimental: Boolean = false, val supportsScreen3dof: Boolean = false, val supports30Hz: Boolean = false, @@ -63,7 +65,7 @@ data class GlassesState( val spatialConnected: Boolean get() = canControl && supportsSpatial fun usesSpatialControllers(settings: GlassesSettings) = spatialConnected && settings.positionalTracking && (settings.handControllers || settings.joyConTracking) - val can3d: Boolean get() = canControl && !noSignal && sbsSupport != SBS_FAILS + val can3d: Boolean get() = canControl && supportsDisplayModeControl && !noSignal && sbsSupport != SBS_FAILS val verticalFovDeg: Float get() { val half = Math.toRadians(diagonalFovDeg / 2.0) val aspect = if (width > 0 && height > 0) @@ -212,7 +214,7 @@ object Glasses { } fun sendMode(mode: Int) = post { - if (!_state.value.canControl) return@post + if (!_state.value.canControl || !_state.value.supportsDisplayModeControl) return@post if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) lastModeSentAt = SystemClock.uptimeMillis() } @@ -469,6 +471,7 @@ object Glasses { .put("controlError", s.controlError ?: JSONObject.NULL) .put("experimental", s.experimental) .put("supportsScreen3dof", s.supportsScreen3dof) + .put("supportsDisplayModeControl", s.supportsDisplayModeControl) .put("supportsSpatial", s.supportsSpatial) .put("firmware", (link as? VitureSdkLink)?.firmware ?: JSONObject.NULL) .put("spatialSdk", (link as? VitureSdkLink)?.spatialDiagnostics()?.let { @@ -613,13 +616,16 @@ object Glasses { switching3d = modeState.pendingStereo, brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8, sunblock = if (m === XrealModel) it.sunblock else _settings.value.sunblock ?: it.sunblock, - hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel, + hardwareControls = m?.hardwareControls == true && + (device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel || m is RokidModel), + supportsDisplayModeControl = m?.supportsDisplayModeControl == true, hasSunblock = m?.hasSunblock == true, diagonalFovDeg = m?.diagonalFovDeg ?: 46f, tracking = it.tracking && link != null, noSignal = noSignal, sbs90 = m?.sbs90 == true, - experimental = m === XrealModel, + rates2d = m?.rates2d ?: listOf(60, 90, 120), + experimental = m === XrealModel || (m is VitureProtocol.Model && m.family == VitureProtocol.Family.PRO2 && !m.needsSdk), supportsScreen3dof = link?.hasImu == true, supports30Hz = display?.let(::supports30Hz) == true, supportsSpatial = (link as? VitureSdkLink)?.isCarina == true, @@ -634,7 +640,8 @@ object Glasses { scheduleModeCheck() } - internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID + internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || + vid == XrealModel.VENDOR_ID || vid == RokidModel.VENDOR_ID internal fun supports30Hz(display: Display): Boolean { val current = display.mode @@ -650,15 +657,18 @@ object Glasses { internal fun deviceModel(device: UsbDevice): GlassesModel? { val name = runCatching { device.productName }.getOrNull() return if (device.vendorId == VitureProtocol.VENDOR_ID) { - VitureSdk.marketName(device.productId)?.let(VitureProtocol::sdkModel) + if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) VitureProtocol.model(device.productId, name) + else VitureSdk.marketName(device.productId)?.let(VitureProtocol::sdkModel) ?: VitureProtocol.model(device.productId, name) } else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel - else RayNeoModel.model(device.vendorId, device.productId, name) + else RokidModel.model(device.vendorId, device.productId, name) + ?: RayNeoModel.model(device.vendorId, device.productId, name) } internal fun isGlassesDisplayName(name: String) = - name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) + name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) || + RokidModel.isDisplay(name) private fun findDisplay(usbPresent: Boolean): Display? { return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent) @@ -715,7 +725,9 @@ object Glasses { calibrationKey = calibrationStore.key(device.vendorId, device.productId, runCatching { device.serialNumber }.getOrNull(), model?.name.orEmpty()) val l = if (device.vendorId == VitureProtocol.VENDOR_ID) { - if ((model as? VitureProtocol.Model)?.needsSdk == true) { + if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) { + VitureV2Link.open(usb, device, v2Listener(generation)) + } else if ((model as? VitureProtocol.Model)?.needsSdk == true) { // Android can evict cacheDir while the SDK is running. Log.i(TAG, "opening VITURE SDK pid=0x%04X spatial=%s".format(device.productId, spatial)) VitureSdkLink.open(usb, device, File(app.noBackupFilesDir, "viture"), sdkListener(generation), @@ -723,6 +735,8 @@ object Glasses { } else VitureLink.open(usb, device, linkListener(generation)) } else if (model === XrealModel) { XrealLink.open(app, xrealListener(generation)) + } else if (model is RokidModel) { + RokidLink.open(usb, device, rokidListener(generation)) } else { val baseKey = calibrationKey!! val gtUsb = RayNeoProtocol.isGt(device.vendorId, device.productId) @@ -790,6 +804,33 @@ object Glasses { } } + private fun rokidListener(generation: Long) = object : RokidLink.Listener { + override fun onReady() = post { + if (generation != linkGeneration) return@post + publish { it.copy(connected = true, controlError = null) } + updateImu() + applyMode(force = false) + } + override fun onValue(message: Int, value: Int) = post { + if (generation != linkGeneration) return@post + when (message) { + VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value + VitureProtocol.MSG_BRIGHTNESS -> publish { it.copy(brightness = value) } + } + } + override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) { + if (generation != linkGeneration) return + imuPackets++ + probe?.invoke(raw, pose) + if (simulating.get() == null) deliver(pose, timeNs) + } + override fun onLost() = post { + if (generation != linkGeneration) return@post + closeLink() + refresh() + } + } + private fun sdkListener(generation: Long) = object : VitureSdkLink.Listener { override fun onSpatial(pose: FloatArray, timeNs: Long, valid: Boolean) { if (generation == linkGeneration) SpatialTracking.accept(pose, timeNs, generation, valid) @@ -817,6 +858,40 @@ object Glasses { } } + private fun v2Listener(generation: Long) = object : VitureV2Link.Listener { + override fun onReady() = post { + if (generation != linkGeneration) return@post + val current = link as? VitureV2Link ?: return@post + publish { it.copy(connected = current.ready, supportsScreen3dof = current.hasImu, + tracking = current.trackingActive, controlError = null) } + updateImu() + } + override fun onValue(msgId: Int, value: Int) = post { + if (generation != linkGeneration) return@post + when (msgId) { + VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value + VitureProtocol.MSG_GET_BRIGHTNESS -> publish { it.copy(brightness = value) } + VitureProtocol.MSG_GET_VOLUME -> publish { it.copy(volume = value) } + } + } + override fun onPose(raw: ByteArray, q: FloatArray, timeNs: Long) { + if (generation != linkGeneration) return + imuPackets++ + probe?.invoke(raw, q) + if (simulating.get() == null) deliver(q, timeNs) + } + override fun onTracking(active: Boolean) = post { + if (generation != linkGeneration) return@post + val current = link as? VitureV2Link ?: return@post + publish { it.copy(tracking = active && current.trackingActive) } + } + override fun onLost() = post { + if (generation != linkGeneration) return@post + closeLink() + publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) } + } + } + private fun rayNeoListener(generation: Long) = object : RayNeoLink.Listener { override fun onDeviceInfo(boardId: Int, calibrationSaved: Boolean) = post { if (generation != linkGeneration) return@post @@ -892,7 +967,7 @@ object Glasses { private fun applyMode(force: Boolean) { val s = _state.value val m = model ?: return - if (!s.canControl) return + if (!s.canControl || !s.supportsDisplayModeControl) return if (modeState.restorePending) { sendMode(false) return @@ -914,6 +989,7 @@ object Glasses { private fun sendMode(sbs: Boolean): Boolean { val m = model ?: return false + if (!m.supportsDisplayModeControl) return false if (sbs && !_settings.value.gameOnGlasses) update { it.copy(gameOnGlasses = true) } val g = _settings.value val resolution = GlassesDisplayModes.selected(g, _state.value, sbs) @@ -946,11 +1022,11 @@ object Glasses { val l = link ?: return if (!l.ready) return val want = (sink != null || poseObservers.isNotEmpty() || probe != null || _calibration.value.running || SpatialTracking.enabled) && l.hasImu - if (want == _state.value.tracking) return + if (l !is VitureV2Link && want == _state.value.tracking) return val accepted = l.send(VitureProtocol.MSG_IMU, byteArrayOf(if (want) 1 else 0)) if (want && !accepted) return if (want) l.send(VitureProtocol.MSG_IMU_RATE, byteArrayOf(VitureProtocol.IMU_RATE_240.toByte())) - publish { it.copy(tracking = want) } + publish { it.copy(tracking = if (l is VitureV2Link) l.trackingActive else want) } } private fun linkListener(generation: Long) = object : VitureLink.Listener { diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt index 0f6c28833..5de0f03f5 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt @@ -4,10 +4,13 @@ package com.droiddeck.launcher.glasses interface GlassesModel { val name: String val controllable: Boolean + val hardwareControls: Boolean get() = true + val supportsDisplayModeControl: Boolean get() = true val brightnessMax: Int val volumeMax: Int val diagonalFovDeg: Float val sbs90: Boolean + val rates2d: List get() = listOf(60, 90, 120) val hasSunblock: Boolean get() = true val resolutions: List get() = emptyList() fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = mode(hz, sbs) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt index 4cd7f0a6d..f8d6f4c5f 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt @@ -12,8 +12,8 @@ internal object GlassesRendering { fun refreshRates(settings: GlassesSettings, state: GlassesState): List = buildList { if (native30HzAvailable(settings, state)) add(30) add(60) - if (!state.sbs || state.sbs90) add(90) - if (!state.sbs) add(120) + if (state.sbs) { if (state.sbs90) add(90) } + else addAll(state.rates2d.filter { it > 60 }) } fun outputHz(settings: GlassesSettings, state: GlassesState): Int { @@ -21,6 +21,7 @@ internal object GlassesRendering { return when { requested == 30 -> if (native30HzAvailable(settings, state)) 30 else 60 state.sbs && !state.sbs90 -> 60 + !state.sbs && requested !in state.rates2d -> state.rates2d.filter { it <= requested }.maxOrNull() ?: 60 else -> requested } } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/HandModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/HandModel.kt index c1bcaaa34..a10f178ae 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/HandModel.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/HandModel.kt @@ -1,8 +1,10 @@ package com.droiddeck.launcher.glasses -internal enum class HandModel(val asset: String, val sha256: String) { - FULL("models/hand_landmarker.task", "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"), - LITE("models/hand_landmarker_lite.task", "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8"); +import com.droiddeck.launcher.handtracking.TrackingPack + +internal enum class HandModel(val file: String, val sha256: String) { + FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"), + LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8"); companion object { val DEFAULT = FULL diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt new file mode 100644 index 000000000..dbf10a6e6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt @@ -0,0 +1,148 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.SystemClock +import android.util.Log + +/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */ +internal class RokidLink private constructor( + private val connection: UsbDeviceConnection, + private val usbInterface: UsbInterface, + private val endpoint: UsbEndpoint, + private val listener: Listener, +) : GlassesLink { + interface Listener { + fun onReady() + fun onValue(message: Int, value: Int) + fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) + fun onLost() + } + + @Volatile private var running = true + @Volatile override var ready = false + private set + @Volatile private var tracking = false + private val controlLock = Any() + private lateinit var worker: Thread + override val hasImu = true + + override fun send(msgId: Int, payload: ByteArray): Boolean { + if (!running) return false + val value = payload.firstOrNull()?.toInt()?.and(0xff) ?: -1 + return when (msgId) { + VitureProtocol.MSG_IMU -> (value in 0..1).also { if (it) tracking = value == 1 } + VitureProtocol.MSG_GET_DISPLAY -> display()?.let { listener.onValue(msgId, it) } != null + VitureProtocol.MSG_DISPLAY -> value in RokidModel.MODES && + control(RokidModel.REQUEST_SET_DISPLAY, value, RokidModel.INDEX_DISPLAY) && + true.also { listener.onValue(VitureProtocol.MSG_GET_DISPLAY, value) } + VitureProtocol.MSG_GET_BRIGHTNESS -> read(RokidModel.REQUEST_GET_BRIGHTNESS, RokidModel.INDEX_BRIGHTNESS) + ?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null + VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS && + control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS) + else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control. + } + } + + private fun display(): Int? = read(RokidModel.REQUEST_GET_DISPLAY, RokidModel.INDEX_DISPLAY) + + private fun read(request: Int, index: Int): Int? = synchronized(controlLock) { + if (!running) return null + val buffer = ByteArray(64) + val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS) + // Display mode is reported in the second byte, brightness in the first. + val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0 + if (count > offset) buffer[offset].toInt() and 0xff else null + } + + private fun control(request: Int, value: Int, index: Int): Boolean = synchronized(controlLock) { + running && connection.controlTransfer(VENDOR_OUT, request, value, index, byteArrayOf(1), 1, TIMEOUT_MS) >= 0 + } + + private fun run() { + val buffer = ByteArray(maxOf(endpoint.maxPacketSize, 64)) + val parser = RokidProtocol.Parser() + var fusion = ImuFusion() + var wasTracking = false + var brightness = -1 + var lastSensor = SystemClock.elapsedRealtime() + try { + ready = true + listener.onReady() + while (running) { + val count = connection.bulkTransfer(endpoint, buffer, buffer.size, 250) + val now = SystemClock.elapsedRealtime() + val sample = if (count > 0) parser.parse(buffer, count) else null + if (sample == null) { + if (now - lastSensor > 3000) error("Rokid IMU stream stalled") + continue + } + lastSensor = now + sample.brightness?.takeIf { it != brightness }?.let { + brightness = it + listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) + } + val enabled = tracking + if (enabled && !wasTracking) fusion = ImuFusion() + wasTracking = enabled + if (!enabled) continue + // Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ. + fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let { + if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos()) + } + } + } catch (e: Exception) { + if (running) Log.w(TAG, "Rokid stream failed", e) + } finally { + ready = false + if (running) listener.onLost() + } + } + + override fun close() { + synchronized(controlLock) { + if (!running) return + running = false + } + if (Thread.currentThread() !== worker) worker.join(500) + runCatching { connection.releaseInterface(usbInterface) } + connection.close() + } + + companion object { + private const val TAG = "RokidLink" + private const val TIMEOUT_MS = 250 + private const val VENDOR_IN = UsbConstants.USB_DIR_IN or UsbConstants.USB_TYPE_VENDOR + private const val VENDOR_OUT = UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_VENDOR + + internal fun sensorEndpoint(device: UsbDevice): Pair? { + for (i in 0 until device.interfaceCount) { + val iface = device.getInterface(i) + if (iface.interfaceClass != UsbConstants.USB_CLASS_HID) continue + for (e in 0 until iface.endpointCount) { + val endpoint = iface.getEndpoint(e) + if (endpoint.direction == UsbConstants.USB_DIR_IN && + endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT) return iface to endpoint + } + } + return null + } + + fun open(usb: UsbManager, device: UsbDevice, listener: Listener): RokidLink? { + val (iface, endpoint) = sensorEndpoint(device) ?: return null + val conn = usb.openDevice(device) ?: return null + if (!conn.claimInterface(iface, true)) { + conn.close() + return null + } + return RokidLink(conn, iface, endpoint, listener).also { link -> + link.worker = Thread({ link.run() }, "rokid-imu").apply { isDaemon = true } + link.worker.start() + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt new file mode 100644 index 000000000..a1ad6e83a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt @@ -0,0 +1,104 @@ +package com.droiddeck.launcher.glasses + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Rokid Air/Max share one USB identity; the product string distinguishes them. */ +internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel { + override val controllable = true + override val brightnessMax = BRIGHTNESS_STEPS + override val volumeMax = 0 + override val hasSunblock = false + override val diagonalFovDeg = if (max) 50f else 43f + override val sbs90 = max + override val rates2d = if (max) listOf(60, 120) else listOf(60) + override val resolutions = if (max) listOf(GlassesResolution(1920, 1080), GlassesResolution(1920, 1200)) + else listOf(GlassesResolution(1920, 1080)) + + override fun mode(hz: Int, sbs: Boolean): Int = mode(hz, sbs, null) + + override fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = when { + !max -> if (sbs) MODE_SBS_1080_60 else MODE_2D_60 + sbs && resolution?.height == 1080 -> MODE_SBS_1080_60 + sbs -> if (hz >= 90) MODE_SBS_1200_90 else MODE_SBS_1200_60 + hz >= 120 -> MODE_2D_120 + else -> MODE_2D_60 + } + + override fun modeForStep(step: Int): Int? = null + + override fun equals(other: Any?) = other is RokidModel && other.name == name && other.max == max + override fun hashCode() = name.hashCode() + + companion object { + const val VENDOR_ID = 0x04d2 + const val PRODUCT_ID = 0x162f + const val BRIGHTNESS_STEPS = 10 + + // Vendor control requests, device recipient. + const val REQUEST_SET_DISPLAY = 0x01 + const val REQUEST_GET_DISPLAY = 0x81 + const val REQUEST_SET_BRIGHTNESS = 0x02 + const val REQUEST_GET_BRIGHTNESS = 0x82 + const val INDEX_DISPLAY = 0x01 + const val INDEX_BRIGHTNESS = 0x02 + + const val MODE_2D_60 = 0 + const val MODE_SBS_1080_60 = 1 + const val MODE_2D_120 = 3 + const val MODE_SBS_1200_90 = 4 + const val MODE_SBS_1200_60 = 5 + val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60) + + // 0x04d2 is a shared vendor ID, so the Rokid product string is required too. + fun model(vid: Int, pid: Int, name: String?): GlassesModel? { + if (vid != VENDOR_ID || pid != PRODUCT_ID) return null + val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null + return RokidModel(product, product.contains("Max", ignoreCase = true)) + } + + fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true) + + /** Device brightness is 0..100; the shared slider uses ten steps. */ + fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS) + fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10 + } +} + +internal object RokidProtocol { + data class Sample(val timeNs: Long, val accel: Vec3, val gyro: Vec3, val brightness: Int?) + + private const val COMBINED = 17 + private const val SEPARATE = 4 + private const val SENSOR_ACCEL = 1 + private const val SENSOR_GYRO = 2 + + /** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */ + class Parser { + private var accel: Vec3? = null + private var accelTime = -1L + private var gyro: Vec3? = null + private var gyroTime = -2L + + fun parse(packet: ByteArray, length: Int = packet.size): Sample? { + if (length < 64) return null + val b = ByteBuffer.wrap(packet, 0, length).order(ByteOrder.LITTLE_ENDIAN) + return when (packet[0].toInt() and 0xff) { + COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff) + SEPARATE -> { + val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds. + when (packet[1].toInt() and 0xff) { + SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time } + SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time } + else -> return null + } + if (accelTime != gyroTime) return null + Sample(time, accel!!, gyro!!, null).also { accelTime = -1L; gyroTime = -2L } + } + else -> null + } + } + + private fun vec(b: ByteBuffer, offset: Int) = Vec3(b.getFloat(offset), b.getFloat(offset + 4), b.getFloat(offset + 8)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt b/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt index 0b086c7c4..becad63a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt @@ -1,7 +1,9 @@ package com.droiddeck.launcher.glasses +import com.droiddeck.launcher.core.AddonPack import android.content.Context import com.droiddeck.launcher.R +import com.droiddeck.launcher.handtracking.TrackingPack import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -11,7 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions import com.google.mediapipe.tasks.core.Delegate import com.google.mediapipe.tasks.vision.core.RunningMode import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker -import org.opencv.android.OpenCVLoader import org.opencv.android.Utils import org.opencv.core.* import org.opencv.imgproc.Imgproc @@ -170,11 +171,11 @@ internal class StereoHandEngine(private val context: Context, private val token: } finally { calibrationProgress = calibrationProgress.copy(solving = false) } } } private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker { - val bytes=context.assets.open(selected.asset).use { it.readBytes() } + val bytes=TrackingPack.file(context,selected.file).readBytes() val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) } return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder() - .setBaseOptions(BaseOptions.builder().setModelAssetPath(selected.asset) + .setBaseOptions(BaseOptions.builder().setModelAssetBuffer(ByteBuffer.allocateDirect(bytes.size).put(bytes).apply { rewind() }) .setDelegate(if(useGpu) Delegate.GPU else Delegate.CPU).build()) .setRunningMode(RunningMode.VIDEO).setNumHands(maxHands).setMinHandDetectionConfidence(0.6f) .setMinHandPresenceConfidence(0.6f).setMinTrackingConfidence(0.6f).build()) @@ -325,7 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token: SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand()) } private fun loop() { - try { check(OpenCVLoader.initLocal()) } catch(e: Throwable) { message=context.getString(R.string.spatial_opencv_error, e.message.orEmpty());return } + val loaded=TrackingPack.awaitNative(context,{ running }) { state -> + if(state is AddonPack.State.Downloading) + message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt()) + } + if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return } var grayL=Mat();var grayR=Mat();val rgba=Mat() var bmpL: Bitmap?=null;var bmpR: Bitmap?=null var leftGray=ByteArray(0);var rightGray=ByteArray(0) @@ -336,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token: var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L var rate=0f try { - check(OpenCVLoader.initLocal()) { context.getString(R.string.spatial_opencv_load_error) } + check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) } joyConFlow=StereoJoyConFlow() while(running) { while(true) { diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt index 4d3147d65..573f0f8dc 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt @@ -6,6 +6,7 @@ import kotlin.math.sqrt object VitureProtocol { const val VENDOR_ID = 0x35CA + const val PRO2_PRODUCT_ID = 0x1301 const val PACKET_SIZE = 64 private const val HEADER = 0x12 @@ -33,13 +34,19 @@ object VitureProtocol { enum class Family { ONE, PRO, LUMA, ULTRA, BEAST, PRO2 } - class Model(override val name: String, val family: Family) : GlassesModel { + class Model( + override val name: String, + val family: Family, + val needsSdk: Boolean = family == Family.ULTRA || family == Family.BEAST || family == Family.PRO2, + ) : GlassesModel { override val controllable: Boolean get() = true - val needsSdk: Boolean get() = family == Family.ULTRA || family == Family.BEAST || family == Family.PRO2 + private val directPro2: Boolean get() = family == Family.PRO2 && !needsSdk + override val hardwareControls: Boolean get() = !directPro2 + override val supportsDisplayModeControl: Boolean get() = !directPro2 val tall: Boolean get() = family == Family.LUMA || family == Family.ULTRA || family == Family.BEAST override val sbs90: Boolean get() = family == Family.ULTRA - override val brightnessMax: Int get() = 8 - override val volumeMax: Int get() = if (family == Family.BEAST) 15 else 8 + override val brightnessMax: Int get() = if (directPro2) 0 else 8 + override val volumeMax: Int get() = if (directPro2) 0 else if (family == Family.BEAST) 15 else 8 override val hasSunblock: Boolean get() = family != Family.PRO2 override val diagonalFovDeg: Float get() = when (family) { Family.ONE -> 43f @@ -89,6 +96,7 @@ object VitureProtocol { } fun model(productId: Int, productName: String?): Model? { + if (productId == PRO2_PRODUCT_ID) return Model("VITURE Pro 2", Family.PRO2, needsSdk = false) val family = when (productId) { 0x1011, 0x1013, 0x1015, 0x1017, 0x101b -> Family.ONE 0x1019, 0x101d -> Family.PRO diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt new file mode 100644 index 000000000..d46c246b7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt @@ -0,0 +1,288 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.hardware.usb.UsbRequest +import android.util.Log +import java.io.IOException +import java.nio.ByteBuffer +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicLong + +internal class VitureV2Link internal constructor( + private val transport: Transport, + private val listener: Listener, +) : GlassesLink { + interface Listener { + fun onReady() + fun onValue(msgId: Int, value: Int) + fun onPose(raw: ByteArray, q: FloatArray, timeNs: Long) + fun onTracking(active: Boolean) + fun onLost() + } + + internal data class Received(val bytes: ByteArray, val timeNs: Long) + internal interface Transport { + fun write(packet: ByteArray): Boolean + fun read(timeoutMs: Long): Received? + fun close() + } + + private data class Command(val packet: ByteArray, val stream: Boolean? = null) + private val commands = ArrayBlockingQueue(16) + private val stateLock = Any() + private val bad = AtomicLong() + @Volatile private var running = true + @Volatile private var wanted = true + @Volatile override var ready = false + private set + @Volatile var trackingActive = false + private set + override val hasImu: Boolean get() = ready + override val badFrames: Long get() = bad.get() + @Volatile private var streamOn = false + private var lastPoseAt = 0L + private var lastTick: Long? = null + private var poseNotBefore = 0L + private val worker = Thread(::run, "viture-v2").apply { isDaemon = true; start() } + + override fun send(msgId: Int, payload: ByteArray): Boolean { + synchronized(stateLock) { + if (!running || !ready) return false + if (msgId == VitureProtocol.MSG_IMU) { + if (payload.size != 1 || payload[0].toInt() !in 0..1) return false + val enabled = payload[0].toInt() == 1 + if (wanted == enabled) return true + val command = Command(VitureV2Protocol.imuControl( + if (enabled) VitureV2Protocol.STREAM_POSE else VitureV2Protocol.STREAM_OFF), enabled) + if (!commands.offer(command)) return false + wanted = enabled + if (!enabled) { + streamOn = false + tracking(false) + } + return true + } + if (msgId == VitureProtocol.MSG_GET_IMU) return true + // Keep the measured 120 Hz mode; the generic controller requests 240 Hz. + if (msgId == VitureProtocol.MSG_IMU_RATE) return false + val query = when (msgId) { + VitureProtocol.MSG_GET_BRIGHTNESS -> VitureV2Protocol.MSG_GET_BRIGHTNESS + VitureProtocol.MSG_GET_VOLUME -> VitureV2Protocol.MSG_GET_VOLUME + VitureProtocol.MSG_GET_DISPLAY -> VitureV2Protocol.MSG_GET_DISPLAY + else -> return false + } + return payload.isEmpty() && commands.offer(Command(VitureV2Protocol.packet(query))) + } + } + + private fun tracking(active: Boolean) { + if (trackingActive != active) { + trackingActive = active + listener.onTracking(active) + } + } + + private fun run() { + try { + if (!transport.write(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE))) + throw IOException("Could not start pose stream") + poseNotBefore = System.nanoTime() + streamOn = true + lastPoseAt = System.nanoTime() + while (running) { + commands.poll()?.let { command -> + if (!transport.write(command.packet)) throw IOException("USB command failed") + val completedAt = System.nanoTime() + synchronized(stateLock) { + command.stream?.let { + streamOn = it + lastTick = null + poseNotBefore = completedAt + lastPoseAt = System.nanoTime() + } + } + } + transport.read(100)?.let(::receive) + if (streamOn && System.nanoTime() - lastPoseAt > POSE_TIMEOUT_NS) + throw IOException("No fresh valid pose samples") + } + } catch (e: Exception) { + if (running) { + ready = false + tracking(false) + Log.w(TAG, "V2 connection lost: ${e.message}") + listener.onLost() + } + } finally { + running = false + ready = false + tracking(false) + runCatching { transport.write(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) } + transport.close() + } + } + + private fun receive(received: Received) = synchronized(stateLock) { + if (!running) return + val frame = VitureV2Protocol.parse(received.bytes) ?: run { bad.incrementAndGet(); return } + if (frame.msgId == VitureV2Protocol.EVENT_POSE) { + val pose = VitureV2Protocol.pose(frame) ?: run { bad.incrementAndGet(); return } + if (!streamOn || !wanted || received.timeNs < poseNotBefore) return + val previous = lastTick + if (previous != null) { + val delta = (pose.deviceTick - previous) and 0xffffffffL + if (delta == 0L || delta >= 0x80000000L) return + } + lastTick = pose.deviceTick + lastPoseAt = System.nanoTime() + val becameReady = !ready + ready = true + tracking(true) + if (becameReady) listener.onReady() + val q = pose.quaternion + // Upstream right/up/back pose converted to the shared VITURE legacy basis. + val shared = floatArrayOf(-q[2], -q[0], q[1], q[3]) + // Upstream does not establish device tick units; use host monotonic receipt time. + if (running && wanted && streamOn) listener.onPose(received.bytes, shared, received.timeNs) + return + } + VitureV2Protocol.reply(frame, VitureV2Protocol.MSG_IMU_CONTROL)?.let { + if (!it.success) throw IOException("Pose stream rejected with status ${it.status}") + } + val queries = arrayOf( + VitureV2Protocol.MSG_GET_BRIGHTNESS to VitureProtocol.MSG_GET_BRIGHTNESS, + VitureV2Protocol.MSG_GET_VOLUME to VitureProtocol.MSG_GET_VOLUME, + VitureV2Protocol.MSG_GET_DISPLAY to VitureProtocol.MSG_GET_DISPLAY, + ) + for ((query, internalId) in queries) { + val reply = VitureV2Protocol.reply(frame, query) ?: continue + if (reply.success && reply.body.size == 1) listener.onValue(internalId, reply.body[0].toInt() and 255) + } + } + + override fun close() { + synchronized(stateLock) { + running = false + ready = false + wanted = false + commands.clear() + } + if (Thread.currentThread() !== worker) worker.join(1500) + } + + companion object { + private const val TAG = "VitureV2Link" + private const val POSE_TIMEOUT_NS = 3_000_000_000L + const val PRODUCT_ID = 0x1301 + + fun open(usb: UsbManager, device: UsbDevice, listener: Listener): VitureV2Link? { + if (device.vendorId != VitureProtocol.VENDOR_ID || device.productId != PRODUCT_ID) return null + val candidates = (0 until device.interfaceCount).map(device::getInterface).filter { + it.interfaceClass == UsbConstants.USB_CLASS_HID && it.alternateSetting == 0 && it.endpointCount == 2 + }.mapNotNull { intf -> + val endpoints = (0 until intf.endpointCount).map(intf::getEndpoint) + val input = endpoints.singleOrNull { it.direction == UsbConstants.USB_DIR_IN && + it.type == UsbConstants.USB_ENDPOINT_XFER_INT && it.maxPacketSize == 64 } + val output = endpoints.singleOrNull { it.direction == UsbConstants.USB_DIR_OUT && + it.type == UsbConstants.USB_ENDPOINT_XFER_INT && it.maxPacketSize == 64 } + if (input != null && output != null) Triple(intf, input, output) else null + } + val (intf, input, output) = candidates.singleOrNull() ?: return null + val connection = usb.openDevice(device) ?: return null + if (!connection.claimInterface(intf, true)) { connection.close(); return null } + return try { + VitureV2Link(AndroidTransport(connection, intf, input, output), listener) + } catch (e: Exception) { + connection.releaseInterface(intf) + connection.close() + Log.w(TAG, "Could not initialize USB requests", e) + null + } + } + } + + private class AndroidTransport( + private val connection: UsbDeviceConnection, + private val intf: UsbInterface, + input: UsbEndpoint, + output: UsbEndpoint, + ) : Transport { + private val inputRequest = UsbRequest() + private val outputRequest = UsbRequest() + private val inputBuffer = ByteBuffer.allocateDirect(64) + private var outputBuffer: ByteBuffer? = null + private var outputPending = false + private val received = ArrayDeque() + + init { + try { + if (!inputRequest.initialize(connection, input) || !outputRequest.initialize(connection, output)) + throw IOException("USB request initialization failed") + queueInput() + } catch (e: Exception) { + inputRequest.close() + outputRequest.close() + throw e + } + } + + private fun queueInput() { + inputBuffer.clear() + if (!inputRequest.queue(inputBuffer)) throw IOException("USB input queue failed") + } + + private fun await(timeoutMs: Long) { + val completed = try { connection.requestWait(timeoutMs) } catch (_: TimeoutException) { return } + ?: throw IOException("USB device disconnected") + if (completed === outputRequest) { + outputPending = false + } else if (completed === inputRequest) { + val count = inputBuffer.position() + if (count > 0) { + val bytes = ByteArray(count) + inputBuffer.flip() + inputBuffer.get(bytes) + if (received.size >= 512) throw IOException("USB input backlog overflow") + received.addLast(Received(bytes, System.nanoTime())) + } + queueInput() + } + } + + override fun write(packet: ByteArray): Boolean { + if (outputPending) throw IOException("Previous USB output has not completed") + val buffer = ByteBuffer.allocateDirect(packet.size).apply { put(packet); flip() } + outputBuffer = buffer + outputPending = outputRequest.queue(buffer) + if (!outputPending) return false + val deadline = System.nanoTime() + 250_000_000L + while (outputPending && System.nanoTime() < deadline) await(25) + if (outputPending) { + outputRequest.cancel() + throw IOException("USB output timed out") + } + return buffer.position() == packet.size + } + + override fun read(timeoutMs: Long): Received? { + if (received.isEmpty()) await(timeoutMs) + return received.removeFirstOrNull() + } + + override fun close() { + inputRequest.cancel() + if (outputPending) outputRequest.cancel() + connection.releaseInterface(intf) + connection.close() + inputRequest.close() + outputRequest.close() + outputBuffer = null + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt new file mode 100644 index 000000000..548ad6b91 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt @@ -0,0 +1,132 @@ +// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. +// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/. +package com.droiddeck.launcher.glasses + +import kotlin.math.sqrt + +object VitureV2Protocol { + const val PACKET_SIZE = 64 + const val HEADER_SIZE = 8 + const val RESPONSE_OFFSET = 0x2000 + private const val PREAMBLE = 0x0010 + private const val MAX_PAYLOAD_SIZE = PACKET_SIZE - HEADER_SIZE + + const val MSG_IMU_CONTROL = 0x0301 + const val MSG_GET_SERIAL = 0x3002 + const val MSG_GET_VERSION = 0x3003 + const val MSG_GET_BRIGHTNESS = 0x3122 + const val MSG_GET_DUTY_CYCLE = 0x3125 + const val MSG_GET_DISPLAY = 0x3141 + const val MSG_GET_VOLUME = 0x3201 + const val MSG_GET_WEAR = 0x3321 + const val EVENT_POSE = 0x7308 + const val EVENT_RAW = 0x7309 + + const val STREAM_OFF = 0 + const val STREAM_POSE = 1 + const val STREAM_RAW = 2 + + enum class Rate(val wireValue: Int, val hz: Int) { + HZ_60(0, 60), HZ_90(1, 90), HZ_120(2, 120), + HZ_240(3, 240), HZ_500(4, 500), HZ_1000(5, 1000), + } + + class Frame internal constructor(val msgId: Int, val payload: ByteArray) + + class Reply internal constructor(val status: Int, val body: ByteArray) { + val success: Boolean get() = status == 0 + } + + // Device ticks have no established time unit; quaternion is xyzw with no axis remapping. + class Pose internal constructor( + val deviceTick: Long, + val quaternion: FloatArray, + val unknown: Long, + ) + + class RawSample internal constructor( + val deviceTick: Long, + val gyroRadPerSec: FloatArray, + val accelG: FloatArray, + val payload: ByteArray, + ) + + fun packet(msgId: Int, payload: ByteArray = ByteArray(0)): ByteArray { + require(msgId in 0..0xFFFF) + require(payload.size <= MAX_PAYLOAD_SIZE) + return ByteArray(HEADER_SIZE + payload.size).also { buffer -> + putU16(buffer, 0, PREAMBLE) + putU16(buffer, 2, msgId) + putU16(buffer, 4, payload.size) + putU16(buffer, 6, checksum(payload, 0, payload.size)) + payload.copyInto(buffer, HEADER_SIZE) + } + } + + fun imuControl(streams: Int, rate: Rate = Rate.HZ_120): ByteArray { + // Upstream measured that stream mask 3 ACKs but produces no events on Pro 2. + require(streams == STREAM_OFF || streams == STREAM_POSE || streams == STREAM_RAW) + require(streams != STREAM_POSE || rate.hz <= 240) + val wireRate = if (streams == STREAM_OFF) 0 else rate.wireValue + return packet(MSG_IMU_CONTROL, byteArrayOf(streams.toByte(), wireRate.toByte())) + } + + fun parse(buffer: ByteArray, size: Int = buffer.size): Frame? { + if (size < HEADER_SIZE || size > PACKET_SIZE || size > buffer.size) return null + if (u16(buffer, 0) != PREAMBLE) return null + val payloadSize = u16(buffer, 4) + if (payloadSize > MAX_PAYLOAD_SIZE || HEADER_SIZE + payloadSize > size) return null + if (checksum(buffer, HEADER_SIZE, payloadSize) != u16(buffer, 6)) return null + return Frame(u16(buffer, 2), buffer.copyOfRange(HEADER_SIZE, HEADER_SIZE + payloadSize)) + } + + fun reply(frame: Frame, requestMsgId: Int): Reply? { + if (requestMsgId !in 0..0xFFFF || frame.msgId != ((requestMsgId + RESPONSE_OFFSET) and 0xFFFF)) return null + if (frame.payload.isEmpty()) return null + return Reply(u8(frame.payload[0]), frame.payload.copyOfRange(1, frame.payload.size)) + } + + fun pose(frame: Frame): Pose? { + if (frame.msgId != EVENT_POSE || frame.payload.size != 24) return null + val payload = frame.payload + val w = float(payload, 8) + val x = float(payload, 12) + val y = float(payload, 16) + val z = float(payload, 20) + if (!w.isFinite() || !x.isFinite() || !y.isFinite() || !z.isFinite()) return null + val norm = sqrt(w.toDouble() * w + x.toDouble() * x + y.toDouble() * y + z.toDouble() * z) + if (norm !in 0.9..1.1) return null + val n = norm.toFloat() + return Pose(u32(payload, 4), floatArrayOf(x / n, y / n, z / n, w / n), u32(payload, 0)) + } + + fun raw(frame: Frame): RawSample? { + if (frame.msgId != EVENT_RAW || frame.payload.size != 56) return null + val payload = frame.payload + val gyro = floatArrayOf(float(payload, 10), float(payload, 14), float(payload, 18)) + val accel = floatArrayOf(float(payload, 22), float(payload, 26), float(payload, 30)) + if (gyro.any { !it.isFinite() } || accel.any { !it.isFinite() }) return null + return RawSample(u32(payload, 4), gyro, accel, payload.copyOf()) + } + + private fun checksum(buffer: ByteArray, offset: Int, size: Int): Int { + var sum = 0 + for (i in offset until offset + size) sum += u8(buffer[i]) + return sum and 0xFFFF + } + + private fun u8(value: Byte) = value.toInt() and 0xFF + + private fun u16(buffer: ByteArray, offset: Int) = + u8(buffer[offset]) or (u8(buffer[offset + 1]) shl 8) + + private fun u32(buffer: ByteArray, offset: Int): Long = + u16(buffer, offset).toLong() or (u16(buffer, offset + 2).toLong() shl 16) + + private fun float(buffer: ByteArray, offset: Int): Float = Float.fromBits(u32(buffer, offset).toInt()) + + private fun putU16(buffer: ByteArray, offset: Int, value: Int) { + buffer[offset] = value.toByte() + buffer[offset + 1] = (value ushr 8).toByte() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt index f6ac28f69..e8d34f44a 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt @@ -4,20 +4,27 @@ import kotlin.math.abs /** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */ internal class XrealOrientationFilter { + private val fusion = ImuFusion() + + // 1S reports right/down/forward. Rotate 180 degrees about X into the + // shared right/up/back basis before either gravity alignment or fusion. + // Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ. + fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs, + Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z), + Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z)) +} + +/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */ +internal class ImuFusion { private var previous = 0L private var rotation = Quaternion.IDENTITY private var initialized = false private var bias = Vec3(0f, 0f, 0f) - fun update(sample: XrealProtocol.Sample): FloatArray? { - if (sample.timeNs <= previous) return null - val dt = if (previous == 0L) 0f else (sample.timeNs - previous) * 1e-9f - previous = sample.timeNs - // 1S reports right/down/forward. Rotate 180 degrees about X into the - // shared right/up/back basis before either gravity alignment or fusion. - // Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ. - val accel = Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z) - val gyro = Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z) + fun update(timeNs: Long, accel: Vec3, gyro: Vec3): FloatArray? { + if (timeNs <= previous) return null + val dt = if (previous == 0L) 0f else (timeNs - previous) * 1e-9f + previous = timeNs val norm = accel.length() val gravityValid = abs(norm - 9.81f) < 1.5f if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion. diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt b/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt index 751ab8298..a63a80249 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt @@ -28,7 +28,7 @@ object AutoDriverTrial { release, p.getString("name", "").orEmpty(), p.getString("display", "").orEmpty(), p.getString("linux", "").orEmpty(), p.getString("previousDisplay", "").orEmpty(), p.getString("previousLinux", "").orEmpty(), - p.getInt("starts", 0), + maxOf(p.getInt("starts", 0), p.getInt("displayStarts", 0)), ) } @@ -37,7 +37,7 @@ object AutoDriverTrial { .putString("release", release).putString("name", name) .putString("display", display).putString("linux", linux) .putString("previousDisplay", previousDisplay).putString("previousLinux", previousLinux) - .putInt("starts", 0) + .putInt("starts", 0).putInt("displayStarts", 0) .commit() } @@ -48,13 +48,24 @@ object AutoDriverTrial { fun bad(context: Context): Set = prefs(context).getStringSet("bad", emptySet()).orEmpty() + /** + * Counted before the compositor loads the trial display driver, so a driver that kills the process during + * Vulkan init still counts towards a rollback; sessionStarting runs too late for that. + */ + fun displayStarting(context: Context, display: String) { + val p = prefs(context) + if (p.getString("release", null) == null || p.getString("display", "") != display) return + val counted = maxOf(p.getInt("displayStarts", 0), p.getInt("starts", 0)) + p.edit().putInt("displayStarts", counted + 1).commit() + } + fun sessionStarting(context: Context, display: String, linux: String) { val trial = current(context) watching = null if (trial == null || trial.display != display || trial.linux != linux) return app = context.applicationContext watching = trial - prefs(context).edit().putInt("starts", trial.starts + 1).commit() + prefs(context).edit().putInt("starts", prefs(context).getInt("starts", 0) + 1).commit() } fun sessionReady() { diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt new file mode 100644 index 000000000..76b6dddc9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.GpuInfo.Family +import org.json.JSONArray +import org.json.JSONObject + +object AutoPicks { + const val URL = "https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/main/auto-drivers.json" + const val SCHEMA = 1 + const val NEWEST_DD_TURNIP = DriverPairs.DD_TURNIP + + class Rule( + val families: Set, + val models: Set = emptySet(), + val oneUi8Gen2: Boolean? = null, + val minSdk: Int = 0, + val blobMin: Int = 0, + val blobMax: Int = Int.MAX_VALUE, + val prefer: List, + ) { + fun matches(gpu: GpuInfo, sdk: Int, blob: Int?): Boolean = + gpu.family in families && + (models.isEmpty() || gpu.model in models) && + (oneUi8Gen2 == null || gpu.oneUi8Gen2 == oneUi8Gen2) && + sdk >= minSdk && + (blobMin == 0 && blobMax == Int.MAX_VALUE || blob != null && blob in blobMin..blobMax) + } + + val BUILT_IN = listOf( + Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)), + Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)), + Rule(setOf(Family.A7XX_LOW), prefer = listOf(DriverPairs.BANNER_710)), + Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)), + Rule(setOf(Family.A7XX, Family.A6XX), prefer = listOf(DriverPairs.BANNER)), + Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)), + Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)), + ) + + fun parse(text: String?): List? = runCatching { + val json = JSONObject(text ?: return null) + if (json.optInt("schemaVersion") != SCHEMA) return null + val rules = json.getJSONArray("rules") + (0 until rules.length()).mapNotNull { i -> rule(rules.getJSONObject(i)) }.takeIf { it.isNotEmpty() } + }.getOrNull() + + private fun rule(o: JSONObject): Rule? { + val families = strings(o.optJSONArray("families")).mapNotNull { name -> Family.values().firstOrNull { it.name == name } }.toSet() + val prefer = strings(o.optJSONArray("prefer")) + if (families.isEmpty() || prefer.isEmpty()) return null + val models = o.optJSONArray("models")?.let { a -> (0 until a.length()).map { a.getInt(it) }.toSet() } ?: emptySet() + return Rule( + families, models, + if (o.has("oneUi8Gen2")) o.getBoolean("oneUi8Gen2") else null, + o.optInt("minSdk", 0), o.optInt("blobMin", 0), o.optInt("blobMax", Int.MAX_VALUE), prefer, + ) + } + + private fun strings(a: JSONArray?): List = a?.let { (0 until it.length()).map { i -> it.getString(i) } }.orEmpty() + + fun preferences(gpu: GpuInfo, rules: List, sdk: Int, blob: Int?): List = + rules.firstOrNull { it.matches(gpu, sdk, blob) }?.prefer.orEmpty() + + fun blobVersion(driverVersion: String?): Int? = driverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DeviceInfo.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DeviceInfo.kt index 18bab2148..7e71c4266 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/DeviceInfo.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DeviceInfo.kt @@ -57,10 +57,11 @@ object DeviceInfo { add("Model found from", when (gpu.modelSource) { "kernel" -> "the kernel's GPU name" "vulkan" -> "the Vulkan driver (the kernel's name has no model)" - "platform" -> "the platform (the kernel's name has no model)" + "platform" -> "the platform or SoC (the kernel and Vulkan names have no model)" else -> "unknown" }) add("Kernel name", gpu.kgslName) + add("Kernel chip id", gpu.kgslChipId) val kgsl = "/sys/class/kgsl/kgsl-3d0" add("Clock range", gpuRange()) add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" }) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt index a67f30b4f..367a747b3 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt @@ -111,15 +111,23 @@ object DriverPairs { } } - fun recommendedKey(gpu: GpuInfo, pairs: List, sdk: Int = android.os.Build.VERSION.SDK_INT): String? = when (gpu.family) { - Family.POWERVR -> NAD_RADV_XCLIPSE.takeIf { gpu.name.contains("Xclipse", ignoreCase = true) } - Family.NOT_ADRENO, Family.MALI_OTHER -> null - Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST -> maliKey(gpu, pairs, sdk) - Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED - Family.A7XX_LOW -> BANNER_710 - Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER - Family.A6XX -> BANNER - Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER + fun recommendedKey( + gpu: GpuInfo, pairs: List, + rules: List = AutoPicks.BUILT_IN, bad: Set = emptySet(), + sdk: Int = android.os.Build.VERSION.SDK_INT, blob: Int? = null, + ): String? { + when (gpu.family) { + Family.POWERVR -> return NAD_RADV_XCLIPSE.takeIf { gpu.name.contains("Xclipse", ignoreCase = true) } + Family.MALI_OTHER, Family.NOT_ADRENO -> return null + Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST -> + return maliKey(gpu, pairs, sdk) + else -> Unit + } + val keys = AutoPicks.preferences(gpu, rules, sdk, blob) + .ifEmpty { AutoPicks.preferences(gpu, AutoPicks.BUILT_IN, sdk, blob) } + .mapNotNull { if (it == AutoPicks.NEWEST_DD_TURNIP) newestDdTurnip(pairs) else it } + return keys.firstOrNull { key -> pairs.any { it.key == key && it.complete && AutoDriverTrial.release(it) !in bad } } + ?: keys.firstOrNull() } private fun maliKey(gpu: GpuInfo, pairs: List, sdk: Int): String? { diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 952ada616..4282ecf6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor object FrameGen { const val ENGINE_OFF = "off" - const val ENGINE_WINFG = "winfg" + const val ENGINE_DDFG = "ddfg" + private const val LEGACY_ENGINE_WINFG = "winfg" const val ENGINE_LSFG = "lsfg" - private const val WINFG_PERF_PRESET = 2 - private const val WINFG_MODEL = 4 - private const val FLOW_SCALE_WINFG = 0.60f + private const val DDFG_PERF_PRESET = 2 + private const val DDFG_MODEL = 4 + private const val FLOW_SCALE_DDFG = 0.60f private const val FLOW_SCALE_LSFG = 0.80f val ADAPTIVE_TARGETS = listOf(120, 90, 60) @@ -29,7 +30,7 @@ object FrameGen { fun mode(context: Context): Mode { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF + val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: ENGINE_OFF } val target = prefs.getInt("target", 0) if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target) return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4)) @@ -43,7 +44,7 @@ object FrameGen { fun label(context: Context): String = label(context, mode(context)) fun label(context: Context, mode: Mode): String = when { - mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier) + mode.engine == ENGINE_DDFG -> context.getString(R.string.frame_gen_ddfg, mode.multiplier) mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target) mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier) else -> context.getString(R.string.frame_gen_off) @@ -68,10 +69,10 @@ object FrameGen { WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz) WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target) } - ENGINE_WINFG -> { - WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG) - WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET) - WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz) + ENGINE_DDFG -> { + WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG) + WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET) + WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz) WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0) } else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt index e58191e89..9ba0536bb 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt @@ -14,6 +14,8 @@ data class GpuInfo( val oneUi8Gen2: Boolean, val kgslName: String = "", val modelSource: String = "", + val kgslChipId: String = "", + val platformGpuModel: Int = 0, ) { enum class Family(val label: String) { A8XX("Adreno 8xx"), @@ -42,9 +44,12 @@ data class GpuInfo( enum class Support { TESTED, UNTESTED, UNSUPPORTED } + private val isA850: Boolean get() = model == 850 || platformGpuModel == 850 + val support: Support get() = when { family == Family.NOT_ADRENO -> Support.UNSUPPORTED + isA850 -> Support.UNTESTED family == Family.A6XX && model == 650 -> Support.TESTED family == Family.A8XX -> Support.TESTED family == Family.A7XX && model >= 725 -> Support.TESTED @@ -54,12 +59,13 @@ data class GpuInfo( val supportText: String get() = when (support) { Support.TESTED -> "Supported" - Support.UNTESTED -> when (family) { - Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) "Experimental: RADV or the system Xclipse driver" + Support.UNTESTED -> when { + family == Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) "Experimental: RADV or the system Xclipse driver" else "System PowerVR driver: compatibility varies by device" - Family.MALI_OTHER -> "Experimental: Mali through the phone's own driver (Venus)" - Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST -> "Experimental: open Mali driver (test builds)" - Family.A7XX_LOW -> "Experimental: its drivers are test builds" + family == Family.MALI_OTHER -> "Experimental: Mali through the phone's own driver (Venus)" + family.mali -> "Experimental: open Mali driver (test builds)" + isA850 -> "Experimental Adreno 850 support: requires compatible experimental display and game drivers; not verified on this device" + family == Family.A7XX_LOW -> "Experimental: its drivers are test builds" else -> "Outside tested hardware (Adreno 650, 725 and newer): it may not run" } Support.UNSUPPORTED -> "Not supported: no compatible graphics backend is available for this GPU" @@ -67,10 +73,10 @@ data class GpuInfo( fun supportText(context: Context): String = context.getString(when (support) { Support.TESTED -> R.string.gpuinfo_supported - Support.UNTESTED -> when (family) { - Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) R.string.gpuinfo_xclipse else R.string.gpuinfo_powervr - Family.A7XX_LOW -> R.string.gpuinfo_experimental - Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST, Family.MALI_OTHER -> R.string.gpuinfo_experimental + Support.UNTESTED -> when { + family == Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) R.string.gpuinfo_xclipse else R.string.gpuinfo_powervr + isA850 -> R.string.gpuinfo_a850_experimental + family == Family.A7XX_LOW || family.mali -> R.string.gpuinfo_experimental else -> R.string.gpuinfo_below_tested } Support.UNSUPPORTED -> R.string.gpuinfo_unsupported @@ -83,19 +89,15 @@ data class GpuInfo( fun detect(): GpuInfo { val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists() - val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid") - .firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) } + val raw = FileUtils.readString(File("/sys/class/kgsl/kgsl-3d0/gpu_model"))?.trim() + val chipId = FileUtils.readString(File("/sys/class/kgsl/kgsl-3d0/gpu_chipid"))?.trim().orEmpty() val soc = if (adreno) SocNames.label() else "" - val fromKernel = raw?.let { threeDigits(it) } ?: 0 - val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0 - val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel() - val model = maxOf(fromKernel, fromVulkan, fromPlatform) - val source = when { - fromKernel > 0 -> "kernel" - fromVulkan > 0 -> "vulkan" - fromPlatform > 0 -> "platform" - else -> "" - } + val platformGpuModel = if (adreno) platformModel() else 0 + val (model, source) = if (adreno) modelFromNames( + raw, + { VulkanInfo.cachedOrNull()?.get("device") }, + { platformGpuModel }, + ) else 0 to "" // Mali, named by the vendor's Vulkan driver ("Mali-G720-Immortalis MC12") or by kbase. val mali = if (adreno || !File("/dev/mali0").exists()) null else maliName() val family = familyOf(adreno, model, SystemVulkanDriver.isPowerVr(), mali) @@ -112,19 +114,34 @@ data class GpuInfo( }, model = model, family = family, soc = soc, oneUi8Gen2 = samsung && model == 740, - kgslName = raw.orEmpty(), modelSource = source, + kgslName = raw.orEmpty(), modelSource = source, kgslChipId = chipId, + platformGpuModel = platformGpuModel, ) } - internal fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + internal fun threeDigits(text: String): Int = Regex("""(?i)\badreno\s*(?:\(TM\)\s*)?(\d{3})(?!\d)""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + + internal fun modelFromNames( + kernelName: String?, + vulkanName: () -> String?, + fallback: () -> Int, + ): Pair { + kernelName?.let { threeDigits(it) }?.takeIf { it > 0 }?.let { return it to "kernel" } + vulkanName()?.let { threeDigits(it) }?.takeIf { it > 0 }?.let { return it to "vulkan" } + return fallback().let { if (it > 0) it to "platform" else 0 to "" } + } private val PLATFORMS = mapOf( "msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730, "kalama" to 740, "pineapple" to 750, "sun" to 830, ) - internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int = - PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0 + internal fun platformModel( + platform: String = systemProperty("ro.board.platform"), + qtiSocName: String = systemProperty("ro.vendor.qti.soc_name"), + socModel: String = SocNames.model(), + ): Int = if (SocNames.normalizedModel(socModel) == "SM8975" || SocNames.normalizedModel(qtiSocName) == "SM8975") 850 + else PLATFORMS[platform.lowercase()] ?: PLATFORMS[qtiSocName.lowercase()] ?: 0 fun systemProperty(name: String): String = try { Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: "" diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SocNames.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SocNames.kt index 39ad3f2ab..c1f2966b6 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/SocNames.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SocNames.kt @@ -8,6 +8,7 @@ object SocNames { "SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1", "SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3", "SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5", + "SM8975" to "Snapdragon 8 Elite Extreme Gen 6", "SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3", "SM6375" to "Snapdragon 695", ) @@ -25,8 +26,10 @@ object SocNames { .firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty() } + internal fun normalizedModel(model: String): String = model.trim().uppercase().removePrefix("QTI ").trim() + fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? = - MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" } + MODELS[normalizedModel(model)] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" } fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String { val name = name(model, platform) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt index 84440c213..1ccee78e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt @@ -11,8 +11,10 @@ object TpUbwcHint { // On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must // match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI. - internal fun features(model: Int, systemDriverVersion: String?): String? { + // Samsung's One UI firmware sets it whatever blob version it reports. + internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? { if (model != 740) return null + if (samsung) return "enable_tp_ubwc_flag_hint=1" val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0" } @@ -20,11 +22,12 @@ object TpUbwcHint { @Synchronized fun forThisDevice(): String? { if (resolved) return cached - val model = GpuInfo.detect().model + val gpu = GpuInfo.detect() + val model = gpu.model val version = if (model == 740) VulkanInfo.query()["driver_version"] else null - cached = features(model, version) + cached = features(model, version, gpu.oneUi8Gen2) resolved = true - Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}") + Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}") return cached } diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java index b7ef669d4..ed22917a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java @@ -307,27 +307,15 @@ public final class TurnipDriver { Log.i(TAG, "Mali: the compositor uses the system Vulkan driver"); return null; } - String model = gpuModel(); - Log.i(TAG, "gpu model: " + (model == null ? "unknown" : model)); - if (model != null) { - java.util.regex.Matcher m = java.util.regex.Pattern.compile("(\\d)\\d\\d").matcher(model); - if (m.find()) { - char generation = m.group(1).charAt(0); - if (generation >= '8') return DRIVER_A8XX; - if (generation == '7') return DRIVER_A7XX; - if (generation == '6') return DRIVER_A6XX; - } - } - return android.os.Build.VERSION.SDK_INT >= 36 ? DRIVER_A8XX : DRIVER_A7XX; + int model = GpuInfo.Companion.detect().getModel(); + Log.i(TAG, "gpu model: " + (model == 0 ? "unknown" : model)); + return chooseByModel(model, android.os.Build.VERSION.SDK_INT); } - private static String gpuModel() { - for (String path : new String[]{ - "/sys/class/kgsl/kgsl-3d0/gpu_model", - "/sys/class/kgsl/kgsl-3d0/gpu_chipid"}) { - String value = FileUtils.readString(new File(path)); - if (value != null && !value.trim().isEmpty()) return value.trim(); - } - return null; + static String chooseByModel(int model, int sdk) { + if (model >= 800) return DRIVER_A8XX; + if (model >= 700) return DRIVER_A7XX; + if (model >= 600) return DRIVER_A6XX; + return sdk >= 36 ? DRIVER_A8XX : DRIVER_A7XX; } } diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt index 6356229bd..1936779cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt @@ -19,6 +19,7 @@ object TurnipReleases { private const val PREFS = "turnip_releases" private const val KEY_RELEASE = "latest" private const val KEY_DOWNLOADS = "downloads" + private const val KEY_PICKS = "autoPicks" class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String, val sha256: String = "", val pair: String = "", val bundle: Boolean = false, @@ -76,6 +77,9 @@ object TurnipReleases { private fun sources(): List = if (com.droiddeck.launcher.core.DeviceSupport.adreno()) SOURCES.filter { it.repo != NON_ADRENO_REPO } else SOURCES + fun autoPicks(context: Context): List = + AutoPicks.parse(prefs(context).getString(KEY_PICKS, null)) ?: AutoPicks.BUILT_IN + fun cached(context: Context): Check? { val raw = prefs(context).getString(KEY_RELEASE, null) ?: return null return runCatching { parse(JSONObject(raw)) }.getOrNull() @@ -123,6 +127,10 @@ object TurnipReleases { } } if (failed.size == sources.size) throw lastError ?: IOException("no source answered") + runCatching { get(context, AutoPicks.URL, AutoPicks.URL) } + .onFailure { Log.i(TAG, "auto picks: ${it.message}") } + .getOrNull()?.takeIf { AutoPicks.parse(it) != null } + ?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() } val stored = JSONObject().put("assets", assets).put("latest", latest) .put("failed", JSONArray(failed)).put("checkedAt", System.currentTimeMillis()) prefs(context).edit().putString(KEY_RELEASE, stored.toString()).apply() diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/HandCamera.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCamera.kt index ff2fccb85..eec56c2be 100644 --- a/app/src/main/java/com/droiddeck/launcher/handtracking/HandCamera.kt +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCamera.kt @@ -39,8 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder import java.util.concurrent.Executors import kotlin.math.abs @@ -188,9 +186,8 @@ internal class HandCamera( private fun prepare() { if (stopped) return runCatching { - val bytes = context.assets.open(MODEL).use { it.readBytes() } - val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes) - model.rewind() + check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" } + val model = TrackingPack.model(context, TrackingPack.POSE) val options = PoseLandmarker.PoseLandmarkerOptions.builder() .setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build()) .setRunningMode(RunningMode.VIDEO) @@ -305,7 +302,6 @@ internal class HandCamera( companion object { private const val TAG = "HandCamera" - private const val MODEL = "handtracking/pose_landmarker_lite.task" private const val ANALYSIS_WIDTH = 960 private const val ANALYSIS_HEIGHT = 720 private const val MIN_CONFIDENCE = 0.5f diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/HandModel.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/HandModel.kt index 0f4e754d1..06aed0363 100644 --- a/app/src/main/java/com/droiddeck/launcher/handtracking/HandModel.kt +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/HandModel.kt @@ -13,8 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions import com.google.mediapipe.tasks.core.Delegate import com.google.mediapipe.tasks.vision.core.RunningMode import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker -import java.nio.ByteBuffer -import java.nio.ByteOrder import kotlin.math.hypot import kotlin.math.max @@ -134,7 +132,6 @@ internal class HandModel private constructor(private val landmarkers: List = emptySet(), + val downloaded: Boolean = false, ) { val usable: Boolean get() = sides.isNotEmpty() - val ready: Boolean get() = enabled && permitted && usable + val ready: Boolean get() = enabled && permitted && usable && downloaded } private val state = MutableStateFlow(Settings()) @@ -38,8 +40,11 @@ internal object HandTracking { side = prefs.getString(SIDE, null), permitted = permitted(context), sides = if (supported) TrackingCameras.sides(found) else emptySet(), + downloaded = TrackingPack.installed(context), ) state.value = next + if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing) + TrackingPack.ensure(context) return next } diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt new file mode 100644 index 000000000..85a1e3844 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt @@ -0,0 +1,56 @@ +package com.droiddeck.launcher.handtracking + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.core.AddonPack +import java.io.File +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel + +/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */ +internal object TrackingPack { + const val HAND = "hand_landmarker.task" + const val HAND_LITE = "hand_landmarker_lite.task" + const val POSE = "pose_landmarker_lite.task" + private const val OPENCV = "libopencv_java4.so" + private const val MEDIAPIPE = "libmediapipe_tasks_jni.so" + private const val TAG = "TrackingPack" + + private val pack = AddonPack.TRACKING + val state get() = pack.state + val size get() = pack.size + private val lock = Any() + @Volatile private var nativeLoaded = false + + fun installed(context: Context) = pack.installed(context) + fun refresh(context: Context) = pack.refresh(context) + fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) } + + fun file(context: Context, name: String): File = pack.file(context, name) + + fun model(context: Context, name: String): ByteBuffer = + FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) } + + /** + * Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the + * already-loaded library by soname, so this must run before any MediaPipe task class initializes. + */ + fun loadNative(context: Context): Boolean { + if (nativeLoaded) return true + synchronized(lock) { + if (nativeLoaded) return true + if (!pack.installed(context)) return false + return runCatching { + System.load(file(context, OPENCV).absolutePath) + System.load(file(context, MEDIAPIPE).absolutePath) + nativeLoaded = true + true + }.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false) + } + } + + /** Blocks the calling worker thread until the pack is usable, starting the download if needed. */ + fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean = + pack.await(context, running, onState) && loadNative(context) +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt new file mode 100644 index 000000000..318941c71 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.input + +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import kotlin.math.abs + +class DirectTouchHold( + private val slopPx: Float, + private val listener: PointerGestures.Listener, + private val cancelTouch: () -> Unit, +) { + private val handler = Handler(Looper.getMainLooper()) + private var x = 0f + private var y = 0f + private var consumed = false + private var pressed = false + private val release = Runnable { + if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y) + pressed = false + } + private val hold = Runnable { + cancelTouch() + consumed = true + listener.onLongPress() + listener.onButton(PointerGestures.BTN_RIGHT, true, x, y) + pressed = true + handler.postDelayed(release, 50L) + } + + fun cancel() { + handler.removeCallbacks(hold) + handler.removeCallbacks(release) + release.run() + consumed = false + } + + fun onTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + cancel() + x = event.x; y = event.y + handler.postDelayed(hold, 500L) + } + MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 || + abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold) + MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> handler.removeCallbacks(hold) + MotionEvent.ACTION_CANCEL -> cancel() + } + return consumed + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index 308b6add8..05469e634 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -99,7 +99,8 @@ public class FakeInputWriter { private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24; private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88; private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96; - private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100; + private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 98; + private static final int TRITON_OUTPUT_ACK_OFFSET = TRITON_OFFSET + 100; private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104; private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168; private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176; @@ -229,8 +230,9 @@ public class FakeInputWriter { data.putInt(TRITON_REPORT_ID_OFFSET, 0); data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0); data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L); - data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0); - data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0); + data.putShort(TRITON_OUTPUT_FEATURE_OFFSET, (short) 0); + data.putShort(TRITON_OUTPUT_LENGTH_OFFSET, (short) 0); + data.putInt(TRITON_OUTPUT_ACK_OFFSET, 0); data.putLong(TRITON_GET_REQUEST_OFFSET, 0L); data.putLong(TRITON_GET_REPLY_OFFSET, 0L); } @@ -587,14 +589,18 @@ public class FakeInputWriter { nativeStoreFence(); long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET); if ((seq & 1) != 0 || seq <= lastSequence) return null; - int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET); + int length = ring.getShort(TRITON_OUTPUT_LENGTH_OFFSET) & 0xffff; if (length <= 0 || length > TRITON_REPORT_MAX) return null; byte[] output = new byte[9 + length]; ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq); - output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0); + output[8] = (byte) (ring.getShort(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0); for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i); nativeStoreFence(); - return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null; + if (seq != ring.getLong(TRITON_OUTPUT_SEQ_OFFSET)) return null; + // Acknowledge the exact report sequence taken so a concurrent timeout overwrite cannot lose its length. + ring.putInt(TRITON_OUTPUT_ACK_OFFSET, (int) seq); + nativeStoreFence(); + return output; } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/GestureKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/GestureKeys.kt new file mode 100644 index 000000000..2efdb940b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/GestureKeys.kt @@ -0,0 +1,111 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Modified for DroidDeck on 2026-10-15. + */ +package com.droiddeck.launcher.input + +object GestureKeys { + val codes: Map = linkedMapOf( + "ESC" to 1, + "1" to 2, + "2" to 3, + "3" to 4, + "4" to 5, + "5" to 6, + "6" to 7, + "7" to 8, + "8" to 9, + "9" to 10, + "0" to 11, + "MINUS" to 12, + "EQUAL" to 13, + "BKSP" to 14, + "TAB" to 15, + "Q" to 16, + "W" to 17, + "E" to 18, + "R" to 19, + "T" to 20, + "Y" to 21, + "U" to 22, + "I" to 23, + "O" to 24, + "P" to 25, + "BRACKET_LEFT" to 26, + "BRACKET_RIGHT" to 27, + "ENTER" to 28, + "CTRL_L" to 29, + "A" to 30, + "S" to 31, + "D" to 32, + "F" to 33, + "G" to 34, + "H" to 35, + "J" to 36, + "K" to 37, + "L" to 38, + "SEMICOLON" to 39, + "APOSTROPHE" to 40, + "GRAVE" to 41, + "SHIFT_L" to 42, + "BACKSLASH" to 43, + "Z" to 44, + "X" to 45, + "C" to 46, + "V" to 47, + "B" to 48, + "N" to 49, + "M" to 50, + "COMMA" to 51, + "PERIOD" to 52, + "SLASH" to 53, + "SHIFT_R" to 54, + "KP_MULTIPLY" to 55, + "ALT_L" to 56, + "SPACE" to 57, + "CAPS_LOCK" to 58, + "F1" to 59, + "F2" to 60, + "F3" to 61, + "F4" to 62, + "F5" to 63, + "F6" to 64, + "F7" to 65, + "F8" to 66, + "F9" to 67, + "F10" to 68, + "NUM_LOCK" to 69, + "SCROLL_LOCK" to 70, + "KP_7" to 71, + "KP_8" to 72, + "KP_9" to 73, + "KP_SUBTRACT" to 74, + "KP_4" to 75, + "KP_5" to 76, + "KP_6" to 77, + "KP_ADD" to 78, + "KP_1" to 79, + "KP_2" to 80, + "KP_3" to 81, + "KP_0" to 82, + "KP_DEL" to 83, + "F11" to 87, + "F12" to 88, + "KP_ENTER" to 96, + "CTRL_R" to 97, + "KP_DIVIDE" to 98, + "PRTSCN" to 99, + "ALT_R" to 100, + "HOME" to 102, + "UP" to 103, + "PRIOR" to 104, + "LEFT" to 105, + "RIGHT" to 106, + "END" to 107, + "DOWN" to 108, + "NEXT" to 109, + "INSERT" to 110, + "DEL" to 111, + ) + @JvmStatic fun code(name: String): Int? = codes[name] +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt new file mode 100644 index 000000000..b8be5bf54 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt @@ -0,0 +1,24 @@ +package com.droiddeck.launcher.input + +import android.content.Context + +/** Global defaults with optional overrides for the currently focused game. */ +object GesturePrefs { + private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE) + + fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game") + + fun read(context: Context, game: String? = null): TouchGestureConfig { + val p = prefs(context) + val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null) + return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json) + } + + fun save(context: Context, game: String?, config: TouchGestureConfig) { + prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply() + } + + fun clear(context: Context, game: String?) { + prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index f25c4d32f..cfb0978cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -55,6 +55,7 @@ public final class PadBridge { private final Handler mainHandler = new Handler(Looper.getMainLooper()); private boolean open; private volatile boolean selectSteam; + private volatile boolean builtinPad = true; private TouchRoute touchRoute; private final PadState routedTouch = new PadState(); private boolean systemGuidePressed; @@ -110,6 +111,35 @@ public final class PadBridge { excluded = devices; } + public synchronized void setBuiltinPad(boolean on) { + boolean changed = builtinPad != on; + builtinPad = on; + if (changed && !on) resetBuiltin(); + } + + private static volatile int firstSlot; + + public static void setFirstSlot(int slot) { + firstSlot = slot; + } + + private int firstPlayerSlot() { + return firstSlot; + } + + private void resetBuiltin() { + routedTouch.clear(); + systemGuidePressed = false; + systemQamPressed = false; + qamTapPressed = false; + qamChordActive = false; + chordButtonPressed = false; + qamChordGeneration++; + chordHandler.removeCallbacksAndMessages(null); + state.clear(); + publish(); + } + public synchronized boolean start() { if (!open) { open = writer.open(); @@ -146,9 +176,15 @@ public final class PadBridge { } } + static boolean isSystemVirtualInputName(String name) { + if (name == null) return false; + String lower = name.trim().toLowerCase(java.util.Locale.ROOT); + return lower.startsWith("uinput-") || lower.startsWith("uinput_"); + } + public static boolean isFromController(InputDevice device) { if (device == null) return false; - if (device.isVirtual()) return false; + if (device.isVirtual() || isSystemVirtualInputName(device.getName())) return false; int sources = device.getSources(); if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true; if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false; @@ -165,7 +201,7 @@ public final class PadBridge { public static boolean isControllerKeyEvent(KeyEvent event) { InputDevice device = event.getDevice(); - if (device == null || device.isVirtual()) return false; + if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false; if (KeyEvent.isGamepadButton(event.getKeyCode())) { return device.supportsSource(InputDevice.SOURCE_GAMEPAD) || device.supportsSource(InputDevice.SOURCE_JOYSTICK); @@ -190,6 +226,7 @@ public final class PadBridge { public static boolean isControllerMotionEvent(MotionEvent event) { InputDevice device = event.getDevice(); return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual() + && !isSystemVirtualInputName(device.getName()) && (device.supportsSource(InputDevice.SOURCE_GAMEPAD) || device.supportsSource(InputDevice.SOURCE_JOYSTICK)); } @@ -225,7 +262,9 @@ public final class PadBridge { public synchronized void syncConnectedControllers() { for (int deviceId : InputDevice.getDeviceIds()) { InputDevice device = InputDevice.getDevice(deviceId); - if (isFromController(device) && !excluded.test(deviceId)) publish(slotFor(device)); + if (!isFromController(device) || excluded.test(deviceId)) continue; + int slot = slotFor(device); + if (slot >= 0) publish(slot); } } @@ -245,6 +284,7 @@ public final class PadBridge { public synchronized boolean onKeyEvent(KeyEvent event) { if (!isControllerKeyEvent(event) || excluded.test(event.getDeviceId())) return false; int slot = slotFor(event.getDevice()); + if (slot < 0) return false; int side = JoyCon.side(event.getDevice()); if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true; PadState pad = stateFor(slot); @@ -293,6 +333,7 @@ public final class PadBridge { if (!isControllerMotionEvent(event) || excluded.test(event.getDeviceId())) return false; if (event.getAction() != MotionEvent.ACTION_MOVE) return false; int slot = slotFor(event.getDevice()); + if (slot < 0) return false; PadState pad = stateFor(slot); noteDevice(event.getDevice(), slot); statAxes++; @@ -319,16 +360,27 @@ public final class PadBridge { boolean wired = wiredXboxLayout(event); pad.rightX = rightX(event, wired); pad.rightY = rightY(event, wired); - float lt = leftTrigger(event, wired); - float rt = rightTrigger(event, wired); - pad.leftTrigger = lt; - pad.rightTrigger = rt; - float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X); - float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y); - pad.up = hatY < -0.5f; - pad.right = hatX > 0.5f; - pad.down = hatY > 0.5f; - pad.left = hatX < -0.5f; + int source = event.getSource(); + float lt = 0f; + float rt = 0f; + if (wired || hasAxis(device, source, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)) { + lt = leftTrigger(event, wired); + pad.leftTrigger = lt; + } + if (wired || hasAxis(device, source, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) { + rt = rightTrigger(event, wired); + pad.rightTrigger = rt; + } + if (hasAxis(device, source, MotionEvent.AXIS_HAT_X)) { + float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X); + pad.right = hatX > 0.5f; + pad.left = hatX < -0.5f; + } + if (hasAxis(device, source, MotionEvent.AXIS_HAT_Y)) { + float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y); + pad.up = hatY < -0.5f; + pad.down = hatY > 0.5f; + } if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f || lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) { notePlayerInput(); @@ -337,6 +389,12 @@ public final class PadBridge { return true; } + static boolean hasAxis(InputDevice device, int source, int... axes) { + if (device == null) return false; + for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true; + return false; + } + public interface TouchRoute { boolean active(); @@ -349,6 +407,7 @@ public final class PadBridge { } public synchronized void applyTouch(java.util.function.Consumer mutation) { + if (!builtinPad) return; if (touchRoute != null && touchRoute.active()) { mutation.accept(routedTouch); touchRoute.accept(routedTouch); @@ -388,6 +447,7 @@ public final class PadBridge { } public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) { + if (!builtinPad) return; if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return; boolean qamStarted = qamPressed && !systemQamPressed; systemGuidePressed = guidePressed; @@ -419,6 +479,7 @@ public final class PadBridge { } public synchronized void triggerQam() { + if (!builtinPad) return; if (!nativeQam()) { startChord(PadState.A); return; @@ -437,6 +498,7 @@ public final class PadBridge { } public synchronized void triggerSteamKeyboard() { + if (!builtinPad) return; startChord(PadState.X); } @@ -486,25 +548,25 @@ public final class PadBridge { if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) leave(slot, owners[slot]); } int side = JoyCon.side(device); - for (int slot = 0; side >= 0 && slot < SLOTS; slot++) { + for (int slot = firstPlayerSlot(); side >= 0 && slot < SLOTS; slot++) { if (owners[slot] == NO_CONTROLLER || partners[slot] != NO_CONTROLLER) continue; InputDevice owner = InputDevice.getDevice(owners[slot]); if (JoyCon.side(owner) != 1 - side) continue; partners[slot] = id; slotJoyCons.set(slot * 2 + side, id); slotJoyCons.set(slot * 2 + 1 - side, owner.getId()); - if (slot == 0) releaseBackPaddles(); + if (slot == firstPlayerSlot()) releaseBackPaddles(); stateFor(slot).clear(); Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") joins \"" + owner.getName() + "\" as player " + (slot + 1)); return slot; } - for (int slot = 0; slot < SLOTS; slot++) { + for (int slot = firstPlayerSlot(); slot < SLOTS; slot++) { if (owners[slot] != NO_CONTROLLER) continue; owners[slot] = id; Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1)); return slot; } - return 0; + return -1; } private PadState stateFor(int slot) { @@ -513,7 +575,8 @@ public final class PadBridge { private boolean isPrimary(KeyEvent event) { int id = event.getDevice().getId(); - return owners[0] == id || partners[0] == id; + int first = firstPlayerSlot(); + return owners[first] == id || partners[first] == id; } private void releaseBackPaddles() { @@ -532,7 +595,7 @@ public final class PadBridge { clearJoyCons(slot); if (slotControllers.get(slot) == id) slotControllers.set(slot, owners[slot]); if (activeControllerId == id) activeControllerId = owners[slot]; - if (slot == 0) releaseBackPaddles(); + if (slot == firstPlayerSlot()) releaseBackPaddles(); stateFor(slot).clear(); publish(slot); } @@ -548,8 +611,8 @@ public final class PadBridge { partners[slot] = NO_CONTROLLER; clearJoyCons(slot); slotControllers.set(slot, NO_CONTROLLER); + if (slot == firstPlayerSlot()) releaseBackPaddles(); if (slot == 0) { - releaseBackPaddles(); state.clear(); publish(); return; @@ -608,6 +671,7 @@ public final class PadBridge { } private void publish() { + if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return; if (!open && !start()) { statDropped++; return; diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt index ee84cffe4..e77b322f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt @@ -13,6 +13,7 @@ import android.content.Context import android.os.Handler import android.os.HandlerThread import android.util.Log +import com.droiddeck.launcher.session.SessionState import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.ArrayDeque @@ -51,6 +52,10 @@ class SteamControllerBle(private val context: Context) { private fun connect() { if (!running || gatt != null) return + if (SessionState.suspended) { + handler.postDelayed(::connect, RETRY_MS) + return + } val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices } .onFailure { Log.w(TAG, "paired devices unreadable", it) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt index 4d19cb87d..9b60e263e 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt @@ -16,10 +16,13 @@ object SteamTouchBindings { const val DEFAULT_FOREGROUND = "#232323" const val DEFAULT_BACKGROUND = "#E4E4E4" + fun cleanLabel(raw: String): String = + raw.replace(Regex("""["\\,\p{Cntrl}]"""), " ").replace(Regex("""\s+"""), " ").trim().let { it.take(it.offsetByCodePoints(0, minOf(32, it.codePointCount(0, it.length)))) } + data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) { fun compose(): String = - if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, " - else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}" + if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, ${cleanLabel(label)}, " + else "$action, ${cleanLabel(label)}, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}" val hasIcon get() = icon.isNotEmpty() } @@ -208,7 +211,7 @@ object SteamTouchBindings { fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons") fun gameIconsDir(context: Context, appId: Int): File? = try { - val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf") + val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_${SteamTouchConfig.steamId(appId)}.acf") val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir") installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory } } catch (_: Exception) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt index 4025db934..1387dce25 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt @@ -2,10 +2,14 @@ package com.droiddeck.launcher.input import android.content.Context import android.util.Log +import com.droiddeck.launcher.frontend.SteamAppInfo import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.ByteArrayOutputStream import java.io.File import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Locale object SteamTouchConfig { private const val TAG = "SteamTouch" @@ -122,20 +126,44 @@ object SteamTouchConfig { } } - private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START) + private val DEFAULT_SET = setOf( + JOYSTICK_LEFT, JOYSTICK_LEFT_BUTTON, JOYSTICK_RIGHT, JOYSTICK_RIGHT_BUTTON, + DPAD, A, B, X, Y, SELECT, START, + BUMPER_LEFT, BUMPER_RIGHT, TRIGGER_LEFT, TRIGGER_RIGHT, + ) private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD) + fun steamId(appId: Int): String = Integer.toUnsignedString(appId) + fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1 - fun gameName(context: Context, appId: Int): String? = try { + fun gameName(context: Context, appId: Int): String? = if (appId < 0) shortcutName(context, appId) else try { val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText()) (kv.child("AppState") ?: kv).string("name") } catch (_: Exception) { null } + fun steamKey(context: Context, appId: Int): String = + if (appId < 0) shortcutName(context, appId)?.lowercase(Locale.ROOT) + ?.takeIf { name -> name != "." && name != ".." && name.none { it == '"' || it == '\\' || it == '/' || it < ' ' } } ?: steamId(appId) + else steamId(appId) + + private fun shortcutName(context: Context, appId: Int): String? { + val account = accountId(context) ?: return null + return try { + val bytes = File(steamRoot(context), "userdata/$account/config/shortcuts.vdf").readBytes() + val root = SteamAppInfo.readMap(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), null) + (root["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance>() + ?.firstOrNull { (it["appid"] as? String)?.toIntOrNull() == appId } + ?.let { (it["AppName"] ?: it["appname"]) as? String }?.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } + } + fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context) fun configDir(context: Context): File? { @@ -158,10 +186,11 @@ object SteamTouchConfig { fun load(context: Context, appId: Int): Config { val dir = configDir(context) - val file = dir?.let { deviceConfig(context, it, appId) } - ?: dir?.let { autosaveConfig(it, appId) } + val key = steamKey(context, appId) + val file = dir?.let { deviceConfig(context, it, key) } + ?: dir?.let { autosaveConfig(it, key) } ?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) } - ?: dir?.let { configsetConfig(context, it, appId) } + ?: dir?.let { configsetConfig(context, it, key) } if (file == null) { Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout") return Config(null, null, null, emptyMap()) @@ -193,7 +222,7 @@ object SteamTouchConfig { } val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1) if (touchIndex == null) null else { - val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""") + val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID ${steamId(appId)}, Controller $touchIndex: ([^\n]+)""") .findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim() loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) } } @@ -201,22 +230,22 @@ object SteamTouchConfig { null } - private fun autosaveConfig(dir: File, appId: Int): File? = try { - val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString()) - if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null + private fun autosaveConfig(dir: File, key: String): File? = try { + val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(key) + if (set?.string("autosave") == "1") File(dir, "$key/controller_mobile_touch.vdf").takeIf { it.isFile } else null } catch (_: Exception) { null } - private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId) + private fun configsetConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, CONFIGSET, key) - private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId) + private fun deviceConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, DEVICE_CONFIGSET, key) - private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try { - val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString()) + private fun entryConfig(context: Context, dir: File, configset: String, key: String): File? = try { + val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(key) when { set == null -> null - set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf") + set.string("autosave") == "1" -> File(dir, "$key/controller_mobile_touch.vdf") set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!) set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template")) else -> null @@ -333,6 +362,11 @@ object SteamTouchConfig { }.sortedBy { it.type } } + fun remapHeld(held: Map, next: List): Map { + val byType = next.associateBy { it.type } + return held.mapNotNull { (id, type) -> byType[type]?.let { id to it } }.toMap() + } + fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color @@ -359,7 +393,8 @@ object SteamTouchConfig { val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) -> SteamTouchBindings.rewrite(text, ref, change) ?: text } - val target = File(dir, "$appId/controller_mobile_touch.vdf") + val key = steamKey(context, appId) + val target = File(dir, "$key/controller_mobile_touch.vdf") val old = config.layouts val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty() val written = sets.map { (id, elements) -> @@ -377,27 +412,27 @@ object SteamTouchConfig { val tmp = File(target.path + ".tmp") tmp.writeText(text) if (!tmp.renameTo(target)) return null - selectAutosave(dir, appId) - dropDeviceEntry(dir, appId) + selectAutosave(dir, key) + dropDeviceEntry(dir, key) Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}") return target } - private fun dropDeviceEntry(dir: File, appId: Int) { + private fun dropDeviceEntry(dir: File, key: String) { val file = File(dir, DEVICE_CONFIGSET) if (!file.isFile) return val text = file.readText() - val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""") + val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE) if (!block.containsMatchIn(text)) return File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file) } - private fun selectAutosave(dir: File, appId: Int) { + private fun selectAutosave(dir: File, key: String) { val file = File(dir, CONFIGSET) val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n" - val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}" - val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""") - val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry") + val entry = "\t\"$key\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}" + val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE) + val next = if (block.containsMatchIn(text)) text.replace(block) { "\n$entry" } else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n" File(file.path + ".tmp").apply { writeText(next) }.renameTo(file) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt index 5a717150e..1c6921a89 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt @@ -154,7 +154,7 @@ class SteamTouchControls( actionSet = state.actionSet layers = state.layers appId = app - rebuild() + rebuild(keepHeld = true) } } @@ -175,7 +175,7 @@ class SteamTouchControls( refreshState() } - private fun rebuild() { + private fun rebuild(keepHeld: Boolean = false) { elements = SteamTouchConfig.elementsFor(config, actionSet, layers) val nextOptions = pendingOptions ?: config.layouts?.options ?: SteamTouchConfig.Options() if (nextOptions != options) { options = nextOptions; onOptions(options) } @@ -183,7 +183,7 @@ class SteamTouchControls( rebuildVisuals() if (!editing) { Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls") - releaseAll() + if (keepHeld) keepHeldFingers() else releaseAll() } invalidate() } @@ -301,9 +301,8 @@ class SteamTouchControls( } } SteamTouchConfig.STEAM -> { - if (pressed) canvas.drawCircle(x, y, r, fill) - canvas.drawCircle(x, y, r, stroke) - drawMark(canvas, x, y, r * 0.5f, if (pressed) Color.argb(alpha, 23, 26, 33) else ink, alpha) + if (isSelected) canvas.drawCircle(x, y, r, stroke) + drawMark(canvas, x, y, r * 0.5f, if (pressed) accent else ink, alpha) } SteamTouchConfig.SELECT, SteamTouchConfig.START -> { val binding = visuals[e.type]?.firstOrNull()?.binding @@ -590,6 +589,13 @@ class SteamTouchControls( invalidate() } + private fun keepHeldFingers() { + val moved = SteamTouchConfig.remapHeld(fingers.mapValues { it.value.element?.type }, live()) + fingers.keys.retainAll(moved.keys) + fingers.replaceAll { id, f -> Finger(moved.getValue(id), f.x, f.y, f.toolbar) } + publish() + } + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { @@ -958,7 +964,7 @@ class SteamTouchControls( BIG_PICTURE -> "Steam" else -> gameNames[appId] ?: run { val app = appId - gameNames[app] = context.getString(R.string.touch_app_id, app) + gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app)) loader.execute { val name = SteamTouchConfig.gameName(context, app) ?: return@execute handler.post { gameNames[app] = name; invalidate() } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchIconPicker.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchIconPicker.kt index 17d665f50..da74416bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchIconPicker.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchIconPicker.kt @@ -84,11 +84,11 @@ class SteamTouchIconPicker( updatePreview(preview) showPanel(host, title, root, listOf( context.getString(R.string.touch_no_icon) to { - onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = "")) + onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = "", foreground = "", background = "")) }, context.getString(R.string.common_cancel) to {}, context.getString(R.string.common_use) to { - onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon, + onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = icon, foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background)) }, )) { loader.shutdown() } diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt new file mode 100644 index 000000000..8021536a9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt @@ -0,0 +1,226 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Modified for DroidDeck on 2026-10-15. + */ +package com.droiddeck.launcher.input + +import org.json.JSONObject + +data class TouchGestureConfig( + val tapEnabled: Boolean = true, + val tapAction: String = ACTION_LEFT_CLICK, + val dragEnabled: Boolean = true, + val dragAction: String = PAN_LEFT_CLICK_DRAG, + val longPressEnabled: Boolean = false, + val longPressAction: String = ACTION_RIGHT_CLICK, + val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD, + val longPressDelay: Int = DEFAULT_DELAY_MS, + val doubleTapEnabled: Boolean = true, + val doubleTapDelay: Int = DEFAULT_DELAY_MS, + val twoFingerDragEnabled: Boolean = true, + val twoFingerDragAction: String = PAN_ARROW_KEYS, + val pinchEnabled: Boolean = true, + val pinchAction: String = ZOOM_SCROLL_WHEEL, + val twoFingerTapEnabled: Boolean = true, + val twoFingerTapAction: String = ACTION_RIGHT_CLICK, + val twoFingerHoldEnabled: Boolean = true, + val twoFingerHoldAction: String = ACTION_MIDDLE_CLICK, + val twoFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD, + val twoFingerHoldDelay: Int = DEFAULT_DELAY_MS, + val threeFingerTapEnabled: Boolean = true, + val threeFingerTapAction: String = ACTION_SHOW_KEYBOARD, + val threeFingerDragEnabled: Boolean = false, + val threeFingerDragAction: String = PAN_ARROW_KEYS, + val threeFingerHoldEnabled: Boolean = true, + val threeFingerHoldAction: String = ACTION_KEY_ESC, + val threeFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD, + val threeFingerHoldDelay: Int = DEFAULT_DELAY_MS, + val showGestureDebugOverlay: Boolean = false, + val gestureThreshold: Int = DEFAULT_GESTURE_THRESHOLD, + val mouseDragMovementMode: String = MOUSE_DRAG_MOVEMENT_DIRECT, +) { + + fun toJson(): String { + return JSONObject().apply { + put(KEY_TAP_ENABLED, tapEnabled) + put(KEY_TAP_ACTION, tapAction) + put(KEY_DRAG_ENABLED, dragEnabled) + put(KEY_DRAG_ACTION, dragAction) + put(KEY_LONG_PRESS_ENABLED, longPressEnabled) + put(KEY_LONG_PRESS_ACTION, longPressAction) + put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior) + put(KEY_LONG_PRESS_DELAY, longPressDelay) + put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled) + put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay) + put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled) + put(KEY_TWO_FINGER_DRAG_ACTION, twoFingerDragAction) + put(KEY_PINCH_ENABLED, pinchEnabled) + put(KEY_PINCH_ACTION, pinchAction) + put(KEY_TWO_FINGER_TAP_ENABLED, twoFingerTapEnabled) + put(KEY_TWO_FINGER_TAP_ACTION, twoFingerTapAction) + put(KEY_TWO_FINGER_HOLD_ENABLED, twoFingerHoldEnabled) + put(KEY_TWO_FINGER_HOLD_ACTION, twoFingerHoldAction) + put(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, twoFingerHoldMouseBehavior) + put(KEY_TWO_FINGER_HOLD_DELAY, twoFingerHoldDelay) + put(KEY_THREE_FINGER_TAP_ENABLED, threeFingerTapEnabled) + put(KEY_THREE_FINGER_TAP_ACTION, threeFingerTapAction) + put(KEY_THREE_FINGER_DRAG_ENABLED, threeFingerDragEnabled) + put(KEY_THREE_FINGER_DRAG_ACTION, threeFingerDragAction) + put(KEY_THREE_FINGER_HOLD_ENABLED, threeFingerHoldEnabled) + put(KEY_THREE_FINGER_HOLD_ACTION, threeFingerHoldAction) + put(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, threeFingerHoldMouseBehavior) + put(KEY_THREE_FINGER_HOLD_DELAY, threeFingerHoldDelay) + put(KEY_SHOW_GESTURE_DEBUG_OVERLAY, showGestureDebugOverlay) + put(KEY_GESTURE_THRESHOLD, gestureThreshold) + put(KEY_MOUSE_DRAG_MOVEMENT_MODE, mouseDragMovementMode) + }.toString() + } + + companion object { + const val DEFAULT_DELAY_MS = 300 + const val DEFAULT_GESTURE_THRESHOLD = 40 + const val ACTION_LEFT_CLICK = "left_click" + const val ACTION_RIGHT_CLICK = "right_click" + const val ACTION_MIDDLE_CLICK = "middle_click" + + const val MOUSE_BEHAVIOR_HOLD = "hold" + const val MOUSE_BEHAVIOR_CLICK = "click" + + const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct" + const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative" + const val ACTION_SHOW_KEYBOARD = "show_keyboard" + const val ACTION_KEY_ESC = "key_ESC" + const val ACTION_KEY_TILDE = "key_TILDE" + const val PAN_WASD = "wasd" + const val PAN_INVERTED_WASD = "inverted_wasd" + const val PAN_ARROW_KEYS = "arrow_keys" + const val PAN_INVERTED_ARROW_KEYS = "inverted_arrow_keys" + const val PAN_MIDDLE_MOUSE = "middle_mouse_pan" + const val PAN_INVERTED_MIDDLE_MOUSE = "inverted_middle_mouse_pan" + const val PAN_LEFT_CLICK_DRAG = "left_click_drag" + const val PAN_RIGHT_CLICK_DRAG = "right_click_drag" + const val ZOOM_SCROLL_WHEEL = "scroll_wheel" + const val ZOOM_PLUS_MINUS = "plus_minus" + const val ZOOM_PAGE_UP_DOWN = "page_up_down" + private const val KEY_TAP_ENABLED = "tapEnabled" + private const val KEY_TAP_ACTION = "tapAction" + private const val KEY_DRAG_ENABLED = "dragEnabled" + private const val KEY_DRAG_ACTION = "dragAction" + private const val KEY_LONG_PRESS_ENABLED = "longPressEnabled" + private const val KEY_LONG_PRESS_ACTION = "longPressAction" + private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior" + private const val KEY_LONG_PRESS_DELAY = "longPressDelay" + private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled" + private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay" + private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled" + private const val KEY_TWO_FINGER_DRAG_ACTION = "twoFingerDragAction" + private const val KEY_PINCH_ENABLED = "pinchEnabled" + private const val KEY_PINCH_ACTION = "pinchAction" + private const val KEY_TWO_FINGER_TAP_ENABLED = "twoFingerTapEnabled" + private const val KEY_TWO_FINGER_TAP_ACTION = "twoFingerTapAction" + private const val KEY_TWO_FINGER_HOLD_ENABLED = "twoFingerHoldEnabled" + private const val KEY_TWO_FINGER_HOLD_ACTION = "twoFingerHoldAction" + private const val KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR = "twoFingerHoldMouseBehavior" + private const val KEY_TWO_FINGER_HOLD_DELAY = "twoFingerHoldDelay" + private const val KEY_THREE_FINGER_TAP_ENABLED = "threeFingerTapEnabled" + private const val KEY_THREE_FINGER_TAP_ACTION = "threeFingerTapAction" + private const val KEY_THREE_FINGER_DRAG_ENABLED = "threeFingerDragEnabled" + private const val KEY_THREE_FINGER_DRAG_ACTION = "threeFingerDragAction" + private const val KEY_THREE_FINGER_HOLD_ENABLED = "threeFingerHoldEnabled" + private const val KEY_THREE_FINGER_HOLD_ACTION = "threeFingerHoldAction" + private const val KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR = "threeFingerHoldMouseBehavior" + private const val KEY_THREE_FINGER_HOLD_DELAY = "threeFingerHoldDelay" + private const val KEY_SHOW_GESTURE_DEBUG_OVERLAY = "showGestureDebugOverlay" + private const val KEY_GESTURE_THRESHOLD = "gestureThreshold" + private const val KEY_MOUSE_DRAG_MOVEMENT_MODE = "mouseDragMovementMode" + + private fun fallbackDefaults(): TouchGestureConfig = TouchGestureConfig( + twoFingerHoldEnabled = false, + threeFingerTapEnabled = false, + threeFingerHoldEnabled = false, + ) + + fun fromJson(json: String?): TouchGestureConfig { + if (json.isNullOrBlank()) return fallbackDefaults() + return try { + val obj = JSONObject(json) + TouchGestureConfig( + tapEnabled = obj.optBoolean(KEY_TAP_ENABLED, true), + tapAction = obj.optString(KEY_TAP_ACTION, ACTION_LEFT_CLICK), + dragEnabled = obj.optBoolean(KEY_DRAG_ENABLED, true), + dragAction = obj.optString(KEY_DRAG_ACTION, PAN_LEFT_CLICK_DRAG), + longPressEnabled = obj.optBoolean(KEY_LONG_PRESS_ENABLED, false), + longPressAction = obj.optString(KEY_LONG_PRESS_ACTION, ACTION_RIGHT_CLICK), + longPressMouseBehavior = normalizeMouseBehavior( + obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD) + ), + longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS), + doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true), + doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS), + twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true), + twoFingerDragAction = obj.optString(KEY_TWO_FINGER_DRAG_ACTION, PAN_ARROW_KEYS), + pinchEnabled = obj.optBoolean(KEY_PINCH_ENABLED, true), + pinchAction = obj.optString(KEY_PINCH_ACTION, ZOOM_SCROLL_WHEEL), + twoFingerTapEnabled = obj.optBoolean(KEY_TWO_FINGER_TAP_ENABLED, true), + twoFingerTapAction = obj.optString(KEY_TWO_FINGER_TAP_ACTION, ACTION_RIGHT_CLICK), + twoFingerHoldEnabled = obj.optBoolean(KEY_TWO_FINGER_HOLD_ENABLED, false), + twoFingerHoldAction = obj.optString(KEY_TWO_FINGER_HOLD_ACTION, ACTION_MIDDLE_CLICK), + twoFingerHoldMouseBehavior = normalizeMouseBehavior( + obj.optString(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD) + ), + twoFingerHoldDelay = obj.optInt(KEY_TWO_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS), + threeFingerTapEnabled = obj.optBoolean(KEY_THREE_FINGER_TAP_ENABLED, false), + threeFingerTapAction = obj.optString(KEY_THREE_FINGER_TAP_ACTION, ACTION_SHOW_KEYBOARD), + threeFingerDragEnabled = obj.optBoolean(KEY_THREE_FINGER_DRAG_ENABLED, false), + threeFingerDragAction = obj.optString(KEY_THREE_FINGER_DRAG_ACTION, PAN_ARROW_KEYS), + threeFingerHoldEnabled = obj.optBoolean(KEY_THREE_FINGER_HOLD_ENABLED, false), + threeFingerHoldAction = obj.optString(KEY_THREE_FINGER_HOLD_ACTION, ACTION_KEY_ESC), + threeFingerHoldMouseBehavior = normalizeMouseBehavior( + obj.optString(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD) + ), + threeFingerHoldDelay = obj.optInt(KEY_THREE_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS), + showGestureDebugOverlay = obj.optBoolean(KEY_SHOW_GESTURE_DEBUG_OVERLAY, false), + gestureThreshold = obj.optInt(KEY_GESTURE_THRESHOLD, DEFAULT_GESTURE_THRESHOLD), + mouseDragMovementMode = normalizeMouseDragMovementMode( + obj.optString(KEY_MOUSE_DRAG_MOVEMENT_MODE, MOUSE_DRAG_MOVEMENT_DIRECT), + ), + ) + } catch (_: Exception) { + fallbackDefaults() + } + } + + private fun normalizeMouseBehavior(value: String): String { + return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD + } + + private fun normalizeMouseDragMovementMode(mode: String?): String { + return if (mode == MOUSE_DRAG_MOVEMENT_RELATIVE) { + MOUSE_DRAG_MOVEMENT_RELATIVE + } else { + MOUSE_DRAG_MOVEMENT_DIRECT + } + } + + val PAN_ACTIONS = listOf( + PAN_ARROW_KEYS, + PAN_INVERTED_ARROW_KEYS, + PAN_WASD, + PAN_INVERTED_WASD, + PAN_MIDDLE_MOUSE, + PAN_INVERTED_MIDDLE_MOUSE, + PAN_LEFT_CLICK_DRAG, + PAN_RIGHT_CLICK_DRAG, + ) + + val ZOOM_ACTIONS = listOf( + ZOOM_SCROLL_WHEEL, + ZOOM_PLUS_MINUS, + ZOOM_PAGE_UP_DOWN, + ) + + @JvmStatic + fun isMouseButtonAction(action: String?): Boolean = + action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt index 2dc403a43..47f0e4b64 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt @@ -38,6 +38,7 @@ class TouchpadGestures( private var moverLastX = 0f private var moverLastY = 0f private var holdDrag = false + private var scrollLocked = false private var clicked = 0 private val longPress = Runnable { held = true @@ -67,6 +68,7 @@ class TouchpadGestures( dragging = false held = false holdDrag = false + scrollLocked = false anchorId = -1 moverId = -1 lastTapAt = 0L @@ -84,7 +86,7 @@ class TouchpadGestures( lastX = event.x; lastY = event.y downAt = event.eventTime travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false - anchorId = event.getPointerId(0); moverId = -1; holdDrag = false + anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) { dragging = true listener.onButton(PointerGestures.BTN_LEFT, true, x, y) @@ -117,10 +119,12 @@ class TouchpadGestures( val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId) if (ai >= 0 && mi >= 0) { val mx = event.getX(mi); val my = event.getY(mi) - if (!holdDrag && !scrolled) { + if (!holdDrag && !scrolled && !scrollLocked) { val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY) val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY) - if (moverMoved > slopPx && anchorMoved < slopPx) { + if (anchorMoved >= slopPx) { + scrollLocked = true + } else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) { holdDrag = true secondWasTap = false if (!dragging) { @@ -186,9 +190,9 @@ class TouchpadGestures( if (gone == moverId) moverId = -1 if (gone == anchorId) { anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0) - event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let { - lastX = event.getX(it); lastY = event.getY(it) - } + } + event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let { + lastX = event.getX(it); lastY = event.getY(it) } if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) { click(PointerGestures.BTN_RIGHT) @@ -212,7 +216,7 @@ class TouchpadGestures( } } dragging = false; held = false; twoFingers = false - holdDrag = false; anchorId = -1; moverId = -1 + holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1 return true } } @@ -225,5 +229,7 @@ class TouchpadGestures( private const val CLICK_MS = 50L private const val DOUBLE_TAP_MS = 300L private const val SCROLL_STEP_PX = 36f + private const val HOLD_DRAG_MOVER_SLOPS = 2f + private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java new file mode 100644 index 000000000..e71a14cd3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java @@ -0,0 +1,1332 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Modified for DroidDeck on 2026-10-15. + */ +package com.droiddeck.launcher.input; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class TouchscreenGestures { + public enum Gesture { + TAP, DRAG, LONG_PRESS, DOUBLE_TAP, + TWO_FINGER_TAP, TWO_FINGER_DRAG, TWO_FINGER_HOLD, PINCH, + THREE_FINGER_TAP, THREE_FINGER_DRAG, THREE_FINGER_HOLD + } + + public interface Output { + void moveTo(float x, float y); + void moveBy(float dx, float dy, boolean relative); + void button(int code, boolean down); + void key(int code, boolean down); + void wheel(int steps); + void showKeyboard(); + /** Establish the pointer origin before a relative middle-mouse pan begins. */ + default void prepareRelativePan() {} + default void gesture(Gesture gesture) {} + } + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Output output; + private final float doubleTapSlop; + private TouchGestureConfig gestureConfig = new TouchGestureConfig(); + private static final int CLICK_DELAYED_TIME = 50; + private Runnable delayedPress, pendingHoldClickRelease; + private Runnable pendingTapPress; + private final ArrayDeque pendingTapClicks = new ArrayDeque<>(); + private long lastInputReleaseTime; + private String delayedPressAction, pendingHoldClickReleaseAction; + private final Map keysDown = new LinkedHashMap<>(); + private final Map buttonsDown = new LinkedHashMap<>(); + private boolean leftClickDragButtonDown, rightClickDragButtonDown; + + private static final class TapClick { + final String action; + final int x, y; + TapClick(String action, float x, float y) { + this.action = action; this.x = (int) x; this.y = (int) y; + } + } + + public TouchscreenGestures(float doubleTapSlop, Output output) { + this.output = output; + this.doubleTapSlop = Math.max(1f, doubleTapSlop); + } + public void configure(TouchGestureConfig config) { + cancel(); + gestureConfig = config; + } + public boolean onTouch(MotionEvent event) { return handleGestureTouchscreenEvent(event); } + public void cancel() { + handleTsCancel(); + handler.removeCallbacksAndMessages(null); + for (int code : keysDown.keySet()) output.key(code, false); + for (int code : buttonsDown.keySet()) output.button(code, false); + keysDown.clear(); buttonsDown.clear(); lastTapTime = 0; + } + private void postDelayed(Runnable action, long delay) { handler.postDelayed(action, delay); } + private void removeCallbacks(Runnable action) { handler.removeCallbacks(action); } + private void notifyGesture(Gesture gesture) { + if (gestureConfig.getShowGestureDebugOverlay()) output.gesture(gesture); + } + private void pressKey(int code) { press(keysDown, code, true); } + private void releaseKey(int code) { release(keysDown, code, true); } + private void pressButton(MouseButton button) { press(buttonsDown, button.code, false); } + private void releaseButton(MouseButton button) { release(buttonsDown, button.code, false); } + private void press(Map held, int code, boolean key) { + int count = held.getOrDefault(code, 0); + held.put(code, count + 1); + if (count == 0) { if (key) output.key(code, true); else output.button(code, true); } + } + private void release(Map held, int code, boolean key) { + int count = held.getOrDefault(code, 0); + if (count == 0) return; + if (count > 1) { held.put(code, count - 1); return; } + held.remove(code); + if (key) output.key(code, false); else output.button(code, false); + lastInputReleaseTime = SystemClock.uptimeMillis(); + } + private enum MouseButton { + BUTTON_LEFT(0x110), BUTTON_RIGHT(0x111), BUTTON_MIDDLE(0x112); + final int code; + MouseButton(int code) { this.code = code; } + } + // Long-press detection + private Runnable longPressRunnable; + private boolean longPressTriggered; + private boolean longPressActionHeld, twoFingerHoldActionHeld, threeFingerHoldActionHeld; + + // Double-tap detection + private long lastTapTime; + private float lastTapX; + private float lastTapY; + private boolean secondTapCandidate; + private float doubleTapAnchorX, doubleTapAnchorY; + private long singleFingerDownTime; + + // Drag tracking (box selection) + private boolean isDragging; + private boolean dragButtonPressed; + + // Two-finger tracking + private boolean twoFingerDragging; + private float twoFingerLastX0, twoFingerLastY0; + private float twoFingerLastX1, twoFingerLastY1; + private boolean twoFingerMiddleButtonDown; + // Pinch tracking + private float pinchLastDistance; + private float pinchZoomRemainder; + private float keyPanRemainderX, keyPanRemainderY; + private static final float PINCH_STEP_PX = 10f; + // Two-finger tap detection + private boolean twoFingerTapPossible; + private boolean twoFingerTapFired; + // Track which WASD/arrow keys are currently held + private boolean panKeyUp, panKeyDown, panKeyLeft, panKeyRight; + // True when finger has moved beyond tap tolerance (prevents spurious tap on lift) + private boolean movedBeyondTapThreshold; + // True after a multi-finger gesture has been active — prevents the remaining + // finger from starting a spurious single-finger drag on lift sequence. + private boolean multiFingerGestureUsed; + // Remember which keycodes were actually pressed so releasePanKeys releases only those + private Integer panLeftKey, panRightKey, panUpKey, panDownKey; + // Long-press movement tolerance (pixels of raw finger movement before cancelling long-press) + private static final float LONG_PRESS_MOVE_TOLERANCE = 50f; + // Raw touch-down coordinates for measuring finger travel + private float touchDownRawX, touchDownRawY; + // Last raw coordinates for single-finger drag delta + private float singleFingerLastRawX, singleFingerLastRawY; + // Two-finger gesture mutual exclusion (only pan OR pinch, not both) + private static final int TWO_FINGER_GESTURE_NONE = 0; + private static final int TWO_FINGER_GESTURE_PAN = 1; + private static final int TWO_FINGER_GESTURE_ZOOM = 2; + private int twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + // Accumulated deltas for deciding which two-finger gesture to lock into + private float accumulatedPinchDelta, accumulatedPanDelta; + + // Settle window: ignore movement for first N ms after second finger lands (#1) + // Also serves as a grace period for a third finger to arrive before 2F locks in + private static final long TWO_FINGER_SETTLE_MS = 120; + private static final long THREE_FINGER_SETTLE_MS = 50; + private long twoFingerDownTime; + private long threeFingerDownTime; + + // Velocity gating for two-finger hold vs drag (#2) + private long twoFingerLastMoveTime; + private static final float MIN_DRAG_VELOCITY = 0.3f; // px/ms to commit to drag + + // Pinch vs drag ratio: pinch delta must exceed pan by this factor to classify as pinch (#4) + private static final float PINCH_RATIO_THRESHOLD = 1.5f; + + // Cancel-and-reclassify: allow reclassification within this window after lock-in (#5) + private static final long RECLASSIFY_WINDOW_MS = 100; + private long twoFingerLockTime; + private float reclassifyPinchDelta, reclassifyPanDelta; + // Gesture pointer ownership tracking (prevents d-pad/button pointer conflicts) + private int gestureFingerCount = 0; + private final ArrayList gestureOwnedPointerIds = new ArrayList<>(); + private final int[] multiFingerStartIds = new int[3]; + private final float[] multiFingerStartX = new float[3], multiFingerStartY = new float[3]; + private int multiFingerStartCount; + + // Two-finger hold detection + private Runnable twoFingerHoldRunnable; + private boolean twoFingerHoldTriggered; + + // Three-finger tracking + private boolean threeFingerTapPossible; + private boolean threeFingerTapFired; + private boolean threeFingerDragging; + private float threeFingerLastMidX, threeFingerLastMidY; + private float threeFingerDragOffsetX, threeFingerDragOffsetY; + private Runnable threeFingerHoldRunnable; + private boolean threeFingerHoldTriggered; + private static final int THREE_FINGER_GESTURE_NONE = 0; + private static final int THREE_FINGER_GESTURE_PAN = 1; + private int threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + private float accumulatedThreeFingerDelta; + + private boolean handleGestureTouchscreenEvent(MotionEvent event) { + int action = event.getActionMasked(); + int actionIndex = event.getActionIndex(); + int pointerId = event.getPointerId(actionIndex); + + switch (action) { + case MotionEvent.ACTION_DOWN: + // A new touch must not shorten or discard an already recognized click. + handleTsCancel(true); + // First pointer overall — gesture system claims it + gestureOwnedPointerIds.clear(); + gestureOwnedPointerIds.add(pointerId); + gestureFingerCount = 1; + handleTsDown(event, actionIndex); + break; + + case MotionEvent.ACTION_POINTER_DOWN: + // New pointer arrived — gesture system claims it + if (!gestureOwnedPointerIds.contains(pointerId)) { + gestureOwnedPointerIds.add(pointerId); + gestureFingerCount++; + } + if (gestureFingerCount == 1) { + // First gesture finger (controls own earlier pointers) + handleTsDown(event, actionIndex); + } else if (gestureFingerCount == 2) { + handleTsPointerDown(event); + } else if (gestureFingerCount == 3) { + handleTsThreeFingerDown(event); + } + break; + + case MotionEvent.ACTION_MOVE: + if (gestureFingerCount >= 3) { + handleTsThreeFingerMove(event); + } else if (gestureFingerCount >= 2) { + handleTsTwoFingerMove(event); + } else if (gestureFingerCount == 1 && !gestureOwnedPointerIds.isEmpty()) { + int idx = event.findPointerIndex(gestureOwnedPointerIds.get(0)); + if (idx >= 0) handleTsMove(event, idx); + } + break; + + case MotionEvent.ACTION_POINTER_UP: + if (gestureOwnedPointerIds.contains(pointerId)) { + int prevCount = gestureFingerCount; + int removedIndex = gestureOwnedPointerIds.indexOf(pointerId); + gestureOwnedPointerIds.remove(Integer.valueOf(pointerId)); + gestureFingerCount = Math.max(0, gestureFingerCount - 1); + if (prevCount == 3 && gestureFingerCount == 2) { + // Three-finger gesture ending + handleTsThreeFingerUp(event); + } else if (prevCount == 2 && gestureFingerCount == 1) { + // Two-finger gesture ending (2→1) + handleTsPointerUp(event); + } else if (prevCount >= 4) { + if (removedIndex < 3) rebaseThreeFingerMovement(event); + } else if (prevCount >= 1 && gestureFingerCount == 0) { + // Last gesture finger lifted while controls still hold pointers + handleTsUp(event, actionIndex); + } + } + break; + + case MotionEvent.ACTION_UP: + if (gestureOwnedPointerIds.contains(pointerId)) { + int prevCount = gestureFingerCount; + gestureOwnedPointerIds.remove(Integer.valueOf(pointerId)); + gestureFingerCount = Math.max(0, gestureFingerCount - 1); + // Clean up multi-finger state if needed, routing by previous + // gesture mode so a 3-finger gesture isn't ended via the + // two-finger cleanup path. + if (prevCount >= 3) { + handleTsThreeFingerUp(event); + } else if (prevCount == 2) { + handleTsPointerUp(event); + } + handleTsUp(event, actionIndex); + } + // ACTION_UP = last pointer overall — full cleanup + gestureOwnedPointerIds.clear(); + gestureFingerCount = 0; + break; + + case MotionEvent.ACTION_CANCEL: + gestureOwnedPointerIds.clear(); + gestureFingerCount = 0; + cancel(); + break; + } + return true; + } + + private void handleTsDown(MotionEvent event, int actionIndex) { + flushPendingHoldClickRelease(); + + int x = (int) event.getX(actionIndex); + int y = (int) event.getY(actionIndex); + + longPressTriggered = longPressActionHeld = false; + secondTapCandidate = false; + isDragging = false; + dragButtonPressed = false; + movedBeyondTapThreshold = false; + multiFingerGestureUsed = false; + twoFingerDragging = false; + twoFingerTapPossible = false; + twoFingerTapFired = false; + twoFingerMiddleButtonDown = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + threeFingerTapPossible = false; + threeFingerDragging = false; + threeFingerTapFired = false; + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + threeFingerHoldTriggered = false; + accumulatedThreeFingerDelta = 0; + + // Store raw touch coordinates for long-press movement tolerance + touchDownRawX = event.getX(actionIndex); + touchDownRawY = event.getY(actionIndex); + singleFingerDownTime = SystemClock.uptimeMillis(); + singleFingerLastRawX = touchDownRawX; + singleFingerLastRawY = touchDownRawY; + + // Keep a nearby re-tap undecided until UP: it can still become a drag, + // long press, or multi-finger gesture instead of a double click. + if (isDoubleTap(event, actionIndex)) { + secondTapCandidate = true; + doubleTapAnchorX = lastTapX; + doubleTapAnchorY = lastTapY; + lastTapTime = 0; + } + + if (!secondTapCandidate && delayedPress == null && pendingTapPress == null) moveCursorTo(x, y); + + // Schedule long-press + if (gestureConfig.getLongPressEnabled()) { + longPressRunnable = () -> { + secondTapCandidate = false; + lastTapTime = 0; + longPressTriggered = true; + moveCursorTo((int) touchDownRawX, (int) touchDownRawY); + longPressActionHeld = injectHoldAction( + gestureConfig.getLongPressAction(), + gestureConfig.getLongPressMouseBehavior()); + notifyGesture(Gesture.LONG_PRESS); + }; + postDelayed(longPressRunnable, gestureConfig.getLongPressDelay()); + } + + // Tap: immediate cursor move (press is deferred to UP for click, or MOVE for drag) + } + + private void handleTsPointerDown(MotionEvent event) { + if (multiFingerGestureUsed) return; + secondTapCandidate = false; + flushPendingTapRelease(); + lastTapTime = 0; + flushPendingHoldClickRelease(); + // Cancel any single-finger long-press. cancelLongPressTimer only + // clears the pending runnable; if the long-press has already fired + // we must also release the held action before we transition into a + // multi-finger gesture, otherwise the long-press button/key stays + // down for the rest of the touch sequence. + if (longPressTriggered) { + longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); + longPressTriggered = false; + } + cancelLongPressTimer(); + cancelDrag(); + + if (gestureFingerCount == 2 && gestureOwnedPointerIds.size() >= 2) { + twoFingerTapPossible = true; + twoFingerDragging = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + twoFingerHoldTriggered = twoFingerHoldActionHeld = false; + accumulatedPinchDelta = 0; + accumulatedPanDelta = 0; + resetSmallMovement(); + twoFingerDownTime = SystemClock.uptimeMillis(); + twoFingerLastMoveTime = twoFingerDownTime; + twoFingerLockTime = 0; + reclassifyPinchDelta = 0; + reclassifyPanDelta = 0; + int idx0 = event.findPointerIndex(gestureOwnedPointerIds.get(0)); + int idx1 = event.findPointerIndex(gestureOwnedPointerIds.get(1)); + if (idx0 >= 0 && idx1 >= 0) { + twoFingerLastX0 = event.getX(idx0); + twoFingerLastY0 = event.getY(idx0); + twoFingerLastX1 = event.getX(idx1); + twoFingerLastY1 = event.getY(idx1); + pinchLastDistance = (float) Math.hypot( + event.getX(idx0) - event.getX(idx1), + event.getY(idx0) - event.getY(idx1)); + recordMultiFingerOrigins(event, 2); + } + // Schedule two-finger hold + if (gestureConfig.getTwoFingerHoldEnabled()) { + twoFingerHoldRunnable = () -> { + twoFingerHoldTriggered = true; + twoFingerTapPossible = false; + twoFingerHoldActionHeld = injectHoldAction( + gestureConfig.getTwoFingerHoldAction(), + gestureConfig.getTwoFingerHoldMouseBehavior()); + notifyGesture(Gesture.TWO_FINGER_HOLD); + }; + postDelayed(twoFingerHoldRunnable, gestureConfig.getTwoFingerHoldDelay()); + } + } + } + + private void handleTsMove(MotionEvent event, int pointerIndex) { + // After a multi-finger gesture, ignore single-finger movement until all fingers lift + if (multiFingerGestureUsed) return; + + int x = (int) event.getX(pointerIndex); + int y = (int) event.getY(pointerIndex); + + // If long press already triggered, ignore movement (right-click already sent) + if (longPressTriggered) return; + + // Measure raw finger travel from initial touch point + float rawTravel = maxFingerTravel(event, pointerIndex, touchDownRawX, touchDownRawY); + if (secondTapCandidate && (rawTravel > doubleTapSlop + || maxFingerTravel(event, pointerIndex, doubleTapAnchorX, doubleTapAnchorY) >= doubleTapSlop)) { + secondTapCandidate = false; + } + + // The threshold decides when a drag starts. An active drag must keep + // following the finger even when it returns through the touch origin. + if (!isDragging && rawTravel <= LONG_PRESS_MOVE_TOLERANCE) { + return; + } + + // Beyond tolerance — cancel long press + cancelLongPressTimer(); + movedBeyondTapThreshold = true; + + // Detect drag start + if (!isDragging && gestureConfig.getDragEnabled()) { + flushPendingTapRelease(); + String dragAction = gestureConfig.getDragAction(); + isDragging = true; + dragButtonPressed = true; + singleFingerLastRawX = event.getX(pointerIndex); + singleFingerLastRawY = event.getY(pointerIndex); + // Send the configured drag press immediately so that a short + // move followed straight by UP still produces a press+release + // pair. A zero-delta performPanAction just presses the button + // (or is a no-op for the key-pan variants, which press per + // direction on their next move frame). + if (isClickDragAction(dragAction)) { + // The candidate's cursor stayed at the previous click. Start + // a drag at this touch's own origin before pressing its button. + moveCursorTo((int) touchDownRawX, (int) touchDownRawY); + if (useRelativeMouseDragMovement()) { + pressClickDragAction(dragAction); + } else { + performClickDragAt(event.getX(pointerIndex), event.getY(pointerIndex), dragAction); + } + } else { + performPanAction(0f, 0f, dragAction); + } + notifyGesture(Gesture.DRAG); + // Non-click pan actions continue with real deltas on the next frame. + return; + } + + if (isDragging) { + String dragAction = gestureConfig.getDragAction(); + if (isClickDragAction(dragAction) && !useRelativeMouseDragMovement()) { + moveCursorTo(x, y); + return; + } + + float rawX = event.getX(pointerIndex); + float rawY = event.getY(pointerIndex); + float dx = rawX - singleFingerLastRawX; + float dy = rawY - singleFingerLastRawY; + singleFingerLastRawX = rawX; + singleFingerLastRawY = rawY; + if (isClickDragAction(dragAction)) { + performClickDragWithRelativeMovement(dx, dy, dragAction); + } else { + performPanAction(dx, dy, dragAction); + } + return; + } + + // Move cursor + moveCursorTo(x, y); + } + + private void handleTsTwoFingerMove(MotionEvent event) { + if (multiFingerGestureUsed) return; + // If a two-finger hold has already fired, ignore subsequent jitter so + // it can't lock into a pan/zoom gesture mid-hold. + if (twoFingerHoldTriggered) return; + if (gestureFingerCount < 2 || gestureOwnedPointerIds.size() < 2) return; + + int idx0 = event.findPointerIndex(gestureOwnedPointerIds.get(0)); + int idx1 = event.findPointerIndex(gestureOwnedPointerIds.get(1)); + if (idx0 < 0 || idx1 < 0) return; + + // Settling delays recognition, not movement tracking. A swipe or pinch + // must stop being a tap/hold even when it finishes inside that delay. + if (multiFingerMovementExceeded(event)) { + twoFingerTapPossible = false; + cancelTwoFingerHoldTimer(); + } + // Slow deliberate travel can cross touch slop before reaching the + // pan/zoom threshold. It must not turn into a stationary hold first. + if (multiFingerMovementExceeded(event, true)) cancelTwoFingerHoldTimer(); + + float x0 = event.getX(idx0), y0 = event.getY(idx0); + float x1 = event.getX(idx1), y1 = event.getY(idx1); + + // Compute per-frame pan and pinch deltas + float currDist = (float) Math.hypot(x0 - x1, y0 - y1); + float framePinchDelta = Math.abs(currDist - pinchLastDistance); + + float midX = (x0 + x1) / 2f; + float midY = (y0 + y1) / 2f; + float lastMidX = (twoFingerLastX0 + twoFingerLastX1) / 2f; + float lastMidY = (twoFingerLastY0 + twoFingerLastY1) / 2f; + float dx = midX - lastMidX; + float dy = midY - lastMidY; + float framePanDelta = (float) Math.hypot(dx, dy); + + long now = SystemClock.uptimeMillis(); + if (now - twoFingerDownTime < TWO_FINGER_SETTLE_MS) { + // Still settling — update stored positions but don't accumulate deltas + twoFingerLastX0 = x0; twoFingerLastY0 = y0; + twoFingerLastX1 = x1; twoFingerLastY1 = y1; + pinchLastDistance = currDist; + return; + } + long frameTime = now - twoFingerLastMoveTime; + twoFingerLastMoveTime = now; + float velocity = (frameTime > 0) ? framePanDelta / frameTime : 0; // px/ms + + // Accumulate deltas until we have enough movement to decide gesture type + if (twoFingerGestureMode == TWO_FINGER_GESTURE_NONE) { + accumulatedPinchDelta += framePinchDelta; + accumulatedPanDelta += framePanDelta; + if (accumulatedPinchDelta + accumulatedPanDelta > (float) gestureConfig.getGestureThreshold()) { + // Enough movement to lock into a gesture — no longer a tap or hold + twoFingerTapPossible = false; + cancelTwoFingerHoldTimer(); + if (accumulatedPinchDelta > accumulatedPanDelta * PINCH_RATIO_THRESHOLD + && gestureConfig.getPinchEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_ZOOM; + twoFingerLockTime = now; + notifyGesture(Gesture.PINCH); + } else if (velocity >= MIN_DRAG_VELOCITY && gestureConfig.getTwoFingerDragEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + twoFingerLockTime = now; + notifyGesture(Gesture.TWO_FINGER_DRAG); + } else if (accumulatedPanDelta >= accumulatedPinchDelta && gestureConfig.getTwoFingerDragEnabled()) { + // Fallback: pan is dominant but velocity was too low — still drag + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + twoFingerLockTime = now; + notifyGesture(Gesture.TWO_FINGER_DRAG); + } else if (gestureConfig.getPinchEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_ZOOM; + twoFingerLockTime = now; + notifyGesture(Gesture.PINCH); + } else if (gestureConfig.getTwoFingerDragEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + twoFingerLockTime = now; + notifyGesture(Gesture.TWO_FINGER_DRAG); + } + } + } + if (twoFingerGestureMode != TWO_FINGER_GESTURE_NONE && twoFingerLockTime > 0 + && (now - twoFingerLockTime) < RECLASSIFY_WINDOW_MS) { + reclassifyPinchDelta += framePinchDelta; + reclassifyPanDelta += framePanDelta; + float reclassifyTotal = reclassifyPinchDelta + reclassifyPanDelta; + if (reclassifyTotal > 20f) { // enough post-lock data to reclassify + boolean shouldBeZoom = reclassifyPinchDelta > reclassifyPanDelta * PINCH_RATIO_THRESHOLD; + if (shouldBeZoom && twoFingerGestureMode == TWO_FINGER_GESTURE_PAN + && gestureConfig.getPinchEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_ZOOM; + releaseDragInput(); + resetSmallMovement(); + notifyGesture(Gesture.PINCH); + } else if (!shouldBeZoom && twoFingerGestureMode == TWO_FINGER_GESTURE_ZOOM + && gestureConfig.getTwoFingerDragEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + resetSmallMovement(); + notifyGesture(Gesture.TWO_FINGER_DRAG); + } + twoFingerLockTime = 0; // done reclassifying + reclassifyPinchDelta = 0; + reclassifyPanDelta = 0; + } + } + if (twoFingerGestureMode == TWO_FINGER_GESTURE_ZOOM) { + float delta = currDist - pinchLastDistance; + pinchZoomRemainder = accumulateSignedMovement(pinchZoomRemainder, delta); + if (Math.abs(pinchZoomRemainder) >= PINCH_STEP_PX) { + performZoomAction(pinchZoomRemainder > 0); + // Keep the fractional travel, without queuing a burst of old + // zoom actions after a large move or a direction reversal. + pinchZoomRemainder %= PINCH_STEP_PX; + } + } + pinchLastDistance = currDist; // Always update baseline to avoid stale deltas + if (twoFingerGestureMode == TWO_FINGER_GESTURE_PAN) { + String twoFingerDragAction = gestureConfig.getTwoFingerDragAction(); + if (isClickDragAction(twoFingerDragAction)) { + if (useRelativeMouseDragMovement()) { + twoFingerDragging = true; + performClickDragWithRelativeMovement(dx, dy, twoFingerDragAction); + } else { + if (!twoFingerDragging) moveCursorToMultiFingerOrigin(); + twoFingerDragging = true; + performClickDragAt(midX, midY, twoFingerDragAction); + } + } else if (dx != 0f || dy != 0f) { + twoFingerDragging = true; + performPanAction(dx, dy, twoFingerDragAction); + } + } + + twoFingerLastX0 = x0; + twoFingerLastY0 = y0; + twoFingerLastX1 = x1; + twoFingerLastY1 = y1; + } + + private void handleTsPointerUp(MotionEvent event) { + cancelTwoFingerHoldTimer(); + if (multiFingerMovementExceeded(event)) twoFingerTapPossible = false; + // Two-finger hold release + if (twoFingerHoldTriggered) { + twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); + twoFingerHoldTriggered = false; + } + // Two-finger tap detection + else if (twoFingerTapPossible && !twoFingerDragging && gestureConfig.getTwoFingerTapEnabled()) { + // Move cursor to midpoint between the two fingers + float midX = (twoFingerLastX0 + twoFingerLastX1) / 2f; + float midY = (twoFingerLastY0 + twoFingerLastY1) / 2f; + queueTapClick(gestureConfig.getTwoFingerTapAction(), midX, midY); + notifyGesture(Gesture.TWO_FINGER_TAP); + twoFingerTapFired = true; + } + releaseDragInput(); + twoFingerDragging = false; + twoFingerTapPossible = false; + multiFingerStartCount = 0; + resetSmallMovement(); + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + accumulatedPinchDelta = 0; + accumulatedPanDelta = 0; + reclassifyPinchDelta = 0; + reclassifyPanDelta = 0; + twoFingerLockTime = 0; + // Suppress single-finger tap/drag when last finger lifts after any 2F gesture + movedBeyondTapThreshold = true; + multiFingerGestureUsed = true; + } + + private void handleTsUp(MotionEvent event, int actionIndex) { + cancelLongPressTimer(); + + if (longPressTriggered) { + // Release the long-press action + longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); + longPressTriggered = false; + return; + } + + float x = event.getX(actionIndex); + float y = event.getY(actionIndex); + + if (isDragging && dragButtonPressed) { + // End drag (release any held drag buttons/keys) + releaseDragInput(); + dragButtonPressed = false; + isDragging = false; + lastTapTime = 0; + return; + } + + if (secondTapCandidate) { + secondTapCandidate = false; + long tapLimit = gestureConfig.getLongPressEnabled() + ? gestureConfig.getLongPressDelay() : ViewConfiguration.getLongPressTimeout(); + if (SystemClock.uptimeMillis() - singleFingerDownTime < tapLimit + && Math.hypot(x - doubleTapAnchorX, y - doubleTapAnchorY) < doubleTapSlop) { + // A normal left tap already supplied the first click. Other + // single-tap mappings need a complete synthesized double click. + boolean firstTapClicked = gestureConfig.getTapEnabled() + && TouchGestureConfig.ACTION_LEFT_CLICK.equals(gestureConfig.getTapAction()); + int clicks = firstTapClicked ? 1 : 2; + for (int i = 0; i < clicks; i++) queueTapClick(TouchGestureConfig.ACTION_LEFT_CLICK, doubleTapAnchorX, doubleTapAnchorY); + notifyGesture(Gesture.DOUBLE_TAP); + lastTapTime = 0; + return; + } + } + + // Two-finger tap already sent its click on POINTER_UP — skip the tap on UP + if (twoFingerTapFired) { + twoFingerTapFired = false; + return; + } + + // Three-finger tap already sent its click — skip the tap on UP + if (threeFingerTapFired) { + threeFingerTapFired = false; + return; + } + + // Simple tap — only if finger stayed within tap tolerance + if (gestureConfig.getTapEnabled() && !movedBeyondTapThreshold) { + queueTapClick(gestureConfig.getTapAction(), x, y); + notifyGesture(Gesture.TAP); + } + + // Record for double-tap detection (even if tap itself is disabled, + // so double-tap can still prime from single touch-ups) + if (!movedBeyondTapThreshold && (gestureConfig.getTapEnabled() || gestureConfig.getDoubleTapEnabled())) { + lastTapTime = SystemClock.uptimeMillis(); + lastTapX = x; + lastTapY = y; + } + movedBeyondTapThreshold = false; + } + + private void handleTsCancel() { + handleTsCancel(false); + } + + private void handleTsCancel(boolean preserveTapRelease) { + cancelLongPressTimer(); + cancelTwoFingerHoldTimer(); + cancelThreeFingerHoldTimer(); + flushPendingHoldClickRelease(); + if (!preserveTapRelease) flushPendingTapRelease(); + if (dragButtonPressed) { + releaseDragInput(); + dragButtonPressed = false; + } + if (longPressTriggered) { + longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); + longPressTriggered = false; + } + if (twoFingerHoldTriggered) { + twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); + twoFingerHoldTriggered = false; + } + if (threeFingerHoldTriggered) { + threeFingerHoldActionHeld = releaseHoldAction(threeFingerHoldActionHeld, gestureConfig.getThreeFingerHoldAction()); + threeFingerHoldTriggered = false; + } + releaseDragInput(); + isDragging = false; + twoFingerDragging = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + threeFingerDragging = false; + threeFingerTapPossible = false; + threeFingerTapFired = false; + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + longPressTriggered = false; + longPressActionHeld = twoFingerHoldActionHeld = threeFingerHoldActionHeld = false; + multiFingerGestureUsed = false; + movedBeyondTapThreshold = false; + secondTapCandidate = false; + gestureOwnedPointerIds.clear(); + gestureFingerCount = 0; + multiFingerStartCount = 0; + resetSmallMovement(); + } + + private void handleTsThreeFingerDown(MotionEvent event) { + if (multiFingerGestureUsed) return; + flushPendingHoldClickRelease(); + cancelLongPressTimer(); + cancelDrag(); + cancelTwoFingerHoldTimer(); + // If a two-finger hold already injected its action, release it before + // transitioning so the held button/key doesn't stay down. + if (twoFingerHoldTriggered) { + twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); + twoFingerHoldTriggered = false; + } + // Clean up two-finger state + releaseDragInput(); + resetSmallMovement(); + twoFingerDragging = false; + twoFingerTapPossible = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + + threeFingerTapPossible = true; + threeFingerDragging = false; + threeFingerHoldTriggered = threeFingerHoldActionHeld = false; + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + accumulatedThreeFingerDelta = 0; + threeFingerDragOffsetX = threeFingerDragOffsetY = 0f; + threeFingerDownTime = SystemClock.uptimeMillis(); + + // Compute midpoint of three gesture fingers + if (gestureOwnedPointerIds.size() >= 3) { + float sumX = 0, sumY = 0; + int validCount = 0; + for (int i = 0; i < 3; i++) { + int idx = event.findPointerIndex(gestureOwnedPointerIds.get(i)); + if (idx >= 0) { + sumX += event.getX(idx); + sumY += event.getY(idx); + validCount++; + } + } + if (validCount < 3) return; // Can't proceed without all 3 pointers + threeFingerLastMidX = sumX / 3f; + threeFingerLastMidY = sumY / 3f; + recordMultiFingerOrigins(event, 3); + } + + // Schedule three-finger hold + if (gestureConfig.getThreeFingerHoldEnabled()) { + threeFingerHoldRunnable = () -> { + threeFingerHoldTriggered = true; + threeFingerTapPossible = false; + threeFingerHoldActionHeld = injectHoldAction( + gestureConfig.getThreeFingerHoldAction(), + gestureConfig.getThreeFingerHoldMouseBehavior()); + notifyGesture(Gesture.THREE_FINGER_HOLD); + }; + postDelayed(threeFingerHoldRunnable, gestureConfig.getThreeFingerHoldDelay()); + } + } + + private void handleTsThreeFingerMove(MotionEvent event) { + if (gestureFingerCount < 3 || gestureOwnedPointerIds.size() < 3) return; + // If a three-finger hold has already fired, ignore subsequent jitter so + // it can't lock into a pan gesture mid-hold. + if (threeFingerHoldTriggered) return; + if (multiFingerMovementExceeded(event)) { + threeFingerTapPossible = false; + cancelThreeFingerHoldTimer(); + } + if (multiFingerMovementExceeded(event, true)) cancelThreeFingerHoldTimer(); + + float sumX = 0, sumY = 0; + int validCount = 0; + for (int i = 0; i < 3; i++) { + int idx = event.findPointerIndex(gestureOwnedPointerIds.get(i)); + if (idx >= 0) { + sumX += event.getX(idx); + sumY += event.getY(idx); + validCount++; + } + } + if (validCount < 3) return; + + float midX = sumX / 3f; + float midY = sumY / 3f; + float dx = midX - threeFingerLastMidX; + float dy = midY - threeFingerLastMidY; + float frameDelta = (float) Math.hypot(dx, dy); + long now = SystemClock.uptimeMillis(); + if (now - threeFingerDownTime < THREE_FINGER_SETTLE_MS) { + threeFingerLastMidX = midX; + threeFingerLastMidY = midY; + return; + } + + if (threeFingerGestureMode == THREE_FINGER_GESTURE_NONE) { + accumulatedThreeFingerDelta += frameDelta; + if (accumulatedThreeFingerDelta > (float) gestureConfig.getGestureThreshold()) { + threeFingerTapPossible = false; + cancelThreeFingerHoldTimer(); + if (gestureConfig.getThreeFingerDragEnabled()) { + threeFingerGestureMode = THREE_FINGER_GESTURE_PAN; + notifyGesture(Gesture.THREE_FINGER_DRAG); + } + } + } + + if (threeFingerGestureMode == THREE_FINGER_GESTURE_PAN) { + // Once recognized, retain small mouse movements. Keyboard panning + // accumulates its own direction threshold in performKeyPan. + String threeFingerDragAction = gestureConfig.getThreeFingerDragAction(); + if (isClickDragAction(threeFingerDragAction)) { + if (useRelativeMouseDragMovement()) { + threeFingerDragging = true; + performClickDragWithRelativeMovement(dx, dy, threeFingerDragAction); + } else { + if (!threeFingerDragging) moveCursorToMultiFingerOrigin(); + threeFingerDragging = true; + performClickDragAt(midX + threeFingerDragOffsetX, midY + threeFingerDragOffsetY, threeFingerDragAction); + } + } else if (dx != 0f || dy != 0f) { + threeFingerDragging = true; + performPanAction(dx, dy, threeFingerDragAction); + } + } + + threeFingerLastMidX = midX; + threeFingerLastMidY = midY; + } + + private void handleTsThreeFingerUp(MotionEvent event) { + cancelThreeFingerHoldTimer(); + if (multiFingerMovementExceeded(event)) threeFingerTapPossible = false; + + if (threeFingerHoldTriggered) { + threeFingerHoldActionHeld = releaseHoldAction(threeFingerHoldActionHeld, gestureConfig.getThreeFingerHoldAction()); + threeFingerHoldTriggered = false; + } else if (threeFingerTapPossible && !threeFingerDragging + && gestureConfig.getThreeFingerTapEnabled()) { + queueTapClick(gestureConfig.getThreeFingerTapAction(), threeFingerLastMidX, threeFingerLastMidY); + notifyGesture(Gesture.THREE_FINGER_TAP); + threeFingerTapFired = true; + } + + releaseDragInput(); + threeFingerDragging = false; + threeFingerTapPossible = false; + multiFingerStartCount = 0; + resetSmallMovement(); + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + // Suppress single-finger tap/drag when last finger lifts after any 3F gesture + movedBeyondTapThreshold = true; + multiFingerGestureUsed = true; + } + + private void resetSmallMovement() { + pinchZoomRemainder = keyPanRemainderX = keyPanRemainderY = 0f; + } + + private static float accumulateSignedMovement(float remainder, float delta) { + if (delta != 0f && Math.signum(delta) != Math.signum(remainder)) remainder = 0f; + return remainder + delta; + } + + private void recordMultiFingerOrigins(MotionEvent event, int count) { + multiFingerStartCount = 0; + for (int i = 0; i < count; i++) { + int id = gestureOwnedPointerIds.get(i); + int index = event.findPointerIndex(id); + if (index < 0) return; + multiFingerStartIds[i] = id; + multiFingerStartX[i] = event.getX(index); + multiFingerStartY[i] = event.getY(index); + } + multiFingerStartCount = count; + } + + private void moveCursorToMultiFingerOrigin() { + if (multiFingerStartCount == 0) return; + float x = 0, y = 0; + for (int i = 0; i < multiFingerStartCount; i++) { + x += multiFingerStartX[i]; + y += multiFingerStartY[i]; + } + moveCursorTo((int) (x / multiFingerStartCount), (int) (y / multiFingerStartCount)); + } + + private void rebaseThreeFingerMovement(MotionEvent event) { + float x = 0, y = 0; + for (int i = 0; i < 3; i++) { + int index = event.findPointerIndex(gestureOwnedPointerIds.get(i)); + if (index < 0) return; + x += event.getX(index); + y += event.getY(index); + } + x /= 3f; + y /= 3f; + // Replacing a tracked finger changes the centroid without moving the cursor. + if (threeFingerDragging) { + threeFingerDragOffsetX += threeFingerLastMidX - x; + threeFingerDragOffsetY += threeFingerLastMidY - y; + } + threeFingerLastMidX = x; + threeFingerLastMidY = y; + accumulatedThreeFingerDelta = 0; + threeFingerTapPossible = false; + cancelThreeFingerHoldTimer(); + recordMultiFingerOrigins(event, 3); + resetSmallMovement(); + } + + private float maxFingerTravel(MotionEvent event, int index, float originX, float originY) { + float distance = (float) Math.hypot(event.getX(index) - originX, event.getY(index) - originY); + for (int h = 0; h < event.getHistorySize(); h++) { + distance = Math.max(distance, (float) Math.hypot( + event.getHistoricalX(index, h) - originX, event.getHistoricalY(index, h) - originY)); + } + return distance; + } + + private boolean multiFingerMovementExceeded(MotionEvent event) { + return multiFingerMovementExceeded(event, false); + } + + private boolean multiFingerMovementExceeded(MotionEvent event, boolean forHold) { + float tolerance = forHold ? doubleTapSlop : gestureConfig.getGestureThreshold(); + for (int i = 0; i < multiFingerStartCount; i++) { + int index = event.findPointerIndex(multiFingerStartIds[i]); + if (index < 0) continue; + if (maxFingerTravel(event, index, multiFingerStartX[i], multiFingerStartY[i]) > tolerance) return true; + for (int j = 0; !forHold && j < i; j++) { + int other = event.findPointerIndex(multiFingerStartIds[j]); + if (other < 0) continue; + double initialDistance = Math.hypot(multiFingerStartX[i] - multiFingerStartX[j], multiFingerStartY[i] - multiFingerStartY[j]); + double distance = Math.hypot(event.getX(index) - event.getX(other), event.getY(index) - event.getY(other)); + if (Math.abs(distance - initialDistance) > tolerance) return true; + for (int h = 0; h < event.getHistorySize(); h++) { + distance = Math.hypot(event.getHistoricalX(index, h) - event.getHistoricalX(other, h), + event.getHistoricalY(index, h) - event.getHistoricalY(other, h)); + if (Math.abs(distance - initialDistance) > tolerance) return true; + } + } + } + return false; + } + + private boolean isDoubleTap(MotionEvent event, int index) { + return gestureConfig.getDoubleTapEnabled() && lastTapTime > 0 + && SystemClock.uptimeMillis() - lastTapTime < gestureConfig.getDoubleTapDelay() + && Math.hypot(event.getX(index) - lastTapX, event.getY(index) - lastTapY) + < doubleTapSlop; + } + + private void queueTapClick(String action, float x, float y) { + pendingTapClicks.addLast(new TapClick(action, x, y)); + startPendingTapClick(); + } + + private void startPendingTapClick() { + if (delayedPress != null || pendingTapPress != null || pendingTapClicks.isEmpty()) return; + TapClick click = pendingTapClicks.peekFirst(); + // Games that poll button state need an observable UP interval too. + // Reuse the first tap's location: normal finger drift must not turn + // this into two clicks on different targets or a tiny drag. + long delay = Math.max(0, CLICK_DELAYED_TIME - (SystemClock.uptimeMillis() - lastInputReleaseTime)); + pendingTapPress = () -> { + pendingTapPress = null; + pendingTapClicks.removeFirst(); + moveCursorTo(click.x, click.y); + injectClick(click.action); + delayedPressAction = click.action; + delayedPress = () -> { + injectRelease(click.action); + delayedPress = null; + delayedPressAction = null; + startPendingTapClick(); + }; + postDelayed(delayedPress, CLICK_DELAYED_TIME); + }; + if (delay > 0) postDelayed(pendingTapPress, delay); + else pendingTapPress.run(); + } + + private void flushPendingTapRelease() { + if (pendingTapPress != null) removeCallbacks(pendingTapPress); + pendingTapPress = null; + pendingTapClicks.clear(); + if (delayedPress != null) { + removeCallbacks(delayedPress); + injectRelease(delayedPressAction); + delayedPressAction = null; + delayedPress = null; + } + } + + private void flushPendingHoldClickRelease() { + if (pendingHoldClickRelease != null) { + removeCallbacks(pendingHoldClickRelease); + String action = pendingHoldClickReleaseAction; + pendingHoldClickRelease = null; + pendingHoldClickReleaseAction = null; + injectRelease(action); + } + } + + private void cancelLongPressTimer() { + if (longPressRunnable != null) { + removeCallbacks(longPressRunnable); + longPressRunnable = null; + } + } + + private void cancelDrag() { + if (isDragging && dragButtonPressed) { + releaseDragInput(); + dragButtonPressed = false; + } + isDragging = false; + } + + private void cancelTwoFingerHoldTimer() { + if (twoFingerHoldRunnable != null) { + removeCallbacks(twoFingerHoldRunnable); + twoFingerHoldRunnable = null; + } + } + + private void cancelThreeFingerHoldTimer() { + if (threeFingerHoldRunnable != null) { + removeCallbacks(threeFingerHoldRunnable); + threeFingerHoldRunnable = null; + } + } + + private void moveCursorTo(int x, int y) { output.moveTo(x, y); } + + private boolean injectClick(String action) { + if (action == null) return false; + if (TouchGestureConfig.ACTION_SHOW_KEYBOARD.equals(action)) { + output.showKeyboard(); return false; + } + if (TouchGestureConfig.ACTION_KEY_TILDE.equals(action)) { + pressKey(42); pressKey(41); return true; + } + Integer code = actionToKeycode(action); + if (code != null) { pressKey(code); return true; } + MouseButton button = actionToButton(action); + if (button != null) { pressButton(button); return true; } + return false; + } + + private void injectRelease(String action) { + if (action == null) return; + if (TouchGestureConfig.ACTION_KEY_TILDE.equals(action)) { releaseKey(41); releaseKey(42); return; } + Integer code = actionToKeycode(action); + if (code != null) { releaseKey(code); return; } + MouseButton button = actionToButton(action); + if (button != null) releaseButton(button); + } + + private boolean injectHoldAction(String action, String mouseBehavior) { + if (TouchGestureConfig.isMouseButtonAction(action) && TouchGestureConfig.MOUSE_BEHAVIOR_CLICK.equals(mouseBehavior)) { + flushPendingHoldClickRelease(); + if (injectClick(action)) { + pendingHoldClickReleaseAction = action; + pendingHoldClickRelease = () -> { + String pendingAction = pendingHoldClickReleaseAction; + pendingHoldClickRelease = null; + pendingHoldClickReleaseAction = null; + injectRelease(pendingAction); + }; + postDelayed(pendingHoldClickRelease, CLICK_DELAYED_TIME); + } + return false; + } + return injectClick(action); + } + + private boolean releaseHoldAction(boolean held, String action) { + if (held) injectRelease(action); + return false; + } + + private Integer actionToKeycode(String action) { + return action != null && action.startsWith("key_") ? GestureKeys.code(action.substring(4)) : null; + } + + private MouseButton actionToButton(String action) { + if (TouchGestureConfig.ACTION_LEFT_CLICK.equals(action)) return MouseButton.BUTTON_LEFT; + if (TouchGestureConfig.ACTION_RIGHT_CLICK.equals(action)) return MouseButton.BUTTON_RIGHT; + if (TouchGestureConfig.ACTION_MIDDLE_CLICK.equals(action)) return MouseButton.BUTTON_MIDDLE; + return null; + } + private void performZoomAction(boolean zoomIn) { + String action = gestureConfig.getPinchAction(); + if (TouchGestureConfig.ZOOM_SCROLL_WHEEL.equals(action)) { output.wheel(zoomIn ? -1 : 1); return; } + int code = TouchGestureConfig.ZOOM_PLUS_MINUS.equals(action) ? (zoomIn ? 13 : 12) : (zoomIn ? 104 : 109); + pressKey(code); + postDelayed(() -> releaseKey(code), CLICK_DELAYED_TIME); + } + + private void performPanAction(float dx, float dy, String action) { + switch (action) { + case TouchGestureConfig.PAN_MIDDLE_MOUSE: + performMiddleMousePan(dx, dy); + break; + case TouchGestureConfig.PAN_INVERTED_MIDDLE_MOUSE: + performMiddleMousePan(-dx, -dy); + break; + case TouchGestureConfig.PAN_WASD: + performKeyPan(dx, dy, GestureKeys.code("A"), GestureKeys.code("D"), GestureKeys.code("W"), GestureKeys.code("S")); + break; + case TouchGestureConfig.PAN_INVERTED_WASD: + performKeyPan(dx, dy, GestureKeys.code("D"), GestureKeys.code("A"), GestureKeys.code("S"), GestureKeys.code("W")); + break; + case TouchGestureConfig.PAN_ARROW_KEYS: + performKeyPan(dx, dy, GestureKeys.code("LEFT"), GestureKeys.code("RIGHT"), GestureKeys.code("UP"), GestureKeys.code("DOWN")); + break; + case TouchGestureConfig.PAN_INVERTED_ARROW_KEYS: + performKeyPan(dx, dy, GestureKeys.code("RIGHT"), GestureKeys.code("LEFT"), GestureKeys.code("DOWN"), GestureKeys.code("UP")); + break; + case TouchGestureConfig.PAN_LEFT_CLICK_DRAG: + performClickDrag(dx, dy, MouseButton.BUTTON_LEFT); + break; + case TouchGestureConfig.PAN_RIGHT_CLICK_DRAG: + performClickDrag(dx, dy, MouseButton.BUTTON_RIGHT); + break; + } + } + + private boolean isClickDragAction(String action) { + return TouchGestureConfig.PAN_LEFT_CLICK_DRAG.equals(action) + || TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(action); + } + + private boolean useRelativeMouseDragMovement() { + return TouchGestureConfig.MOUSE_DRAG_MOVEMENT_RELATIVE.equals(gestureConfig.getMouseDragMovementMode()); + } + + private MouseButton buttonForClickDragAction(String action) { + return TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(action) + ? MouseButton.BUTTON_RIGHT : MouseButton.BUTTON_LEFT; + } + + private void performClickDragAt(float rawX, float rawY, String action) { + pressClickDragAction(action); + moveCursorTo((int) rawX, (int) rawY); + } + + private void performMiddleMousePan(float dx, float dy) { + if (!twoFingerMiddleButtonDown) { + output.prepareRelativePan(); + pressButton(MouseButton.BUTTON_MIDDLE); + twoFingerMiddleButtonDown = true; + } + // Pan from the client's current pointer. Games can recenter the mouse + // when the middle button goes down; replaying our old absolute touch + // anchor after that warp causes a large, unintended camera movement. + moveCursorByRelativeDelta(dx, dy); + } + + private void performClickDrag(float dx, float dy, MouseButton button) { + pressClickDragButton(button); + moveCursorByDelta(dx, dy); + } + + private void performClickDragWithRelativeMovement(float dx, float dy, String action) { + pressClickDragAction(action); + moveCursorByRelativeDelta(dx, dy); + } + + private void pressClickDragAction(String action) { + pressClickDragButton(buttonForClickDragAction(action)); + } + + private void pressClickDragButton(MouseButton button) { + if (button == MouseButton.BUTTON_LEFT && !leftClickDragButtonDown) { + pressButton(MouseButton.BUTTON_LEFT); + leftClickDragButtonDown = true; + } else if (button == MouseButton.BUTTON_RIGHT && !rightClickDragButtonDown) { + pressButton(MouseButton.BUTTON_RIGHT); + rightClickDragButtonDown = true; + } + } + + private void moveCursorByRelativeDelta(float dx, float dy) { output.moveBy(dx, dy, true); } + + private void moveCursorByDelta(float dx, float dy) { output.moveBy(dx, dy, false); } + + private void releaseTwoFingerMiddleButton() { + if (twoFingerMiddleButtonDown) { + releaseButton(MouseButton.BUTTON_MIDDLE); + twoFingerMiddleButtonDown = false; + } + } + + private void releaseClickDragButtons() { + if (leftClickDragButtonDown) { + releaseButton(MouseButton.BUTTON_LEFT); + leftClickDragButtonDown = false; + } + if (rightClickDragButtonDown) { + releaseButton(MouseButton.BUTTON_RIGHT); + rightClickDragButtonDown = false; + } + } + + private void releaseDragInput() { + releasePanKeys(); + releaseAllDragButtons(); + } + + private void releaseAllDragButtons() { + releaseTwoFingerMiddleButton(); + releaseClickDragButtons(); + } + + private void performKeyPan(float dx, float dy, Integer leftKey, Integer rightKey, Integer upKey, Integer downKey) { + // When fingers are actively moving, update key states to match the + // swipe direction. When fingers stop moving but stay on screen, + // keep the current keys held — movement continues until finger-up + // calls releasePanKeys(). + + keyPanRemainderX = accumulateSignedMovement(keyPanRemainderX, dx); + keyPanRemainderY = accumulateSignedMovement(keyPanRemainderY, dy); + float totalDelta = Math.abs(keyPanRemainderX) + Math.abs(keyPanRemainderY); + if (totalDelta < 5f || (Math.abs(keyPanRemainderX) <= 3f && Math.abs(keyPanRemainderY) <= 3f)) return; + dx = keyPanRemainderX; + dy = keyPanRemainderY; + keyPanRemainderX = keyPanRemainderY = 0f; + + // --- Significant movement detected: update each axis --- + + // Horizontal: determine desired state from this frame's delta + boolean wantLeft = dx < -3f; + boolean wantRight = dx > 3f; + + if (panKeyLeft && !wantLeft) { releaseKey(leftKey); panKeyLeft = false; } + if (panKeyRight && !wantRight) { releaseKey(rightKey); panKeyRight = false; } + if (wantLeft && !panKeyLeft) { pressKey(leftKey); panKeyLeft = true; panLeftKey = leftKey; } + if (wantRight && !panKeyRight) { pressKey(rightKey); panKeyRight = true; panRightKey = rightKey; } + + // Vertical: determine desired state from this frame's delta + boolean wantUp = dy < -3f; + boolean wantDown = dy > 3f; + + if (panKeyUp && !wantUp) { releaseKey(upKey); panKeyUp = false; } + if (panKeyDown && !wantDown) { releaseKey(downKey); panKeyDown = false; } + if (wantUp && !panKeyUp) { pressKey(upKey); panKeyUp = true; panUpKey = upKey; } + if (wantDown && !panKeyDown) { pressKey(downKey); panKeyDown = true; panDownKey = downKey; } + } + + private void releasePanKeys() { + keyPanRemainderX = keyPanRemainderY = 0f; + if (panKeyLeft && panLeftKey != null) { releaseKey(panLeftKey); panKeyLeft = false; } + if (panKeyRight && panRightKey != null) { releaseKey(panRightKey); panKeyRight = false; } + if (panKeyUp && panUpKey != null) { releaseKey(panUpKey); panKeyUp = false; } + if (panKeyDown && panDownKey != null) { releaseKey(panDownKey); panKeyDown = false; } + } + +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt b/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt new file mode 100644 index 000000000..343d18bdd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt @@ -0,0 +1,58 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +/** What SteamVR sees for the game pad. The driver reads the file once, when SteamVR starts. */ +object VrControllers { + enum class Emulate(val key: String) { OFF("off"), INDEX("index"), TOUCH("touch"), VIVE("vive") } + + data class Settings(val emulate: Emulate = Emulate.INDEX, val sticksMoveHands: Boolean = false) + + const val FILE = "droiddeck-vr-controllers" + private const val PREFS = "vr_controllers" + + private val _settings = MutableStateFlow(Settings()) + val settings: StateFlow = _settings.asStateFlow() + private var loaded = false + + fun load(context: Context): Settings { + if (!loaded) { + val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + _settings.value = Settings( + emulate = Emulate.entries.firstOrNull { it.key == p.getString("emulate", null) } ?: Emulate.INDEX, + sticksMoveHands = p.getBoolean("sticksMoveHands", false), + ) + loaded = true + } + return _settings.value + } + + fun setEmulate(context: Context, emulate: Emulate) = save(context, load(context).copy(emulate = emulate)) + fun setSticksMoveHands(context: Context, on: Boolean) = save(context, load(context).copy(sticksMoveHands = on)) + + private fun save(context: Context, settings: Settings) { + _settings.value = settings + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString("emulate", settings.emulate.key) + .putBoolean("sticksMoveHands", settings.sticksMoveHands) + .apply() + publish(context) + } + + fun encode(settings: Settings): String = + "emulate=${settings.emulate.key}\nsticks=${if (settings.sticksMoveHands) "hands" else "game"}\n" + + /** Writes into the session's runtime dir, which the guest sees as /run/droiddeck. */ + fun publish(context: Context) { + runCatching { + val dir = File(context.filesDir, ".wayland-rt").apply { mkdirs() } + val tmp = File(dir, "$FILE.tmp") + tmp.writeText(encode(load(context))) + tmp.renameTo(File(dir, FILE)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt index 9bce1791a..fecab0022 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -181,6 +181,7 @@ object BwrapSpawner { request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } } if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) } preload(session, rootfs, env).forEach { addBind(it) } + zypakShims(binds, root, dir).forEach { addBind(it) } browserSandboxes(env) return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env) } @@ -247,6 +248,20 @@ object BwrapSpawner { private const val PRELOAD_DIR = "/run/droiddeck-preload" + private const val ZYPAK_SHIM = "#!/bin/sh\nprogram=\$1\nshift\nexec \"\$program\" --no-sandbox \"\$@\"\n" + + private fun zypakShims(binds: List, root: File, dir: File): List { + val wrappers = listOf("/app/bin/zypak-wrapper.sh", "/app/bin/zypak-wrapper").filter { guest -> + val host = File(hostPath(binds, root.path, guest)) + host.isFile && !Files.isSymbolicLink(host.toPath()) + } + if (wrappers.isEmpty()) return emptyList() + val shim = File(dir, "zypak-wrapper") + shim.writeText(ZYPAK_SHIM) + chmod(shim, 0x1ed) + return wrappers.map { Bind(shim.path, it) } + } + private fun browserSandboxes(env: MutableMap) { env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0") env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1") diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index 4bf70d9e4..5c5687c96 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -18,10 +18,11 @@ import org.json.JSONObject object DeckyManager { private const val TAG = "DeckyManager" - const val REPOSITORY = "Droid-Deck/decky-loader" + const val REPOSITORY = "SteamDeckHomebrew/decky-loader" private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30" private const val PATH = "root/homebrew/services/PluginLoader" private const val VERSION = "root/homebrew/services/.droiddeck-decky-version" + private const val SETTINGS = "root/homebrew/settings/loader.json" // droiddeck-session reads the marker from the root of the client it runs. private val CEF_REMOTE_DEBUG_MARKERS = listOf( "root/.local/share/Steam/.cef-enable-remote-debugging", @@ -32,7 +33,34 @@ object DeckyManager { private set data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int) - data class ReleaseChannels(val stable: List, val prerelease: List) + data class ReleaseChannels(val stable: List, val prerelease: List, val all: List = stable + prerelease) + + const val BRANCH_STABLE = 0 + const val BRANCH_PRERELEASE = 1 + const val BRANCH_TESTING = 2 + + private fun settingsFile(context: Context) = File(LinuxRuntime.rootDir(context), SETTINGS) + + fun branch(context: Context): Int { + val saved = runCatching { JSONObject(settingsFile(context).readText()).optInt("branch", -1) }.getOrDefault(-1) + if (saved >= 0) return saved + val version = File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim().orEmpty() + return if (version.startsWith("v") && "-pre" in version) BRANCH_PRERELEASE else BRANCH_STABLE + } + + fun setBranch(context: Context, branch: Int) { + val file = settingsFile(context) + val settings = runCatching { JSONObject(file.readText()) }.getOrDefault(JSONObject()) + settings.put("branch", branch) + file.parentFile?.mkdirs() + val staged = File(file.parentFile, "${file.name}.tmp") + staged.writeText(settings.toString(4)) + check(staged.renameTo(file)) { "cannot write ${file.path}" } + } + + // Mirrors Decky's updater: Stable takes the newest non-prerelease tag, Prerelease the newest tag of either kind. + fun latest(channels: ReleaseChannels, branch: Int): Release? = + if (branch == BRANCH_STABLE) channels.stable.firstOrNull() else channels.all.firstOrNull() fun installed(context: Context): String? { val loader = loader(context) @@ -74,17 +102,13 @@ object DeckyManager { } } - fun releases(context: Context, prerelease: Boolean): List { - val channels = releaseChannels(context) - return if (prerelease) channels.prerelease else channels.stable - } - fun releaseChannels(context: Context): ReleaseChannels { val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList()) return try { val array = JSONArray(body) val stable = mutableListOf() val prerelease = mutableListOf() + val all = mutableListOf() for (i in 0 until array.length()) { val release = array.getJSONObject(i) if (release.optBoolean("draft", false)) continue @@ -105,9 +129,11 @@ object DeckyManager { val size = binary.optLong("size", 0) if (size <= 0) continue val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62) - (if (isPrerelease) prerelease else stable).add(row) + if (!row.tag.startsWith("v")) continue + all.add(row) + (if (isPrerelease || "-pre" in row.tag) prerelease else stable).add(row) } - ReleaseChannels(stable, prerelease) + ReleaseChannels(stable, prerelease, all) } catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index 3fad171f2..20714b552 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -47,6 +47,53 @@ object DesktopCatalog { private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id") + private const val JOURNAL_SUFFIX = ".installing" + + private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX") + + /** Packages whose extraction over the runtime started and never finished. */ + fun interrupted(context: Context): List = interrupted(LinuxRuntime.rootDir(context)) + + fun interrupted(root: File): List = + root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty() + .sortedBy { it.name } + .mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } } + + private val installLock = Any() + + private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download") + + /** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */ + fun pruneDownloads(context: Context) = synchronized(installLock) { + val keep = interrupted(context).map { downloadFile(context, it).name }.toSet() + context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") } + ?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() } + } + + fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) { + for (entry in interrupted(context)) { + Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}") + install(context, entry, listener)?.let { return it } + } + null + } + + internal fun entryJson(e: Entry): JSONObject = JSONObject() + .put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind) + .put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes) + .put("icon", e.icon).put("category", e.category) + + internal fun entryFrom(o: JSONObject) = Entry( + o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"), + o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""), + o.optString("icon", "applications-games"), o.optString("category", "Game"), + ) + + /** A finished download is kept until its extraction completes, so resuming does not fetch it again. */ + private fun downloaded(file: File, entry: Entry): Boolean = + file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() && + (entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true) + fun installed(context: Context, id: String): String? = FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() } @@ -71,7 +118,8 @@ object DesktopCatalog { fun desktopPresent(context: Context): Boolean { val root = LinuxRuntime.rootDir(context) - return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists() + return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists() || + journal(root, DESKTOP_ID).exists() } fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context) @@ -79,25 +127,54 @@ object DesktopCatalog { fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) = DesktopRemoval.cleanLegacy(context, listener) - fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = + fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = synchronized(installLock) { DesktopRemoval.remove(context, listener) + } - fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? { + internal fun discardDesktopInstall(context: Context) { + val entry = interrupted(context).firstOrNull { it.id == DESKTOP_ID } + val journal = journal(LinuxRuntime.rootDir(context), DESKTOP_ID) + entry?.let { + val download = downloadFile(context, it) + for (file in listOf(download, File(download.path + ".resume.properties"))) { + check(!file.exists() || file.delete()) { "Could not remove desktop download ${file.name}" } + } + } + check(!journal.exists() || journal.delete()) { "Could not clear desktop installation" } + } + + fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) { + installLocked(context, entry, listener) + } + + private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? { val root = LinuxRuntime.rootDir(context) if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing) if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name) - val download = File(context.cacheDir, "pkg-${entry.id}.download") - try { - val downloading = context.getString(R.string.user_apps_downloading, entry.name) - listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0) - val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f -> - listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f)) + val download = downloadFile(context, entry) + val journal = journal(root, entry.id) + var keepDownload = false + val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name)) + val report = object : LinuxRuntimeInstaller.ProgressListener { + override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent) + override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) { + RuntimeInstallService.packageProgress(stage, percent) + listener?.onProgress(step, stage, percent) } - if (!ok) return context.getString(R.string.user_apps_download_failed) - listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1) - val actual = Hashes.sha256(download) - if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch) - listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1) + } + try { + if (!downloaded(download, entry)) { + val downloading = context.getString(R.string.user_apps_downloading, entry.name) + report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0) + val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f -> + report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f)) + } + if (!ok) return context.getString(R.string.user_apps_download_failed) + report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1) + val actual = Hashes.sha256(download) + if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch) + } + report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1) when (entry.kind) { "appimage" -> { val dir = File(root, "opt/appimages").apply { mkdirs() } @@ -112,23 +189,28 @@ object DesktopCatalog { "Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n") } else -> { + check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" } + keepDownload = true marker(context, entry.id).delete() - if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed) + if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed) } } if (entry.id == DESKTOP_ID) { - cleanLegacy(context, listener) + cleanLegacy(context, report) val pending = File(root, DesktopRemoval.PENDING) check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" } } FileUtils.writeString(marker(context, entry.id), entry.version) check(installed(context, entry.id) == entry.version) { "Could not finish package installation" } + check(!journal.exists() || journal.delete()) { "Could not finish package installation" } + keepDownload = false return null } catch (e: Exception) { Log.e(TAG, "install ${entry.id}", e) return e.message ?: context.getString(R.string.deskpkg_install_failed) } finally { - download.delete() + if (!keepDownload) download.delete() + release() } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt index 3d031a85a..ff244e6ad 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt @@ -44,9 +44,10 @@ internal object DesktopRemoval { val root = LinuxRuntime.rootDir(context) val pending = File(root, PENDING) val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}") - val version = FileUtils.readString(pending)?.trim() ?: FileUtils.readString(marker)?.trim() + val installing = DesktopCatalog.interrupted(root).firstOrNull { it.id == DesktopCatalog.DESKTOP_ID } + val version = FileUtils.readString(pending)?.trim() ?: installing?.version ?: FileUtils.readString(marker)?.trim() val versions = context.assets.open("desktop-removal/versions.txt").bufferedReader().use { it.readLines() } - check(version in versions) { context.getString(R.string.desktop_removal_unknown) } + check(version in versions && (installing == null || installing.version in versions)) { context.getString(R.string.desktop_removal_unknown) } val entries = lines(context, "kde-remove.tsv.gzip").map(DesktopFileRemoval.Entry::parse) val keep = protected(context, root) cleanLegacy(context, listener) @@ -63,6 +64,7 @@ internal object DesktopRemoval { if (!target.delete()) error("Could not remove $path") } } + DesktopCatalog.discardDesktopInstall(context) if (!pending.delete()) error("Could not finish desktop removal") Log.i("DesktopRemoval", "KDE: $result") listener.onProgress(context.getString(R.string.desktop_removed), 100) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 9c65c426a..8b4f14e5a 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -11,7 +11,7 @@ import java.io.File object GuestCommand { fun run(context: Context, argv: List, fakeRoot: Boolean = false, logName: String? = null, - linkDir: File? = null, onLine: (String) -> Unit): Int { + linkDir: File? = null, extraBinds: List? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int { val log = logName?.let { try { File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter() @@ -19,13 +19,14 @@ object GuestCommand { } log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}") try { - return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) } + return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) } } finally { log?.close() } } - private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int { + private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, extraBinds: List?, + fastPath: Boolean, onLine: (String) -> Unit): Int { val root = LinuxRuntime.rootDir(context) LinuxRuntime.writeAccounts(context) SessionFiles.stage(context, root) @@ -33,8 +34,10 @@ object GuestCommand { val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() } val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot) if (linkDir != null) cmd.add(1, "--link2symlink") - LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null) - .forEach { cmd.add("-b"); cmd.add(it) } + val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds) + binds.forEach { cmd.add("-b"); cmd.add(it) } + // Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path. + val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null cmd += listOf( "/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8", "PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}", @@ -42,6 +45,7 @@ object GuestCommand { "DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}", "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", ) + fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) } cmd += argv val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true) builder.environment().apply { @@ -49,6 +53,7 @@ object GuestCommand { put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path) if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path) if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1") + fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } } LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } } val process = builder.start() diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt index 3bf43fc34..ac87bd0aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -104,7 +104,6 @@ class LinuxNetworkLinkComponent( synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) } } } - wifiReceiver = receiver appContext.registerReceiver(receiver, IntentFilter().apply { addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) addAction(WifiManager.RSSI_CHANGED_ACTION) @@ -112,6 +111,7 @@ class LinuxNetworkLinkComponent( addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION) addAction(LocationManager.MODE_CHANGED_ACTION) }) + wifiReceiver = receiver scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified() scanHandler.post(scanRequests) requestWifiScan() @@ -119,7 +119,13 @@ class LinuxNetworkLinkComponent( override fun stop() { scanHandler.removeCallbacks(scanRequests) - wifiReceiver?.let { appContext.unregisterReceiver(it) } + wifiReceiver?.let { receiver -> + try { + appContext.unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Wi-Fi receiver was already gone", e) + } + } wifiReceiver = null val registered = synchronized(lock) { callback.also { callback = null } } ?: return try { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 288385ce1..65cca5919 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -1,5 +1,6 @@ package com.droiddeck.launcher.runtime; +import com.droiddeck.launcher.core.AddonPack; import android.content.Context; import android.os.Process; import android.system.ErrnoException; @@ -65,14 +66,12 @@ public final class LinuxRuntime { private static final String HOST_DIR = "opt/android-host"; public static File prootBinary(Context context) { - File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so"); - if (packaged.isFile()) return packaged; + if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot.so"); return new File(rootDir(context), HOST_DIR + "/proot"); } public static File prootLoader(Context context) { - File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so"); - if (packaged.isFile()) return packaged; + if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot-loader.so"); return new File(rootDir(context), HOST_DIR + "/loader"); } @@ -199,10 +198,15 @@ public final class LinuxRuntime { } catch (java.io.IOException e) { } } - File fileMax = new File(fakeProc, "file-max"); - if (!fileMax.isFile()) { + String[][] seeded = { + {"file-max", "9223372036854775807\n"}, + {"overcommit_memory", "0\n"}, + }; + for (String[] entry : seeded) { + File fake = new File(fakeProc, entry[0]); + if (fake.isFile()) continue; try { - Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII)); + Files.write(fake.toPath(), entry[1].getBytes(StandardCharsets.US_ASCII)); } catch (IOException e) { } } @@ -217,6 +221,7 @@ public final class LinuxRuntime { {"overflowuid", "/proc/sys/kernel/overflowuid"}, {"overflowgid", "/proc/sys/kernel/overflowgid"}, {"file-max", "/proc/sys/fs/file-max"}, + {"overcommit_memory", "/proc/sys/vm/overcommit_memory"}, }; for (String[] entry : procFiles) { File fake = new File(fakeProc, entry[0]); @@ -226,6 +231,9 @@ public final class LinuxRuntime { bind(cmd, fake.getPath() + ":" + entry[1]); } } + if (unreadablePciDevice()) { + bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/bus/pci/devices"); + } if (com.droiddeck.launcher.gpu.SystemVulkanDriver.isPowerVr()) { bindPowerVrGpuNode(context, cmd); } else { @@ -279,6 +287,15 @@ public final class LinuxRuntime { } } + private static boolean unreadablePciDevice() { + File[] devices = new File("/sys/bus/pci/devices").listFiles(); + if (devices == null) return false; + for (File device : devices) { + if (!new File(device, "vendor").canRead()) return true; + } + return false; + } + private static void bindGpuNode(Context context, List cmd) { String standIn = gpuStandIn(context); if (standIn == null) return; diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index efecb6fb2..d6f6b0076 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -1,5 +1,6 @@ package com.droiddeck.launcher.runtime; +import com.droiddeck.launcher.core.AddonPack; import android.content.Context; import android.util.Log; @@ -261,6 +262,8 @@ public final class LinuxRuntimeInstaller { emptyTrash(LinuxRuntime.rootDir(context).getParentFile()); String downloading = context.getString(R.string.rtinst_downloading); if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0); + if (!AddonPack.PROOT.await(context)) throw new IOException("Cannot download proot"); + if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio"); boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> { if (listener != null) { listener.onProgress(Step.DOWNLOADING, downloading, @@ -456,9 +459,12 @@ public final class LinuxRuntimeInstaller { } } + // Packages extract over the live runtime and their archives list a link before the file it + // names, so a kill mid-way must leave neither a truncated file nor a link to a missing one. public static boolean extract(Context context, File archive, File destination, ProgressListener listener) { long entries = 0; String extracting = context.getString(R.string.rtinst_extracting); + java.util.LinkedHashMap links = new java.util.LinkedHashMap<>(); try (InputStream in = new ZstdCompressorInputStream( new BufferedInputStream(new FileInputStream(archive), 1 << 16)); TarArchiveInputStream tar = new TarArchiveInputStream(in)) { @@ -477,29 +483,35 @@ public final class LinuxRuntimeInstaller { if (parent != null && !parent.isDirectory()) parent.mkdirs(); if (entry.isSymbolicLink()) { - file.delete(); - FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath()); + links.remove(file); + links.put(file, entry.getLinkName()); } else if (entry.isLink()) { File target = ArchivePaths.inside(destination, entry.getLinkName()); if (target == null) { Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName()); continue; } - file.delete(); + links.remove(file); + File part = partFile(file); + part.delete(); try { - Files.createLink(file.toPath(), target.toPath()); + Files.createLink(part.toPath(), target.toPath()); } catch (IOException | UnsupportedOperationException e) { if (target.isFile()) { - Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING); } } + if (part.exists()) place(part, file); } else if (entry.isFile()) { - try (OutputStream out = new FileOutputStream(file)) { + links.remove(file); + File part = partFile(file); + try (OutputStream out = new FileOutputStream(part)) { byte[] buffer = new byte[1 << 16]; int read; while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read); } - if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false); + if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false); + place(part, file); } else { continue; } @@ -507,10 +519,32 @@ public final class LinuxRuntimeInstaller { listener.onProgress(extracting, -1); } } + for (java.util.Map.Entry link : links.entrySet()) { + File file = link.getKey(); + File part = partFile(file); + try { + Files.deleteIfExists(part.toPath()); + Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue())); + place(part, file); + } catch (IOException e) { + Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e); + part.delete(); + } + } return true; } catch (Exception e) { Log.e(TAG, "extract", e); return false; } } + + static File partFile(File file) { + return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode())); + } + + private static void place(File part, File file) throws IOException { + java.nio.file.Path path = file.toPath(); + if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path); + Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt index 08ca03667..94508e372 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt @@ -1,6 +1,7 @@ package com.droiddeck.launcher.runtime import android.content.Context +import com.droiddeck.launcher.core.AddonPack import com.droiddeck.launcher.session.SessionPrefs import java.io.File import java.security.MessageDigest @@ -11,7 +12,7 @@ object ProotFastPath { fun enabled(context: Context): Boolean = SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) && - LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir + LinuxRuntime.prootBinary(context).parentFile == AddonPack.PROOT.dir(context) fun key(root: File, binds: List): String? { if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt new file mode 100644 index 000000000..19ca93d5f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt @@ -0,0 +1,34 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +object RuntimeHealth { + private const val MAX_HOPS = 16 + + fun brokenLibraryLinks(context: Context): List = brokenLibraryLinks(LinuxRuntime.rootDir(context)) + + /** A complete runtime has no library link in usr/lib whose target is missing. */ + fun brokenLibraryLinks(root: File): List { + val lib = File(root, "usr/lib") + val names = lib.list() ?: return emptyList() + return names.sorted().filter { name -> + val path = File(lib, name).toPath() + Files.isSymbolicLink(path) && !resolves(root, path) + } + } + + private fun resolves(root: File, link: Path): Boolean = try { + var path = link + var hops = 0 + while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) { + val target = Files.readSymbolicLink(path) + path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target) + } + !Files.isSymbolicLink(path) && Files.exists(path) + } catch (e: Exception) { + false + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index 95a96b899..d2cee0435 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -19,6 +19,7 @@ class RuntimeInstallService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_PACKAGE) return holdPackage(startId) val removal = intent?.action == ACTION_REMOVE val release = intent?.let(::releaseFrom) startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1)) @@ -44,7 +45,27 @@ class RuntimeInstallService : Service() { return START_NOT_STICKY } - private fun notification(stage: String, percent: Int): Notification { + private fun holdPackage(startId: Int): Int { + startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle)) + Thread({ + val manager = getSystemService(NotificationManager::class.java) + var shown = "" + while (packageHolds.get() > 0) { + val now = "$packageStage:$packagePercent" + if (now != shown) { + shown = now + manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle)) + } + Thread.sleep(500) + } + @Suppress("DEPRECATION") + stopForeground(true) + stopSelf(startId) + }, "package-install").start() + return START_NOT_STICKY + } + + private fun notification(stage: String, percent: Int, title: String? = null): Notification { val manager = getSystemService(NotificationManager::class.java) manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime), NotificationManager.IMPORTANCE_LOW).apply { @@ -58,7 +79,7 @@ class RuntimeInstallService : Service() { PendingIntent.FLAG_IMMUTABLE) return Notification.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_session) - .setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing)) + .setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing)) .setContentText(stage) .setProgress(100, percent.coerceIn(0, 100), percent < 0) .setContentIntent(open) @@ -72,6 +93,11 @@ class RuntimeInstallService : Service() { private const val CHANNEL_ID = "runtime-install" private const val NOTIFICATION_ID = 2 private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME" + private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE" + private val packageHolds = java.util.concurrent.atomic.AtomicInteger() + @Volatile private var packageTitle = "" + @Volatile private var packageStage = "" + @Volatile private var packagePercent = -1 private const val EXTRA_VERSION = "version" private const val EXTRA_URL = "url" private const val EXTRA_SHA256 = "sha256" @@ -91,6 +117,24 @@ class RuntimeInstallService : Service() { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) } + /** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */ + fun holdForPackage(context: Context, title: String): () -> Unit { + packageTitle = title + packageStage = title + packagePercent = -1 + packageHolds.incrementAndGet() + val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE) + runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) } + .onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") } + val released = java.util.concurrent.atomic.AtomicBoolean() + return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() } + } + + fun packageProgress(stage: String, percent: Int) { + packageStage = stage + packagePercent = percent + } + private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? { val version = intent.getStringExtra(EXTRA_VERSION) ?: return null val url = intent.getStringExtra(EXTRA_URL) ?: return null diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt index 445a56348..b7ca15a18 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt @@ -13,6 +13,7 @@ import com.caverock.androidsvg.SVG import com.droiddeck.launcher.R import com.droiddeck.launcher.core.Downloader import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.Hashes import com.droiddeck.launcher.store.FlathubApi import org.json.JSONArray import org.json.JSONObject @@ -44,7 +45,7 @@ object UserApps { val fex: String = LinuxFex.AUTO, val arch: String? = null, ) - class Release(val tag: String, val published: String, val asset: String, val url: String) + class Release(val tag: String, val published: String, val asset: String, val url: String, val sha256: String? = null) sealed class UpdateCheck { class Current(val tag: String) : UpdateCheck() @@ -71,6 +72,9 @@ object UserApps { private fun menuEntry(context: Context, id: String) = File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop") + fun launches(program: String?): Boolean = + program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER + fun list(context: Context): List { val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir -> App( @@ -315,9 +319,12 @@ object UserApps { val r = releases.getJSONObject(i) if (r.optBoolean("draft")) return@mapNotNull null val assets = r.optJSONArray("assets") ?: return@mapNotNull null - val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it.getString("browser_download_url") } } + val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } } pickAsset(byName.keys.toList())?.let { asset -> - r.optBoolean("prerelease") to Release(r.optString("tag_name", asset), r.optString("published_at"), asset, byName.getValue(asset)) + r.optBoolean("prerelease") to byName.getValue(asset).let { a -> + Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"), + Hashes.githubSha256(a.optString("digest"))) + } } } val pick = (candidates.firstOrNull { !it.first } ?: candidates.firstOrNull())?.second @@ -328,7 +335,7 @@ object UserApps { val download = File(context.cacheDir, "github-${release.asset}") try { val label = context.getString(R.string.user_apps_downloading, release.asset) - val ok = Downloader.downloadFile(release.url, download, false) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) } + val ok = Downloader.downloadFile(release.url, download, false, release.sha256) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) } return if (ok) use(download) else context.getString(R.string.user_apps_download_failed) } finally { download.delete() diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt index 3641caec5..8267edd57 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt @@ -30,7 +30,7 @@ object BootMovie { internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS) sealed class Import { - data class Done(val name: String, val audioDropped: Boolean) : Import() + data class Done(val name: String, val audioDropped: Boolean, val id: String = "") : Import() object TooLong : Import() data class Unsupported(val codec: String?) : Import() object Failed : Import() @@ -39,6 +39,21 @@ object BootMovie { private fun dir(context: Context) = File(context.filesDir, "boot-movie") private fun customFile(context: Context) = File(dir(context), "custom.webm") + /** A movie imported before movies were kept in Steam's own folder. */ + internal fun legacyCustomFile(context: Context) = customFile(context) + + /** Unpacks one of DroidDeck's own movies on demand; null for anything else. */ + internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) } + + /** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */ + fun select(context: Context, id: String) { + SessionPrefs.setBootMovie(context, id) + SessionPrefs.setBootMovieApplyNext(context, true) + BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}") + // Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later. + SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) } + } + fun customName(context: Context): String? = if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null @@ -46,21 +61,127 @@ object BootMovie { val chosen = SessionPrefs.bootMovie(context) return when { chosen == CUSTOM && customFile(context).isFile -> CUSTOM + isLibraryId(chosen) && BootMovieLibrary.file(context, chosen) != null -> chosen chosen in ASSETS -> chosen else -> DROIDDECK_30 } } + /** The file the current choice plays from, before any volume or length change. */ + fun currentFile(context: Context): File? = sourceOf(context, choice(context)) + + private fun sourceOf(context: Context, choice: String): File? = when { + choice == CUSTOM -> customFile(context) + isLibraryId(choice) -> BootMovieLibrary.file(context, choice) + else -> ASSETS[choice]?.let { extractAsset(context, it) } + } + + private fun isLibraryId(id: String) = id.startsWith(BootMovieLibrary.PREFIX) || id.startsWith(BootMovieLibrary.STEAM_PREFIX) + fun prepare(context: Context): File? { - val choice = choice(context) - val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null - if (SessionPrefs.bootMovieSound(context)) return source - val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm") - if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted - if (remux(source, muted, audio = false)) return muted - if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted - Log.e(TAG, "could not strip audio from ${source.name}; not staging it") - return null + BootMovieLibrary.restore(context) + val source = sourceOf(context, choice(context)) ?: return null + return variant(context, source) + } + + /** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */ + private fun variant(context: Context, source: File): File? { + val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0 + val seconds = SessionPrefs.bootMovieSeconds(context) + val folder = File(dir(context), "variants") + val plan = if (isOversized(source)) oversizedPlan(volume) else plan(volume, seconds) + if (plan.passthrough) { + prune(folder, null) + return source + } + folder.mkdirs() + val limitUs = if (seconds > 0) seconds * 1_000_000L else Long.MAX_VALUE + val out = File(folder, BootMovieLibrary.variantPrefix(source) + "v$volume-s$seconds.webm") + if (out.isFile && out.length() > 0L && out.lastModified() >= source.lastModified()) { + prune(folder, out.name) + return out + } + val staged = File(folder, out.name + ".staged") + val ok = runCatching { + plan.steps.any { step -> + when (step) { + Step.REMUX_SILENT -> remux(source, staged, audio = false, limitUs) + Step.REMUX_AUDIO -> remux(source, staged, audio = true, limitUs) + Step.TRANSCODE_SILENT -> transcodeSilent(source, staged, limitUs) + Step.TRANSCODE_AUDIO -> BootMovieTranscoder.transcode(source, staged, limitUs = limitUs, gain = volume / 100f) {} != null + Step.REGAIN -> BootMovieTranscoder.regain(source, staged, volume / 100f, limitUs) + } + } + }.getOrDefault(false) + if (ok && staged.renameTo(out)) { + prune(folder, out.name) + return out + } + staged.delete() + Log.e(TAG, "could not prepare ${source.name} (volume $volume, $seconds s)") + return if (plan.sourceOnFailure) source else null + } + + internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN } + + /** + * How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and + * whether it still plays as it is when every step fails. + */ + internal class Plan(val passthrough: Boolean, val steps: List, val sourceOnFailure: Boolean) + + internal fun plan(volume: Int, seconds: Int): Plan = when { + volume == 100 && seconds == 0 -> Plan(true, emptyList(), true) + volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false) + // Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder. + volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true) + else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false) + } + + /** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */ + internal const val MAX_LONG_SIDE = 1920 + internal const val MAX_SHORT_SIDE = 1080 + + internal fun oversized(width: Int, height: Int) = maxOf(width, height) > MAX_LONG_SIDE || minOf(width, height) > MAX_SHORT_SIDE + + private fun oversized(format: MediaFormat) = + format.containsKey(MediaFormat.KEY_WIDTH) && format.containsKey(MediaFormat.KEY_HEIGHT) && + oversized(format.getInteger(MediaFormat.KEY_WIDTH), format.getInteger(MediaFormat.KEY_HEIGHT)) + + private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true + + /** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */ + internal fun oversizedPlan(volume: Int): Plan = + Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false) + + private fun transcodeSilent(source: File, target: File, limitUs: Long) = + BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null + + /** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */ + private fun prune(folder: File, keep: String?) { + folder.listFiles()?.forEach { if (it.name != keep) it.delete() } + } + + /** + * Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before, + * that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed. + */ + fun syncFromSteam(context: Context): Boolean { + val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false + if (path == SessionPrefs.bootMovieSteamSeen(context)) return false + SessionPrefs.setBootMovieSteamSeen(context, path) + if (path == SessionFiles.BOOT_MOVIE_PATH) return false + val prefix = "/uioverrides/movies/" + // The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder. + val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else { + val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) } + listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path + } + if (BootMovieLibrary.file(context, id) == null) return false + val changed = SessionPrefs.bootMovie(context) != id + BootMovieReport.note(context, "Steam names $path, taken over as $id") + SessionPrefs.setBootMovie(context, id) + return changed } private fun extractAsset(context: Context, name: String): File? { @@ -82,11 +203,15 @@ object BootMovie { } } - fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import { + fun import( + context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}, + title: String? = null, source: String = BootMovieLibrary.SOURCE_IMPORT, repoId: String? = null, + cover: ByteArray? = null, use: Boolean = true, + ): Import { val (format, audio) = probe(file) ?: return Import.Unsupported(null) val video = format.getString(MediaFormat.KEY_MIME) val audioPlayable = audio == null || audio in AUDIO_CODECS - val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES + val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES && !oversized(format) val copy = playable && audioPlayable && isWebm(file) val remux = !copy && playable && video in REMUX_CODECS if (!copy && !remux) { @@ -95,11 +220,12 @@ object BootMovie { if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong onStart() } - val target = customFile(context) - val staged = File(dir(context), "custom.webm.staged") + val shown = title?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension + val library = BootMovieLibrary.libraryDir(context) + val staged = File(library, ".import-${System.nanoTime()}.staged") var audioDropped = remux && !audioPlayable return try { - target.parentFile?.mkdirs() + library.mkdirs() val ok = when { copy -> { file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } } @@ -108,11 +234,10 @@ object BootMovie { remux -> remux(file, staged, audio = true) else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null } - if (!ok || !staged.renameTo(target)) return Import.Failed - File(target.parentFile, "custom-muted.webm").delete() - SessionPrefs.setBootMovieCustomName(context, file.name) - SessionPrefs.setBootMovie(context, CUSTOM) - Import.Done(file.name, audioDropped) + if (!ok || !staged.setReadable(true, false)) return Import.Failed + val name = BootMovieLibrary.commit(context, staged, shown, source, repoId, cover) ?: return Import.Failed + if (use) select(context, BootMovieLibrary.idOf(name)) + Import.Done(shown, audioDropped, BootMovieLibrary.idOf(name)) } catch (e: Exception) { Log.w(TAG, "could not import ${file.name}", e) Import.Failed @@ -124,8 +249,9 @@ object BootMovie { fun removeCustom(context: Context) { customFile(context).delete() File(dir(context), "custom-muted.webm").delete() + File(dir(context), "variants").listFiles { f -> f.name.startsWith(BootMovieLibrary.variantPrefix(customFile(context))) }?.forEach { it.delete() } SessionPrefs.setBootMovieCustomName(context, null) - if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30) + if (SessionPrefs.bootMovie(context) == CUSTOM) select(context, DROIDDECK_30) } private fun isWebm(file: File): Boolean { @@ -156,7 +282,7 @@ object BootMovie { } } - private fun remux(source: File, target: File, audio: Boolean): Boolean { + private fun remux(source: File, target: File, audio: Boolean, limitUs: Long = Long.MAX_VALUE): Boolean { val staged = File(target.parentFile, target.name + ".remux") val extractor = MediaExtractor() var muxer: MediaMuxer? = null @@ -183,7 +309,7 @@ object BootMovie { val info = MediaCodec.BufferInfo() while (true) { val size = extractor.readSampleData(buffer, 0) - if (size < 0) break + if (size < 0 || extractor.sampleTime > limitUs) break val track = tracks[extractor.sampleTrackIndex] if (track != null) { val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0 diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt new file mode 100644 index 000000000..7c125e819 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt @@ -0,0 +1,410 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.media.MediaMetadataRetriever +import android.util.Log +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONObject +import java.io.File + +/** + * The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in + * Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own + * three startup movies and the movies bought in Steam's points shop (neither can be removed). + */ +object BootMovieLibrary { + private const val TAG = "BootMovieLibrary" + const val PREFIX = "file:" + + /** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:`. */ + const val STEAM_PREFIX = "steam:" + + /** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */ + private val lock = Any() + + /** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */ + internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm") + + const val SOURCE_DROIDDECK = "droiddeck" + const val SOURCE_IMPORT = "import" + const val SOURCE_REPO = "repo" + const val SOURCE_STEAM = "steam" + const val SOURCE_POINTSHOP = "pointshop" + + class Entry(val id: String, val title: String, val source: String, val file: File?, val removable: Boolean) + + internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies") + + /** + * The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the + * x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file. + */ + internal fun customMovieDirs(context: Context): List = + listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path } + + /** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */ + internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library") + + private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json") + + internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + + /** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */ + internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf") + + private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs") + + fun idOf(name: String) = PREFIX + name + + /** Every Steam client folder: the usual one and the x86-64 client's. */ + internal fun steamRoots(context: Context): List = listOf( + steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"), + ).filter { it.isDirectory } + + private fun steamFile(context: Context, id: String): File? { + val path = id.removePrefix(STEAM_PREFIX) + if (!path.startsWith("/") || path.contains("..") || path.contains('\\') || !path.endsWith(".webm", ignoreCase = true)) return null + return steamRoots(context).firstNotNullOfOrNull { root -> + listOf("config", "steamui", "").map { File(root, it + path) }.firstOrNull { it.isFile && it.length() > 0L } + } + } + + fun file(context: Context, id: String): File? { + if (id.startsWith(STEAM_PREFIX)) return steamFile(context, id) + if (!id.startsWith(PREFIX)) return null + val name = id.removePrefix(PREFIX) + if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return null + return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L } + } + + private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) && + file.name !in STAGED && !file.name.startsWith(".") + + private fun readMeta(context: Context): JSONObject = + runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject()) + + /** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */ + private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) { + val meta = readMeta(context) + if (change(meta)) { + val file = metaFile(context) + file.parentFile?.mkdirs() + val staged = File(file.parentFile, file.name + ".staged") + runCatching { + staged.writeText(meta.toString()) + if (!staged.renameTo(file)) staged.delete() + }.onFailure { staged.delete(); Log.w(TAG, "could not write the movie list", it) } + } + } + + /** The post a movie was installed from, if it came from SteamDeckRepo. */ + fun repoId(context: Context, name: String): String? = + readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() } + + fun installedRepoIds(context: Context): Set { + val meta = readMeta(context) + return meta.keys().asSequence().filter { file(context, idOf(it)) != null } + .mapNotNull { meta.optJSONObject(it)?.optString("repo")?.takeIf { id -> id.isNotEmpty() } }.toSet() + } + + fun nameForRepo(context: Context, repoId: String): String? { + val meta = readMeta(context) + return meta.keys().asSequence().firstOrNull { file(context, idOf(it)) != null && meta.optJSONObject(it)?.optString("repo") == repoId } + } + + fun list(context: Context): List { + val out = ArrayList() + out.add(Entry(BootMovie.DROIDDECK_30, "DroidDeck · 30 fps", SOURCE_DROIDDECK, null, removable = false)) + out.add(Entry(BootMovie.DROIDDECK_60, "DroidDeck · 60 fps", SOURCE_DROIDDECK, null, removable = false)) + BootMovie.customName(context)?.let { out.add(Entry(BootMovie.CUSTOM, it, SOURCE_IMPORT, BootMovie.legacyCustomFile(context), removable = true)) } + restore(context) + val meta = readMeta(context) + val names = HashSet() + customMovieDirs(context).flatMap { dir -> dir.listFiles { f -> isMovie(f) }.orEmpty().toList() } + .filter { names.add(it.name) }.sortedBy { it.name.lowercase() }.forEach { f -> + val info = meta.optJSONObject(f.name) + val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM + val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension + // Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand). + out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true)) + } + val steam = LinkedHashMap() + SessionFiles.startupMoviePath(steamConfig(context))?.takeIf { !it.startsWith(OVERRIDES) }?.let { path -> + steamFile(context, STEAM_PREFIX + path)?.let { steam[STEAM_PREFIX + path] = it } + } + val bought = HashMap() + for (root in steamRoots(context)) { + File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty() + .sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) } + val scan = pointShopMovies(root) + bought.putAll(scan.titles) + for (found in scan.files) steam.putIfAbsent(STEAM_PREFIX + "/" + found.relativeTo(root).invariantSeparatorsPath, found) + } + val seen = HashSet() + for ((id, file) in steam) { + if (file.name in STAGED || !seen.add(file.canonicalPath)) continue + val native = file.nameWithoutExtension.lowercase() in NATIVE + // Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it. + val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension + out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false)) + } + return out + } + + /** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */ + private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup") + + private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache") + + private val scanLock = Any() + private class Scan(val at: Long, val files: List, val titles: Map) + + private val scans = HashMap() + + /** Forgets the last search for Steam's own movies. */ + internal fun forgetScan() = synchronized(scanLock) { scans.clear() } + + /** + * Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the + * client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder. + */ + private fun pointShopMovies(root: File): Scan = synchronized(scanLock) { + val now = System.currentTimeMillis() + scans[root.path]?.let { if (now - it.at < 15_000L) return it } + val found = listOf("config", "userdata", "appcache", "steamui").flatMap { top -> + val base = File(root, top) + if (!base.isDirectory) return@flatMap emptyList() + base.walkTopDown().maxDepth(5) + .onEnter { dir -> dir.name !in skippedFolders && !(top == "steamui" && dir.parentFile == base && dir.name == "movies") } + .take(20_000).filter { isMovie(it) }.toList() + }.sortedBy { it.path.lowercase() } + val titles = HashMap() + for (file in found) boughtId(file)?.let { id -> boughtTitle(root, id)?.let { titles[file.path] = it } } + val scan = Scan(now, found, titles) + scans[root.path] = scan + scan + } + + /** The item number in the name Steam gives a bought movie, `_.webm`; null for any other name. */ + private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1) + + /** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */ + private fun boughtTitle(root: File, id: String): String? { + val key = "communityitemid\\\":\\\"$id\\\"" + val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)") + for (user in File(root, "userdata").listFiles { f -> f.isDirectory }.orEmpty()) { + val text = runCatching { File(user, "config/localconfig.vdf").readText() }.getOrNull() ?: continue + val at = text.indexOf(key) + if (at < 0) continue + title.find(text.substring(at, (at + 3000).coerceAtMost(text.length)))?.groupValues?.get(1)?.takeIf { it.isNotBlank() }?.let { return it } + } + return null + } + + private const val OVERRIDES = "/uioverrides/movies/" + + /** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */ + internal fun restore(context: Context) = synchronized(lock) { + migrateLegacyMeta(context) + val meta = readMeta(context) + val library = libraryDir(context) + meta.keys().asSequence().toList().forEach { name -> + val kept = File(library, name) + if (name.contains('/') || !kept.isFile || kept.length() == 0L) return@forEach + val copy = File(moviesDir(context), name) + if (!copy.isFile || copy.length() != kept.length()) mirror(kept, copy) + } + } + + /** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */ + private fun migrateLegacyMeta(context: Context) { + val legacy = File(moviesDir(context), ".droiddeck-movies.json") + if (!legacy.isFile) return + val old = runCatching { JSONObject(legacy.readText()) }.getOrNull() ?: return + editMeta(context) { meta -> + var changed = false + old.keys().asSequence().toList().forEach { name -> + val info = old.optJSONObject(name) ?: return@forEach + if (meta.optJSONObject(name) == null && File(moviesDir(context), name).isFile) { meta.put(name, info); changed = true } + } + changed + } + legacy.delete() + } + + /** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */ + private fun mirror(kept: File, copy: File): Boolean { + val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged") + return try { + copy.parentFile?.mkdirs() + kept.inputStream().use { input -> staged.outputStream().use { output -> input.copyTo(output) } } + staged.setReadable(true, false) && staged.renameTo(copy) + } catch (e: Exception) { + Log.w(TAG, "could not copy ${kept.name} into Steam's movies", e) + false + } finally { + staged.delete() + } + } + + fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id + + /** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */ + fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id) + + /** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */ + fun freeName(context: Context, title: String): String { + val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" } + val dirs = customMovieDirs(context) + val library = libraryDir(context) + var name = "$base.webm" + var n = 2 + while (dirs.any { File(it, name).exists() } || File(library, name).exists() || name in STAGED) name = "$base ($n).webm".also { n++ } + return name + } + + /** + * Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its + * title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time. + */ + internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? = + synchronized(lock) { + val name = freeName(context, title) + val kept = File(libraryDir(context), name) + if (!staged.renameTo(kept)) return null + if (!mirror(kept, File(moviesDir(context), name))) { kept.delete(); return null } + register(context, name, title, source, repoId, cover) + name + } + + /** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */ + fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) { + editMeta(context) { meta -> + val info = JSONObject().put("title", title).put("source", source) + if (repoId != null) info.put("repo", repoId) + meta.put(name, info) + true + } + if (cover != null) runCatching { + thumbsDir(context).mkdirs() + File(thumbsDir(context), coverName(name)).writeBytes(cover) + } + } + + fun remove(context: Context, id: String): Boolean { + val removed = when { + id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true } + else -> { + // Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone. + if (!id.startsWith(PREFIX)) return false + val name = id.removePrefix(PREFIX) + if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false + val copies = customMovieDirs(context).map { File(it, name) } + // A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted. + val target = copies.firstOrNull { it.exists() } + val managed = File(libraryDir(context), name) + if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false + synchronized(lock) { + val main = File(moviesDir(context), name) + val another = copies.any { it != target && it.exists() } + val ownsRecord = target == null || target.path == main.path || (!another && !managed.isFile) + var gone = target == null || target.delete() + if (gone && ownsRecord) { + gone = !managed.exists() || managed.delete() + if (gone) { + editMeta(context) { it.remove(name) != null } + File(thumbsDir(context), coverName(name)).delete() + thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() } + } + } + if (gone) { + val prefixes = listOfNotNull(target, main.takeIf { ownsRecord }).map { variantPrefix(it) } + File(context.filesDir, "boot-movie/variants").listFiles { f -> prefixes.any { f.name.startsWith(it) } }?.forEach { it.delete() } + } + gone + } + } + } + if (removed && SessionPrefs.bootMovie(context) == id) BootMovie.select(context, BootMovie.DROIDDECK_30) + return removed + } + + /** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */ + internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-" + + private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_") + + private fun coverName(name: String) = "cover-" + safe(name) + + /** How long a movie runs, in whole seconds; 0 when it cannot be read. */ + fun durationSeconds(file: File?): Int { + if (file == null || !file.isFile) return 0 + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.path) + val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + ((ms + 500) / 1000).toInt() + } catch (e: Exception) { + 0 + } finally { + runCatching { retriever.release() } + } + } + + /** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */ + private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? { + var best: Bitmap? = null + var bestLuma = -1 + for (fraction in doubleArrayOf(0.33, 0.5, 0.66, 0.2, 0.8, 0.1, 0.9)) { + val frame = runCatching { retriever.getFrameAtTime((durationMs * 1000L * fraction).toLong(), MediaMetadataRetriever.OPTION_CLOSEST) }.getOrNull() ?: continue + val luma = averageLuma(frame) + if (luma >= MIN_COVER_LUMA) return frame + if (luma > bestLuma) { best = frame; bestLuma = luma } + } + return best ?: runCatching { retriever.frameAtTime }.getOrNull() + } + + private fun averageLuma(bitmap: Bitmap): Int { + val small = Bitmap.createScaledBitmap(bitmap, 16, 9, true) + var sum = 0L + for (y in 0 until small.height) for (x in 0 until small.width) { + val c = small.getPixel(x, y) + sum += (((c shr 16) and 0xFF) * 3 + ((c shr 8) and 0xFF) * 6 + (c and 0xFF)) / 10 + } + return (sum / (small.width * small.height)).toInt() + } + + private const val MIN_COVER_LUMA = 24 + + /** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */ + fun cover(context: Context, entry: Entry): File? { + val file = fileOf(context, entry) ?: return null + val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id + // Points Shop covers once kept a picture that was not always the movie's own; those files are not used. + val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name)) + if (given.isFile && given.length() > 0L) return given + val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg" + val frame = File(thumbsDir(context), key) + if (frame.isFile && frame.length() > 0L) return frame + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.path) + val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + val bitmap = brightestFrame(retriever, ms) ?: return null + val width = 480 + val scaled = if (bitmap.width > width) Bitmap.createScaledBitmap(bitmap, width, (bitmap.height * width / bitmap.width).coerceAtLeast(1), true) else bitmap + thumbsDir(context).mkdirs() + thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() } + val staged = File(thumbsDir(context), "$key.staged") + staged.outputStream().use { scaled.compress(CompressFormat.JPEG, 82, it) } + if (staged.renameTo(frame)) frame else null + } catch (e: Exception) { + Log.w(TAG, "no cover for ${file.name}", e) + null + } finally { + runCatching { retriever.release() } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt new file mode 100644 index 000000000..3a672dcfd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt @@ -0,0 +1,160 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.Environment +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and + * where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config. + */ +object BootMovieReport { + private const val TAG = "BootMovieReport" + private const val MAX_TRACE_LINES = 200 + private const val MAX_HITS = 60 + private val KNOWN_TITLES = listOf("Turret Factory") + private val SKIPPED = setOf("steamapps", "compatdata", "shadercache", "logs") + private val BINARY = setOf("webm", "jpg", "jpeg", "png", "ico", "mp4", "ogg", "mp3", "wav", "ttf", "woff", "woff2", "gif", "webp", "bin", "so", "dll", "exe", "zip", "pak") + private val lock = Any() + + private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log") + + /** Adds a line to the short trace kept for the report; only the latest lines are kept. */ + fun note(context: Context, line: String) { + synchronized(lock) { + runCatching { + val file = traceFile(context) + file.parentFile?.mkdirs() + val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) + val lines = (if (file.isFile) file.readLines() else emptyList()) + "$stamp $line" + file.writeText(lines.takeLast(MAX_TRACE_LINES).joinToString("\n") + "\n") + } + } + } + + /** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */ + fun write(context: Context): File? { + val text = runCatching { build(context) }.getOrElse { + Log.w(TAG, "could not build the report", it) + return null + } + val folders = listOfNotNull( + File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck"), + context.getExternalFilesDir(null), + ) + for (folder in folders) { + val file = File(folder, "boot-movie-report.txt") + val saved = runCatching { folder.mkdirs(); file.writeText(text); true }.getOrDefault(false) + if (saved) return file + Log.w(TAG, "could not write the report to ${file.path}") + } + return null + } + + private fun describe(file: File) = + "${file.path} ${file.length()} bytes ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(file.lastModified()))}" + + internal fun build(context: Context): String { + val out = StringBuilder() + fun line(text: String = "") { out.append(text).append('\n') } + fun listing(title: String, folder: File) { + line("-- $title: ${folder.path}") + val files = folder.listFiles()?.sortedBy { it.name.lowercase() } + if (files == null) line(" (missing)") else if (files.isEmpty()) line(" (empty)") + else files.forEach { line(" " + describe(it)) } + } + + line("DroidDeck boot movie report, ${Date()}") + line() + line("== DroidDeck state") + line("choice=${BootMovie.choice(context)} saved choice=${SessionPrefs.bootMovie(context)} pending pick=${SessionPrefs.bootMovieApplyNext(context)}") + line("last Steam path seen=${SessionPrefs.bootMovieSteamSeen(context)}") + line("sound=${SessionPrefs.bootMovieSound(context)} volume=${SessionPrefs.bootMovieVolume(context)} seconds=${SessionPrefs.bootMovieSeconds(context)}") + line("session running=${SessionState.running}") + line() + line("== The movie list as shown") + BootMovieLibrary.list(context).forEach { line("${it.id} title=${it.title} source=${it.source} removable=${it.removable} file=${it.file?.path}") } + line() + line("== DroidDeck folders") + listing("library", BootMovieLibrary.libraryDir(context)) + listing("processed copies", File(context.filesDir, "boot-movie/variants")) + val meta = File(context.filesDir, "boot-movie/movies.json") + line("-- movie list file: ${if (meta.isFile) meta.readText().take(8000) else "(missing)"}") + + val roots = BootMovieLibrary.steamRoots(context) + val movieIds = HashSet() + for (root in roots) { + line() + line("== Steam folder ${root.path}") + val config = File(root, "config/config.vdf") + line("config.vdf: ${if (config.isFile) describe(config) else "(missing)"}") + val text = runCatching { config.readText() }.getOrNull() + if (text != null) { + line("-- StartupMovie block:") + (SessionFiles.startupMovieBlock(config) ?: " (none)").lines().forEach { line(" $it") } + line("-- Customization section with the lines before it:") + (SessionFiles.customizationBlock(config) ?: " (none)").lines().forEach { line(" $it") } + line("-- every line of config.vdf that mentions a movie:") + text.lines().filter { it.contains("movie", ignoreCase = true) }.take(40).forEach { line(" ${it.trim()}") } + Regex("\"MovieID\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)?.takeIf { it.isNotEmpty() && it != "0" }?.let { movieIds.add(it) } + } + listing("custom movies", File(root, "config/uioverrides/movies")) + listing("Steam's own movies", File(root, "steamui/movies")) + listing("config/movies", File(root, "config/movies")) + line("-- every .webm below config, userdata, appcache and steamui:") + var shown = 0 + for (top in listOf("config", "userdata", "appcache", "steamui")) { + val base = File(root, top) + if (!base.isDirectory) continue + for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) { + if (!file.isFile || !file.name.endsWith(".webm", ignoreCase = true)) continue + if (shown++ >= 300) break + line(" " + describe(file)) + Regex("^(\\d+)_[0-9a-fA-F]+").find(file.name)?.groupValues?.get(1)?.let { movieIds.add(it) } + } + } + } + + line() + line("== Where Steam keeps movie names (text found in Steam's files)") + val needles = (KNOWN_TITLES + movieIds).distinct() + line("looking for: ${needles.joinToString(", ")}") + search(roots, needles) { line(" $it") } + + line() + line("== What each session start did to Steam's config (latest last)") + val trace = traceFile(context) + if (trace.isFile) trace.readLines().forEach { line(" $it") } else line(" (nothing yet: start a session after picking a movie)") + return out.toString() + } + + private fun search(roots: List, needles: List, report: (String) -> Unit) { + if (needles.isEmpty()) return + val deadline = System.currentTimeMillis() + 25_000L + var scanned = 0 + var hits = 0 + outer@ for (root in roots) { + for (top in listOf("config", "userdata", "appcache", "steamui")) { + val base = File(root, top) + if (!base.isDirectory) continue + for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) { + if (!file.isFile || file.length() > (2L shl 20) || file.extension.lowercase() in BINARY) continue + if (++scanned > 20_000 || System.currentTimeMillis() > deadline || hits >= MAX_HITS) break@outer + val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: continue + for (needle in needles) { + val at = text.indexOf(needle) + if (at < 0) continue + hits++ + val around = text.substring((at - 60).coerceAtLeast(0), (at + needle.length + 100).coerceAtMost(text.length)) + report("$needle in ${file.path}: " + around.map { if (it in ' '..'~') it else '.' }.joinToString("")) + } + } + } + } + report("(searched $scanned files, $hits matches)") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt index ff1275d85..92bbbae4e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt @@ -37,15 +37,77 @@ internal object BootMovieTranscoder { MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null }.getOrDefault(false) - fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? { + fun transcode(source: File, target: File, keepAudio: Boolean = true, limitUs: Long = Long.MAX_VALUE, gain: Float = 1f, onProgress: (Int) -> Unit): Result? { val hadAudio = keepAudio && trackFormat(source, "audio/") != null - val audio = if (hadAudio) runCatching { encodeAudio(source) } + val audio = if (hadAudio) runCatching { encodeAudio(source, gain, limitUs) } .onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null - val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) } + val muxedAudio = runCatching { encodeVideo(source, target, audio, limitUs, onProgress) } .onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null return Result(audioDropped = hadAudio && !muxedAudio) } + /** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */ + fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean { + val audio = runCatching { encodeAudio(source, gain, limitUs) } + .onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false + val extractor = MediaExtractor() + var muxer: MediaMuxer? = null + val staged = File(target.parentFile, target.name + ".regain") + return try { + extractor.setDataSource(source.path) + var videoTrack = -1 + var videoFormat: MediaFormat? = null + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME) ?: continue + if (videoTrack < 0 && (mime == MediaFormat.MIMETYPE_VIDEO_VP8 || mime == MediaFormat.MIMETYPE_VIDEO_VP9)) { + videoTrack = i + videoFormat = format + } + } + if (videoTrack < 0 || videoFormat == null) return false + extractor.selectTrack(videoTrack) + val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it } + val outVideo = mux.addTrack(videoFormat) + val outAudio = mux.addTrack(audio.format) + mux.start() + val info = MediaCodec.BufferInfo() + var audioIndex = 0 + fun writeAudioUpTo(timeUs: Long) { + while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) { + val sample = audio.samples[audioIndex++] + info.set(0, sample.data.size, sample.timeUs, 0) + mux.writeSampleData(outAudio, ByteBuffer.wrap(sample.data), info) + } + } + var bufferSize = 8 shl 20 + if (videoFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)) + val buffer = ByteBuffer.allocate(bufferSize) + while (true) { + val size = extractor.readSampleData(buffer, 0) + if (size < 0 || extractor.sampleTime > limitUs) break + val time = extractor.sampleTime + writeAudioUpTo(time) + val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0 + info.set(0, size, time, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0) + mux.writeSampleData(outVideo, buffer, info) + extractor.advance() + } + writeAudioUpTo(Long.MAX_VALUE) + mux.stop() + mux.release() + muxer = null + staged.renameTo(target) + } catch (e: Exception) { + Log.w(TAG, "could not change the volume of ${source.name}", e) + false + } finally { + runCatching { muxer?.release() } + extractor.release() + staged.delete() + } + } + private fun trackFormat(source: File, prefix: String): Pair? { val extractor = MediaExtractor() return try { @@ -75,6 +137,14 @@ internal object BootMovieTranscoder { return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch) } + /** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */ + internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray { + val frames = pcm.size / channels + val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt() + if (gain == 1f && kept == frames) return pcm + return ShortArray(kept * channels) { (pcm[it] * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() } + } + internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray { if (rate == OPUS_RATE) return pcm val frames = pcm.size / channels @@ -94,7 +164,10 @@ internal object BootMovieTranscoder { return out } - private fun encodeAudio(source: File): Audio? { + /** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */ + private const val MAX_PCM_BYTES = 64 shl 20 + + private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? { val (track, inFormat) = trackFormat(source, "audio/") ?: return null val extractor = MediaExtractor() var decoder: MediaCodec? = null @@ -139,13 +212,16 @@ internal object BootMovieTranscoder { } dec.releaseOutputBuffer(index, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break + // Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped. + if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break + if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break } } } if (channels !in 1..2) return null val raw = pcm.toByteArray() val shorts = ShortArray(raw.size / 2) ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts) - val samples = resample(shorts, channels, rate) + val samples = scale(resample(shorts, channels, rate), channels, gain, limitUs) val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels) format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000) @@ -201,7 +277,7 @@ internal object BootMovieTranscoder { private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() } - private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean { + private fun encodeVideo(source: File, target: File, audio: Audio?, limitUs: Long, onProgress: (Int) -> Unit): Boolean { val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track") val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360) val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation) @@ -278,7 +354,8 @@ internal object BootMovieTranscoder { val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US) if (index >= 0) { val timeUs = decInfo.presentationTimeUs - val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs) + val pastLimit = timeUs > limitUs + val keep = decInfo.size > 0 && !pastLimit && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs) dec.releaseOutputBuffer(index, keep) if (keep) { if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out") @@ -290,9 +367,10 @@ internal object BootMovieTranscoder { if (percent != lastProgress) { lastProgress = percent; onProgress(percent) } } } - if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + if (pastLimit || decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { enc.signalEndOfInputStream() decoderDone = true + inputDone = true } } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index 78ff63c1a..40a0f18c8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -26,6 +26,8 @@ import java.io.FileOutputStream object ComponentsManager { private const val TAG = "Components" private const val NIGHTLIES = "The412Banner/Nightlies" + private const val DD_COMPONENTS = "Droid-Deck/DroidDeck-Components" + internal const val DD_NIGHTLY = "linux-nightly" private val RELEASES = linkedMapOf( "FexCore-Linux" to "fex", "Dxvk-Linux" to "dxvk", @@ -33,6 +35,10 @@ object ComponentsManager { "Dxvk-gplasync-Linux" to "dxvk", "Vkd3d-proton-Linux" to "vkd3d", ) + // DD_NIGHTLY holds every component in one release; the file name says which. + private val NIGHTLY_PREFIXES = linkedMapOf("DD-dxvk-" to "dxvk", "DD-vkd3d-proton-" to "vkd3d", "DD-FEXCore-" to "fex") + private val SOURCES = RELEASES.keys.map { NIGHTLIES to it } + (DD_COMPONENTS to DD_NIGHTLY) + private val DOWNLOAD_REPOS = listOf(NIGHTLIES, DD_COMPONENTS) private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d") private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux") val COMPONENTS = listOf("fex", "dxvk", "vkd3d") @@ -835,14 +841,15 @@ object ComponentsManager { if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) } if (!force) return Catalog(emptyList(), 0) val items = JSONArray() - for ((tag, comp) in RELEASES) { - val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue + for ((repo, tag) in SOURCES) { + val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue for (i in 0 until assets.length()) { val a = assets.getJSONObject(i) val name = a.optString("name") if (!name.endsWith(".wcp")) continue if (!Hashes.isGithubSha256(a.optString("digest"))) continue + val comp = assetComp(tag, name) ?: continue items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag) .put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest"))) } @@ -857,10 +864,32 @@ object ComponentsManager { val arr = o.optJSONArray("items") ?: JSONArray() val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map { CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest")) - }.sortedWith(compareBy { it.comp }.then(newestFirst { it.file })) + }.sortedWith(CATALOG_ORDER) return Catalog(items, o.optLong("fetchedAt")) } + internal fun assetComp(release: String, name: String): String? = + RELEASES[release] ?: if (release == DD_NIGHTLY) NIGHTLY_PREFIXES.entries.firstOrNull { name.startsWith(it.key) }?.value else null + + // Per component: the stable packages newest first, then the nightlies. + internal val CATALOG_ORDER: Comparator = + compareBy { it.comp }.thenBy { it.release == DD_NIGHTLY }.then(newestFirst { it.file }) + + // A nightly DD--[+N]-nightly.wcp pairs with the newest stable -[-]-linux.wcp + // in the same list, so the stable row can offer it behind a Stable/Nightly switch. Unpaired nightlies keep their own row. + internal fun nightlyPairs(items: List): Map { + val stable = items.filter { it.release != DD_NIGHTLY } + return items.filter { it.release == DD_NIGHTLY }.mapNotNull { n -> + val base = n.file.removePrefix("DD-").removeSuffix("-nightly.wcp").substringBefore('+') + val rev = Regex(Regex.escape(base) + "(-\\d+)?-linux\\.wcp") + stable.filter { it.comp == n.comp && rev.matches(it.file) } + .maxWithOrNull { a, b -> ProtonExtras.compareVersions(a.file, b.file) }?.let { it to n } + }.groupBy({ it.first }, { it.second }) + .mapValues { (_, ns) -> ns.maxWith { a, b -> ProtonExtras.compareVersions(a.file, b.file) } } + } + + internal fun allowedDownload(url: String) = DOWNLOAD_REPOS.any { url.startsWith("https://github.com/$it/releases/download/") } + private fun newestFirst(of: (T) -> String): Comparator = Comparator { a, b -> ProtonExtras.compareVersions(of(b), of(a)) } @@ -945,11 +974,13 @@ object ComponentsManager { ): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release) fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package { - require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { context.getString(R.string.cmgr_nightlies_only) } + require(allowedDownload(item.url)) { context.getString(R.string.cmgr_nightlies_only) } require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) } val dest = File(packagesDir(context), safeName(item.file)) val part = File(dest.parentFile, dest.name + ".part") - check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) } + check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { + context.getString(if (item.release == DD_NIGHTLY) R.string.cmgr_nightly_replaced else R.string.user_apps_download_failed) + } if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) } val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it } synchronized(lock) { part.renameTo(dest) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/InputSysfsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/InputSysfsComponent.kt new file mode 100644 index 000000000..03ecd7e8b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/InputSysfsComponent.kt @@ -0,0 +1,179 @@ +package com.droiddeck.launcher.session + +import android.os.FileObserver +import android.os.SystemClock +import android.system.Os +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.SessionPart +import com.droiddeck.launcher.input.FakeInputWriter +import java.io.File + +class InputSysfsComponent( + private val nodes: File, + base: File, + private val udevData: File, + private val deckPad: Boolean, + private val steamVirtual: Boolean, +) : SessionPart() { + private val devices = File(base, "devices") + private val classDir = File(base, "class") + private val shown = HashSet() + private var observer: FileObserver? = null + private var running = false + + fun prepare(): Boolean = try { + FileUtils.clear(devices) + FileUtils.clear(classDir) + devices.mkdirs() + classDir.mkdirs() + udevData.mkdirs() + udevData.listFiles()?.forEach { if (isPadRecord(it.name)) it.delete() } + devices.isDirectory && classDir.isDirectory + } catch (e: Exception) { + Log.w(TAG, "pads: could not prepare the input sysfs: $e") + false + } + + fun binds() = listOf(devices.path + ":" + GUEST_DEVICES, classDir.path + ":" + GUEST_CLASS) + + override fun start() { + synchronized(this) { running = true } + active = this + @Suppress("DEPRECATION") + observer = object : FileObserver(nodes.path, CREATE or DELETE or MOVED_TO or MOVED_FROM or CLOSE_WRITE) { + override fun onEvent(event: Int, path: String?) { + if (path != null && NODE_FILE.matches(path)) sync() + } + }.also { it.startWatching() } + sync() + } + + override fun stop() { + observer?.stopWatching() + observer = null + if (active === this) active = null + synchronized(this) { + running = false + for (node in shown.toList()) hide(node) + } + } + + @Synchronized + fun sync() { + if (!running) return + val wanted = (0 until NODE_LIMIT).filter { node -> + File(nodes, "event$node").exists() && if (node >= FIRST_VIRTUAL) File(nodes, ".uinput-event$node.uevent").isFile + else !ringWithdrawn(deckPad, padType) + }.toSet() + for (node in shown - wanted) hide(node) + for (node in wanted - shown) show(node) + } + + private fun show(node: Int) { + val pad = (if (node >= FIRST_VIRTUAL) runCatching { File(nodes, ".uinput-event$node.uevent").readText() }.getOrNull()?.let(::virtualPad) + else ringPad(node, steamVirtual)) ?: return + try { + val input = File(devices, "input$node") + val event = File(input, "event$node") + event.mkdirs() + File(input, "uevent").writeText(pad.uevent()) + File(input, "name").writeText(pad.name + "\n") + File(input, "phys").writeText(pad.phys + "\n") + File(input, "uniq").writeText(pad.uniq + "\n") + File(input, "properties").writeText("0\n") + File(input, "id").mkdirs() + File(input, "id/bustype").writeText("%04x\n".format(pad.bus)) + File(input, "id/vendor").writeText("%04x\n".format(pad.vendor)) + File(input, "id/product").writeText("%04x\n".format(pad.product)) + File(input, "id/version").writeText("%04x\n".format(pad.version)) + File(input, "capabilities").mkdirs() + for (name in CAPABILITIES) File(input, "capabilities/$name").writeText("0\n") + link(File(input, "subsystem"), GUEST_CLASS) + File(event, "uevent").writeText("MAJOR=$MAJOR\nMINOR=${MINOR_BASE + node}\nDEVNAME=input/event$node\n") + File(event, "dev").writeText("$MAJOR:${MINOR_BASE + node}\n") + link(File(event, "subsystem"), GUEST_CLASS) + link(File(event, "device"), "$GUEST_DEVICES/input$node") + val record = udevRecord(SystemClock.elapsedRealtimeNanos() / 1000) + File(udevData, "+input:input$node").writeText(record) + File(udevData, "c$MAJOR:${MINOR_BASE + node}").writeText(record) + link(File(classDir, "input$node"), "$GUEST_DEVICES/input$node") + link(File(classDir, "event$node"), "$GUEST_DEVICES/input$node/event$node") + shown.add(node) + Log.i(TAG, "pads: /dev/input/event$node listed for udev as \"${pad.name}\" (%04x:%04x)".format(pad.vendor, pad.product)) + } catch (e: Exception) { + Log.w(TAG, "pads: could not list /dev/input/event$node for udev: $e") + hide(node) + } + } + + private fun hide(node: Int) { + File(classDir, "event$node").delete() + File(classDir, "input$node").delete() + File(udevData, "c$MAJOR:${MINOR_BASE + node}").delete() + File(udevData, "+input:input$node").delete() + File(devices, "input$node").let { FileUtils.clear(it); it.delete() } + if (shown.remove(node)) Log.i(TAG, "pads: /dev/input/event$node no longer listed for udev") + } + + private fun link(file: File, target: String) { + file.delete() + Os.symlink(target, file.path) + } + + data class Pad( + val bus: Int, + val vendor: Int, + val product: Int, + val version: Int, + val name: String, + val phys: String, + val uniq: String, + ) { + fun uevent(): String = + "PRODUCT=%x/%x/%x/%x\nNAME=\"$name\"\nPHYS=\"$phys\"\nUNIQ=\"$uniq\"\nPROP=0\n".format(bus, vendor, product, version) + } + + companion object { + private const val TAG = "SessionService" + const val GUEST_DEVICES = "/sys/devices/virtual/input" + const val GUEST_CLASS = "/sys/class/input" + private const val MAJOR = 13 + private const val MINOR_BASE = 64 + private const val FIRST_VIRTUAL = 16 + private const val NODE_LIMIT = 64 + private val NODE_FILE = Regex("(\\.uinput-)?event\\d+(\\.uevent)?") + private val CAPABILITIES = listOf("ev", "key", "rel", "abs", "msc", "sw", "led", "snd", "ff") + + @Volatile private var active: InputSysfsComponent? = null + @Volatile private var padType = -1 + + fun padTypeChanged(type: Int) { + padType = type + active?.let { Thread(it::sync, "input-sysfs").start() } + } + + internal fun ringWithdrawn(deckPad: Boolean, type: Int) = deckPad && type != FakeInputWriter.PAD_TYPE_XBOX + + internal fun isPadRecord(name: String) = + Regex("c$MAJOR:(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it in MINOR_BASE until MINOR_BASE + NODE_LIMIT } == true || + Regex("\\+input:input(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it < NODE_LIMIT } == true + + internal fun udevRecord(initializedUs: Long) = + "I:$initializedUs\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\nE:ID_SERIAL=noserial\nG:seat\nG:uaccess\nQ:seat\nQ:uaccess\nV:1\n" + + internal fun ringPad(node: Int, steamVirtual: Boolean) = if (steamVirtual) { + Pad(3, 0x28de, 0x11ff, 0x110, "Microsoft X-Box 360 pad $node", "usb-fakeinput/input$node", "0000000000%02d".format(node)) + } else { + Pad(3, 0x045e, 0x028e, 0x110, "Xbox 360 Controller ($node)", "usb-fakeinput/input$node", "0000000000%02d".format(node)) + } + + internal fun virtualPad(uevent: String): Pad? { + val values = uevent.lines().mapNotNull { line -> + line.indexOf('=').takeIf { it > 0 }?.let { line.substring(0, it) to line.substring(it + 1).trim('"') } + }.toMap() + val id = values["PRODUCT"]?.split('/')?.mapNotNull { it.toIntOrNull(16) }?.takeIf { it.size == 4 } ?: return null + return Pad(id[0], id[1], id[2], id[3], values["NAME"] ?: return null, values["PHYS"].orEmpty(), values["UNIQ"].orEmpty()) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index 042396b9f..3829d9a25 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -1,6 +1,9 @@ package com.droiddeck.launcher.session import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager import android.os.Environment import android.os.Handler import android.os.Looper @@ -15,21 +18,35 @@ import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.util.Locale import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong class PerfHud(context: Context) { var text by mutableStateOf("") private set + var graph by mutableStateOf(FloatArray(0)) + private set private val context = context.applicationContext private val frames = AtomicInteger() private var lastTick = 0L + private var lastFps = 0f private var running = false private val handler = Handler(Looper.getMainLooper()) + private val intervals = FloatArray(GRAPH) + private val graphWrite = AtomicInteger() + private val lastFrameNs = AtomicLong() + + @Volatile private var showFps = true + @Volatile private var showBattery = true + @Volatile private var showGraph = true private val enabled: Boolean get() = SessionPrefs.hudEnabled(context) && !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists() + private val anyPart: Boolean + get() = showFps || showBattery || showGraph + var onPresentingWindowChanged: (() -> Unit)? = null private var lastWindow: String? = null @@ -40,7 +57,17 @@ class PerfHud(context: Context) { handler.post { onPresentingWindowChanged?.invoke() } } } - override fun onGameFrame() { frames.incrementAndGet() } + override fun onGameFrame() { + frames.incrementAndGet() + if (!showGraph) return + val now = System.nanoTime() + val previous = lastFrameNs.getAndSet(now) + if (previous == 0L) return + val ms = ((now - previous) / 1_000_000f).coerceIn(0f, 50f) + val slot = graphWrite.get() + intervals[slot and (GRAPH - 1)] = ms + graphWrite.compareAndSet(slot, slot + 1) + } } private val tick = object : Runnable { @@ -49,35 +76,89 @@ class PerfHud(context: Context) { val now = SystemClock.elapsedRealtime() val dt = (now - lastTick).coerceAtLeast(1L) / 1000f lastTick = now - text = line(frames.getAndSet(0) / dt) - handler.postDelayed(this, 1000) + lastFps = frames.getAndSet(0) / dt + text = readout(lastFps) + if (showGraph) publishGraph() else clearGraph() + handler.postDelayed(this, TICK_MS) } } fun refresh() { - if (enabled) start() else stop() + readOptions() + if (!enabled || !anyPart) { + stop() + return + } + if (!running) start() else { + if (!showGraph) clearGraph() else publishGraph() + text = readout(lastFps) + } } fun start() { - if (!enabled || running) return + readOptions() + if (!enabled || !anyPart || running) return running = true lastTick = SystemClock.elapsedRealtime() + lastFrameNs.set(0L) frames.set(0) + graphWrite.set(0) WaylandCompositor.setGameListener(listener) - handler.post(tick) + handler.postDelayed(tick, TICK_MS) } fun stop() { running = false + handler.removeCallbacks(tick) WaylandCompositor.clearGameListener(listener) text = "" + graph = FloatArray(0) + } + + private fun readOptions() { + showFps = SessionPrefs.hudShowFps(context) + showBattery = SessionPrefs.hudShowBattery(context) + showGraph = SessionPrefs.hudShowGraph(context) + } + + private fun readout(fpsValue: Float): String { + val parts = ArrayList(2) + if (showFps) parts.add(line(fpsValue)) + if (showBattery) batteryCelsius(readBatteryDeci())?.let { parts.add(it) } + return parts.joinToString("\n") + } + + private fun readBatteryDeci(): Int { + val sticky = runCatching { + context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + }.getOrNull() ?: return 0 + return sticky.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) + } + + private fun clearGraph() { + lastFrameNs.set(0L) + graphWrite.set(0) + if (graph.isNotEmpty()) graph = FloatArray(0) + } + + private fun publishGraph() { + val count = graphWrite.get() + val shown = minOf(count, GRAPH) + if (shown < 2) { + graph = FloatArray(0) + return + } + val out = FloatArray(shown) + val start = count - shown + for (i in 0 until shown) out[i] = intervals[(start + i) and (GRAPH - 1)] + graph = out } private fun line(base: Float): String { val mode = FrameGen.mode(context) if (mode.engine == FrameGen.ENGINE_OFF) return fps(base) val lsfg = mode.engine == FrameGen.ENGINE_LSFG - val name = if (lsfg) "LSFG" else "Win-FG" + val name = if (lsfg) "LSFG" else "DD-FG" val label = FrameGen.label(context, mode) if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name) val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null } @@ -91,4 +172,12 @@ class PerfHud(context: Context) { } private fun fps(value: Float) = String.format(Locale.US, "%.0f fps", value) + + companion object { + private const val TICK_MS = 1_000L + private const val GRAPH = 128 + + internal fun batteryCelsius(deci: Int): String? = + if (deci <= 0) null else String.format(Locale.US, "%.1f°C", deci / 10f) + } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index f9d7e46ac..5a661df23 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -118,7 +118,9 @@ object SessionArtifacts { private fun scrubFolder(dir: File, record: Record) { dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f -> - if (!LogRedactor.isText(f)) return@forEach + if (LogRedactor.isBinary(f)) return@forEach + // Too large to rewrite in memory: the folder must not be recorded as scrubbed. + if (!LogRedactor.isText(f)) { record.failed = true; return@forEach } val done = scrubFile(dir, f) if (done == null) record.failed = true else record.files[f] = done } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt index ae64b98cf..c0bba6093 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -44,6 +44,19 @@ object SessionDisplay { fun unfoldedGameHeight(width: Int, height: Int): Int = minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv() + fun unfoldedControlsWanted( + enabled: Boolean, + foldable: Boolean, + controllerConnected: Boolean, + keepWithController: Boolean, + steamDeckPad: Boolean, + pip: Boolean, + width: Int, + height: Int, + smallestWidthDp: Int, + ): Boolean = enabled && foldable && (keepWithController || !controllerConnected) && steamDeckPad && + !pip && width > 0 && height > 0 && smallestWidthDp >= UNFOLDED_MIN_DP + fun panelSize(context: Context): Pair { val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index c0586d2e3..163baccf5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -1,10 +1,12 @@ package com.droiddeck.launcher.session +import com.droiddeck.launcher.core.AddonPack import android.content.Context import android.os.Environment import android.util.Log import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.runtime.DesktopCatalog import com.droiddeck.launcher.runtime.LegacyNames import com.droiddeck.launcher.runtime.LinuxRuntime @@ -15,6 +17,7 @@ object SessionFiles { private const val TAG = "SessionFiles" private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio" + private const val STAGED_LEDGER = "usr/local/.droiddeck-staged" val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread") internal val DESKTOP_FILES = arrayOf( @@ -27,7 +30,8 @@ object SessionFiles { "etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop", ) - fun stage(context: Context, root: File) { + /** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */ + fun stage(context: Context, root: File, startsSession: Boolean = false) { if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) } catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) } GameEnvironmentStore.publish(context) @@ -67,6 +71,7 @@ object SessionFiles { "usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks", "usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim", "usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts", + "usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox", "usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch", "usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic", "usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam", @@ -78,8 +83,10 @@ object SessionFiles { "usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper", "usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper", "usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr", + "usr/local/bin/droiddeck-pipewire" to "usr/local/bin/droiddeck-pipewire", "usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings", "usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game", + "usr/local/bin/droiddeck-steamvr-flat" to "usr/local/bin/droiddeck-steamvr-flat", "usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap", "usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak", "usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup", @@ -147,14 +154,17 @@ object SessionFiles { "usr/local/bin/droiddeck-vkbridge-smoke" to "usr/local/bin/droiddeck-vkbridge-smoke", "usr/local/bin/droiddeck-glprobe" to "usr/local/bin/droiddeck-glprobe", "usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime", + "usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst", "usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE", ) + mangoapp + xclipse.map { it to it } + maliCompat + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) -> val dir = asset.substringBeforeLast('/') runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) } val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional + val ledger = StageLedger(File(root, STAGED_LEDGER), apkIdentity(context)) for ((asset, relative) in all) { val target = File(root, relative) + if (ledger.current(relative, target)) continue val staged = File(target.parentFile, target.name + ".staged") var installed = false try { @@ -168,7 +178,7 @@ object SessionFiles { } finally { if (!installed) staged.delete() } - if (!installed) Log.e(TAG, "$relative NOT staged") + if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged") } File(root, "usr/local/bin/bannerlator-steam-compat").delete() LegacyNames.migrateRootfs(root) @@ -180,13 +190,17 @@ object SessionFiles { "i386-windows/winedirectaudio.drv", "version.txt", ) - for (set in DIRECTAUDIO_SETS) for (relative in directAudio) { - val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative") + val directAudioPack = AddonPack.DIRECTAUDIO.takeIf { it.installed(context) }?.dir(context) + if (directAudioPack == null) Log.w(TAG, "DirectAudio pack is not installed; drivers not staged") + if (directAudioPack != null) for (set in DIRECTAUDIO_SETS) for (relative in directAudio) { + val key = "$DIRECTAUDIO_DIR/$set/lib/wine/$relative" + val target = File(root, key) + if (ledger.current(key, target)) continue val staged = File(target.parentFile, target.name + ".staged") var installed = false try { target.parentFile?.mkdirs() - context.assets.open("directaudio/$set/$relative").use { input -> + File(directAudioPack, "$set/$relative").inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } } installed = staged.setReadable(true, false) && staged.renameTo(target) @@ -195,8 +209,9 @@ object SessionFiles { } finally { if (!installed) staged.delete() } - if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged") + if (installed) ledger.staged(key, target) else Log.e(TAG, "DirectAudio $set/$relative NOT staged") } + ledger.save() for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively() val preload = StringBuilder("/usr/local/lib/libblsession.so\n") if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) { @@ -213,20 +228,119 @@ object SessionFiles { if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) { Log.e(TAG, "could not write steam-compatibility-label") } + writeCjkFontConfig(root, etc) + val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() } + val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged") + if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n") + || !stagedSocket.renameTo(File(droiddeckEtc, "bwrap-socket"))) { + stagedSocket.delete() + Log.e(TAG, "could not write bwrap-socket") + } + BootMovie.syncFromSteam(context) val startupMovie = BootMovie.prepare(context) - // The x86-64 client's config is left alone until its first start creates it. - val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam")) - .filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory } - for (steam in steamRoots) { - val startupMovieDir = File(steam, "config/uioverrides/movies") - val bigPictureMovieInstalled = startupMovie != null && - stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm") - if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm") - if (bigPictureMovieInstalled) { - ensureStartupMovieDefault(File(steam, "config/config.vdf")) + val force = SessionPrefs.bootMovieApplyNext(context) + val steamHomes = listOf(File(root, "root/.local/share/Steam")) + + listOf(File(root, "root/.droiddeck-x64/.local/share/Steam")).filter { File(it, "config").isDirectory } + var allDone = true + var allInstalled = true + var seen: String? = null + val activeIsX64 = SessionPrefs.activeSteamClientArch(context) == "x86_64" + for (home in steamHomes) { + val movieDir = File(home, "config/uioverrides/movies") + val config = File(home, "config/config.vdf") + if (startupMovie == null) { + // Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either. + for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) { + File(movieDir, name).delete() + File(movieDir, ".$name.source").delete() + } + } + val bigPictureMovieInstalled = startupMovie != null && stageStartupMovie(startupMovie, movieDir, "bigpicture_startup.webm") + if (startupMovie != null) stageStartupMovie(startupMovie, movieDir, "steam_os_startup.webm") + val before = startupMoviePath(config) + val done = if (bigPictureMovieInstalled) ensureStartupMovieDefault(config, force) else resetMissingStartupMovie(config, force) + BootMovieReport.note( + context, + "stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " + + "staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}", + ) + if (!done) allDone = false + if (!bigPictureMovieInstalled) allInstalled = false + if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else "" + } + // A pick made in the app is done once a session started with every Steam client's config having it; guest commands run + // before that, and a Steam that is already running writes its own choice back when it exits. + if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false) + seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) } + } + + /** + * Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that + * could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config + * no longer names a missing movie. + */ + internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean { + if (!config.isFile) return true + val text = runCatching { config.readText() }.getOrElse { + Log.w(TAG, "could not read Steam config to reset the startup movie", it) + return false + } + val movie = activeStartupMovie(text) ?: return true + val block = text.substring(movie.keyStart, movie.close + 1) + val path = scalar(block, "LocalPath").orEmpty() + if (path.isEmpty()) return true + val missing = File(config.parentFile, "uioverrides/movies/" + path.removePrefix(BOOT_MOVIES_DIR)) + val gone = path.startsWith(BOOT_MOVIES_DIR) && !missing.isFile && (force || path == BOOT_MOVIE_PATH) + if (!gone) return false + val newline = if (text.contains("\r\n")) "\r\n" else "\n" + val updated = setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", "", newline) + val staged = File(config.parentFile, config.name + ".staged") + return try { + staged.writeText(text.replaceRange(movie.keyStart, movie.close + 1, updated)) + (staged.renameTo(config)).also { if (!it) staged.delete() } + } catch (e: Exception) { + staged.delete() + Log.w(TAG, "could not reset Steam startup movie selection", e) + false + } + } + + private fun apkIdentity(context: Context): String { + val apk = File(context.applicationInfo.sourceDir) + return "${apk.path} ${apk.length()} ${apk.lastModified()}" + } + + private class StageLedger(private val file: File, private val identity: String) { + private val before: Map = FileUtils.readString(file)?.lines() + ?.takeIf { it.firstOrNull() == identity } + ?.drop(1)?.mapNotNull { line -> line.split('\t').takeIf { it.size == 2 }?.let { it[0] to it[1] } }?.toMap() + .orEmpty() + private val after = LinkedHashMap() + + fun current(relative: String, target: File): Boolean { + val mark = mark(target) ?: return false + if (before[relative] != mark) return false + after[relative] = mark + return true + } + + fun staged(relative: String, target: File) { + mark(target)?.let { after[relative] = it } + } + + fun save() { + if (after == before) return + val text = (listOf(identity) + after.map { (relative, mark) -> "$relative\t$mark" }).joinToString("\n") + val staged = File(file.parentFile, file.name + ".staged") + if (!FileUtils.writeString(staged, text) || !staged.renameTo(file)) { + staged.delete() + file.delete() } } + + private fun mark(target: File): String? = + if (target.isFile) "${target.length()} ${target.lastModified()} ${target.canExecute()}" else null } private fun stageStartupMovie(source: File, directory: File, name: String): Boolean { @@ -242,7 +356,11 @@ object SessionFiles { staged.outputStream().use { output -> FileUtils.copy(input, output) } } installed = staged.setReadable(true, false) && staged.renameTo(movie) - if (installed) FileUtils.writeString(stamp, identity) + if (installed) { + FileUtils.writeString(stamp, identity) + // Steam's picker draws its preview from this picture and does not redraw it when the movie changes. + File(directory, name.removeSuffix(".webm") + ".jpg").delete() + } } catch (e: Exception) { Log.w(TAG, "could not stage Steam startup movie $name", e) } finally { @@ -254,43 +372,108 @@ object SessionFiles { private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int) - private fun ensureStartupMovieDefault(config: File) { - val path = "/uioverrides/movies/bigpicture_startup.webm" + internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/" + internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm" + + /** + * Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam + * settings is where older clients kept it, and DroidDeck still keeps it in step. + */ + private fun customizationMovie(text: String): VdfBlock? { + var from = 0 + while (true) { + val customization = findObject(text, "Customization", from, text.length) ?: return null + findObject(text, "StartupMovie", customization.open + 1, customization.close)?.let { return it } + from = customization.close + 1 + } + } + + /** The older `StartupMovie` section: the first one that is not inside `Customization`. */ + private fun olderMovie(text: String): VdfBlock? { + val inside = customizationMovie(text) + var from = 0 + while (true) { + val movie = findObject(text, "StartupMovie", from, text.length) ?: return null + if (inside == null || movie.keyStart != inside.keyStart) return movie + from = movie.close + 1 + } + } + + /** The section Steam reads: the one under `Customization` when there is one. */ + private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text) + + /** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */ + internal fun startupMoviePath(config: File): String? { + val text = runCatching { config.readText() }.getOrNull() ?: return null + val movie = activeStartupMovie(text) ?: return null + return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() } + } + + /** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */ + internal fun startupMovieBlock(config: File): String? { + val text = runCatching { config.readText() }.getOrNull() ?: return null + val parts = listOfNotNull( + customizationMovie(text)?.let { "under Customization:\n" + text.substring(it.keyStart, it.close + 1) }, + olderMovie(text)?.let { "older place:\n" + text.substring(it.keyStart, it.close + 1) }, + ) + return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") + } + + /** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */ + internal fun customizationBlock(config: File): String? { + val text = runCatching { config.readText() }.getOrNull() ?: return null + val customization = findObject(text, "Customization", 0, text.length) ?: return null + val before = text.substring(0, customization.keyStart).lines().takeLast(4).joinToString("\n") + return (before + "\n" + text.substring(customization.keyStart, customization.close + 1)).take(4000) + } + + private fun pointAt(text: String, movie: VdfBlock, path: String, newline: String): String { + val block = text.substring(movie.keyStart, movie.close + 1) + return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline)) + } + + /** True when Steam's config selects DroidDeck's staged movie once this returns. */ + internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean { + val path = BOOT_MOVIE_PATH val legacyPath = "/uioverrides/movies/droiddeck-startup.webm" var text = if (config.isFile) runCatching { config.readText() }.getOrElse { Log.w(TAG, "could not read Steam config for startup movie", it) - return + return false } else "" val newline = if (text.contains("\r\n")) "\r\n" else "\n" - val movie = findObject(text, "StartupMovie", 0, text.length) - if (movie != null) { - val block = text.substring(movie.keyStart, movie.close + 1) + val custom = customizationMovie(text) + val active = custom ?: olderMovie(text) + if (active != null) { + val block = text.substring(active.keyStart, active.close + 1) val selectedId = scalar(block, "MovieID") val selectedPath = scalar(block, "LocalPath") - if (selectedPath == path) { + if (selectedPath == path && custom != null) { Log.i(TAG, "Steam startup movie is set to DroidDeck") - return + return true } val legacyDefault = selectedId == "0" && selectedPath == legacyPath val hasExplicitSelection = (!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty() - if (!legacyDefault && hasExplicitSelection) { + if (!force && !legacyDefault && hasExplicitSelection && selectedPath != path) { Log.i(TAG, "Steam startup movie selection preserved") - return + return false } - val updated = setScalar(block, "MovieID", "0", newline) - .let { setScalar(it, "LocalPath", path, newline) } - text = text.replaceRange(movie.keyStart, movie.close + 1, updated) - } else { + } + // Later sections first, so the positions of earlier ones stay valid. + val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart } + for (target in targets) text = pointAt(text, target, path, newline) + if (olderMovie(text) == null) { val steam = findSteamBlock(text) if (steam == null) { - if (text.isNotBlank()) { + if (text.isNotBlank() && customizationMovie(text) == null) { Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set") - return + return false + } + if (text.isBlank()) { + text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}" + val section = startupMovieSection("\t\t\t\t", path, newline) + text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}" } - text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}" - val section = startupMovieSection("\t\t\t\t", path, newline) - text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}" } else { val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 } val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' } @@ -298,22 +481,35 @@ object SessionFiles { text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart) } } + if (customizationMovie(text) == null) text = withCustomization(text, path, newline) val staged = File(config.parentFile, config.name + ".staged") - try { + return try { config.parentFile?.mkdirs() staged.writeText(text) if (!staged.renameTo(config)) { staged.delete() Log.e(TAG, "could not write Steam startup movie selection") + false } else { Log.i(TAG, "Steam startup movie default selected") + true } } catch (e: Exception) { staged.delete() Log.w(TAG, "could not write Steam startup movie selection", e) + false } } + /** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */ + private fun withCustomization(text: String, path: String, newline: String): String { + val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text + val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 } + val indent = text.substring(lineStart, root.close).takeWhile { it == '\t' || it == ' ' } + val section = listOf(indent + "\t\"Customization\"", indent + "\t{", startupMovieSection(indent + "\t\t", path, newline), indent + "\t}").joinToString(newline) + return text.substring(0, lineStart) + section + newline + text.substring(lineStart) + } + private fun findSteamBlock(text: String): VdfBlock? { val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null val software = findObject(text, "Software", install.open + 1, install.close) ?: return null @@ -374,4 +570,96 @@ object SessionFiles { ).joinToString(newline) fun logDirectory(context: Context): File = LinuxRuntime.logDir(context).apply { mkdirs() } + + /** + * Registers the Android system fonts (which include CJK faces such as Noto Sans CJK / + * MiSans) with the container's fontconfig, so the Steam Deck UI (CEF/webkit renderer) + * resolves Chinese/Japanese glyphs instead of showing tofu / mojibake. + * The fonts themselves are bind-mounted by LinuxRuntime.bindSystemFonts() into + * /usr/local/share/fonts/android; here we only need to point fontconfig at them. + */ + private fun writeCjkFontConfig(root: File, etc: File) { + try { + val confDir = File(etc, "fonts/conf.d").apply { mkdirs() } + val conf = File(confDir, "99-droiddeck-cjk.conf") + val xml = """ + + + /usr/local/share/fonts/android + /system/fonts + + ja + sans-serif + Noto Sans CJK JP + + + ja + serif + Noto Serif CJK JP + + + zh + sans-serif + Noto Sans CJK SC + + + zh + serif + Noto Serif CJK SC + + + zh-cn + sans-serif + Noto Sans CJK SC + + + zh-cn + serif + Noto Serif CJK SC + + + zh-tw + sans-serif + Noto Sans CJK TC + + + zh-tw + serif + Noto Serif CJK TC + + + zh-hk + sans-serif + Noto Sans CJK TC + + + zh-hk + serif + Noto Serif CJK TC + + + ko + sans-serif + Noto Sans CJK KR + + + ko + serif + Noto Serif CJK KR + + +""" + if (!conf.isFile || FileUtils.readString(conf) != xml) { + val staged = File(confDir, conf.name + ".staged") + if (FileUtils.writeString(staged, xml) && staged.renameTo(conf)) { + Log.i(TAG, "Wrote CJK fontconfig 99-droiddeck-cjk.conf") + } else { + staged.delete() + Log.e(TAG, "Could not write CJK fontconfig") + } + } + } catch (e: Exception) { + Log.w(TAG, "CJK fontconfig setup failed", e) + } + } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index 80c00cc99..b44c5c626 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -66,15 +66,12 @@ object SessionLogShare { return zip } + /** Every entry is scrubbed line by line, whatever its size: a raw copy can carry tokens out of the device. */ private fun addEntry(z: ZipOutputStream, name: String, f: File) { z.putNextEntry(ZipEntry(name)) - if (LogRedactor.isText(f)) { - val w = z.bufferedWriter() - LogRedactor.scrubForShare(f, w) - w.flush() - } else { - f.inputStream().use { it.copyTo(z) } - } + val w = z.bufferedWriter() + LogRedactor.scrubForShare(f, w) + w.flush() z.closeEntry() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 5f1cf52a1..6cbb89c58 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -23,12 +23,14 @@ object SessionPrefs { const val SUSPEND_NEVER = "never" private const val LEGACY_SUSPEND_DOWNLOADS = "downloads" private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground" + private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen" const val CONTROLLER_DECK = "deck" const val CONTROLLER_XBOX360 = "xbox360" const val CONTROLLER_STEAM = "steamcontroller" const val CONTROLLER_DS4 = "dualshock4" const val CONTROLLER_DUALSENSE = "dualsense" + const val CONTROLLER_NONE = "none" const val OSC_AUTO = "auto" const val OSC_ALWAYS = "always" const val OSC_STEAM_QAM = "steam-qam" @@ -51,9 +53,9 @@ object SessionPrefs { ) fun orientation(context: Context): Int = - prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) + prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) .takeIf { value -> orientationOptions.any { it.first == value } } - ?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + ?: ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE fun setOrientation(context: Context, orientation: Int) { require(orientationOptions.any { it.first == orientation }) @@ -120,8 +122,22 @@ object SessionPrefs { prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply() } + fun storesTabOrder(context: Context): List = idList(prefs(context).getString("storesTabOrder", null)) + + fun setStoresTabOrder(context: Context, order: List) { + prefs(context).edit().putString("storesTabOrder", order.joinToString(",")).apply() + } + + fun storesTabHidden(context: Context): Set = idList(prefs(context).getString("storesTabHidden", null)).toSet() + + fun setStoresTabHidden(context: Context, hidden: Set) { + prefs(context).edit().putString("storesTabHidden", hidden.joinToString(",")).apply() + } + + private fun idList(raw: String?): List = raw.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() } + val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc") - val STORES_VIEWS = listOf("cards", "list") + val STORES_VIEWS = listOf("cards", "capsules", "list") const val STORES_OPEN_LIBRARY = "library" const val STORES_OPEN_STORE = "store" @@ -132,6 +148,13 @@ object SessionPrefs { prefs(context).edit().putString("storesInstallTarget", path).apply() } + /** Empty means follow the app/system language for GOG and Epic installs. */ + fun storesInstallLanguage(context: Context): String = prefs(context).getString("storesInstallLanguage", "") ?: "" + + fun setStoresInstallLanguage(context: Context, code: String) { + prefs(context).edit().putString("storesInstallLanguage", code).apply() + } + fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3) fun setGameStoresParallel(context: Context, count: Int) { @@ -144,6 +167,43 @@ object SessionPrefs { prefs(context).edit().putBoolean(hudKey(context), on).apply() } + fun hudShowFps(context: Context): Boolean = prefs(context).getBoolean("hudFps", true) + + fun setHudShowFps(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("hudFps", on).apply() + } + + fun hudShowBattery(context: Context): Boolean = prefs(context).getBoolean("hudBattery", true) + + fun setHudShowBattery(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("hudBattery", on).apply() + } + + fun hudShowGraph(context: Context): Boolean = prefs(context).getBoolean("hudGraph", true) + + fun setHudShowGraph(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("hudGraph", on).apply() + } + + fun hudColor(context: Context): String = + prefs(context).getString("hudColor", HUD_COLOR_MINT)?.takeIf { it in HUD_COLORS } ?: HUD_COLOR_MINT + + fun setHudColor(context: Context, color: String) { + if (color in HUD_COLORS) prefs(context).edit().putString("hudColor", color).apply() + } + + fun hudOpacity(context: Context): Int = + prefs(context).getInt("hudOpacity", HUD_OPACITY_DEFAULT).takeIf { it in HUD_OPACITY_RANGE } ?: HUD_OPACITY_DEFAULT + + fun setHudOpacity(context: Context, percent: Int) { + prefs(context).edit().putInt("hudOpacity", percent.coerceIn(HUD_OPACITY_RANGE)).apply() + } + + const val HUD_COLOR_MINT = "mint" + val HUD_COLORS = listOf(HUD_COLOR_MINT, "white", "sky", "amber", "violet") + val HUD_OPACITY_RANGE = 30..100 + const val HUD_OPACITY_DEFAULT = 70 + private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud" private fun mangoappSession(context: Context) = @@ -170,6 +230,7 @@ object SessionPrefs { const val TOUCH_AUTO = "auto" const val TOUCH_PAD = "touchpad" const val TOUCH_DIRECT = "direct" + const val TOUCH_GESTURES = "gestures" const val TOUCH_OFF = "off" fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO @@ -247,6 +308,16 @@ object SessionPrefs { position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply() } + /** + * After a game launched from the front end (or another launcher's link) closes, the client + * exits cleanly and the session returns there, instead of staying in Big Picture. + */ + fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true) + + fun setReturnAfterGame(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("returnAfterGame", on).apply() + } + fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false) fun setStretch16x9(context: Context, on: Boolean) { @@ -418,6 +489,8 @@ object SessionPrefs { fun steamController(context: Context): String = prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() } + + fun builtinPad(context: Context): Boolean = steamController(context) != CONTROLLER_NONE fun padType(id: String): Int = when (id) { CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX @@ -649,6 +722,17 @@ object SessionPrefs { fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() } fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true) fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() } + fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100) + fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() } + /** How long the boot movie plays, in seconds; 0 plays all of it. */ + fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0) + fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() } + /** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */ + fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false) + fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() } + /** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */ + fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null) + fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() } fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null) fun setBootMovieCustomName(context: Context, name: String?) { prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply() @@ -797,6 +881,16 @@ object SessionPrefs { prefs(context).edit().putString("theme", id).apply() } + /** The custom Gradient theme's primary and secondary colours (ARGB). */ + fun gradientColors(context: Context): Pair = prefs(context).let { + it.getInt("gradientPrimary", com.droiddeck.launcher.ui.Themes.DEFAULT_PRIMARY) to + it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY) + } + + fun setGradientColors(context: Context, primary: Int, secondary: Int) { + prefs(context).edit().putInt("gradientPrimary", primary).putInt("gradientSecondary", secondary).apply() + } + private fun customResolution(context: Context, mode: String): Pair? = parseResolution(prefs(context).getString("customRes.$mode", null)) @@ -1000,6 +1094,11 @@ object SessionPrefs { fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode + fun displayMode(context: Context, mode: String, program: String?): String = + if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) && + resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP + else prefMode(mode) + fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false) fun setPipAutoEnter(context: Context, enabled: Boolean) { @@ -1012,6 +1111,13 @@ object SessionPrefs { prefs(context).edit().putBoolean("unfoldedControls", enabled).apply() } + fun unfoldedControlsWithController(context: Context): Boolean = + prefs(context).getBoolean("unfoldedControlsWithController", false) + + fun setUnfoldedControlsWithController(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean("unfoldedControlsWithController", enabled).apply() + } + const val SURFACE_STICK = "stick" const val SURFACE_TRACKPAD = "trackpad" @@ -1044,6 +1150,12 @@ object SessionPrefs { }.apply() } + fun deckyZipWarningSeen(context: Context): Boolean = prefs(context).getBoolean(DECKY_ZIP_WARNING_SEEN, false) + + fun setDeckyZipWarningSeen(context: Context) { + prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply() + } + fun steamDownloadsInBackground(context: Context): Boolean { val prefs = prefs(context) return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) || diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 6835d6b0a..5ae9f753c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.session +import com.droiddeck.launcher.core.AddonPack import com.droiddeck.launcher.gpu.LinuxVulkanDriver import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager import com.droiddeck.launcher.gpu.SystemVulkanDriver @@ -22,6 +23,8 @@ import android.os.IBinder import android.os.Handler import android.os.Looper import android.os.PowerManager +import android.hardware.display.DisplayManager +import android.view.Display import android.util.Log import com.droiddeck.launcher.R import com.droiddeck.launcher.SessionActivity @@ -57,6 +60,7 @@ class SessionService : Service() { private val components = java.util.concurrent.CopyOnWriteArrayList() private var deckBinds: List = emptyList() + private var inputSysfs: InputSysfsComponent? = null private val stopLock = Any() private var wakeLock: PowerManager.WakeLock? = null private var wifiLock: WifiManager.WifiLock? = null @@ -68,25 +72,37 @@ class SessionService : Service() { private val steamDownloadMonitor = SteamDownloadMonitor() private var downloadMonitorTask: Runnable? = null private var downloadActive = false + @Volatile private var steamLibraryMounts = emptyMap() private var suspendDownloadNow = false private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL private var steamDownloadsInBackground = false private var activityVisible = true + private var activityDisplayId = Display.DEFAULT_DISPLAY private var xrActivityVisible = false private var signInHold = false - private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible || signInHold, screenOn, xrActivityVisible) + private val hasVisibleSurface get() = !SessionVisibilityPolicy.isHidden( + activityStarted = activityVisible || signInHold, + defaultDisplayOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn, + activityOnDefaultDisplay = activityDisplayId == Display.DEFAULT_DISPLAY, + activityDisplayOn = activityDisplayOn(), + xrActivityVisible = xrActivityVisible, + ) private var screenOn = true + private var sessionDisplayId = Display.DEFAULT_DISPLAY private var manualPauseRequested = false private var steamSleepToken: String? = null private var nativeSleepToken: String? = null private var nativeSleepReady = false private var nativeSleepFallback = false + private var nativeSleepIdle = false private var nativeSleepTimeout: Runnable? = null private var suspendOperationPending = false private var pipTask = false private var suspendAttemptFailed = false private var pendingSteamGameId: String? = null private var pendingSteamGameGeneration = -1 + // Bumped to cancel a running game-exit watch (a new launch, or the session ending). + @Volatile private var gameExitWatchToken = 0 private val flushSteamGame = object : Runnable { override fun run() = flushQueuedSteamGame() } @@ -94,8 +110,8 @@ class SessionService : Service() { private val screenReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { - Intent.ACTION_SCREEN_OFF -> screenOn = false - Intent.ACTION_SCREEN_ON -> screenOn = true + Intent.ACTION_SCREEN_OFF -> screenOn = readScreenOn(false) + Intent.ACTION_SCREEN_ON -> screenOn = readScreenOn(true) else -> return } if (screenOn) resumeSteamSleepOnReturn() @@ -103,17 +119,67 @@ class SessionService : Service() { updateSuspendPolicy() } } + private var displayListenerRegistered = false + private val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) {} + override fun onDisplayRemoved(displayId: Int) { + if (displayId == sessionDisplayId) { + sessionDisplayId = Display.DEFAULT_DISPLAY + refreshScreenOn() + } + if (displayId == activityDisplayId) { + suspendAttemptFailed = false + updateSuspendPolicy() + } + } + override fun onDisplayChanged(displayId: Int) { + if (displayId == sessionDisplayId && displayId != Display.DEFAULT_DISPLAY) refreshScreenOn() + if (displayId == activityDisplayId) { + resumeSteamSleepOnReturn() + suspendAttemptFailed = false + updateSuspendPolicy() + } + } + } + + private fun readScreenOn(interactive: Boolean? = null): Boolean { + val id = sessionDisplayId + val state = if (id == Display.DEFAULT_DISPLAY) null + else (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.getDisplay(id)?.state + return sessionScreenOn(id == Display.DEFAULT_DISPLAY, + interactive ?: (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true, state) + } + + private fun activityDisplayOn(): Boolean { + if (activityDisplayId == Display.DEFAULT_DISPLAY) { + return (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn + } + val displays = getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return false + return displays.getDisplay(activityDisplayId)?.state == Display.STATE_ON + } + + private fun refreshScreenOn() { + val on = readScreenOn() + if (on == screenOn) return + screenOn = on + if (on) resumeSteamSleepOnReturn() + suspendAttemptFailed = false + updateSuspendPolicy() + } @Volatile private var sessionGen = 0 override fun onCreate() { super.onCreate() - val power = getSystemService(Context.POWER_SERVICE) as? PowerManager - screenOn = power?.isInteractive ?: true + screenOn = readScreenOn() registerReceiver(screenReceiver, IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) addAction(Intent.ACTION_SCREEN_ON) }) screenReceiverRegistered = true + (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.let { + it.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) + displayListenerRegistered = true + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -162,14 +228,30 @@ class SessionService : Service() { ACTION_ACTIVITY_VISIBLE -> { if (!SessionState.pipActive) pipTask = false activityVisible = true + activityDisplayId = intent.getIntExtra(EXTRA_ACTIVITY_DISPLAY_ID, activityDisplayId) + sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId) + screenOn = readScreenOn() suspendDownloadNow = false resumeSteamSleepOnReturn() suspendAttemptFailed = false updateSuspendPolicy() return START_NOT_STICKY } + ACTION_ACTIVITY_DISPLAY -> { + sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId) + refreshScreenOn() + return START_NOT_STICKY + } + ACTION_ACTIVITY_DISPLAY_CHANGED -> { + activityDisplayId = intent.getIntExtra(EXTRA_ACTIVITY_DISPLAY_ID, activityDisplayId) + resumeSteamSleepOnReturn() + suspendAttemptFailed = false + updateSuspendPolicy() + return START_NOT_STICKY + } ACTION_ACTIVITY_HIDDEN -> { activityVisible = false + activityDisplayId = intent.getIntExtra(EXTRA_ACTIVITY_DISPLAY_ID, activityDisplayId) suspendAttemptFailed = false updateSuspendPolicy() return START_NOT_STICKY @@ -231,7 +313,9 @@ class SessionService : Service() { activityVisible = true xrActivityVisible = false signInHold = false - screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true + sessionDisplayId = intent?.getIntExtra(EXTRA_DISPLAY_ID, Display.DEFAULT_DISPLAY) ?: Display.DEFAULT_DISPLAY + activityDisplayId = intent?.getIntExtra(EXTRA_ACTIVITY_DISPLAY_ID, sessionDisplayId) ?: sessionDisplayId + screenOn = readScreenOn() manualPauseRequested = false suspendDownloadNow = false steamDownloadMonitor.reset() @@ -264,6 +348,8 @@ class SessionService : Service() { val gen = ++sessionGen cancelQueuedSteamGame(clearMailbox = false) clearSteamGameMailbox() + if (SessionState.mode == MODE_STEAM) SessionState.steamUrl + ?.takeIf { it.startsWith(GAME_URL_PREFIX) }?.let { watchGameExit(it.removePrefix(GAME_URL_PREFIX), gen) } acquireLocks() Thread({ OrphanReaper.reap("session starting") @@ -313,13 +399,19 @@ class SessionService : Service() { private fun tuDebug(linuxDriverId: String): String? { // Turnip's switch; a non-Adreno GPU runs another driver (PanVK, RADV, the bridge). if (!com.droiddeck.launcher.core.DeviceSupport.adreno()) return null - val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH) - .takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() } + val override = tuDebugOverride() if (!override.isNullOrEmpty()) return override - if (SessionPrefs.tuSysmem(this)) return "sysmem" - if (linuxDriverId.isEmpty()) return null + if (SessionPrefs.tuSysmem(this) || sysmemDriver(linuxDriverId)) return "sysmem" + return null + } + + private fun tuDebugOverride(): String? = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH) + .takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }?.takeIf { it.isNotEmpty() } + + private fun sysmemDriver(linuxDriverId: String): Boolean { + if (linuxDriverId.isEmpty()) return false val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase() - return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null + return name.contains("710-720") || name.contains("710_720") } @@ -346,11 +438,13 @@ class SessionService : Service() { val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() } killStragglers() try { + for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO)) + if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable") if (SystemVulkanDriver.usesDefault(SessionPrefs.linuxDriver(this))) { SystemVulkanDriver.prepare(this) pruneSystemVulkanRuntime() } - SessionFiles.stage(this, root) + SessionFiles.stage(this, root, startsSession = true) } catch (error: Exception) { Log.e(TAG, "Could not prepare the session graphics runtime", error) stopSession(-1) @@ -387,6 +481,12 @@ class SessionService : Service() { if (VenusComponent.wanted(this)) { components.add(VenusComponent(File(sessionDir, "venus.log")).also { it.attach(this) }) } + if (steamHere) { + val encoder = VideoEncodeBridge(File(filesDir, "venc/venc.sock")) + encoder.attach(this) + components.add(encoder) + guest.add("BL_VENC_SOCKET=" + encoder.socket.absolutePath) + } guest.add("BL_WIDTH=" + size.first) guest.add("BL_HEIGHT=" + size.second) @@ -405,6 +505,7 @@ class SessionService : Service() { val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() SessionState.deckPad = false deckBinds = emptyList() + inputSysfs = null if (controllersOn) { addControllerEnvironment(guest, fakeInputDir, sessionDir) SessionState.padBridge?.syncConnectedControllers() @@ -682,8 +783,9 @@ class SessionService : Service() { if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) && com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1") guest.add("BANNER_AUDIO_DIRECT_DECAY=0") - extraEnv().forEach { guest.add(it) } - com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) } + val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this) + if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else "")) + extra.forEach { guest.add(it) } val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this) if (clientPinned) { guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) @@ -756,17 +858,23 @@ class SessionService : Service() { private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File, sessionDir: File) { FakeInputWriter.prepareRingSlots(fakeInputDir, 4) + val builtinPad = SessionPrefs.builtinPad(this) + val hiddenPad = SessionState.mode == MODE_STEAM && !builtinPad && + !com.droiddeck.launcher.input.PadBridge.anyControllerConnected() + com.droiddeck.launcher.input.PadBridge.setFirstSlot(if (hiddenPad) 1 else 0) fakeInputDir.listFiles()?.forEach { file -> val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull() - if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete() + if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD) || + (hiddenPad && node == 0)) file.delete() } guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path) val rings = FakeInputWriter.getRingEnv(fakeInputDir) - if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings") + val presented = if (hiddenPad) rings?.split(';')?.filterNot { it.startsWith("0=") }?.joinToString(";") else rings + if (!presented.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$presented") guest.add("FAKE_EVDEV_IDENTITY=xbox360") guest.add("FAKE_EVDEV_VIBRATION=1") val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists() - val wantsDeck = uinput && SessionState.mode == MODE_STEAM && + val wantsDeck = uinput && builtinPad && SessionState.mode == MODE_STEAM && SessionState.steamUi != "desktop" && !File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists() val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists() @@ -785,6 +893,7 @@ class SessionService : Service() { val padType = SessionPrefs.steamController(this) guest.add("FAKE_EVDEV_PAD_TYPE=$padType") com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType)) + InputSysfsComponent.padTypeChanged(SessionPrefs.padType(padType)) if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let { guest.add("FAKE_TOUCHCTL_RING=" + it.path) } @@ -798,6 +907,9 @@ class SessionService : Service() { if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0") guest.add("FAKE_EVDEV_LOG=1") guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path) + inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"), + File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM) + .takeIf { it.prepare() } SessionState.fakeInputDir = fakeInputDir } @@ -812,6 +924,11 @@ class SessionService : Service() { val binds = ArrayList() if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input") if (controllersOn) binds.addAll(deckBinds) + if (controllersOn) inputSysfs?.let { + it.attach(this) + components.add(it) + binds.addAll(it.binds()) + } val battery = BatteryComponent( File(filesDir, "session/sys/power_supply"), File(LinuxRuntime.rootDir(this), "run/vpower"), @@ -847,6 +964,7 @@ class SessionService : Service() { File(home, "Storage").mkdirs() binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage") val library = GameStorage.effective(this) + steamLibraryMounts = emptyMap() var storageDiagnosticLibrary: File? = null if (library != null) { val problem = GameStorage.prepare(this, library.path) @@ -858,6 +976,7 @@ class SessionService : Service() { val content = SecondaryLibrary.contentPath(configured) binds.addAll(SecondaryLibrary.binds(filesDir, configured, content = content)) storageDiagnosticLibrary = content + steamLibraryMounts = SecondaryLibrary.CARD_GUESTS.associateWith { content } val route = if (content === configured) "configured" else "verified-native" Log.i(TAG, "game storage: ${content.path} ($route) -> /mnt/droiddeck-sd (\"${library.label}\"); prefixes and native tools private") } catch (e: Exception) { @@ -1116,15 +1235,77 @@ class SessionService : Service() { pendingSteamGameId = null pendingSteamGameGeneration = -1 SessionEvents.record("steam.game_launch_requested", mapOf("gameId" to gameId, "generation" to gen)) + watchGameExit(gameId, gen) Log.i(TAG, "queued Steam game $gameId for the running client") } + /** + * A game launched from the front end (the Games tab, or another launcher's link): once it has + * started and then been gone for [GAME_EXIT_GRACE_MS], ask the client to exit through the + * session script's steam-stop request. The client quits cleanly, finishing its cloud sync, so + * the session ends on its own and the activity returns to where the launch came from instead + * of leaving the player in Big Picture. The grace covers launchers that restart the game. + */ + private fun watchGameExit(gameId: String, gen: Int) { + if (!SessionPrefs.returnAfterGame(this)) return + val token = ++gameExitWatchToken + Thread({ + val startBy = android.os.SystemClock.elapsedRealtime() + GAME_START_TIMEOUT_MS + var seen = false + var goneSince = 0L + while (token == gameExitWatchToken && gen == sessionGen && SessionState.running && !SessionState.stopRequested) { + val now = android.os.SystemClock.elapsedRealtime() + if (steamGameRunning()) { + if (!seen) Log.i(TAG, "game $gameId started; returning to the launcher when it closes") + seen = true + goneSince = 0L + } else if (!seen) { + // Never started (an install, an update or a failed launch): leave the session be. + if (now > startBy) { + Log.i(TAG, "game $gameId did not start; not watching for its exit") + return@Thread + } + } else if (goneSince == 0L) { + goneSince = now + } else if (now - goneSince >= GAME_EXIT_GRACE_MS) { + mainHandler.post { returnAfterGame(gameId, gen, token) } + return@Thread + } + try { Thread.sleep(GAME_EXIT_POLL_MS) } catch (e: InterruptedException) { return@Thread } + } + }, "game-exit-watch").start() + } + + private fun returnAfterGame(gameId: String, gen: Int, token: Int) { + if (token != gameExitWatchToken || gen != sessionGen || !SessionState.running || + SessionState.stopRequested || SessionState.mode != MODE_STEAM || !SessionPrefs.returnAfterGame(this)) return + val written = runCatching { File(LinuxRuntime.sessionRoot(this), "steam-stop").writeText("") } + .onFailure { Log.w(TAG, "could not ask the client to exit after game $gameId", it) }.isSuccess + if (!written) return + SessionEvents.record("steam.return_after_game", mapOf("gameId" to gameId)) + Log.i(TAG, "game $gameId closed: asking the client to exit and returning to the launcher") + } + + /** A Steam game is running: the client runs each one under its reaper, and Proton's script + * waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */ + private fun steamGameRunning(): Boolean { + val procs = File("/proc").listFiles() ?: return false + for (p in procs) { + if (!p.name.all(Char::isDigit)) continue + val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue + if (cmd.isEmpty()) continue + val text = String(cmd).replace('\u0000', ' ') + if (text.contains("SteamLaunch AppId=") || text.contains("waitforexitandrun")) return true + } + return false + } + private fun resumeSession(panelVisible: Boolean = true) { if (panelVisible) activityVisible = true - screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn + screenOn = readScreenOn() manualPauseRequested = false suspendDownloadNow = false - if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake() + if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake() completeNativeSleep() completeSteamSleep() suspendAttemptFailed = false @@ -1316,24 +1497,30 @@ class SessionService : Service() { private fun updateSuspendPolicy() { if (!SessionState.running) return - if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && !hasVisibleSurface) { - manualPauseRequested = true - } - val controller = suspendController ?: return val hidden = !hasVisibleSurface val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow + if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) { + manualPauseRequested = true + } + val controller = suspendController ?: return if (downloadsBlockingSuspend) acquireLocks() - if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) { + val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) { + SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden + SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested + else -> false + } + if (!steamPause) { completeNativeSleep() } else if (nativeSleepToken == null && steamSleepToken == null && !SessionState.suspended && !suspendAttemptFailed) { prepareNativeSleep() } + val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady val shouldSuspend = steamSleepToken != null || when (suspendPolicy) { - SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend + SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend - SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend + SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend else -> false } if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return @@ -1341,7 +1528,7 @@ class SessionService : Service() { suspendOperationPending = true SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf( "policy" to suspendPolicy, "activityVisible" to activityVisible, - "screenOn" to screenOn, "steamSleep" to (steamSleepToken != null), + "screenOn" to screenOn, "display" to sessionDisplayId, "steamSleep" to (steamSleepToken != null), "xrActivityVisible" to xrActivityVisible, "nativeSleep" to (nativeSleepToken != null), )) @@ -1401,7 +1588,7 @@ class SessionService : Service() { private fun resumeSteamSleepOnReturn() { if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) { - if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake() + if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake() completeNativeSleep() completeSteamSleep() } @@ -1412,13 +1599,14 @@ class SessionService : Service() { nativeSleepToken = token nativeSleepReady = false nativeSleepFallback = false + nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE val dir = LinuxRuntime.sessionRoot(this) runCatching { val staged = File(dir, "steam-native-sleep.tmp") - staged.writeText("$token\n") + staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n") check(staged.renameTo(File(dir, "steam-native-sleep"))) }.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) } - SessionEvents.record("steam.native_sleep_requested") + SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle)) val gen = sessionGen val timeout = Runnable { if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable @@ -1444,6 +1632,7 @@ class SessionService : Service() { nativeSleepToken = null nativeSleepReady = false nativeSleepFallback = false + nativeSleepIdle = false } private fun requestSteamWake() { @@ -1516,6 +1705,7 @@ class SessionService : Service() { private fun stopSession(status: Int) { cancelQueuedSteamGame() + gameExitWatchToken++ completeNativeSleep() val (ended, endedArch) = synchronized(stopLock) { if (!SessionState.running) return @@ -1610,6 +1800,10 @@ class SessionService : Service() { unregisterReceiver(screenReceiver) screenReceiverRegistered = false } + if (displayListenerRegistered) { + (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.unregisterDisplayListener(displayListener) + displayListenerRegistered = false + } super.onDestroy() } @@ -1752,6 +1946,7 @@ class SessionService : Service() { LinuxRuntime.rootDir(this@SessionService), receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()), client = SessionState.steamClientArch, + mounts = steamLibraryMounts, ) if (wasActive != downloadActive) refreshNotification() updateSuspendPolicy() @@ -1770,13 +1965,16 @@ class SessionService : Service() { pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId)) } Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) + - "; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller") + "; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) + else if (SessionState.mode == MODE_STEAM && !SessionPrefs.builtinPad(this)) "one per connected controller, no built-in pad" + else "an Xbox 360 controller") } companion object { private const val TAG = "SessionService" private const val TRACER_NICE = -6 private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug" + private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG" private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env" private const val ENV_SWITCH_NAME = "droiddeck-env" @@ -1796,6 +1994,10 @@ class SessionService : Service() { private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED" private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE" private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN" + private const val ACTION_ACTIVITY_DISPLAY = "com.droiddeck.launcher.ACTIVITY_DISPLAY" + private const val ACTION_ACTIVITY_DISPLAY_CHANGED = "com.droiddeck.launcher.ACTIVITY_DISPLAY_CHANGED" + private const val EXTRA_DISPLAY_ID = "displayId" + private const val EXTRA_ACTIVITY_DISPLAY_ID = "activityDisplayId" private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE" private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN" private const val ACTION_SIGN_IN_HOLD = "com.droiddeck.launcher.SIGN_IN_HOLD" @@ -1812,6 +2014,12 @@ class SessionService : Service() { private const val GAME_LAUNCH_RETRY_MS = 250L private const val DOWNLOAD_POLL_MS = 2_000L private const val STEAM_GAME_REQUEST = "steam-game" + private const val GAME_URL_PREFIX = "steam://rungameid/" + /** How long a front-end launch may take to start (downloads, first-run installs) before the exit watch gives up. */ + private const val GAME_START_TIMEOUT_MS = 20 * 60_000L + /** How long the game must stay gone before the client is asked to exit: launchers restart games. */ + private const val GAME_EXIT_GRACE_MS = 10_000L + private const val GAME_EXIT_POLL_MS = 2_000L private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput" private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad" @@ -1832,8 +2040,12 @@ class SessionService : Service() { fun start( context: Context, mode: String = MODE_STEAM, program: String? = null, steamUi: String? = null, steamUrl: String? = null, programArgs: Array? = null, + displayId: Int = Display.DEFAULT_DISPLAY, ) { + SessionState.activityDisplayId = displayId val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode) + .putExtra(EXTRA_DISPLAY_ID, displayId) + .putExtra(EXTRA_ACTIVITY_DISPLAY_ID, displayId) if (program != null) intent.putExtra(EXTRA_PROGRAM, program) if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs) if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi) @@ -1854,10 +2066,29 @@ class SessionService : Service() { context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PIP_BEGIN)) } - fun setActivityVisible(context: Context, visible: Boolean) { + fun setActivityVisible(context: Context, visible: Boolean, displayId: Int = contextDisplayId(context)) { if (!SessionState.running) return val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN - context.startService(Intent(context, SessionService::class.java).setAction(action)) + SessionState.activityDisplayId = displayId + context.startService(Intent(context, SessionService::class.java).setAction(action) + .putExtra(EXTRA_DISPLAY_ID, displayId) + .putExtra(EXTRA_ACTIVITY_DISPLAY_ID, displayId)) + } + + fun setActivityDisplay(context: Context, displayId: Int) { + if (!SessionState.running) return + SessionState.activityDisplayId = displayId + context.startService(Intent(context, SessionService::class.java).setAction(ACTION_ACTIVITY_DISPLAY) + .putExtra(EXTRA_DISPLAY_ID, displayId)) + context.startService(Intent(context, SessionService::class.java).setAction(ACTION_ACTIVITY_DISPLAY_CHANGED) + .putExtra(EXTRA_ACTIVITY_DISPLAY_ID, displayId)) + } + + @Suppress("DEPRECATION") + private fun contextDisplayId(context: Context): Int { + val activity = context as? android.app.Activity ?: return Display.DEFAULT_DISPLAY + return if (Build.VERSION.SDK_INT >= 30) activity.display?.displayId ?: activity.windowManager.defaultDisplay.displayId + else activity.windowManager.defaultDisplay.displayId } fun setXrActivityVisible(context: Context, visible: Boolean) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index ab32b9059..cebc33caa 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -42,6 +42,8 @@ object SessionState { @Volatile var running = false @Volatile + var activityDisplayId = 0 + @Volatile var runtimeDriver: String? = null var suspended by mutableStateOf(false) @Volatile var pipActive = false diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt index fbe908408..d694d6786 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -2,14 +2,22 @@ package com.droiddeck.launcher.session import android.os.Process import java.io.File +import java.io.IOException import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException internal class SessionSuspendController( private val sessionRoot: () -> Int, private val helperRoots: () -> List, private val suspendAudio: () -> Unit, private val resumeAudio: () -> Unit, + private val procRoot: File = File("/proc"), + private val signal: (Int, Int) -> Unit = Process::sendSignal, + private val threadSignal: (Int, Int, Int) -> Unit = { pid, tid, signal -> ThreadSignals.tgkill(pid, tid, signal) }, + private val sleep: (Long) -> Unit = Thread::sleep, + private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }, + private val executor: ExecutorService = Executors.newSingleThreadExecutor(), ) { private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD } @@ -24,96 +32,95 @@ internal class SessionSuspendController( val parent: Int, val started: Long, val state: Char, + val tracer: Int, ) - private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long) + @Volatile private var closing = false private var frozen = emptyList() fun freeze(completion: (Boolean) -> Unit) { - submit { - if (closing) { - completion(false) - return@submit - } - if (frozen.isNotEmpty()) { - val fullyFrozen = frozen.any { it.role == Role.SESSION_ROOT } && frozen.all(::isStoppedOrGone) - if (!fullyFrozen && restore(frozen)) frozen = emptyList() - completion(fullyFrozen) - return@submit - } - val records = LinkedHashMap, ProcessRecord>() - val root = sessionRoot() - if (root <= 1) { - completion(false) - return@submit - } - runAudio(suspendAudio) - val helpers = helperRoots().filter { it > 1 && it != root }.distinct() - var stablePasses = 0 + submit({ + val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started } var complete = false - repeat(10) { pass -> - if (closing) { - restore(records.values.toList()) - runAudio(resumeAudio) - completion(false) - return@submit + var interrupted = false + try { + val root = records.values.firstOrNull { it.role == Role.SESSION_ROOT } + ?: readProcess(sessionRoot())?.takeIf { it.pid > 1 }?.let { + ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) + } ?: throw IOException("Session root is unavailable") + records[root.pid to root.started] = root + runAudio(suspendAudio) + val helpers = helperRoots().filter { it > 1 && it != root.pid }.distinct() + for (helper in helpers) { + if (records.values.any { it.pid == helper && it.role == Role.HELPER_ROOT }) continue + readProcess(helper)?.let { + records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) + } } - val includeRoots = pass >= 2 - addSnapshot(records, root, helpers, includeRoots) - stop(records.values.toList(), includeRoots) - Thread.sleep(if (pass == 0) 70L else 35L) - val rootCaptured = records.values.any { it.role == Role.SESSION_ROOT } - if (rootCaptured && records.isNotEmpty() && records.values.all(::isStoppedOrGone)) { - stablePasses++ - if (stablePasses >= 2) complete = true - } else { - stablePasses = 0 + val deadline = nowMillis() + FREEZE_TIMEOUT_MS + var previous: Map? = null + for (pass in 0 until FREEZE_MAX_PASSES) { + if (closing || nowMillis() >= deadline) break + try { + if (readProcess(root.pid)?.started != root.started) break + addSnapshot(records, root.pid, helpers) + if (readProcess(root.pid)?.started != root.started) break + stop(records.values.toList()) + sleep(if (pass == 0) 70L else 35L) + if (closing || nowMillis() >= deadline) break + val current = stoppedSnapshot(records.values.toList(), root) + if (current != null && current == previous) { + complete = true + break + } + previous = current + } catch (_: IOException) { + previous = null + sleep(35L) + } } - } - if (complete) { + } catch (_: InterruptedException) { + interrupted = true + Thread.interrupted() + } catch (_: Exception) { + } finally { frozen = records.values.toList() - completion(true) - } else { - val restored = restore(records.values.toList()) - runAudio(resumeAudio) - if (!restored) frozen = records.values.toList() - completion(false) + if (!complete) { + if (restoreSafely(frozen)) frozen = emptyList() + runAudio(resumeAudio) + } + if (interrupted) Thread.currentThread().interrupt() } - } + completion(complete) + }, { completion(false) }) } fun resume(completion: (Boolean) -> Unit) { - submit { + submit({ val records = frozen runAudio(resumeAudio) - val resumed = restore(records) + val resumed = restoreSafely(records) if (resumed) frozen = emptyList() completion(resumed) - } + }, { completion(false) }) } fun closeAndResume(completion: () -> Unit) { closing = true - submit { - restore(frozen) - frozen = emptyList() + submit({ + if (restoreSafely(frozen)) frozen = emptyList() completion() executor.shutdown() - } + }, completion) } - private fun submit(task: () -> Unit) { + private fun submit(task: () -> Unit, rejected: () -> Unit) { try { - executor.execute { - try { - task() - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - } - } - } catch (_: RuntimeException) { - task() + executor.execute(task) + } catch (_: RejectedExecutionException) { + rejected() } } @@ -128,23 +135,25 @@ internal class SessionSuspendController( records: MutableMap, ProcessRecord>, root: Int, helpers: List, - includeRoots: Boolean, ) { val processes = readProcesses() val byPid = processes.associateBy { it.pid } - for (process in descendants(root, processes, byPid)) { - records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.SESSION_CHILD) + val rootRecord = records.values.first { it.role == Role.SESSION_ROOT } + if (byPid[root]?.started != rootRecord.started) throw IOException("Session root exited or changed") + val liveHelpers = helpers.filter { pid -> + records.values.any { it.pid == pid && it.role == Role.HELPER_ROOT && it.started == byPid[pid]?.started } } - for (helper in helpers) { + for (process in descendants(root, processes, byPid) + processes.filter { it.tracer == root }) { + records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.SESSION_CHILD)) + } + for (helper in liveHelpers) { for (process in descendants(helper, processes, byPid)) { - records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.HELPER_CHILD) + records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.HELPER_CHILD)) } } - if (includeRoots) { - byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) } - for (helper in helpers) { - byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) } - } + byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) } + for (helper in liveHelpers) { + byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) } } } @@ -168,36 +177,84 @@ internal class SessionSuspendController( } private fun readProcesses(): List = - File("/proc").listFiles().orEmpty().mapNotNull { entry -> + (procRoot.listFiles() ?: throw IOException("Cannot enumerate processes")).mapNotNull { entry -> val pid = entry.name.toIntOrNull() ?: return@mapNotNull null - readProcess(pid) + try { readProcess(pid) } catch (_: IOException) { null } } private fun readProcess(pid: Int): ProcessInfo? { - val stat = try { - File("/proc/$pid/stat").readText() - } catch (_: Exception) { - return null - } - val close = stat.lastIndexOf(')') - if (close < 0 || close + 2 >= stat.length) return null - val fields = stat.substring(close + 2).trim().split(Regex("\\s+")) - if (fields.size < 20) return null - return try { - ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first()) - } catch (_: NumberFormatException) { - null + val base = File(procRoot, pid.toString()) + try { + val fields = readStat(File(base, "stat")) + val tracer = File(base, "status").useLines { lines -> + lines.firstOrNull { it.startsWith("TracerPid:") }?.substringAfter(':')?.trim()?.toIntOrNull() ?: 0 + } + return ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first(), tracer) + } catch (error: IOException) { + if (procRoot.list()?.contains(pid.toString()) == false) return null + throw error } } - private fun stop(records: List, includeRoots: Boolean) { - val descendants = records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD } - val session = records.filter { it.role == Role.SESSION_ROOT } - val helpers = records.filter { it.role == Role.HELPER_ROOT } - for (record in descendants) send(record, SIGSTOP) - if (includeRoots) { - for (record in session) send(record, SIGSTOP) - for (record in helpers) send(record, SIGSTOP) + private fun readStat(file: File): List { + val stat = file.readText() + val close = stat.lastIndexOf(')') + if (close < 0 || close + 2 >= stat.length) throw IOException("Invalid process stat: $file") + val fields = stat.substring(close + 2).trim().split(Regex("\\s+")) + if (fields.size < 20) throw IOException("Incomplete process stat: $file") + return fields + } + + private fun stoppedSnapshot(records: List, root: ProcessRecord): Map? { + // PRoot can leave the leader stopped while native worker threads keep running. + val tasks = LinkedHashMap() + var stopped = true + for (record in records) { + val current = readProcess(record.pid) + if (current == null || current.started != record.started) { + if (record == root) throw IOException("Session root exited or changed") + continue + } + val entries = File(procRoot, "${record.pid}/task").listFiles() + ?: throw IOException("Cannot enumerate threads: ${record.pid}") + if (entries.none { it.name.toIntOrNull() != null }) throw IOException("Empty thread inventory: ${record.pid}") + var liveRoot = false + for (entry in entries) { + val tid = entry.name.toIntOrNull() ?: continue + val fields = readStat(File(entry, "stat")) + val state = fields[0].first() + if (state != 'Z' && state != 'X') liveRoot = true + if (state != 'T' && state != 't' && state != 'Z' && state != 'X') stopped = false + tasks[TaskIdentity(record, tid, fields[19].toLong())] = fields[11].toLong() + fields[12].toLong() + } + if (record == root && !liveRoot) throw IOException("Session root has no live threads") + if (readProcess(record.pid)?.started != record.started) throw IOException("Process changed during inspection") + } + return tasks.takeIf { stopped && it.isNotEmpty() } + } + + // PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts + // thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its + // signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP. + private fun stop(records: List) { + for (record in records.filter { it.role == Role.SESSION_ROOT }) send(record, SIGSTOP) + for (record in records.filter { it.role == Role.HELPER_ROOT }) send(record, SIGSTOP) + for (record in records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }) { + stopThreads(record) + } + } + + private fun stopThreads(record: ProcessRecord) { + val current = try { readProcess(record.pid) } catch (_: Exception) { return } + if (current == null || current.started != record.started) return + for (entry in File(procRoot, "${record.pid}/task").listFiles().orEmpty()) { + val tid = entry.name.toIntOrNull() ?: continue + val state = try { readStat(File(entry, "stat"))[0].first() } catch (_: IOException) { continue } + if (state == 'T' || state == 't' || state == 'Z' || state == 'X') continue + try { + threadSignal(record.pid, tid, SIGSTOP) + } catch (_: Throwable) { + } } } @@ -213,7 +270,7 @@ internal class SessionSuspendController( for (record in ordered) send(record, SIGCONT) val waiting = ordered.toMutableList() repeat(RESTORE_CHECKS) { check -> - Thread.sleep(25L) + sleep(25L) waiting.removeAll { isRunningOrGone(it) } if (waiting.isEmpty()) return true if (check % 8 == 7) for (record in waiting) send(record, SIGCONT) @@ -221,30 +278,40 @@ internal class SessionSuspendController( return false } + private fun restoreSafely(records: List): Boolean = try { + restore(records) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } catch (_: Exception) { + false + } + private fun send(record: ProcessRecord, signal: Int) { - val current = readProcess(record.pid) ?: return - if (current.started != record.started) return + val current = try { readProcess(record.pid) } catch (_: Exception) { return } + if (current == null || current.started != record.started) return try { - Process.sendSignal(record.pid, signal) + this.signal(record.pid, signal) } catch (_: Throwable) { } } - private fun isStoppedOrGone(record: ProcessRecord): Boolean { - val current = readProcess(record.pid) ?: return true - if (current.started != record.started) return true - return current.state == 'T' || current.state == 't' || current.state == 'Z' || current.state == 'X' - } - private fun isRunningOrGone(record: ProcessRecord): Boolean { - val current = readProcess(record.pid) ?: return true - if (current.started != record.started) return true - return current.state != 'T' && current.state != 't' + val current = try { readProcess(record.pid) } catch (_: Exception) { return false } + if (current == null || current.started != record.started) return true + if (current.state == 'T' || current.state == 't') return false + // A traced worker in 't' is usually PRoot handling a system call; 'T' is a job-control stop. + return File(procRoot, "${record.pid}/task").listFiles().orEmpty().none { entry -> + entry.name.toIntOrNull() != null && + try { readStat(File(entry, "stat"))[0].first() == 'T' } catch (_: IOException) { false } + } } private companion object { const val SIGSTOP = 19 const val SIGCONT = 18 const val RESTORE_CHECKS = 80 + const val FREEZE_TIMEOUT_MS = 8_000L + const val FREEZE_MAX_PASSES = 256 } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt index 33b456ecc..36f320965 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt @@ -1,4 +1,10 @@ package com.droiddeck.launcher.session +import android.view.Display + internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean = xrVisible || (panelVisible && screenOn) + +internal fun sessionScreenOn(onDefaultDisplay: Boolean, interactive: Boolean, displayState: Int?): Boolean = + if (onDefaultDisplay || displayState == null || displayState == Display.STATE_UNKNOWN) interactive + else displayState == Display.STATE_ON || displayState == Display.STATE_VR || displayState == Display.STATE_ON_SUSPEND diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionVisibilityPolicy.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionVisibilityPolicy.kt new file mode 100644 index 000000000..90630ebbc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionVisibilityPolicy.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.session + +internal object SessionVisibilityPolicy { + fun isHidden( + activityStarted: Boolean, + defaultDisplayOn: Boolean, + activityOnDefaultDisplay: Boolean, + activityDisplayOn: Boolean, + xrActivityVisible: Boolean = false, + ): Boolean = !sessionHasVisibleSurface( + panelVisible = activityStarted, + screenOn = if (activityOnDefaultDisplay) defaultDisplayOn else activityDisplayOn, + xrVisible = xrActivityVisible, + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index f5304ed1e..0c430c1c5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -13,15 +13,18 @@ object SettingsReset { internal val SESSION_KEYS = setOf( "orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt", "gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature", - "hud", "hudDeck", "backActionsInverted", "touch", "osc", "shape", "steamController", - "gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9", "picturePosition", + "storesInstallLanguage", + "hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "touch", "osc", "shape", "steamController", + "gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController", + "stretch16x9", "picturePosition", "gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop", "tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus", "noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics", "upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias", - "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", + "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds", + "bootMovieApplyNext", "bootMovieSteamSeen", "questDefaultsV1", ) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamClientSetupFailure.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamClientSetupFailure.kt new file mode 100644 index 000000000..c0a53ca7a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamClientSetupFailure.kt @@ -0,0 +1,28 @@ +package com.droiddeck.launcher.session + +class SteamClientSetupFailure(val curlError: String?) { + val download get() = curlError != null + + companion object { + const val MARKER = "== steam: the Steam client could not be set up" + private const val STEP = "checking the Steam client" + private const val X64_STEP = "checking the x86-64 Steam client" + private val CURL_ERROR = Regex("""curl: \(\d+\).*""") + + fun find(lines: Sequence): SteamClientSetupFailure? { + var curlError: String? = null + var found: SteamClientSetupFailure? = null + lines.forEach { line -> + when { + line.contains(STEP) || line.contains(X64_STEP) -> { + curlError = null + found = null + } + line.contains(MARKER) -> found = SteamClientSetupFailure(curlError) + else -> CURL_ERROR.find(line)?.let { curlError = it.value.trim() } + } + } + return found + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt new file mode 100644 index 000000000..2a940a96f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt @@ -0,0 +1,218 @@ +package com.droiddeck.launcher.session + +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */ +object SteamDeckRepo { + private const val TAG = "SteamDeckRepo" + const val BASE = "https://steamdeckrepo.com" + + /** The site shows twelve movies a page. */ + const val PAGE_SIZE = 12 + const val MAX_DOWNLOAD_BYTES = 120L shl 20 + private const val MAX_JSON_BYTES = 1 shl 20 + private const val USER_AGENT = "DroidDeck (+https://github.com/Droid-Deck/DroidDeck)" + private const val CACHE_MS = 10 * 60 * 1000L + + enum class Sort(val key: String) { + TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"), + } + + /** `null` is every device. */ + val DEVICES = listOf("steam_deck", "steam_machine") + + class Post( + val id: String, val title: String, val content: String, val author: String, val thumbnail: String, val video: String, + val seconds: Int, val likes: Int, val downloads: Int, val devices: List, val url: String, + ) + + sealed class Page { + class Ok(val posts: List) : Page() + object Failed : Page() + } + + /** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */ + private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key) + + fun parse(json: String): List? { + val array = runCatching { JSONObject(json).optJSONArray("posts") }.getOrNull() ?: return null + val out = ArrayList() + for (i in 0 until array.length()) { + val o = array.optJSONObject(i) ?: continue + val id = o.text("id") + val video = o.text("video") + if (id.isEmpty() || !video.startsWith("https://")) continue + val devices = o.optJSONArray("devices")?.let { d -> (0 until d.length()).map { d.optString(it) } }.orEmpty() + out.add( + Post( + id = id, title = o.text("title").ifBlank { id }, content = o.text("content"), + author = o.optJSONObject("user")?.text("steam_name").orEmpty(), + thumbnail = o.text("thumbnail").takeIf { it.startsWith("https://") }.orEmpty(), video = video, + seconds = o.optInt("video_duration"), likes = o.optInt("likes"), downloads = o.optInt("downloads"), + devices = devices, url = o.text("url"), + ), + ) + } + return out + } + + fun url(sort: Sort, device: String?, search: String, page: Int = 1): String { + val query = StringBuilder("sort=").append(sort.key) + if (device != null) query.append("&device=").append(URLEncoder.encode(device, "UTF-8")) + val term = search.trim() + if (term.isNotEmpty()) query.append("&search=").append(URLEncoder.encode(term, "UTF-8").replace("+", "%20")) + if (page > 1) query.append("&page=").append(page) + return "$BASE/api/posts?$query" + } + + private class Cached(val at: Long, val page: Page.Ok) + private val cache = HashMap() + + fun fetch(sort: Sort, device: String?, search: String, page: Int = 1, now: Long = System.currentTimeMillis()): Page { + val url = url(sort, device, search, page) + synchronized(cache) { cache[url]?.takeIf { now - it.at < CACHE_MS }?.let { return it.page } } + val body = get(url) ?: return Page.Failed + val posts = parse(body) ?: return Page.Failed + val result = Page.Ok(posts.take(PAGE_SIZE)) + synchronized(cache) { cache[url] = Cached(now, result) } + return result + } + + /** + * The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a + * page past the end is empty and the last one is short. Null when the site cannot be reached. + */ + fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p -> + (fetch(sort, device, search, p) as? Page.Ok)?.posts?.size + }): Int? { + val first = count(1) ?: return null + if (first < PAGE_SIZE) return 1 + var good = 1 + var bad = -1 + var probe = 2 + while (probe <= cap) { + val n = count(probe) ?: return null + if (n == 0) { bad = probe; break } + if (n < PAGE_SIZE) return probe + good = probe + probe *= 2 + } + if (bad < 0) return good + while (bad - good > 1) { + val mid = (good + bad) / 2 + val n = count(mid) ?: return null + if (n == 0) bad = mid else if (n < PAGE_SIZE) return mid else good = mid + } + return good + } + + /** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */ + internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + if (out.size() + n > limit) return null + out.write(buffer, 0, n) + } + return out.toByteArray() + } + + private fun get(url: String): String? { + var connection: HttpURLConnection? = null + return try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 20_000 + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Accept", "application/json") + } + if (connection.responseCode !in 200..299) null else connection.inputStream.use { readLimited(it, MAX_JSON_BYTES)?.toString(Charsets.UTF_8) } + } catch (e: Exception) { + Log.w(TAG, "request failed: ${e.javaClass.simpleName}") + null + } finally { + runCatching { connection?.disconnect() } + } + } + + /** Fetches a small picture such as a cover; null when it cannot be had. */ + fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? { + if (!url.startsWith("https://")) return null + var connection: HttpURLConnection? = null + return try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 20_000 + setRequestProperty("User-Agent", USER_AGENT) + } + if (connection.responseCode !in 200..299 || connection.contentLengthLong > limit) null + else connection.inputStream.use { input -> readLimited(input, limit)?.takeIf { it.isNotEmpty() } } + } catch (e: Exception) { + null + } finally { + runCatching { connection?.disconnect() } + } + } + + /** + * Downloads a movie to [target] through the site's own download address, so the site counts it like a download from + * its website, and falls back to the video's address. True when a file was saved. + */ + fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean { + for (url in listOf("$BASE/post/download/${post.id}", post.video)) { + if (save(url, target, onProgress)) return true + } + return false + } + + private fun save(url: String, target: File, onProgress: (Int) -> Unit): Boolean { + var connection: HttpURLConnection? = null + val staged = File(target.parentFile, target.name + ".part") + return try { + target.parentFile?.mkdirs() + connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 30_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", USER_AGENT) + } + if (connection.responseCode !in 200..299) return false + val type = connection.contentType.orEmpty().lowercase() + if (type.startsWith("text/") || type.contains("json")) return false + val total = connection.contentLengthLong + if (total > MAX_DOWNLOAD_BYTES) return false + var done = 0L + var lastPercent = -1 + connection.inputStream.use { input -> + staged.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + done += n + if (done > MAX_DOWNLOAD_BYTES) return false + output.write(buffer, 0, n) + if (total > 0) { + val percent = (done * 100 / total).toInt() + if (percent != lastPercent) { lastPercent = percent; onProgress(percent) } + } + } + } + } + done > 0 && (total <= 0 || done == total) && staged.renameTo(target) + } catch (e: Exception) { + Log.w(TAG, "download failed: ${e.javaClass.simpleName}") + false + } finally { + runCatching { connection?.disconnect() } + staged.delete() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt index 2558bfe94..37f7c1fb9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt @@ -18,8 +18,9 @@ internal class SteamDownloadMonitor { private var lastReceivedBytes = -1L private var lastReceivedAt = 0L - fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null): Boolean { - val current = manifests(runtimeRoot).associate { it.path to read(it) } + fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null, + mounts: Map = emptyMap()): Boolean { + val current = manifests(runtimeRoot, mounts).associate { it.path to read(it) } val progressed = current.any { (path, state) -> state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded) } @@ -98,7 +99,7 @@ internal class SteamDownloadMonitor { now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS } - private fun manifests(runtimeRoot: File): List { + private fun manifests(runtimeRoot: File, mounts: Map): List { val steamRoots = linkedSetOf() for (home in STEAM_HOMES) { val primary = File(runtimeRoot, home) @@ -106,7 +107,7 @@ internal class SteamDownloadMonitor { val libraryFolders = File(primary, "steamapps/libraryfolders.vdf") runCatching { PATH.findAll(libraryFolders.readText()).forEach { match -> - steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/")) + steamRoots += hostPath(runtimeRoot, match.groupValues[1], mounts) } } } @@ -117,6 +118,15 @@ internal class SteamDownloadMonitor { } } + private fun hostPath(runtimeRoot: File, guest: String, mounts: Map): File { + val path = guest.trimEnd('/') + for ((mount, host) in mounts) { + if (path == mount) return host + if (path.startsWith("$mount/")) return File(host, path.removePrefix("$mount/")) + } + return File(runtimeRoot, path.removePrefix("/")) + } + private fun read(file: File): ManifestState { val text = runCatching { file.readText() }.getOrDefault("") val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L diff --git a/app/src/main/java/com/droiddeck/launcher/session/ThreadSignals.kt b/app/src/main/java/com/droiddeck/launcher/session/ThreadSignals.kt new file mode 100644 index 000000000..e3323ce95 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/ThreadSignals.kt @@ -0,0 +1,9 @@ +package com.droiddeck.launcher.session + +internal object ThreadSignals { + init { + System.loadLibrary("sdnative") + } + + @JvmStatic external fun tgkill(tgid: Int, tid: Int, signal: Int): Int +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt b/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt new file mode 100644 index 000000000..8e4b24730 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt @@ -0,0 +1,28 @@ +package com.droiddeck.launcher.session + +import android.util.Log +import com.droiddeck.launcher.core.SessionPart +import java.io.File + +/** Steam's Remote Play V4L2 encoder in the guest talks to this MediaCodec encoder (venc.c, venc_server.cpp). */ +class VideoEncodeBridge(val socket: File) : SessionPart() { + override fun start() { + socket.parentFile?.mkdirs() + val ok = runCatching { System.loadLibrary("ddvenc"); nativeStart(socket.absolutePath) } + .onFailure { Log.w(TAG, "hardware video encoding unavailable", it) } + .getOrDefault(false) + Log.i(TAG, if (ok) "Remote Play hardware encoding on at $socket" else "Remote Play hardware encoding off") + } + + override fun stop() { + runCatching { nativeStop() } + socket.delete() + } + + private external fun nativeStart(path: String): Boolean + private external fun nativeStop() + + private companion object { + const val TAG = "VideoEncodeBridge" + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt index c79171df2..2c5a1aa0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt @@ -369,7 +369,9 @@ object WinComponents { val archiveName = archive.optString("name") val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName val file = File(work, "${c.name}.files.tar.xz") - if (!Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) { + // The snapshot came straight from github.com, so its digest may vouch for a mirror's copy of the archive. + val sha = archive.optString("sha256").takeIf { it.matches(Regex("(?i)[0-9a-f]{64}")) } + if (!Downloader.downloadFile(url, file, false, sha) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) { return null to "download failed: $archiveName" } archive.optString("sha256").takeIf { it.length == 64 }?.let { sha -> diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index 3c3d38d26..669a13eee 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -13,6 +13,7 @@ import java.io.File import java.net.HttpURLConnection import java.net.URL import java.security.MessageDigest +import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import org.json.JSONObject @@ -57,7 +58,7 @@ object CloudSaves { val item = sidecar.extra["catalogItemId"].orEmpty() if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder") } - Store.AMAZON -> "" + Store.AMAZON, Store.ITCH, Store.XBOX -> "" } ?: return null p.edit().putString("cloud_template_$id", fetched).apply() Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}") @@ -95,7 +96,7 @@ object CloudSaves { val dir = when (store) { Store.GOG -> CloudSavePaths.gog(template, pfx, folder) Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id) - Store.AMAZON -> null + Store.AMAZON, Store.ITCH, Store.XBOX -> null } return dir to (if (dir == null) "unresolved" else "ok") } @@ -120,7 +121,7 @@ object CloudSaves { val account = EpicCredentialStore.load(context)?.accountId.orEmpty() if (token == null || account.isEmpty()) null else EpicCloud(account, id, token) } - Store.AMAZON -> null + Store.AMAZON, Store.ITCH, Store.XBOX -> null } private fun gogScopedToken(context: Context, id: String, clientId: String): String? { @@ -150,19 +151,22 @@ object CloudSaves { fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true) fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true) - fun downloadOrDefer(context: Context, store: Store, id: String): Result { + fun downloadOrDefer(context: Context, store: Store, id: String): Result = + downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() }) + + internal fun downloadOrDefer(context: Context, store: Store, id: String, executor: Executor): Result { val app = context.applicationContext val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed")) if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off")) if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id) - Thread({ + executor.execute { val end = System.currentTimeMillis() + PREFIX_WAIT_MS while (System.currentTimeMillis() < end) { - if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread } + if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@execute } Thread.sleep(2000) } done(store, id, "down", Result("skipped", 0, 0, "no-prefix")) - }, "cloud-deferred").start() + } return done(store, id, "down", Result("deferred", 0, 0, "no-prefix")) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt index 7a4f579ae..bf9d5f6dc 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt @@ -12,6 +12,7 @@ internal object LoginFlows { Store.GOG -> GogFlow(state) Store.EPIC -> epicFlow() Store.AMAZON -> amazonFlow() + Store.ITCH, Store.XBOX -> null } @Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null } @@ -22,6 +23,14 @@ internal object LoginFlows { override val userAgent: String get() = GogAuth.GALAXY_UA override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX) + override fun forgetSignIn(done: () -> Unit) { + runCatching { WebCookies.expire(GogAuth.SIGN_IN_URLS, GogAuth.COOKIE_DOMAIN, listOf("/")) { done() } } + .onFailure { + Log.w(TAG, "gog: could not clear the previous web sign-in: ${it.javaClass.simpleName}") + done() + } + } + override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? { val fragment = Uri.parse(url).fragment ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification) val frag = Uri.parse("x://x?$fragment") diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt index 3288e1c6e..b5e0d1fbf 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt @@ -3,7 +3,9 @@ package com.droiddeck.launcher.stores enum class Store(val id: String, val label: String, val shortLabel: String) { GOG("gog", "GOG", "GOG"), EPIC("epic", "Epic Games", "Epic"), - AMAZON("amazon", "Amazon Games", "Amazon"); + AMAZON("amazon", "Amazon Games", "Amazon"), + XBOX("xbox", "Xbox", "Xbox"), + ITCH("itch", "itch.io", "itch"); companion object { fun byId(id: String?): Store? = entries.firstOrNull { it.id == id } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt index 6b5f30186..27bf133f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt @@ -67,7 +67,10 @@ object StoreAccounts { fun write(context: Context, store: Store, json: JSONObject) { unavailable.remove(store) val f = credentialsFile(context, store) - if (seal(f, json) == null) put(f, json.toString()) + if (seal(f, json, retry = store == Store.XBOX) == null) { + check(store != Store.XBOX) { "Android Keystore is unavailable for Xbox credentials" } + put(f, json.toString()) + } } @Synchronized @@ -86,18 +89,19 @@ object StoreAccounts { fun signedInAs(context: Context, store: Store): String? { val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null + if (store == Store.XBOX) return json.optString("username").takeIf { it.isNotBlank() } if (json.optString("access_token", "").isEmpty()) return null return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label } } - private fun seal(f: File, json: JSONObject): File? { - if (keystoreFailed) return null + private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? { + if (keystoreFailed && !retry) return null val text = json.toString() val envelope = try { CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } } } catch (e: Exception) { keystoreFailed = true - Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})") + Log.w(TAG, "stores: keystore unavailable (${e.javaClass.simpleName})") return null } put(f, envelope.toString()) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index 049e2b926..debaf4ad0 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -21,7 +21,12 @@ data class CatalogItem( val sizeBytes: Long = 0L, val extra: Map = emptyMap(), val mature: Boolean = false, + val subscription: Boolean = false, + val includedWith: String = "", ) { + val accessible: Boolean get() = owned || subscription + /** Licensed now, or a supported package the account's subscription can install. */ + val installable: Boolean get() = accessible || includedWith.isNotBlank() val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank() val key: String get() = "${store.id}:$id" } @@ -49,7 +54,9 @@ fun installedCards(store: Store, installed: List, library: L return installed.filter { it.sidecar.store == store }.map { game -> val s = game.sidecar byId[s.id] ?: byTitle[s.title.lowercase()] - ?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true) + ?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, + owned = s.store != Store.XBOX || s.extra["access"] == "owned", + subscription = s.store == Store.XBOX && s.extra["access"] == "gamepass") }.distinctBy { it.id }.sortedBy { it.title.lowercase() } } @@ -82,6 +89,18 @@ internal fun retainCatalogOrder(items: List, previous: List, + found: List, + shelves: StoreShelves?, +): CatalogItem? = + library.firstOrNull { it.store == store && it.id == id } + ?: found.firstOrNull { it.store == store && it.id == id } + ?: shelves?.all?.firstOrNull { it.store == store && it.id == id } + fun mergeItems(old: List?, new: List): List { if (old.isNullOrEmpty()) return new val before = old.associateBy { it.key } @@ -89,6 +108,19 @@ fun mergeItems(old: List?, new: List): List?, patched: List): List { + if (old.isNullOrEmpty()) return patched + val byKey = patched.associateBy { it.key } + val kept = old.map { byKey[it.key] ?: it } + val added = patched.filter { n -> old.none { it.key == n.key } } + return if (added.isEmpty()) kept else (kept + added).sortedBy { it.title.lowercase() } +} + class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) { val key: String get() = "${sidecar.store.id}:${sidecar.id}" } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLanguage.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLanguage.kt new file mode 100644 index 000000000..bc9afe3c2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLanguage.kt @@ -0,0 +1,160 @@ +package com.droiddeck.launcher.stores + +import android.content.Context +import com.droiddeck.launcher.core.AppLanguage +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONArray +import java.util.Locale + +/** + * Language for a third-party store install ([Store.GOG], [Store.EPIC]): the app/system locale by + * default, or the code the user picked in the install dialog / store settings. + * + * GOG depots and Epic install tags use overlapping but not identical codes (`en` vs `en-US`, + * `nb` vs `no`). Matching is on the language part plus a few aliases, so a Polish phone selects + * `pl-PL` depots without the user having to know the store's spelling. + */ +object StoreLanguage { + const val EXTRA = "installLanguage" + + /** Codes the settings list and Epic's tag table share; GOG often uses the same with a region. */ + val KNOWN: List = listOf( + "en", "pl", "de", "fr", "es", "es-419", "pt", "pt-BR", "it", "ru", "uk", "cs", "hu", + "nl", "sv", "da", "fi", "nb", "ro", "bg", "el", "tr", "ar", "th", "vi", "ja", "ko", + "zh-Hans", "zh-Hant", + ) + + private val ALIASES = mapOf( + "english" to "en", "polish" to "pl", "german" to "de", "french" to "fr", "spanish" to "es", + "italian" to "it", "russian" to "ru", "ukrainian" to "uk", "czech" to "cs", "hungarian" to "hu", + "dutch" to "nl", "swedish" to "sv", "danish" to "da", "finnish" to "fi", "norwegian" to "nb", + "no" to "nb", "romanian" to "ro", "bulgarian" to "bg", "greek" to "el", "turkish" to "tr", + "arabic" to "ar", "thai" to "th", "vietnamese" to "vi", "japanese" to "ja", "korean" to "ko", + "schinese" to "zh-hans", "tchinese" to "zh-hant", "chinese" to "zh-hans", + "brazilian" to "pt-br", "portuguese" to "pt", + ) + + fun selectable(store: Store): Boolean = store == Store.GOG || store == Store.EPIC + + /** Tag actually sent to the store: settings override, else the app/system locale. */ + @JvmStatic + fun preferredCode(context: Context): String { + val chosen = SessionPrefs.storesInstallLanguage(context) + if (chosen.isNotEmpty()) return chosen + return tagFor(AppLanguage.effective(context)) + } + + @JvmStatic + fun preferredCode(): String = tagFor(Locale.getDefault()) + + fun tagFor(locale: Locale): String { + val language = locale.language.ifEmpty { "en" } + val country = locale.country + val script = locale.script + return when { + language == "zh" && (script == "Hant" || country == "TW" || country == "HK" || country == "MO") -> "zh-Hant" + language == "zh" -> "zh-Hans" + language == "pt" && (country.isEmpty() || country == "BR") -> "pt-BR" + language == "es" && country.isNotEmpty() && country != "ES" -> "es-419" + country.isNotEmpty() -> "$language-$country" + else -> language + } + } + + fun localeFor(code: String?, context: Context): Locale { + val tag = code?.takeIf { it.isNotBlank() } ?: preferredCode(context) + return Locale.forLanguageTag(tag.replace('_', '-')) + } + + fun fallbackList(store: Store): List = if (selectable(store)) KNOWN else emptyList() + + /** + * One code from [available]. An exact normalized code wins (`pl-PL` over `pl`). A base-language + * match is used only when that exact code is not offered, then English, then the first code. + */ + @JvmStatic + fun pick(available: List, preferred: String): String { + if (available.isEmpty()) return preferred + val want = primary(preferred) ?: return available.first() + return available.firstOrNull { primary(it) == want } + ?: available.firstOrNull { depotAccepted(arrayOf(it), preferred) } + ?: available.firstOrNull { depotAccepted(arrayOf(it), "en") } + ?: available.first() + } + + /** + * True when a GOG depot whose `languages` array is [listed] overlaps [chosen], including a + * base-language match (`pl` accepts `pl-PL`). Shared depots (`*`, empty, missing) always match. + * Install selection uses [depotMatchesResolved] after [pick] has chosen a single code. + */ + @JvmStatic + fun depotAccepted(listed: Array?, chosen: String): Boolean { + if (listed.isNullOrEmpty()) return true + val want = tokens(chosen) + return listed.any { have -> + val parts = tokens(have) + parts.contains("*") || parts.any { it in want } + } + } + + /** + * True when [listed] is shared, or contains the one code [resolved] already selected. + * `pl-PL` does not also keep a `pl` depot. + */ + @JvmStatic + fun depotMatchesResolved(listed: Array?, resolved: String): Boolean { + if (listed.isNullOrEmpty()) return true + if (listed.any { it.trim() == "*" }) return true + val want = primary(resolved) ?: return false + return listed.any { primary(it) == want } + } + + /** Normalized code without collapsing to the base language. `pl-PL` stays `pl-pl`. */ + internal fun primary(code: String): String? { + val raw = code.trim().lowercase().replace('_', '-') + if (raw.isEmpty()) return null + if (raw == "*") return "*" + return ALIASES[raw] ?: raw + } + + @JvmStatic + fun codesFromDepots(depots: JSONArray?): List { + if (depots == null) return emptyList() + val seen = LinkedHashSet() + for (i in 0 until depots.length()) { + val langs = depots.optJSONObject(i)?.optJSONArray("languages") ?: continue + for (j in 0 until langs.length()) { + val raw = langs.optString(j, "").trim() + if (raw.isEmpty() || raw == "*") continue + val key = raw.lowercase() + if (seen.none { it.lowercase() == key }) seen.add(raw) + } + } + return seen.toList() + } + + fun displayName(code: String): String { + if (code.isEmpty()) return "" + val locale = Locale.forLanguageTag(code.replace('_', '-')) + AppLanguage.supportedTag(locale)?.let { return AppLanguage.nativeName(it) } + val name = locale.getDisplayName(locale) + return name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } + } + + internal fun tokens(code: String): Set { + val raw = code.trim().lowercase().replace('_', '-') + if (raw.isEmpty()) return emptySet() + if (raw == "*") return setOf("*") + val mapped = ALIASES[raw] ?: raw + val lang = mapped.substringBefore('-').let { ALIASES[it] ?: it } + val out = mutableSetOf(mapped, lang) + if (mapped.startsWith("zh-hant") || mapped == "zh-tw" || mapped == "zh-hk") { + out.add("zh-hant"); out.add("zh") + } + if (mapped.startsWith("zh-hans") || mapped == "zh-cn") { + out.add("zh-hans"); out.add("zh") + } + if (mapped == "pt-br" || mapped == "br") out.add("pt-br") + return out + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index 4486df81e..e17aac578 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores import android.content.Context import android.util.Log +import com.droiddeck.launcher.frontend.AddedGames import com.droiddeck.launcher.frontend.Library import java.io.File @@ -63,6 +64,14 @@ object StoreLaunch { private const val CODE_VAR = "DD_X" private const val CLIENT_VAR = "DD_A" + fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? { + val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder -> + StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true + } ?: return null + val game = AddedGames.single(context, folder.path) ?: return null + return Library.addedGame(context, game) + } + fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) { if (sidecar.launcher != LAUNCHER) return val file = File(folder, LAUNCHER) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt index 4005ff2b3..b4b9f7669 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt @@ -37,6 +37,7 @@ object StoreLaunchRequests { } fun stop() { + com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() synchronized(lock) { observer?.stopWatching() observer = null @@ -50,6 +51,13 @@ object StoreLaunchRequests { return } val reply = when (request.optString("op")) { + "xbox-released" -> { + com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease")) + JSONObject().put("ok", true) + } + "xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease"))) + "xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) } + .getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") } "gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) } .getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") } "epic-code" -> { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt index 241635843..1b5c6ccdd 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt @@ -5,6 +5,7 @@ import android.content.res.Configuration import android.graphics.Color import android.net.Uri import android.net.http.SslError +import android.os.Build import android.os.Bundle import android.os.Message import android.util.Log @@ -18,6 +19,7 @@ import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Button @@ -38,8 +40,12 @@ class StoreLoginActivity : ComponentActivity() { const val EXTRA_STORE = "store" const val EXTRA_CLIENT_URL = "client_url" private const val KEY_STATE = "oauth_state" + private const val KEY_SIGN_IN_CLEARED = "sign_in_cleared" @Volatile internal var floodColor: Int? = null - internal const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36" + internal fun chromeUserAgent(webView: String?): String = + webView?.replace(Regex(" Build/[^;)]*"), "")?.replace("; wv)", ")")?.replace(" Version/4.0", "") + ?.takeIf { "Chrome/" in it } + ?: "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36" fun generateState(): String { val bytes = ByteArray(24) @@ -54,6 +60,7 @@ class StoreLoginActivity : ComponentActivity() { fun isRedirect(url: String): Boolean fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? val readsPage: Boolean get() = false + fun forgetSignIn(done: () -> Unit) = done() } private var store: Store? = null @@ -74,6 +81,8 @@ class StoreLoginActivity : ComponentActivity() { private var oauthState: String? = null private val captured = AtomicBoolean(false) private var flooded = false + private val chromeUa by lazy { chromeUserAgent(runCatching { WebSettings.getDefaultUserAgent(this) }.getOrNull()) } + private var signInCleared = false private fun dp(value: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt() @@ -109,12 +118,18 @@ class StoreLoginActivity : ComponentActivity() { } } }) - webView.loadUrl(flow!!.startUrl) + val start = flow!!.startUrl + signInCleared = savedInstanceState?.getBoolean(KEY_SIGN_IN_CLEARED) == true + if (signInCleared) webView.loadUrl(start) + else flow!!.forgetSignIn { + signInCleared = true + if (!isDestroyed) webView.loadUrl(start) + } } private fun newFlow(state: String): Flow? { val url = clientUrl - if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url) + if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url, chromeUa) return store?.let { LoginFlows.forStore(it, state) } } @@ -225,7 +240,7 @@ class StoreLoginActivity : ComponentActivity() { override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean { if (resultMsg == null) return false dismissPopup() - val child = newWebView(CHROME_UA, isPopup = true) + val child = newWebView(chromeUa, isPopup = true) popupWebView = child popupHost.addView(child, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) popupHost.visibility = View.VISIBLE @@ -255,6 +270,8 @@ class StoreLoginActivity : ComponentActivity() { } private inner class Client(private val isPopup: Boolean) : WebViewClient() { + private var mainFrameUrl: String? = null + override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { Log.d(TAG, "page[popup=$isPopup]: ${StoreLog.redactUrl(url)}") if (url != null && view != null && flow?.isRedirect(url) == true && flow?.readsPage != true) complete(view, url) @@ -277,10 +294,14 @@ class StoreLoginActivity : ComponentActivity() { override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { handler?.cancel() - showError(getString(R.string.stores_login_error_ssl)) + val url = error?.url + if (url == null || url == view?.url || url == mainFrameUrl) showError(getString(R.string.stores_login_error_ssl)) } - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = route(view, request.url) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + if (request.isForMainFrame) mainFrameUrl = request.url.toString() + return route(view, request.url) + } @Suppress("DEPRECATION") override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = route(view, Uri.parse(url)) @@ -291,8 +312,8 @@ class StoreLoginActivity : ComponentActivity() { if (f.isRedirect(url) && !f.readsPage) { complete(view, url); return true } if (!isPopup && store == Store.GOG) { if (GogAuth.isGogHost(uri.host) && view.settings.userAgentString != f.userAgent) { view.settings.userAgentString = f.userAgent; return false } - if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != CHROME_UA) { - view.settings.userAgentString = CHROME_UA + if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != chromeUa) { + view.settings.userAgentString = chromeUa CookieManager.getInstance().setAcceptThirdPartyCookies(view, true) view.loadUrl(url) return true @@ -330,6 +351,7 @@ class StoreLoginActivity : ComponentActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) oauthState?.let { outState.putString(KEY_STATE, it) } + outState.putBoolean(KEY_SIGN_IN_CLEARED, signInCleared) } override fun onStart() { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt index 8517a1aa0..f3a4e495f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt @@ -25,7 +25,7 @@ internal object StoreNet { headers.forEach { (k, v) -> setRequestProperty(k, v) } } val code = conn.responseCode - if (code !in 200..299) { Log.w(TAG, "GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn) + if (code !in 200..299) { StoresState.logLine("GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn) } catch (e: Exception) { Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}") null diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt index 9e37226f8..454e3d23b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -31,7 +31,7 @@ object StoreSizes { private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) } fun request(context: Context, item: CatalogItem) { - if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return + if (!item.accessible || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return resolve(context, item).thenAccept { bytes -> if (bytes != null) StoresState.post { known[item.key] = bytes } } @@ -39,7 +39,7 @@ object StoreSizes { fun resolve(context: Context, item: CatalogItem): CompletableFuture { val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 } - if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available) + if (!item.accessible || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available) if (available != null) return CompletableFuture.completedFuture(available) val app = context.applicationContext val version = item.extra["version"].orEmpty() @@ -54,7 +54,8 @@ object StoreSizes { when (item.store) { Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply() - Store.GOG -> {} + Store.ITCH -> {} + Store.GOG, Store.XBOX -> {} } } }, pool).exceptionally { error -> @@ -98,7 +99,10 @@ object StoreSizes { val json = EpicApiClient.getManifestApiJson(token, ns, catalogItemId, item.id) ?: return 0L val bytes = EpicDownloadManager.downloadManifest(json, EpicDownloadManager.parseCdnUrls(json)) ?: return 0L val manifest = EpicDownloadManager.parseManifest(bytes) ?: return 0L - EpicDownloadManager.resolveInstallFiles(manifest, EpicInstallTags.tagsForDevice()).sumOf { it.fileSize() } + EpicDownloadManager.resolveInstallFiles( + manifest, + EpicInstallTags.tagsFor(StoreLanguage.localeFor(null, context)), + ).sumOf { it.fileSize() } } Store.AMAZON -> { val token = AmazonCredentialStore.getValidAccessToken(context) ?: return 0L @@ -107,6 +111,7 @@ object StoreSizes { val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L AmazonManifest.parse(bytes).totalInstallSize } - Store.GOG -> 0L + Store.GOG, Store.XBOX -> 0L + Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreTabs.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreTabs.kt new file mode 100644 index 000000000..975626168 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreTabs.kt @@ -0,0 +1,9 @@ +package com.droiddeck.launcher.stores + +data class StoreTab(val store: Store, val shown: Boolean) + +fun arrangeStoreTabs(order: List, hidden: Set): List { + val known = Store.entries.associateBy { it.id } + val listed = order.mapNotNull { known[it] }.distinct() + return (listed + Store.entries.filterNot { it in listed }).map { StoreTab(it, it.id !in hidden) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 6b99210e9..e780733d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -16,14 +16,19 @@ import java.util.Locale object StoresState { private const val TAG = "Stores" + private const val SEARCH_DELAY_MS = 400L private val main = Handler(Looper.getMainLooper()) val accounts = mutableStateMapOf() + var accountsLoaded by mutableStateOf(false) + private set val library = mutableStateMapOf>() val shelves = mutableStateMapOf() + val found = mutableStateMapOf>() + val status = mutableStateMapOf() val problems = mutableStateMapOf() @@ -64,6 +69,8 @@ object StoresState { private val backends = HashMap() + private val searchSeq = java.util.concurrent.ConcurrentHashMap() + fun backend(store: Store): StoreBackend? = backends[store] fun register(backend: StoreBackend) { backends[backend.store] = backend } @@ -79,11 +86,15 @@ object StoresState { val partial = StoreInstallRoot.unfinished(app) val version = if (StoresNative.available) StoresNative.version ?: "" else "" main.post { - accounts.clear() - found.forEach { (store, name) -> accounts[store] = name } - installed = onDisk - unfinished = partial - engine = version + androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot { + val restored = found.toMap() + accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) } + accounts.putAll(restored) + installed = onDisk + unfinished = partial + engine = version + accountsLoaded = true + } } } } @@ -117,6 +128,10 @@ object StoresState { fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id } + fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem( + store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store], + ) + fun open(context: Context, store: Store, force: Boolean = false) { val backend = backends[store] ?: return val app = context.applicationContext @@ -129,6 +144,26 @@ object StoresState { if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false) } + fun search(context: Context, store: Store, query: String) { + val backend = backends[store] ?: return + val text = query.trim() + if (text.isEmpty()) { + searchSeq.remove(store) + found.remove(store) + return + } + val seq = (searchSeq[store] ?: 0) + 1 + searchSeq[store] = seq + val app = context.applicationContext + Thread({ + Thread.sleep(SEARCH_DELAY_MS) + if (searchSeq[store] != seq) return@Thread + val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList()) + if (searchSeq[store] != seq) return@Thread + main.post { found[store] = items } + }, "stores-search").start() + } + fun signIn(context: Context, store: Store) { expired.remove(store) backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet") @@ -137,10 +172,12 @@ object StoresState { fun signOut(context: Context, store: Store) { val app = context.applicationContext background("stores-signout") { - backends[store]?.signOut(app) + val backend = backends[store] + backend?.signOut(app) if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) } - else StoreAccounts.clear(app, store) + else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store) main.post { + if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store) logLine("${store.label}: signed out") } @@ -165,32 +202,35 @@ object StoresState { askForNotifications(context) if (isUnfinished(item)) { install(context, item); return } val targets = StoreInstallRoot.targets(context) - if (targets.size > 1) pendingInstall = item else install(context, item, targets.first().root) + if (targets.size > 1 || StoreLanguage.selectable(item.store)) pendingInstall = item + else install(context, item, targets.first().root) } - fun install(context: Context, item: CatalogItem, root: java.io.File? = null) { + fun install(context: Context, item: CatalogItem, root: java.io.File? = null, language: String? = null) { val app = context.applicationContext val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, root ?: StoreInstallRoot.installRoot(app)) val target = folder.parentFile!!.parentFile!! - val check = StoreInstallRoot.checkStorage(app, item, target) + val code = language?.takeIf { it.isNotBlank() } ?: StoreLanguage.preferredCode(app) + val tagged = if (StoreLanguage.selectable(item.store)) item.copy(extra = item.extra + (StoreLanguage.EXTRA to code)) else item + val check = StoreInstallRoot.checkStorage(app, tagged, target) if (!check.enough) { val errorMsg = check.errorMessage ?: app.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(check.requiredBytes), formatBytes(check.availableBytes)) val entry = com.droiddeck.launcher.stores.download.DownloadEntry( - item.store, - item.id, - item.title, - cover = item.imageUrl, - bytesTotal = item.sizeBytes, + tagged.store, + tagged.id, + tagged.title, + cover = tagged.imageUrl, + bytesTotal = tagged.sizeBytes, location = StoreInstallRoot.labelFor(app, folder) ) - com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target) + com.droiddeck.launcher.stores.download.SavedQueue.remember(app, tagged, target) com.droiddeck.launcher.stores.download.DownloadQueue.recordFailed(app, entry, errorMsg) { - install(app, item, target) + install(app, tagged, target, code) } return } - com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target) - backends[item.store]?.install(app, item, target) ?: logLine("${item.store.label}: this build cannot install yet") + com.droiddeck.launcher.stores.download.SavedQueue.remember(app, tagged, target) + backends[tagged.store]?.install(app, tagged, target) ?: logLine("${tagged.store.label}: this build cannot install yet") } val uninstalling = mutableStateMapOf() @@ -265,6 +305,7 @@ interface StoreBackend { fun signOut(context: Context) fun syncLibrary(context: Context, force: Boolean) fun loadShelves(context: Context, force: Boolean) + fun search(context: Context, query: String): List = emptyList() fun install(context: Context, item: CatalogItem, root: java.io.File) fun uninstall(context: Context, game: InstalledStoreGame) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/WebCookies.kt b/app/src/main/java/com/droiddeck/launcher/stores/WebCookies.kt new file mode 100644 index 000000000..b44a274c5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/WebCookies.kt @@ -0,0 +1,34 @@ +package com.droiddeck.launcher.stores + +import android.net.Uri +import android.webkit.CookieManager + +internal object WebCookies { + fun expired(header: String?, domain: String, paths: List, host: String? = null): List { + val domains = listOfNotNull(domain, host?.takeIf { it.endsWith(domain) }) + return header.orEmpty().split(';').map { it.substringBefore('=').trim() }.filter { it.isNotEmpty() }.distinct() + .flatMap { name -> + paths.flatMap { path -> + listOf("$name=; Max-Age=0; Path=$path; Secure") + domains.map { "$name=; Max-Age=0; Path=$path; Secure; Domain=$it" } + } + } + } + + fun expire(urls: List, domain: String, paths: List, done: (Int) -> Unit) { + val cookies = CookieManager.getInstance() + val lines = urls.flatMap { url -> expired(cookies.getCookie(url), domain, paths, Uri.parse(url).host).map { url to it } } + if (lines.isEmpty()) { + done(0) + return + } + var left = lines.size + for ((url, line) in lines) { + cookies.setCookie(url, line) { + if (--left == 0) { + cookies.flush() + done(lines.size) + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt index e2549a3be..cfb1324f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt @@ -335,7 +335,7 @@ object DownloadQueue { StoresState.logLine("installed \"${item.entry.name}\" → ${result}") StoresState.notifyLibraryChanged(context) val games = runCatching { - com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" } + com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" } }.getOrNull() StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt index 0c9fd0ccb..5a5eae4fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt @@ -78,7 +78,7 @@ object SavedQueue { put("store", i.store.id); put("id", i.id); put("title", i.title) put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "") put("tags", i.tags); put("developer", i.developer); put("description", i.description) - put("size", i.sizeBytes); put("owned", i.owned) + put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription) put("extra", JSONObject(i.extra)) } @@ -89,7 +89,7 @@ object SavedQueue { id = o.getString("id"), title = o.getString("title"), imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null }, tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"), - owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"), + owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), sizeBytes = o.optLong("size"), extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt index a1bad4622..7f0e86b14 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt @@ -21,6 +21,7 @@ import com.droiddeck.launcher.stores.StoreInstallRoot import com.droiddeck.launcher.stores.StoreLog import com.droiddeck.launcher.stores.StoreLoginActivity import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.WebCookies import com.droiddeck.launcher.stores.download.DownloadEntry import com.droiddeck.launcher.stores.download.DownloadQueue import com.droiddeck.launcher.stores.SteamLiveShortcuts @@ -108,28 +109,15 @@ object EpicClient { fun forgetWebSignIn() { android.os.Handler(android.os.Looper.getMainLooper()).post { runCatching { - val cookies = android.webkit.CookieManager.getInstance() - var cleared = 0 - for (url in COOKIE_URLS) { - val lines = expiredCookies(cookies.getCookie(url), Uri.parse(url).host) - for (line in lines) cookies.setCookie(url, line) - cleared += lines.size + WebCookies.expire(COOKIE_URLS, COOKIE_DOMAIN, COOKIE_PATHS) { cleared -> + Log.i(TAG, "cleared the Epic web sign-in ($cleared cookie lines)") } - cookies.flush() - Log.i(TAG, "cleared the Epic web sign-in ($cleared cookie lines)") }.onFailure { Log.w(TAG, "could not clear the Epic web sign-in: ${it.javaClass.simpleName}") } } } - internal fun expiredCookies(header: String?, host: String? = null): List { - val domains = listOfNotNull(COOKIE_DOMAIN, host?.takeIf { it.endsWith(COOKIE_DOMAIN) }) - return header.orEmpty().split(';').map { it.substringBefore('=').trim() }.filter { it.isNotEmpty() }.distinct() - .flatMap { name -> - COOKIE_PATHS.flatMap { path -> - listOf("$name=; Max-Age=0; Path=$path; Secure") + domains.map { "$name=; Max-Age=0; Path=$path; Secure; Domain=$it" } - } - } - } + internal fun expiredCookies(header: String?, host: String? = null): List = + WebCookies.expired(header, COOKIE_DOMAIN, COOKIE_PATHS, host) fun read(context: Context): Status { val app = context.applicationContext @@ -404,10 +392,10 @@ object EpicClient { } } - internal fun loginFlow(url: String): StoreLoginActivity.Flow? { + internal fun loginFlow(url: String, browserAgent: String): StoreLoginActivity.Flow? { val redirect = redirectOf(url) ?: return null val agent = runCatching { EpicNative.call("auth.config")?.optString("webViewUserAgent") }.getOrNull()?.ifEmpty { null } - return ClientLogin(url, redirect, agent ?: StoreLoginActivity.CHROME_UA) + return ClientLogin(url, redirect, agent ?: browserAgent) } private class ClientLogin( diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstallTags.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstallTags.kt index 8d1018c54..3566e37ad 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstallTags.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstallTags.kt @@ -8,17 +8,20 @@ object EpicInstallTags { @Volatile private var last: Answer? = null - private fun current(): Answer? { - val locale = Locale.getDefault() + private fun answer(locale: Locale): Answer? { last?.takeIf { it.locale == locale }?.let { return it } - val answer = EpicNative.call("locale", JSONObject().put("language", locale.language).put("region", locale.country)) ?: return null - val list = answer.optJSONArray("tags") + val call = EpicNative.call("locale", JSONObject().put("language", locale.language).put("region", locale.country)) ?: return null + val list = call.optJSONArray("tags") val tags = List(list?.length() ?: 0) { list!!.optString(it) }.filter { it.isNotEmpty() } - return Answer(locale, tags, answer.optString("launchLocale", "en").ifEmpty { "en" }).also { last = it } + return Answer(locale, tags, call.optString("launchLocale", "en").ifEmpty { "en" }).also { last = it } } - @JvmStatic - fun tagsForDevice(): List = current()?.tags ?: emptyList() + fun tagsFor(locale: Locale): List = answer(locale)?.tags ?: emptyList() - fun launchLocale(): String = current()?.launchLocale ?: "en" + fun launchLocale(locale: Locale): String = answer(locale)?.launchLocale ?: "en" + + @JvmStatic + fun tagsForDevice(): List = tagsFor(Locale.getDefault()) + + fun launchLocale(): String = launchLocale(Locale.getDefault()) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt index ee6a2181d..8d40d002b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt @@ -9,6 +9,7 @@ import com.droiddeck.launcher.stores.StoreExe import com.droiddeck.launcher.stores.StoreGameSidecar import com.droiddeck.launcher.stores.StoreInstallRoot import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.StoreLanguage import com.droiddeck.launcher.stores.download.DownloadQueue import com.droiddeck.launcher.stores.download.DownloadStage import com.droiddeck.launcher.stores.download.StoreDownloadTier @@ -29,6 +30,8 @@ internal class EpicInstaller( private fun scratch(): File = StoreInstallRoot.scratchDir(app, Store.EPIC, item.id) + private fun installLocale() = StoreLanguage.localeFor(item.extra[StoreLanguage.EXTRA], app) + override fun run(handle: DownloadQueue.JobHandle): String? { handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in)) val token = EpicCredentialStore.getValidAccessToken(app) ?: throw IllegalStateException("Not signed in to Epic Games") @@ -47,7 +50,7 @@ internal class EpicInstaller( .put("gameDir", folder.absolutePath) .put("scratchDir", if (StoreInstallRoot.isRemovable(app, folder)) scratch().absolutePath else "") .put("storageLabel", StoreInstallRoot.labelFor(app, folder)) - .put("tags", JSONArray(EpicInstallTags.tagsForDevice())) + .put("tags", JSONArray(EpicInstallTags.tagsFor(installLocale()))) .put("maxWorkers", tier.networkWindow.coerceIn(1, 128)) .put("processWorkers", tier.processWorkers) .put("caBundle", "") @@ -111,7 +114,7 @@ internal class EpicInstaller( val argsAnswer = EpicNative.call( "launch.args", JSONObject().put("displayName", creds?.displayName.orEmpty()).put("accountId", creds?.accountId.orEmpty()) - .put("namespace", namespace).put("locale", EpicInstallTags.launchLocale()).put("appName", item.id) + .put("namespace", namespace).put("locale", EpicInstallTags.launchLocale(installLocale())).put("appName", item.id) .put("deploymentId", deployment).put("additional", additional), ) val argList = argsAnswer?.optJSONArray("args") @@ -122,6 +125,7 @@ internal class EpicInstaller( extra["namespace"] = namespace extra["catalogItemId"] = catalogItemId if (deployment.isNotEmpty()) extra["deploymentId"] = deployment + extra[StoreLanguage.EXTRA] = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app) extra[WinCompSources.EPIC_PREREQ_PATH] = result.optString("prereqPath") extra[WinCompSources.EPIC_PREREQ_NAME] = result.optString("prereqName") if (result.optBoolean("prereqRead", false)) extra[WinCompSources.LIST_READ] = "1" diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt index 2de0880bd..8e58fab03 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt @@ -20,6 +20,8 @@ object GogAuth { "&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=token&layout=client2" const val REDIRECT_PREFIX = "https://embed.gog.com/on_login_success" const val GALAXY_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) GOG Galaxy/2.0" + const val COOKIE_DOMAIN = ".gog.com" + val SIGN_IN_URLS = listOf("https://login.gog.com/", "https://auth.gog.com/") fun authUrl(state: String): String = "$AUTH_URL&state=${Uri.encode(state)}" diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt index 1003921c0..1ba78d795 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt @@ -9,6 +9,7 @@ import com.droiddeck.launcher.stores.StoreBackend import com.droiddeck.launcher.stores.StoreGameSidecar import com.droiddeck.launcher.stores.StoreInstallRoot import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.StoreLanguage import com.droiddeck.launcher.stores.StoreLoginActivity import com.droiddeck.launcher.stores.StoresState import com.droiddeck.launcher.stores.download.DownloadEntry @@ -37,7 +38,16 @@ object GogBackend : StoreBackend { if (cached.isNotEmpty()) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached) if (!quiet) StoresState.status[store] = app.getString(com.droiddeck.launcher.R.string.stores_sync_library) } - val result = GogLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } } + val result = GogLibrary.sync(app, force, onStatus = { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }, onList = { list -> + // Mapped here, off the main thread: the listing paints the tab before any product is asked about. + val items = list.map { GogLibrary.toCatalogItem(app, it) } + // An empty listing is not news: posting it would drop the cards already on screen. + if (items.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], items) } + }, onItems = { changed -> + // Only the products that came back are touched: a card already on screen keeps its object, and a product the grid has not seen yet is added where its title belongs. + val items = changed.map { GogLibrary.toCatalogItem(app, it) } + StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.patchItems(StoresState.library[store], items) } + }) StoresState.post { StoresState.status.remove(store) when (result) { @@ -82,6 +92,7 @@ object GogBackend : StoreBackend { handle.stage(DownloadStage.MANIFEST) StoreInstalls.begin(folder, Store.GOG, game.gameId, game.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl) var downloading = false + val language = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app) val result = GogDownloadManager.install(app, game, folder, object : GogDownloadManager.Callback { override fun onProgress(message: String, pct: Int) { when { @@ -99,15 +110,17 @@ object GogBackend : StoreBackend { handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL }) handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install") } - }, cancelled) ?: return null + }, cancelled, language) ?: return null if (cancelled.get()) return null handle.stage(DownloadStage.INSTALL, app.getString(com.droiddeck.launcher.R.string.stores_registering)) + val installedLanguage = result.language?.takeIf { it.isNotEmpty() } ?: language val sidecar = StoreGameSidecar( Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" }, installVersion = result.buildId, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl, extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(",")) - + (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()), + + (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()) + + mapOf(StoreLanguage.EXTRA to installedLanguage), ) if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games") StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogComet.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogComet.kt index 624177821..6bc1980f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogComet.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogComet.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.droiddeck.launcher.core.AddonPack import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.SessionEvents import com.droiddeck.launcher.session.SessionPrefs @@ -24,7 +25,8 @@ import java.util.concurrent.TimeUnit object GogComet { private const val TAG = "GogComet" - private const val BIN = "/usr/local/lib/droiddeck-comet/comet" + private const val DIR = "/usr/local/lib/droiddeck-comet" + private const val BIN = "$DIR/comet" private val lock = Any() @Volatile private var process: Process? = null private var pid = -1 @@ -41,7 +43,27 @@ object GogComet { fun setEnabled(context: Context, on: Boolean) { GogPrefs.get(context).edit().putBoolean("achievements", on).apply() + if (on) AddonPack.COMET.ensure(context) + } + private fun stage(context: Context): Boolean { + if (!AddonPack.COMET.installed(context)) { + AddonPack.COMET.ensure(context) + return false + } + val pack = AddonPack.COMET.dir(context) + val root = File(LinuxRuntime.rootDir(context), DIR.removePrefix("/")) + val packaged = File(pack, "version").readText() + if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return true + root.mkdirs() + pack.listFiles().orEmpty().filter { it.isFile && it.name != "version" && it.name != AddonPack.COMPLETE }.forEach { source -> + val staged = File(root, source.name + ".staged") + source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } } + staged.setExecutable(true, false) + check(staged.renameTo(File(root, source.name))) + } + File(root, "version").writeText(packaged) + return true } fun begin() = synchronized(lock) { @@ -113,7 +135,8 @@ object GogComet { return JSONObject().put("ok", true).put("ready", true).put("reason", "ready") } stopLocked() - if (!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed") + if (!runCatching { stage(context) }.onFailure { Log.w(TAG, "staging failed", it) }.getOrDefault(false) || + !File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed") val data = File(context.filesDir, "gog-achievements").apply { mkdirs() } val ready = CountDownLatch(1) val command = LinuxRuntime.command(context, null, null, Environment.getExternalStorageDirectory(), diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java index b61af1e96..05b182df7 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java @@ -3,6 +3,7 @@ package com.droiddeck.launcher.stores.gog; import android.content.Context; import android.util.Log; +import com.droiddeck.launcher.stores.StoreLanguage; import com.droiddeck.launcher.stores.StoreLog; import com.droiddeck.launcher.stores.download.StoreDownloadTier; @@ -57,6 +58,8 @@ public final class GogDownloadManager { public final long bytes; public List dependencies = new ArrayList<>(); public boolean dependenciesRead = false; + /** Language actually written, after the manifest's depot list resolved the request. Null for builds that have no language depots. */ + public String language; Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; } } @@ -67,17 +70,23 @@ public final class GogDownloadManager { private GogDownloadManager() {} public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled) throws InstallException { + return install(ctx, game, installPath, cb, cancelled, null); + } + + public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled, String language) throws InstallException { cb.onProgress("Checking sign-in…", 0); String token = GogAuth.validToken(ctx); if (token == null) throw new InstallException("Not signed in to GOG"); if (cancelled.get()) return null; + String lang = (language == null || language.isEmpty()) ? StoreLanguage.preferredCode(ctx) : language; + cb.onLog("gog: installing language " + lang); cb.onProgress("Fetching builds…", 2); String buildsUrl = "https://content-system.gog.com/products/" + game.gameId + "/os/windows/builds?generation=2"; String buildsJson = httpGet(buildsUrl, null); if (buildsJson == null) buildsJson = httpGet(buildsUrl, token); if (buildsJson != null) { - Gen2Outcome out = runGen2(ctx, game, token, buildsJson, installPath, cb, cancelled); + Gen2Outcome out = runGen2(ctx, game, token, buildsJson, installPath, cb, cancelled, lang); if (out.result != null || cancelled.get()) return out.result; if (out.hardStop) throw new InstallException(out.error); cb.onLog("gen2 unavailable: " + out.error); @@ -100,7 +109,7 @@ public final class GogDownloadManager { Result result; String error; boolean hardStop; } - private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled) { + private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) { Gen2Outcome out = new Gen2Outcome(); try { cb.onProgress("Fetching manifest…", 5); @@ -127,12 +136,21 @@ public final class GogDownloadManager { cb.onProgress("Reading depot manifests…", 10); List files = new ArrayList<>(); List depotJsons = new ArrayList<>(); + JSONArray owned = new JSONArray(); + List ownedDepots = new ArrayList<>(); for (int i = 0; i < depots.length(); i++) { - if (cancelled.get()) return out; JSONObject depot = depots.getJSONObject(i); String depotPid = depot.optString("productId", ""); if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue; - if (!languageCompatible(depot)) continue; + owned.put(depot); + ownedDepots.add(depot); + } + String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language); + cb.onLog("gog: language " + resolved); + for (int i = 0; i < ownedDepots.size(); i++) { + if (cancelled.get()) return out; + JSONObject depot = ownedDepots.get(i); + if (!depotSelected(depot, resolved)) continue; String manifestHash = depot.optString("manifest"); if (manifestHash == null || manifestHash.isEmpty()) continue; byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null); @@ -232,6 +250,7 @@ public final class GogDownloadManager { out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned); out.result.dependencies = dependencies; out.result.dependenciesRead = true; + out.result.language = resolved; return out; } catch (Exception e) { out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : ""); @@ -270,11 +289,31 @@ public final class GogDownloadManager { } } - private static boolean languageCompatible(JSONObject depot) { + private static boolean depotSelected(JSONObject depot, String resolved) { JSONArray languages = depot.optJSONArray("languages"); if (languages == null || languages.length() == 0) return true; - String s = languages.toString(); - return s.contains("*") || s.contains("en-US") || s.contains("\"en\"") || s.contains("english"); + String[] listed = new String[languages.length()]; + for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, ""); + return StoreLanguage.depotMatchesResolved(listed, resolved); + } + + /** Language codes listed on this product's Windows gen2 depots, excluding `*`. */ + public static List listLanguages(Context ctx, String gameId) { + try { + String token = GogAuth.validToken(ctx); + String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2"; + String buildsJson = httpGet(buildsUrl, null); + if (buildsJson == null) buildsJson = httpGet(buildsUrl, token); + if (buildsJson == null) return Collections.emptyList(); + Callback silent = new Callback() { + @Override public void onProgress(String message, int pct) {} + }; + Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent); + if (gm == null) return Collections.emptyList(); + return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots")); + } catch (Exception e) { + return Collections.emptyList(); + } } @@ -1037,9 +1076,10 @@ public final class GogDownloadManager { JSONArray depots = new JSONObject(manifestStr).optJSONArray("depots"); if (depots == null) return -1; long total = 0; + String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode()); for (int i = 0; i < depots.length(); i++) { JSONObject depot = depots.getJSONObject(i); - if (languageCompatible(depot)) total += depot.optLong("size", 0); + if (depotSelected(depot, resolved)) total += depot.optLong("size", 0); } return total > 0 ? total : -1; } catch (Exception e) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt index 61fae1a40..0e76d2fab 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt @@ -7,6 +7,7 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.stores.CatalogItem import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreNet +import com.droiddeck.launcher.stores.StoresState import com.droiddeck.launcher.stores.cleanStoreText import org.json.JSONArray import org.json.JSONObject @@ -20,6 +21,14 @@ object GogLibrary { private const val LAST_SYNC_KEY = "library_synced_at" private const val SKIPPED_KEY = "library_skipped" private const val THROTTLE_MS = 6L * 60L * 60L * 1000L + // mediaType=1 leaves the movies and soundtracks out, hiddenFlag=0 the hidden ones. + private const val LIST_URL = "https://embed.gog.com/account/getFilteredProducts?mediaType=1&hiddenFlag=0&sortBy=title" + private const val LIST_MAX_PAGES = 100 + // The listing's image is a bare hash, and the CDN serves it only through Galaxy's own formatters: the + // vertical cover is the one card art it carries. The wide background only the per-game fetch knows. + private const val COVER_SUFFIX = "_glx_vertical_cover.webp" + // The older account endpoint: owned ids only. The fallback for a listing that cannot be read. + private const val OWNED_URL = "https://embed.gog.com/user/data/games" private val syncing = AtomicBoolean(false) @@ -45,42 +54,44 @@ object GogLibrary { fun sizeOf(context: Context, gameId: String): Long = GogPrefs.get(context).getLong("size_$gameId", 0L) - fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult { + fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}, onList: (List) -> Unit = {}, onItems: (List) -> Unit = {}): SyncResult { if (!syncing.compareAndSet(false, true)) return SyncResult.Busy try { val app = context.applicationContext val p = GogPrefs.get(app) val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn onStatus(app.getString(R.string.stores_sync_game_list)) - val gamesJson = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token, userAgent = GogAuth.GALAXY_UA) + val listing = ownedListing(token) + // The listing is the only source of titles: without it the owned ids still sync, just without the early paint. + val listed: List = listing?.games + ?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") } ?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable)) - val ids = ArrayList() - val parsed = runCatching { - val owned = JSONObject(gamesJson).optJSONArray("owned") - if (owned != null) for (i in 0 until owned.length()) { - val id = owned.optLong(i).toString() - if (id != "1801418160" && id != "0") ids.add(id) - } - }.isSuccess - if (!parsed) return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreadable)) - - val ownedSet = ids.toHashSet() + val named = listing != null + StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids") + // A page that failed leaves games unnamed: they are still owned, so their cards and cached details stay. + val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId } val cachedList = cached(app) val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS val heavy = force || cachedList.isEmpty() || stale val cachedIds = cachedList.mapTo(HashSet()) { it.gameId } + // A product the last sync found uninstallable stays out of the list while it is re-checked. val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) - if (!heavy) skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty()) - val idsToFetch = if (heavy) ids else ids.filter { it !in cachedIds && it !in skipped } - val fetchSet = idsToFetch.toHashSet() + skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty()) + val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped } val merged = LinkedHashMap() - for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in fetchSet) merged[g.gameId] = g + // The cache's own entry stays even when the game is re-fetched: its details beat the listing's. + for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in skipped) merged[g.gameId] = g + // A game the sync has not reached keeps the listing's entry, so its card is there from the start. + for (g in listed) if (g.gameId !in merged && g.gameId !in skipped) merged[g.gameId] = g + // Only products that were asked about are cached: a bare listing entry is asked about again. + val detailed = HashSet(cachedIds) + if (named) onList(sorted(merged)) if (idsToFetch.isEmpty()) { val list = merged.values.toList() skipped.retainAll(ownedSet) p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply() - saveCache(p, list) - return SyncResult.Ok(list.sortedBy { it.title.lowercase() }, 0) + saveCache(p, list.filter { it.gameId in detailed }) + return SyncResult.Ok(sorted(merged), 0) } onStatus(app.resources.getQuantityString(R.plurals.stores_sync_games, idsToFetch.size, idsToFetch.size)) val pool = Executors.newFixedThreadPool(5) @@ -89,16 +100,24 @@ object GogLibrary { var fetched = 0 for ((idx, f) in futures.withIndex()) { val g = runCatching { f.get() }.getOrNull() - if (g != null) { merged[g.gameId] = g; fetched++ } + if (g != null) { + // The cover the card already shows stays, so the wide art lands over it instead of over a gap. + val filled = g.withCover(merged[g.gameId]?.verticalCover) + merged[g.gameId] = filled; detailed.add(g.gameId); fetched++; skipped.remove(g.gameId); onItems(listOf(filled)) + } if ((idx + 1) % 5 == 0) onStatus(app.getString(R.string.stores_sync_progress, idx + 1, futures.size)) + // Saved as it goes: a sync the user closes the app on still leaves a library to paint next launch. + if ((idx + 1) % 20 == 0) saveCache(p, merged.values.filter { it.gameId in detailed }) } + // A product this round found uninstallable leaves when the round is over, not in the middle of it. + for (id in skipped) merged.remove(id) val finalList = merged.values.toList() - saveCache(p, finalList) + saveCache(p, finalList.filter { it.gameId in detailed }) skipped.retainAll(ownedSet) p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply() if (heavy) p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply() - Log.i(TAG, "sync: owned=${ids.size} fetched=$fetched cached=${finalList.size} heavy=$heavy") - return SyncResult.Ok(finalList.sortedBy { it.title.lowercase() }, fetched) + Log.i(TAG, "sync: owned=${ownedSet.size} fetched=$fetched cached=${finalList.size} heavy=$heavy") + return SyncResult.Ok(sorted(merged), fetched) } catch (e: Exception) { Log.w(TAG, "sync failed: ${e.message}") return SyncResult.Failed(e.message ?: context.getString(R.string.stores_sync_failed)) @@ -107,6 +126,68 @@ object GogLibrary { } } + private fun ownedListing(token: String): Listing? { + val all = ArrayList() + val owned = HashSet() + var page = 1 + var pages = 1 + while (page <= pages && page <= LIST_MAX_PAGES) { + // A page that cannot be read ends the listing; the pages already read still name most of the library. + val listed = StoreNet.get("$LIST_URL&page=$page", bearer = token, userAgent = GogAuth.GALAXY_UA)?.let { parseListing(it) } + if (listed == null) { + StoresState.logLine("GOG: listing page $page of $pages unreadable") + // The unread pages hold games too: without their ids those games would leave the library and the cache. + owned.addAll(ownedIds(token) ?: return null) + break + } + all.addAll(listed.games) + owned.addAll(listed.games.map { it.gameId }) + pages = listed.totalPages + page++ + } + // Not one page read: the caller falls back to the owned ids. + return all.ifEmpty { null }?.let { Listing(it, owned) } + } + + private fun ownedIds(token: String): List? { + val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null + val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null + val ids = ArrayList(owned.length()) + for (i in 0 until owned.length()) { + val id = owned.optLong(i).toString() + if (id != "1801418160" && id != "0") ids.add(id) + } + return ids + } + + fun parseListing(json: String): ListingPage? = runCatching { + val root = JSONObject(json) + val arr = root.optJSONArray("products") ?: return@runCatching null + val games = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + // 1801418160 is GOG's own "Galaxy" entry, in every library. + val id = o.optLong("id", 0L).toString() + if (id == "0" || id == "1801418160") continue + if (o.optBoolean("isMovie", false) || !o.optBoolean("isGame", true)) continue + val title = o.optString("title", "").trim() + if (title.isEmpty()) continue + val worksOn = o.optJSONObject("worksOn") + if (worksOn != null && !worksOn.optBoolean("Windows", true)) continue + val released = o.optJSONObject("releaseDate")?.optString("date", "").orEmpty().substringBefore(' ') + val image = o.optString("image", "").ifBlank { null } + games.add(GogGame(id, title, "", "", "", o.optString("category", ""), 1, + image?.let { GogStoreCatalog.absolutize(it + COVER_SUFFIX) }, released, "")) + } + ListingPage(games, maxOf(1, root.optInt("totalPages", 1))) + }.getOrNull() + + class ListingPage(val games: List, val totalPages: Int) + + private class Listing(val games: List, val owned: Set) + + private fun sorted(merged: Map): List = merged.values.sortedBy { it.title.lowercase() } + private fun fetchGame(prefs: SharedPreferences, id: String, token: String, skipped: MutableSet): GogGame? { fun skip(): GogGame? { skipped.add(id); return null } try { @@ -198,6 +279,10 @@ object GogLibrary { } } + private fun GogGame.withCover(cover: String?): GogGame = + if (cover.isNullOrBlank() || cover == verticalCover) this + else GogGame(gameId, title, imageUrl, description, developer, category, generation, cover, releaseDate, publisher) + fun toCatalogItem(context: Context, g: GogGame): CatalogItem = CatalogItem( store = Store.GOG, id = g.gameId, title = g.title, imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null }, diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt new file mode 100644 index 000000000..92635630d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt @@ -0,0 +1,34 @@ +package com.droiddeck.launcher.stores.itch + +import com.droiddeck.launcher.stores.StoreNet +import org.json.JSONArray +import org.json.JSONObject + +internal object ItchApi { + const val KEYS_PAGE = "https://itch.io/user/settings/api-keys" + + private const val BASE = "https://api.itch.io" + private const val WEB = "https://itch.io" + + fun get(path: String, key: String): JSONObject? { + val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null + return runCatching { JSONObject(body) }.getOrNull() + } + + fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user") + + fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key) + + fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? = + get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads") + + fun search(key: String, query: String, page: Int): JSONArray? { + val q = java.net.URLEncoder.encode(query, "UTF-8") + return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games") + } + + fun downloadUrl(uploadId: String, downloadKeyId: String): String = + "$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId" + + fun pageUrl(slug: String): String = "$WEB/$slug" +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt new file mode 100644 index 000000000..622aa5383 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONObject + +internal object ItchAuth { + private val TOKEN = Regex("[A-Za-z0-9]{40,64}") + + fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty() + + fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty() + + fun verify(key: String): JSONObject? = ItchApi.profile(key) + + fun save(context: Context, key: String, user: JSONObject) { + StoreAccounts.write( + context, + Store.ITCH, + JSONObject() + .put("access_token", key) + .put("username", user.optString("username")) + .put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") }) + .put("user_id", user.optLong("id")), + ) + } + + fun candidates(text: String): List { + if (text.isBlank()) return emptyList() + val out = LinkedHashSet() + text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) } + TOKEN.findAll(text).forEach { out.add(it.value) } + return out.take(8) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt new file mode 100644 index 000000000..b1bf834c1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt @@ -0,0 +1,71 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreBackend +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreSizes +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import java.io.File + +object ItchBackend : StoreBackend { + override val store: Store = Store.ITCH + + override fun signIn(context: Context) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + override fun signOut(context: Context) { + ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply() + StoresState.post { + StoresState.library.remove(store) + StoresState.shelves.remove(store) + StoresState.found.remove(store) + StoresState.problems.remove(store) + StoresState.status.remove(store) + } + } + + override fun syncLibrary(context: Context, force: Boolean) { + val app = context.applicationContext + Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start() + } + + override fun loadShelves(context: Context, force: Boolean) { + val app = context.applicationContext + ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } } + Thread({ + val shelves = ItchShelves.featured(app, force) + if (shelves != null) { + StoresState.post { StoresState.shelves[store] = shelves } + } else { + StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) } + } + }, "itch-shelves").start() + } + + override fun search(context: Context, query: String): List = ItchShelves.search(context, query) + + override fun install(context: Context, item: CatalogItem, root: File) { + val app = context.applicationContext + if (item.extra["downloadKeyId"].isNullOrEmpty()) { + StoresState.logLine("itch.io: \"${item.title}\" is not in the library") + return + } + val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root) + val entry = DownloadEntry( + Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item), + location = StoreInstallRoot.labelFor(app, folder), + ) + DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) } + } + + override fun uninstall(context: Context, game: InstalledStoreGame) {} +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt new file mode 100644 index 000000000..fc4f4a168 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt @@ -0,0 +1,90 @@ +package com.droiddeck.launcher.stores.itch + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreShelves +import org.json.JSONArray +import org.json.JSONObject + +internal object ItchCatalog { + fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/') + + fun shelvesJson(shelves: StoreShelves): String = JSONObject() + .put("new", array(shelves.whatsNew)) + .put("deals", array(shelves.deals)) + .put("free", array(shelves.free)) + .put("top", array(shelves.trending)) + .toString() + + fun shelves(raw: String?): StoreShelves? { + if (raw.isNullOrBlank()) return null + val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null + val shelves = StoreShelves( + whatsNew = list(o.optJSONArray("new")), + deals = list(o.optJSONArray("deals")), + free = list(o.optJSONArray("free")), + trending = list(o.optJSONArray("top")), + ) + return shelves.takeUnless { it.isEmpty } + } + + fun array(items: List): JSONArray { + val out = JSONArray() + items.forEach { out.put(json(it)) } + return out + } + + fun list(raw: JSONArray?): List { + if (raw == null) return emptyList() + return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) } + } + + private fun json(i: CatalogItem): JSONObject = JSONObject() + .put("id", i.id) + .put("title", i.title) + .put("image", i.imageUrl ?: "") + .put("tall", i.tallImageUrl ?: "") + .put("tags", i.tags) + .put("free", i.isFree) + .put("hasPrice", i.hasPrice) + .put("finalPrice", i.finalPrice) + .put("originalPrice", i.originalPrice) + .put("discount", i.discountPercent) + .put("url", i.storeUrl) + .put("developer", i.developer) + .put("publisher", i.publisher) + .put("releaseDate", i.releaseDate) + .put("description", i.description) + .put("owned", i.owned) + .put("size", i.sizeBytes) + .put("extra", JSONObject(i.extra)) + .put("mature", i.mature) + + private fun item(o: JSONObject): CatalogItem { + val extra = o.optJSONObject("extra") + val map = HashMap() + extra?.keys()?.forEach { k -> map[k] = extra.optString(k) } + return CatalogItem( + store = Store.ITCH, + id = o.optString("id"), + title = o.optString("title"), + imageUrl = o.optString("image").ifBlank { null }, + tallImageUrl = o.optString("tall").ifBlank { null }, + tags = o.optString("tags"), + isFree = o.optBoolean("free"), + hasPrice = o.optBoolean("hasPrice"), + finalPrice = o.optString("finalPrice"), + originalPrice = o.optString("originalPrice"), + discountPercent = o.optInt("discount"), + storeUrl = o.optString("url"), + developer = o.optString("developer"), + publisher = o.optString("publisher"), + releaseDate = o.optString("releaseDate"), + description = o.optString("description"), + owned = o.optBoolean("owned"), + sizeBytes = o.optLong("size"), + extra = map, + mature = o.optBoolean("mature"), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt new file mode 100644 index 000000000..96ec0ae35 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt @@ -0,0 +1,372 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import com.droiddeck.launcher.R +import com.droiddeck.launcher.runtime.GuestCommand +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreExe +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.StoreNet +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.apache.commons.compress.archivers.sevenz.SevenZFile +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.zip.ZipFile +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +internal class ItchInstaller( + private val app: Context, + private val item: CatalogItem, + private val folder: File, +) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + + override fun run(handle: DownloadQueue.JobHandle): String? { + val key = ItchAuth.key(app) + if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in)) + val downloadKeyId = item.extra["downloadKeyId"].orEmpty() + if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned)) + val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') } + StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl) + handle.stage(DownloadStage.MANIFEST) + val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) + ?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload)) + val uploadId = upload.optString("id") + val filename = ItchUploads.filename(upload) + val size = ItchUploads.size(upload) + val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id) + scratch.mkdirs() + val archive = File(scratch, filename.ifBlank { "download.bin" }) + val exe = try { + handle.stage(DownloadStage.DOWNLOAD) + download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle) + if (cancelled.get()) return null + handle.stage(DownloadStage.INSTALL) + install(archive, filename, handle) + } catch (t: Throwable) { + if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch)) + throw t + } + if (exe == null) { + if (cancelled.get()) return null + StoreInstalls.discard(app, folder, listOf(scratch)) + throw IllegalStateException(app.getString(R.string.stores_itch_no_exe)) + } + archive.delete() + StoreInstalls.deleteTree(scratch) + val sidecar = StoreGameSidecar( + store = Store.ITCH, + id = item.id, + title = item.title, + exe = exe, + installVersion = upload.optString("updated_at", upload.optString("updatedAt")), + installedAt = System.currentTimeMillis(), + cover = item.imageUrl, + hero = item.imageUrl, + extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId), + cloud = false, + ) + StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) } + return folder.path + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id))) + } + + private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long, + handle: DownloadQueue.JobHandle) { + // A partial file is only resumed when it came from this exact upload build. + val origin = File(target.path + ".from") + val stamp = "$uploadId $version $expected" + if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) { + target.delete() + origin.writeText(stamp) + } + if (expected > 0 && target.length() == expected) { + handle.progress(expected, expected) + return + } + for (attempt in 0..1) { + val have = target.length() + var conn: HttpURLConnection? = null + try { + conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply { + connectTimeout = 30_000 + readTimeout = 60_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", StoreNet.BROWSER_UA) + setRequestProperty("Authorization", "Bearer $key") + setRequestProperty("Accept", "*/*") + if (have > 0) setRequestProperty("Range", "bytes=$have-") + } + val code = conn.responseCode + if (code == 416 && attempt == 0) { + target.delete() + continue + } + if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code)) + val append = code == 206 && have > 0 + if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over") + var done = if (append) have else 0L + val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected + // The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput. + handle.progress(done, total) + val buffer = ByteArray(128 * 1024) + conn.inputStream.use { input -> + FileOutputStream(target, append).use { out -> + while (true) { + if (cancelled.get()) return + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + done += read + handle.progress(done, total) + } + } + } + if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code)) + return + } finally { + runCatching { conn?.disconnect() } + } + } + } + + private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? { + folder.mkdirs() + when (val kind = sniff(archive, filename)) { + "zip" -> unzip(archive, handle) + "7z" -> sevenZip(archive, handle) + "rar" -> unrar(archive, handle) + "exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" })) + "tar" -> untar(archive, null, handle) + "gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle) + else -> copy(archive, File(folder, filename.ifBlank { "game" })) + } + val hint = filename.substringBeforeLast('.') + val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title) + if (exe.isBlank()) { + handle.log("itch.io: no exe found in ${folder.name}") + return null + } + return exe + } + + private fun sniff(file: File, filename: String): String { + val extension = filename.substringAfterLast('.', "").lowercase() + if (extension == "exe") return "exe" + val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull() + ?: return extension + fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() } + return when { + at(0, 0x50, 0x4B) -> "zip" + at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z" + at(0, 0x52, 0x61, 0x72, 0x21) -> "rar" + at(0, 0x1F, 0x8B) -> "gz" + at(0, 0x42, 0x5A, 0x68) -> "bz2" + at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz" + at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar" + else -> extension + } + } + + private fun target(name: String): File? { + val file = File(folder, name.replace('\\', '/')) + val root = folder.canonicalPath + val path = file.canonicalPath + return if (path == root || path.startsWith(root + File.separator)) file else null + } + + // No JVM library reads RAR5; the runtime's bsdtar (libarchive) does. + private fun unrar(archive: File, handle: DownloadQueue.JobHandle) { + if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar")) + val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null + else listOf(folder.absolutePath) + fun bsdtar(args: List, onLine: (String) -> Unit) = + GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line -> + if (!line.startsWith("proot ")) onLine(line) + } + var entries = 0L + bsdtar(listOf("-tf", archive.absolutePath)) { entries++ } + var done = 0L + val tail = ArrayDeque() + val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line -> + if (line.startsWith("x ")) handle.stageProgress(++done, entries) else { + if (tail.size == 5) tail.removeFirst() + tail.addLast(line) + } + } + if (cancelled.get()) return + if (code != 0) { + handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}") + throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code)) + } + } + + private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) { + val total = archive.length() + var written = 0L + val buffer = ByteArray(64 * 1024) + SevenZFile.builder().setFile(archive).get().use { seven -> + while (true) { + if (cancelled.get()) return + val entry = seven.nextEntry ?: break + val file = target(entry.name) + if (file == null) { + handle.log("itch.io: skipped an entry outside the game folder") + continue + } + if (entry.isDirectory) { + file.mkdirs() + continue + } + file.parentFile?.mkdirs() + FileOutputStream(file).use { out -> + while (true) { + if (cancelled.get()) return + val read = seven.read(buffer, 0, buffer.size) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + + private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? { + val total = archive.length() + var written = 0L + var entries = 0 + val buffer = ByteArray(64 * 1024) + val opened = runCatching { + FileInputStream(archive).use { raw -> + val stream = when (codec) { + "gz" -> GzipCompressorInputStream(BufferedInputStream(raw)) + "bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw)) + "xz" -> XZCompressorInputStream(BufferedInputStream(raw)) + else -> BufferedInputStream(raw) + } + stream.use { input -> + TarArchiveInputStream(input).use { tar -> + while (true) { + if (cancelled.get()) return null + val entry = tar.nextEntry ?: break + entries++ + val file = target(entry.name) + if (file == null) { + handle.log("itch.io: skipped an entry outside the game folder") + continue + } + if (entry.isDirectory) { + file.mkdirs() + continue + } + file.parentFile?.mkdirs() + FileOutputStream(file).use { out -> + while (true) { + if (cancelled.get()) return null + val read = tar.read(buffer, 0, buffer.size) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + } + true + } + if (opened.isSuccess && entries > 0) return Unit + if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar") + return null + } + + private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) { + val name = archive.name.substringBeforeLast('.') + val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive") + file.parentFile?.mkdirs() + val total = archive.length() + var written = 0L + val buffer = ByteArray(64 * 1024) + FileInputStream(archive).use { raw -> + val stream = when (codec) { + "bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw)) + "xz" -> XZCompressorInputStream(BufferedInputStream(raw)) + else -> GzipCompressorInputStream(BufferedInputStream(raw)) + } + stream.use { input -> + FileOutputStream(file).use { out -> + while (true) { + if (cancelled.get()) return + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + + private fun copy(source: File, target: File) { + target.parentFile?.mkdirs() + FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } } + } + + // Streaming readers stop silently on zips whose data descriptors disagree with the local headers; + // the central directory is authoritative. + private fun unzip(archive: File, handle: DownloadQueue.JobHandle) { + val total = archive.length() + var written = 0L + val buffer = ByteArray(64 * 1024) + ZipFile.builder().setFile(archive).get().use { zip -> + for (entry in zip.entries) { + if (cancelled.get()) return + val target = target(entry.name) + if (target == null) { + handle.log("itch.io: skipped an entry outside the game folder") + continue + } + if (entry.isDirectory) { + target.mkdirs() + continue + } + target.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> + FileOutputStream(target).use { out -> + while (true) { + if (cancelled.get()) return + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + } + + private companion object +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt new file mode 100644 index 000000000..e4b7cba83 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt @@ -0,0 +1,162 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.cleanStoreText +import com.droiddeck.launcher.stores.mergeItems +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.roundToInt + +internal object ItchLibrary { + private const val KEY_CACHE = "library_cache" + private const val KEY_AT = "library_synced_at" + private const val MAX_PAGES = 20 + private const val THROTTLE_MS = 6 * 60 * 60 * 1000L + + private val running = AtomicBoolean(false) + + fun cached(context: Context): List { + val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList() + return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList()) + } + + fun sync(context: Context, force: Boolean) { + val app = context.applicationContext + if (!running.compareAndSet(false, true)) return + try { + val key = ItchAuth.key(app) + val cache = cached(app) + val prefs = ItchPrefs.get(app) + if (key.isBlank()) { + StoresState.post { + StoresState.status.remove(Store.ITCH) + if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache + StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in) + } + return + } + val quiet = cache.isNotEmpty() && !force + if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) { + StoresState.post { StoresState.library[Store.ITCH] = cache } + return + } + if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) } + val items = fetch(key) + if (items == null) { + StoresState.post { + StoresState.status.remove(Store.ITCH) + if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache + StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed) + } + return + } + prefs.edit() + .putString(KEY_CACHE, ItchCatalog.array(items).toString()) + .putLong(KEY_AT, System.currentTimeMillis()) + .apply() + StoresState.post { + StoresState.status.remove(Store.ITCH) + StoresState.problems.remove(Store.ITCH) + StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items) + } + StoresState.logLine("itch.io: ${items.size} owned games") + } finally { + running.set(false) + } + } + + fun size(context: Context, item: CatalogItem): Long { + val key = ItchAuth.key(context.applicationContext) + if (key.isBlank()) return 0L + val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L } + val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L } + val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L + return ItchUploads.size(upload) + } + + private fun fetch(key: String): List? { + val out = ArrayList() + var page = 1 + var keys = 0 + while (page <= MAX_PAGES) { + val body = ItchApi.ownedKeys(key, page) ?: return null + val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out + if (list.length() == 0) break + out.addAll(items(list)) + keys += list.length() + page++ + } + val games = out.distinctBy { it.id } + StoresState.logLine("itch.io: $keys download keys, ${games.size} games") + return games + } + + internal fun items(keys: JSONArray): List { + val out = ArrayList(keys.length()) + for (i in 0 until keys.length()) { + val entry = keys.optJSONObject(i) ?: continue + val game = entry.optJSONObject("game") ?: continue + item(game, entry.optString("id"))?.let { out.add(it) } + } + return out + } + + private fun JSONObject.text(snake: String, camel: String): String = + optString(snake).ifBlank { optString(camel) } + + internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? { + val gameId = game.optLong("id") + if (gameId <= 0L) return null + val user = game.optJSONObject("user") + val url = game.optString("url").ifBlank { + val owner = user?.optString("username").orEmpty() + if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId") + } + val traits = game.optJSONArray("traits")?.let { arr -> + (0 until arr.length()).map { arr.optString(it) } + }.orEmpty() + val platforms = game.optJSONObject("platforms") + val sale = game.optJSONObject("sale") + val rate = sale?.optDouble("rate", 0.0) ?: 0.0 + val cents = game.optLong("min_price", game.optLong("minPrice")) + val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits)) + val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0 + val price = if (canBuy && cents > 0) money(cents) else "" + val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else "" + val tags = buildList { + if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows") + if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux") + if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS") + }.joinToString(", ") + val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") } + val tall = game.text("still_cover_url", "stillCoverUrl") + val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() } + return CatalogItem( + store = Store.ITCH, + id = ItchCatalog.slug(url).ifBlank { gameId.toString() }, + title = game.optString("title").ifBlank { "itch.io game $gameId" }, + imageUrl = cover.ifBlank { null }, + tallImageUrl = tall.ifBlank { null }, + tags = tags, + isFree = price.isEmpty(), + hasPrice = price.isNotEmpty(), + finalPrice = salePrice.ifEmpty { price }, + originalPrice = if (salePrice.isNotEmpty()) price else "", + discountPercent = if (salePrice.isNotEmpty()) discount else 0, + storeUrl = url, + developer = author, + publisher = author, + releaseDate = game.text("published_at", "publishedAt").take(10), + description = cleanStoreText(game.text("short_text", "shortText")), + owned = downloadKeyId.isNotBlank(), + extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()), + ) + } + + private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0') +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt new file mode 100644 index 000000000..43470e47a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt @@ -0,0 +1,11 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import android.content.SharedPreferences + +object ItchPrefs { + const val FILE = "stores.itch" + + @JvmStatic + fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt new file mode 100644 index 000000000..0fd783ed6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt @@ -0,0 +1,199 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import android.util.Xml +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreNet +import com.droiddeck.launcher.stores.StoreShelves +import com.droiddeck.launcher.stores.cleanStoreText +import org.xmlpull.v1.XmlPullParser +import java.io.StringReader +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Locale + +internal object ItchShelves { + private const val TAG = "ItchShelves" + private const val KEY = "shelves_cache" + private const val KEY_AT = "shelves_at" + private const val FRESH_MS = 3 * 60 * 60 * 1000L + private const val GAP_MS = 1_200L + private const val RETRY_MS = 20_000L + + private val FEEDS = listOf( + "https://itch.io/games/newest.xml", + "https://itch.io/games/on-sale.xml", + "https://itch.io/games/price-free.xml", + "https://itch.io/games/top-rated.xml", + ) + private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile") + + @Volatile + private var memory: StoreShelves? = null + + fun cached(context: Context): StoreShelves? { + memory?.let { return it } + val stored = stored(ItchPrefs.get(context.applicationContext)) + if (stored != null) memory = stored + return stored + } + + fun featured(context: Context, force: Boolean): StoreShelves? { + val prefs = ItchPrefs.get(context.applicationContext) + val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS + if (fresh && !force) { + cached(context)?.let { return it } + } + val fetched = fetch() + if (fetched != null) { + memory = fetched + prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply() + return fetched + } + return cached(context) + } + + fun search(context: Context, query: String): List { + val key = ItchAuth.key(context.applicationContext) + if (key.isBlank()) return emptyList() + val out = ArrayList() + for (page in 1..2) { + val games = ItchApi.search(key, query, page) ?: break + if (games.length() == 0) break + for (i in 0 until games.length()) { + val game = games.optJSONObject(i) ?: continue + ItchLibrary.item(game, "")?.let { out.add(it) } + } + if (games.length() < 30) break + Thread.sleep(GAP_MS) + } + return out.distinctBy { it.id } + } + + private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null)) + + private fun fetch(): StoreShelves? { + val lists = ArrayList>(FEEDS.size) + var answered = false + for ((index, url) in FEEDS.withIndex()) { + if (index > 0) Thread.sleep(GAP_MS) + var (code, body) = open(url) + if (code == 429) { + Log.w(TAG, "feed 429; backing off") + Thread.sleep(RETRY_MS) + val again = open(url) + code = again.first + body = again.second + if (code == 429) { + Log.w(TAG, "feed still 429; keeping the cached shelves") + lists.add(emptyList()) + break + } + } + if (code in 200..299 && body != null) { + answered = true + lists.add(parse(body)) + } else { + lists.add(emptyList()) + } + } + if (!answered) return null + while (lists.size < FEEDS.size) lists.add(emptyList()) + val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12) + val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art) + return shelves.takeUnless { it.isEmpty } + } + + private fun open(url: String): Pair { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 20_000 + readTimeout = 20_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", StoreNet.BROWSER_UA) + setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*") + } + val code = conn.responseCode + code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null + } catch (e: Exception) { + Log.w(TAG, "feed ${e.javaClass.simpleName}") + 0 to null + } finally { + runCatching { conn?.disconnect() } + } + } + + internal fun parse(xml: String): List { + val out = ArrayList() + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(StringReader(xml)) + var event = parser.eventType + var field: String? = null + var current: HashMap? = null + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> { + if (parser.name == "item") current = HashMap() + field = parser.name + } + XmlPullParser.TEXT -> { + val map = current + val name = field + if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text + } + XmlPullParser.END_TAG -> { + if (parser.name == "item") { + current?.let { item(it)?.let(out::add) } + current = null + } + field = null + } + } + event = parser.next() + } + return out + } + + internal fun item(m: Map): CatalogItem? { + val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() } + if (link.isBlank()) return null + val title = m["plainTitle"]?.trim().orEmpty() + .ifBlank { m["title"].orEmpty().substringBefore(" [").trim() } + .ifBlank { ItchCatalog.slug(link) } + val price = m["price"]?.trim().orEmpty() + val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true) + val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty()) + .map { it.groupValues[1].trim() } + .filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS } + .joinToString(", ") + return CatalogItem( + store = Store.ITCH, + id = ItchCatalog.slug(link), + title = title, + imageUrl = m["imageurl"]?.trim()?.ifBlank { null }, + tags = tags, + isFree = free, + hasPrice = !free, + finalPrice = if (free) "" else price, + originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(), + discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0, + storeUrl = link, + releaseDate = date(m["pubDate"]), + description = cleanStoreText(m["description"]), + ) + } + + private fun date(raw: String?): String { + val text = raw?.trim().orEmpty() + if (text.isBlank()) return "" + return runCatching { + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text) + }.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt new file mode 100644 index 000000000..773ad22c3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.stores.itch + +import org.json.JSONArray +import org.json.JSONObject + +internal object ItchUploads { + private val GAME_TYPES = setOf("default", "flash", "unity", "java", "html", "") + private val PLATFORM_TRAITS = setOf("p_windows", "p_linux", "p_osx", "p_android") + + fun windows(uploads: JSONArray?): JSONObject? { + if (uploads == null) return null + val all = (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) } + .filter { it.optString("storage") != "external" && !it.optBoolean("demo") } + if (all.isEmpty()) return null + val games = all.filter { it.optString("type") in GAME_TYPES } + val preferred = games.ifEmpty { all } + val candidates = preferred.filter { windows(it) }.ifEmpty { preferred } + return candidates.maxWithOrNull(compareBy({ it.optString("updated_at", it.optString("updatedAt")) }, { it.optLong("id") })) + } + + fun windows(upload: JSONObject): Boolean { + val platforms = upload.optJSONObject("platforms") + if (platforms != null) { + if (platforms.optBoolean("windows")) return true + if (platforms.optBoolean("linux") || platforms.optBoolean("osx") || platforms.optBoolean("android")) return false + } + val traits = upload.optJSONArray("traits") ?: return true + val names = (0 until traits.length()).map { traits.optString(it) } + if (names.any { it == "p_windows" }) return true + return names.none { it in PLATFORM_TRAITS } + } + + fun size(upload: JSONObject): Long = upload.optLong("size", upload.optLong("size_bytes")) + + fun filename(upload: JSONObject): String = upload.optString("filename").substringAfterLast('/') +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt new file mode 100644 index 000000000..24d7ff622 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -0,0 +1,153 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.stores.* +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import java.io.File + +object XboxBackend : StoreBackend { + override val store = Store.XBOX + @Volatile private var access: Map = emptyMap() + @Volatile private var generation = 0 + private var syncing: Int? = null + + override fun signIn(context: Context) { + context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + override fun signOut(context: Context) { + XboxCredentials.invalidate(context) { + synchronized(this) { + generation++ + access = emptyMap() + } + XboxLibraryCache.clear(context) + XboxRuntime.stop() + } + StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) } + } + + @Synchronized override fun syncLibrary(context: Context, force: Boolean) { + val app = context.applicationContext + val current = generation + if (syncing == current) return + syncing = current + Thread({ + try { + val account = StoreAccounts.signedInAs(app, store) ?: error("Sign in to Xbox first.") + val market = Xodus.market() + val cached = XboxLibraryCache.read(app, account, market) + synchronized(this) { + if (current != generation) return@Thread + if (cached != null) access = cached.games.associate { it.id to it.extra.getValue("access") } + } + StoresState.post { + if (current == generation) { + if (cached != null) StoresState.library[store] = mergeItems(StoresState.library[store], cached.games) + StoresState.problems.remove(store) + if (cached == null || force) StoresState.status[store] = "Syncing Xbox library…" + else StoresState.status.remove(store) + } + } + if (cached != null && !cached.needsRefresh(force)) return@Thread + val response = Xodus.run(app, listOf("library", "--json", "--market", market)) + val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") }) + val products = XboxCatalog.rawProducts(licenses.keys.toList(), market) + val games = products.mapNotNull { XboxCatalog.product(it, licenses) }.sortedBy { it.title.lowercase() } + StoresState.logLine("Xbox: library catalog requested=${licenses.size} returned=${products.size} supported=${games.size} " + + "filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")}") + synchronized(this) { + if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread + access = licenses + runCatching { XboxLibraryCache.write(app, account, market, games) } + .onFailure { StoresState.logLine("Xbox: library cache could not be saved") } + } + StoresState.post { + if (current == generation) { + StoresState.library[store] = mergeItems(StoresState.library[store], games) + StoresState.status.remove(store) + StoresState.problems.remove(store) + } + } + } catch (e: Exception) { + StoresState.post { + if (current == generation) { + StoresState.status.remove(store) + StoresState.problems[store] = e.message ?: "Xbox library could not be loaded." + } + } + } finally { + synchronized(this) { if (syncing == current) syncing = null } + } + }, "xbox-library").start() + } + + override fun loadShelves(context: Context, force: Boolean) { + val current = generation + Thread({ + runCatching { XboxCatalog.featured(access) }.onSuccess { games -> + if (current == generation) StoresState.post { StoresState.shelves[store] = StoreShelves(trending = games) } + }.onFailure { if (current == generation) StoresState.post { StoresState.problems[store] = "Microsoft Store could not be reached. Try again." } } + }, "xbox-store").start() + } + + override fun search(context: Context, query: String): List = XboxCatalog.search(query, access) + + class PackageChoice(val item: CatalogItem, val root: File, val choices: List) + var packageChoice by androidx.compose.runtime.mutableStateOf(null) + private set + + fun dismissPackages() { packageChoice = null } + fun selectPackage(context: Context, choice: org.json.JSONObject) { + val pending = packageChoice ?: return + packageChoice = null + val item = pending.item.copy(extra = pending.item.extra + ("contentId" to choice.getString("contentId"))) + com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, pending.root) + enqueue(context.applicationContext, item, pending.root) + } + + override fun install(context: Context, item: CatalogItem, root: File) { + val app = context.applicationContext + if (!item.extra["baseId"].isNullOrBlank()) { XboxContent.install(app, item); return } + if (!item.extra["contentId"].isNullOrBlank()) { enqueue(app, item, root); return } + Thread({ + try { + val plan = Xodus.run(app, listOf("resolve-package", item.id, "--json", "--market", item.extra["market"] ?: Xodus.market())) + require(plan.getInt("schema") == 1 && plan.getString("rootProductId") == item.id) + val choices = XboxCatalog.objects(plan.getJSONArray("choices")).filter { it.optBoolean("supported") && it.optString("kind") == "Game" } + check(choices.isNotEmpty()) { "This offer has no supported PC package." } + if (plan.getString("status") == "ready" && choices.size == 1) { + val selected = item.copy(extra = item.extra + ("contentId" to choices.single().getString("contentId"))) + com.droiddeck.launcher.stores.download.SavedQueue.remember(app, selected, root) + enqueue(app, selected, root) + } else StoresState.post { packageChoice = PackageChoice(item, root, choices) } + } catch (e: Exception) { + DownloadQueue.recordFailed(app, DownloadEntry(store, item.id, item.title), e.message ?: "Xbox package could not be resolved.") { install(app, item, root) } + } + }, "xbox-package").start() + } + + fun update(context: Context, game: InstalledStoreGame) { + check(!XboxRuntime.isActive(game.sidecar.id)) { "Close this Xbox game before updating it." } + val sidecar = game.sidecar + val item = CatalogItem(store, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover, + owned = sidecar.extra["access"] == "owned", subscription = sidecar.extra["access"] == "gamepass", extra = sidecar.extra) + val root = game.folder.parentFile!!.parentFile!! + com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root) + enqueue(context, item, root) + } + + private fun enqueue(context: Context, item: CatalogItem, root: File) { + val app = context.applicationContext + val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root) + val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, + location = StoreInstallRoot.labelFor(app, folder)) + DownloadQueue.enqueue(app, entry) { XboxInstaller(app, item, folder) } + } + + override fun uninstall(context: Context, game: InstalledStoreGame) {} +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt new file mode 100644 index 000000000..100371b06 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -0,0 +1,139 @@ +package com.droiddeck.launcher.stores.xbox + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreNet +import org.json.JSONArray +import org.json.JSONObject +import java.time.Instant + +internal object XboxCatalog { + private val productId = Regex("[A-Z0-9]{12}") + private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD") + /** Plans that include PC games; the retired console-only plan must not offer a Windows install. */ + private val pcPlans = mapOf( + "CFQ7TTC0KGQ8" to "PC Game Pass", + "CFQ7TTC0KHS0" to "Game Pass Ultimate", + "CFQ7TTC0P85B" to "Xbox Game Pass Premium", + "CFQ7TTC0K5DJ" to "Xbox Game Pass Essential", + "CFQ7TTC10QFD" to "Xbox Game Pass (Starter Edition)", + ) + + fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map { + require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) + val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds + require(age in -60..299) + val memberships: Set? = if (json.has("subscriptions")) { + val subscriptions = json.getJSONArray("subscriptions") + require(subscriptions.length() <= plans.size) + val seen = HashSet() + buildSet { + for (i in 0 until subscriptions.length()) { + val row = subscriptions.getJSONObject(i) + val id = row.getString("productId") + require(id in plans && seen.add(id)) + val status = row.getString("status") + val expiry = timestamp(row.getString("endDate")) + if (status == "Active" && expiry.isAfter(now)) add(id) + } + } + } else null + val array = json.getJSONArray("entitlements") + require(array.length() <= 10_000) + val rows = LinkedHashMap() + val blocked = HashSet() + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + val id = row.optString("productId") + if (!productId.matches(id)) continue + val valid = row.opt("status") is String && row.opt("isTrial") is Boolean && row.opt("special") is Boolean && + row.opt("sharingSource") is String && runCatching { timestamp(row.getString("endDate")) }.isSuccess && + (row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId"))) + if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row + } + fun active(row: JSONObject) = row.getString("status") == "Active" && !row.getBoolean("isTrial") && !row.getBoolean("special") && + row.getString("sharingSource") == "None" && timestamp(row.getString("endDate")).isAfter(now) + fun activePlan(id: String) = rows[id]?.let(::active) ?: (id in memberships.orEmpty()) + fun kind(id: String, visited: Set = emptySet()): String? { + if (id in blocked || id in visited || visited.size >= 16) return null + val row = rows[id] ?: return null + if (!active(row)) return null + val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" } + if (parent == null) return if (id in plans) null else "owned" + if (parent in blocked) return null + if (parent in plans) return if (parent in pcPlans && activePlan(parent)) "gamepass" else null + return kind(parent, visited + id) + } + val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() + val missingPlan = rows.values.count { row -> row.optString("satisfiedByProductId").let { it in plans && !activePlan(it) } } + report("library entitlements=${array.length()} invalid=${array.length() - rows.size} restricted=${rows.values.count { !active(it) }} " + + "unverified-plan=$missingPlan owned=${result.values.count { it == "owned" }} gamepass=${result.values.count { it == "gamepass" }}") + return result + } + + private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant() + + fun products(ids: List, access: Map = emptyMap(), market: String = Xodus.market()): List { + return rawProducts(ids, market).mapNotNull { product(it, access) } + } + + fun rawProducts(ids: List, market: String = Xodus.market()): List { + return ids.map { it.uppercase() }.filter { productId.matches(it) }.distinct().chunked(25).flatMap { batch -> + val body = get("https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=${batch.joinToString(",")}&market=$market&languages=en-us,neutral") + objects(body.optJSONArray("Products")).filter { it.optString("ProductId").uppercase() in batch } + } + } + + fun search(query: String, access: Map): List { + if (productId.matches(query.uppercase())) return products(listOf(query.uppercase()), access) + if (query.length !in 2..100) return emptyList() + val q = java.net.URLEncoder.encode(query, "UTF-8") + val body = get("https://apps.microsoft.com/api/products/search?query=$q&mediaType=games&age=all&price=all&category=all&subscription=all&gl=${Xodus.market()}&hl=en-us") + val ids = objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(60) + return products(ids, access) + } + + fun featured(access: Map): List { + val body = get("https://apps.microsoft.com/api/Reco/GetComputedProductsList?listName=TopTrending&pgNo=1&noItems=12&filteredCategories=AllProducts&mediaType=Games&gl=${Xodus.market()}&hl=en-us&exp=0") + return products(objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(24), access) + } + + internal fun product(json: JSONObject, access: Map): CatalogItem? { + val id = json.optString("ProductId").uppercase() + if (!productId.matches(id) || json.optString("ProductType") != "Game") return null + val localized = objects(json.optJSONArray("LocalizedProperties")).firstOrNull() ?: return null + val title = localized.optString("ProductTitle").takeIf { it.isNotBlank() } ?: return null + val packages = objects(json.optJSONArray("DisplaySkuAvailabilities")).flatMap { + objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages")) + } + val pc = packages.filter { pkg -> objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } } + if (pc.none { it.optString("PackageFormat").equals("MSIXVC", true) }) return null + val images = objects(localized.optJSONArray("Images")) + fun image(vararg kinds: String): String? = kinds.firstNotNullOfOrNull { purpose -> + images.firstOrNull { it.optString("ImagePurpose") == purpose }?.optString("Uri")?.let { raw -> + val url = if (raw.startsWith("//")) "https:$raw" else raw + runCatching { java.net.URI(url) }.getOrNull()?.takeIf { + it.scheme == "https" && it.host in setOf("store-images.s-microsoft.com", "store-images.microsoft.com") && it.userInfo == null && it.port == -1 + }?.toString() + } + } + val kind = access[id] + val includedWith = objects(localized.optJSONObject("EligibilityProperties")?.optJSONArray("Affirmations")) + .mapNotNull { pcPlans[it.optString("AffirmationProductId")] } + .firstOrNull().orEmpty() + return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"), + tallImageUrl = image("TitledPoster", "Poster", "BoxArt"), + owned = kind == "owned", subscription = kind == "gamepass", includedWith = includedWith, + storeUrl = "https://www.xbox.com/games/store/$id", + publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"), + description = localized.optString("ProductDescription"), + sizeBytes = pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L, + extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty()), + ) + } + + fun objects(array: JSONArray?): List = if (array == null) emptyList() else + (0 until array.length()).mapNotNull { array.optJSONObject(it) } + + private fun get(url: String): JSONObject = JSONObject(StoreNet.get(url) ?: error("Microsoft Store could not be reached. Try again.")) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt new file mode 100644 index 000000000..1594dee92 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt @@ -0,0 +1,105 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.stores.* +import org.json.JSONObject +import java.io.File + +internal object XboxContent { + data class Offer(val item: CatalogItem, val installed: Boolean) + data class Plan(val base: InstalledStoreGame, val offers: List, val error: String? = null) + var plan by mutableStateOf(null) + private set + fun dismiss() { plan = null } + + fun manage(context: Context, base: InstalledStoreGame) { + val app = context.applicationContext + Thread({ + try { + val manifest = XboxManifest.read(base.folder) + check(manifest.storeId.matches(Regex("[A-Z0-9]{12}"))) { "The package has no valid Store ID." } + val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox first.") + val market = base.sidecar.extra["market"] ?: Xodus.market() + val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market) + val products = XboxCatalog.rawProducts(listOf(manifest.storeId) + manifest.related, market) + val baseProduct = products.singleOrNull { it.optString("ProductId") == manifest.storeId } + val families = packages(baseProduct).map { it.optString("PackageFamilyName") }.filter { it.startsWith(manifest.name + "_") }.toSet() + val offers = products.filter { it.optString("ProductId") in manifest.related && it.optString("ProductType") == "Durable" }.mapNotNull { product -> + val id = product.getString("ProductId") + val access = licenses[id] ?: return@mapNotNull null + val packages = packages(product).filter { it.optString("MainPackageFamilyNameForDlc") in families } + .distinctBy { it.optString("ContentId") } + if (packages.size != 1) return@mapNotNull null + val pkg = packages.single() + val cid = pkg.optString("ContentId") + if (!cid.matches(Regex("[0-9a-fA-F-]{36}"))) return@mapNotNull null + val localized = XboxCatalog.objects(product.optJSONArray("LocalizedProperties")).firstOrNull() + val item = CatalogItem(Store.XBOX, id, localized?.optString("ProductTitle")?.ifBlank { id } ?: id, + imageUrl = base.sidecar.cover, owned = access == "owned", subscription = access == "gamepass", + sizeBytes = pkg.optLong("MaxDownloadSizeInBytes"), + extra = mapOf("baseId" to base.sidecar.id, "contentId" to cid, "market" to market, "family" to pkg.getString("PackageFamilyName"))) + Offer(item, File(base.folder, ".xbox-content/$id/.droiddeck-content.json").isFile) + } + StoresState.post { plan = Plan(base, offers) } + } catch (e: Exception) { StoresState.post { plan = Plan(base, emptyList(), e.message ?: "Xbox content could not be loaded.") } } + }, "xbox-content").start() + } + + private fun packages(product: JSONObject?): List = XboxCatalog.objects(product?.optJSONArray("DisplaySkuAvailabilities")) + .filter { it.optJSONObject("Sku")?.optJSONObject("Properties")?.optBoolean("IsTrial", true) == false } + .flatMap { XboxCatalog.objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages")) } + .filter { pkg -> pkg.optString("PackageFormat") == "MSIXVC" && + XboxCatalog.objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } } + + private val parents = java.util.concurrent.ConcurrentHashMap() + fun busy(base: String): Boolean = StoresState.latestDownloads.any { it.isActive && parents[it.id] == base } + + fun install(context: Context, item: CatalogItem) { + plan = null + val base = runCatching { base(context, item) }.getOrElse { error -> + StoresState.post { StoresState.problems[Store.XBOX] = error.message ?: "The base game is not available." } + return + } + if (XboxRuntime.isActive(base.sidecar.id)) { + StoresState.post { StoresState.problems[Store.XBOX] = "Close the Xbox game before changing its content." } + return + } + parents[item.id] = base.sidecar.id + val folder = File(base.folder, ".xbox-content/${item.id}") + if (folder.canonicalPath != base.folder.canonicalPath + "/.xbox-content/${item.id}") { + StoresState.post { StoresState.problems[Store.XBOX] = "The Xbox content folder contains a link outside this game." } + return + } + com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, base.folder.parentFile!!.parentFile!!) + com.droiddeck.launcher.stores.download.DownloadQueue.enqueue(context, + com.droiddeck.launcher.stores.download.DownloadEntry(Store.XBOX, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes)) { + XboxInstaller(context.applicationContext, item, folder) + } + } + + fun base(context: Context, item: CatalogItem): InstalledStoreGame { + require(item.id.matches(Regex("[A-Z0-9]{12}"))) + val id = item.extra["baseId"] ?: error("Missing Xbox base game") + val folder = StoreInstallRoot.gameFolders(context).firstOrNull { StoreGameSidecar.read(it)?.let { s -> s.store == Store.XBOX && s.id == id && s.isInstalled } == true } + ?: error("The Xbox base game is not installed.") + return InstalledStoreGame(StoreGameSidecar.read(folder)!!, folder) + } + + fun complete(context: Context, item: CatalogItem, folder: File) { + val base = base(context, item) + val parent = XboxManifest.read(base.folder) + val content = XboxManifest.read(folder) + check(item.id in parent.related && content.storeId == item.id && content.dependency == parent.name && content.publisher == parent.publisher && item.extra["family"].orEmpty().startsWith(content.name + "_")) { + "The downloaded content does not belong to this Xbox game." + } + require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}") + val marker = File(folder, ".droiddeck-content.json") + val temporary = File(folder, marker.name + ".tmp") + temporary.writeText(JSONObject().put("productId", item.id).put("parent", base.sidecar.id) + .put("contentId", item.extra["contentId"]).put("version", content.version).toString()) + check(temporary.renameTo(marker)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt new file mode 100644 index 000000000..40cd28225 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt @@ -0,0 +1,159 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.net.LocalServerSocket +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONArray +import org.json.JSONObject +import java.io.Closeable +import java.io.InputStream +import java.util.UUID + +internal class XboxCredentials(private val app: Context, expectedGeneration: String? = null) : Closeable { + val name = "droiddeck-xbox-${UUID.randomUUID()}" + private val generation = lifecycle(app, expectedGeneration) { it } + private val server = LocalServerSocket(name) + @Volatile private var closed = false + private val bound = java.util.concurrent.CountDownLatch(1) + private var backend: Process? = null + private var binaryHash = byteArrayOf() + private lateinit var binary: java.io.File + private val peers = java.util.concurrent.ConcurrentHashMap() + + fun bind(process: Process, file: java.io.File, digest: ByteArray) { + backend = process + binary = file.canonicalFile + binaryHash = digest + bound.countDown() + } + + private fun rejected(reason: String): Boolean { + android.util.Log.w("XboxCredentials", "Rejected credential peer: $reason") + return false + } + + private fun authorized(pid: Int): Boolean { + if (!bound.await(5, java.util.concurrent.TimeUnit.SECONDS) || closed) return rejected("unbound") + val process = backend ?: return rejected("missing-child") + if (!process.isAlive) return rejected("exited-child") + val root = com.droiddeck.launcher.core.HostProcess.pidOf(process) + if (root <= 0 || pid <= 0) return rejected("missing-process-id") + val identity = identity(pid, root) ?: return rejected("foreign-backend") + if (peers[pid] == identity) return true + val digest = java.security.MessageDigest.getInstance("SHA-256") + binary.inputStream().use { input -> + val buffer = ByteArray(65536) + while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) } + } + if (!java.security.MessageDigest.isEqual(binaryHash, digest.digest())) return rejected("different-executable") + peers[pid] = identity + return true + } + + private fun identity(pid: Int, root: Int): String? { + if (root <= 0 || pid <= 0) return null + val status = java.io.File("/proc/$pid/status").readLines() + val tracer = status.first { it.startsWith("TracerPid:") }.substringAfter(':').trim().toInt() + val tracerGroup = java.io.File("/proc/$tracer/status").readLines() + .first { it.startsWith("Tgid:") }.substringAfter(':').trim().toInt() + if (tracerGroup != root) return null + val executable = android.system.Os.stat(binary.path) + if (!mapsBackend(java.io.File("/proc/$pid/maps").readText(), binary.path, executable.st_ino)) + return null + return java.io.File("/proc/$pid/stat").readText().substringAfterLast(") ").split(' ')[19] + ":${executable.st_dev}:${executable.st_ino}" + } + + fun signalBackend(signal: Int) { + val process = backend ?: return + if (!process.isAlive) return + val root = com.droiddeck.launcher.core.HostProcess.pidOf(process) + peers.forEach { (pid, expected) -> runCatching { + if (identity(pid, root) == expected) android.system.Os.kill(pid, signal) + } } + } + + init { + Thread({ + while (!closed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + socket.use { + it.soTimeout = 10_000 + val reply = runCatching { + val peer = it.peerCredentials + check(peer.uid == android.os.Process.myUid() && authorized(peer.pid)) + val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152)) + storage(app) { + check(!closed && epoch(app) == generation && authorized(peer.pid)) + answer(request) + } + }.getOrElse { + android.util.Log.w("XboxCredentials", "Credential request rejected: ${it.javaClass.simpleName}") + JSONObject().put("ok", false) + } + runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) } + } + } + }, "xbox-credentials").start() + } + + private fun answer(request: JSONObject): JSONObject { + val key = request.getString("key") + require(key.isNotEmpty() && key.length <= 512) + val record = StoreAccounts.read(app, Store.XBOX) + check(!StoreAccounts.isUnavailable(Store.XBOX)) + val json = record ?: JSONObject() + val slots = json.optJSONObject("slots") ?: JSONObject().also { json.put("slots", it) } + val result = JSONObject().put("ok", true) + when (request.getString("op")) { + "get" -> result.put("value", slots.opt(key) ?: JSONObject.NULL) + "set" -> { + val bytes = request.getJSONArray("value") + require(bytes.length() <= 262_144) + for (i in 0 until bytes.length()) require(bytes.getInt(i) in 0..255) + slots.put(key, bytes) + StoreAccounts.write(app, Store.XBOX, json) + } + "remove" -> { slots.remove(key); StoreAccounts.write(app, Store.XBOX, json) } + else -> error("Invalid credential operation") + } + return result + } + + override fun close() { closed = true; bound.countDown(); runCatching { server.close() } } + + companion object { + val lock = Any() + internal fun mapsBackend(maps: String, path: String, inode: Long): Boolean = maps.lineSequence().any { line -> + val fields = line.trim().split(Regex("\\s+"), limit = 6) + fields.size == 6 && 'x' in fields[1] && fields[4].toLongOrNull() == inode && + runCatching { java.io.File(fields[5]).canonicalPath == path }.getOrDefault(false) + } + private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("") + fun lifecycle(app: Context, expected: String? = null, block: (String) -> T): T = storage(app) { + val current = epoch(app) + check(expected == null || expected == current) { "Xbox account changed during the request" } + block(current) + } + fun invalidate(app: Context, revoked: () -> Unit = {}) = storage(app) { + java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString()) + StoreAccounts.clear(app, Store.XBOX) + revoked() + } + fun storage(app: Context, block: () -> T): T = synchronized(lock) { + java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file -> + file.channel.lock().use { block() } + } + } + fun readLine(input: InputStream, limit: Int): String { + val out = java.io.ByteArrayOutputStream() + while (out.size() <= limit) { + val byte = input.read() + if (byte == 10) return out.toString("UTF-8") + if (byte < 0) throw java.io.EOFException("Xbox backend disconnected") + out.write(byte) + } + error("Xbox backend response exceeded its limit") + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt new file mode 100644 index 000000000..44e9ceff3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt @@ -0,0 +1,104 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.stores.* +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.json.JSONObject +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal class XboxInstaller(private val app: Context, private val item: CatalogItem, private val folder: File) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + private val paused = CountDownLatch(1) + @Volatile private var child: Xodus.Child? = null + @Volatile private var delete = false + + override fun run(handle: DownloadQueue.JobHandle): String? { + if (cancelled.get()) return null + check(StoreAccounts.signedInAs(app, Store.XBOX) != null) { "Sign in to Xbox before installing." } + val market = item.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market() + val selected = item.extra["contentId"].orEmpty() + require(selected.matches(Regex("[a-fA-F0-9-]{36}"))) { "Select a PC game package before installing." } + val content = !item.extra["baseId"].isNullOrBlank() + if (content) { + check(!XboxRuntime.isActive(item.extra["baseId"].orEmpty())) { "Close the base game before changing Xbox content." } + check(item.id in XboxManifest.read(XboxContent.base(app, item).folder).related) { "This content is not related to the base game." } + val base = XboxContent.base(app, item) + require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}") + File(folder, ".droiddeck-content.json").delete() + folder.mkdirs() + } else { + check(!XboxRuntime.isActive(item.id)) { "Close this Xbox game before updating it." } + StoreInstalls.markUpdating(folder) + StoreInstalls.begin(folder, Store.XBOX, item.id, item.title, item.tallImageUrl, item.imageUrl) + } + var complete = false + var sequence = -1L + try { + val source = if (content) selected else item.id + val selection = if (content) emptyList() else listOf("--selected-content-id", selected) + Xodus.start(app, listOf("streaming", source, folder.path, "--market", market, + "--progress-json", "--control-stdin") + selection).use { process -> + child = process + val diagnostics = Xodus.diagnostics(process) + if (cancelled.get()) process.process.destroy() + process.process.inputStream.bufferedReader().useLines { lines -> lines.forEach { line -> + if (line.startsWith("NEXUS_PROGRESS ")) { + val progress = JSONObject(line.removePrefix("NEXUS_PROGRESS ")) + require(progress.getInt("schema") == 1 && progress.getLong("sequence") > sequence) + sequence = progress.getLong("sequence") + if (progress.optBoolean("paused")) paused.countDown() + when (progress.getString("phase")) { + "complete" -> complete = true + "downloading", "download", "transfer" -> handle.progress(progress.getLong("completedBytes"), progress.optLong("totalBytes")) + "finalizing", "installing" -> handle.stage(DownloadStage.INSTALL) + else -> handle.stage(DownloadStage.MANIFEST) + } + } + } } + check(process.process.waitFor(15, TimeUnit.SECONDS)) { "Xbox installer did not exit." } + diagnostics.join(2000) + if (cancelled.get()) return null + check(process.process.exitValue() == 0 && complete) { "Xbox download did not complete. Resume to retry with fresh licenses." } + } + if (content) { + XboxContent.complete(app, item, folder) + return folder.path + } + val exe = executable(folder) + StoreInstalls.complete(app, folder, StoreGameSidecar(Store.XBOX, item.id, item.title, exe, + installVersion = XboxManifest.read(folder).version, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl, hero = item.imageUrl, + extra = item.extra + mapOf("contentId" to selected, "market" to market))) + return folder.path + } finally { + child = null + if (delete && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder) + else if (delete) StoreInstalls.discard(app, folder, emptyList()) + } + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + delete = deleteFiles + val process = child + if (process == null) { + if (deleteFiles && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder) + else if (deleteFiles) StoreInstalls.discard(app, folder, emptyList()) + return + } + Thread({ + runCatching { + process.send(JSONObject().put("schema", 1).put("command", "pause")) + paused.await(3, TimeUnit.SECONDS) + } + process.close() + }, "xbox-download-stop").start() + } + + companion object { + internal fun executable(folder: File): String = XboxManifest.executable(folder) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt new file mode 100644 index 000000000..6caba8450 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt @@ -0,0 +1,38 @@ +package com.droiddeck.launcher.stores.xbox + +import java.io.Closeable +import java.util.UUID + +internal class XboxLaunchLeases(private val now: () -> Long) { + private data class Lease(val id: String, val account: String, val session: String, var expires: Long, var child: Closeable? = null) + private val leases = HashMap() + + @Synchronized fun active(id: String) = leases.values.any { it.id == id } + @Synchronized fun begin(id: String, account: String, session: String): String { + check(!active(id)) { "This Xbox game is already starting or running." } + val token = UUID.randomUUID().toString() + leases[token] = Lease(id, account, session, now() + 180_000) + return token + } + @Synchronized fun attach(token: String, child: Closeable): Boolean { + val lease = leases[token] ?: return false + lease.child = child + return true + } + @Synchronized fun renew(token: String, id: String, account: String?, session: String?): Boolean { + val lease = leases[token] ?: return false + if (lease.id != id || lease.account != account || lease.session != session || lease.expires <= now()) return false + lease.expires = now() + 60_000 + return true + } + @Synchronized fun release(token: String): Closeable? = leases.remove(token)?.child + @Synchronized fun stale(account: String?, session: String?, suspended: Boolean): List { + if (suspended) leases.values.filter { it.account == account && it.session == session } + .forEach { it.expires = maxOf(it.expires, now() + 60_000) } + val expired = leases.filterValues { it.account != account || it.session != session || (!suspended && it.expires <= now()) }.keys + return expired.mapNotNull { leases.remove(it)?.child } + } + @Synchronized fun clear(): List { + return leases.values.mapNotNull { it.child }.also { leases.clear() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt new file mode 100644 index 000000000..9316ab27e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt @@ -0,0 +1,40 @@ +package com.droiddeck.launcher.stores.xbox + +import java.io.File +import java.nio.file.Files +import java.nio.file.FileVisitResult +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +internal class XboxLaunchViews { + private val recovered = HashSet() + + @Synchronized fun create(files: File): File { + val parent = File(files, "xbox-runtime/views").canonicalFile + check(parent == File(files.canonicalFile, "xbox-runtime/views")) { "Xbox launch storage contains a redirected path" } + check(parent.mkdirs() || parent.isDirectory) + if (parent.path !in recovered) { + parent.listFiles()?.filter { it.name.startsWith("launch-") }?.forEach(::remove) + recovered.add(parent.path) + } + return Files.createTempDirectory(parent.toPath(), "launch-").toFile() + } + + companion object { + fun remove(directory: File) { + if (!Files.exists(directory.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return + Files.walkFileTree(directory.toPath(), object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult { + if (error != null) throw error + Files.deleteIfExists(dir) + return FileVisitResult.CONTINUE + } + }) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt new file mode 100644 index 000000000..bf9e7ee70 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import org.json.JSONArray +import org.json.JSONObject + +internal object XboxLibraryCache { + data class Snapshot(val games: List, val savedAt: Long) { + fun needsRefresh(force: Boolean, now: Long = System.currentTimeMillis()): Boolean = + force || now - savedAt !in 0 until 15 * 60 * 1000L + } + + private fun prefs(app: Context) = app.getSharedPreferences("xbox", Context.MODE_PRIVATE) + fun read(app: Context, account: String, market: String): Snapshot? = + decode(prefs(app).getString("library", null), account, market) + + fun write(app: Context, account: String, market: String, games: List) { + check(prefs(app).edit().putString("library", encode(account, market, games)).commit()) + } + + fun clear(app: Context) { prefs(app).edit().remove("library").commit() } + + internal fun encode(account: String, market: String, games: List, now: Long = System.currentTimeMillis()): String = + JSONObject().put("schema", 1).put("account", account).put("market", market).put("savedAt", now) + .put("games", JSONArray().apply { + for (game in games) put(JSONObject().put("id", game.id).put("title", game.title) + .put("image", game.imageUrl).put("poster", game.tallImageUrl).put("description", game.description) + .put("developer", game.developer).put("publisher", game.publisher).put("size", game.sizeBytes) + .put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none")) + }).toString() + + internal fun decode(text: String?, account: String, market: String, now: Long = System.currentTimeMillis()): Snapshot? = runCatching { + require(text != null && text.length <= 8_000_000) + val json = JSONObject(text) + require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) + val savedAt = json.getLong("savedAt") + require(savedAt > 0 && savedAt <= now + 60_000) + val rows = json.getJSONArray("games") + require(rows.length() <= 10_000) + val games = (0 until rows.length()).map { index -> + val row = rows.getJSONObject(index) + val id = row.getString("id") + val title = row.getString("title") + val access = row.getString("access") + require(id.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && access in setOf("owned", "gamepass")) + CatalogItem(Store.XBOX, id, title, row.optString("image").takeIf { it.isNotEmpty() }, + tallImageUrl = row.optString("poster").takeIf { it.isNotEmpty() }, + description = row.optString("description"), developer = row.optString("developer"), publisher = row.optString("publisher"), + sizeBytes = row.optLong("size"), owned = access == "owned", subscription = access == "gamepass", + storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access)) + } + require(games.map { it.id }.distinct().size == games.size) + Snapshot(games, savedAt) + }.getOrNull() +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt new file mode 100644 index 000000000..01e19158f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt @@ -0,0 +1,183 @@ +package com.droiddeck.launcher.stores.xbox + +import android.annotation.SuppressLint +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class XboxLoginActivity : ComponentActivity() { + private lateinit var host: LinearLayout + private lateinit var status: TextView + private var view: WebView? = null + @Volatile private var child: Xodus.Child? = null + @Volatile private var session = 0L + @Volatile private var closed = false + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + host = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(Color.BLACK) } + status = TextView(this).apply { setTextColor(Color.WHITE); text = "Preparing Microsoft sign-in…"; setPadding(24, 16, 24, 16) } + host.addView(status) + host.addView(Button(this).apply { text = getString(com.droiddeck.launcher.R.string.stores_login_close); setOnClickListener { finish() } }) + setContentView(host) + if (android.os.Build.VERSION.SDK_INT < 28 || !WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) || + !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + status.text = "Update Android System WebView to sign in to Xbox." + return + } + CookieManager.getInstance().removeAllCookies { begin() } + } + + private fun begin() { + if (closed) return + Thread({ + if (closed) return@Thread + try { + val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it } + Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process -> + child = process + if (closed) return@use + Xodus.diagnostics(process) + val timer = Thread({ + runCatching { if (!process.process.waitFor(10, TimeUnit.MINUTES)) process.process.destroy() } + }, "xbox-login-timeout").apply { start() } + var ready = false + var latest = 0L + val input = process.process.inputStream.buffered() + while (!closed) { + val line = try { XboxCredentials.readLine(input, 131_072) } catch (e: java.io.EOFException) { break } + if (!line.startsWith("NEXUS_AUTH_V1 ")) continue + val frame = JSONObject(line.removePrefix("NEXUS_AUTH_V1 ")) + when (frame.getString("type")) { + "ready" -> { + check(!ready && frame.getInt("version") == 1) + ready = true + process.send(JSONObject().put("type", "hello").put("version", 1)) + } + "open" -> { + val next = frame.getLong("session") + check(ready && next > latest && next <= 32 && trusted(frame.getString("url"))) + latest = next; session = next + runOnUiThread { if (!closed) open(frame) } + } + "close" -> { + check(frame.getLong("session") == session) + session = 0L + runOnUiThread { disposeView(); status.text = "Completing Microsoft sign-in…" } + } + "context" -> { + check(frame.getLong("session") == session) + val context = frame.getString("context") + check(context.length <= 4096) + val message = JSONObject().put("type", "callback").put("value", JSONObject() + .put("name", "CloudExperienceHost.getContext") + .put("args", org.json.JSONArray(listOf("CloudExperienceHost", "TokenBroker", "TokenBroker", "{\"PrivatePropertyBag\":1,\"PasswordlessConnect\":1,\"PreferAssociate\":1,\"ChromelessUI\":0}"))) + .put("context", context)) + val active = session + runOnUiThread { if (active == session && trusted(view?.url.orEmpty())) view?.evaluateJavascript( + "globalThis['CloudExperienceHost.Bridge.dispatchMessage'](${JSONObject.quote(message.toString())})", null) } + } + else -> error("Unsupported Xbox sign-in response") + } + } + check(process.process.waitFor(5, TimeUnit.SECONDS) && process.process.exitValue() == 0 && ready && latest > 0 && session == 0L) + timer.interrupt() + } + child = null + if (closed) return@Thread + val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration) + check(account.optBoolean("stored")) + XboxCredentials.lifecycle(this, accountGeneration) { + val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing") + record.put("username", account.getString("username")) + StoreAccounts.write(this, Store.XBOX, record) + } + runOnUiThread { finish() } + } catch (e: Exception) { + child = null + if (!closed) runOnUiThread { disposeView(); status.text = "Microsoft sign-in did not complete. Close this window and try again." } + } + }, "xbox-login").start() + } + + @SuppressLint("SetJavaScriptEnabled") + private fun open(frame: JSONObject) { + disposeView() + val active = frame.getLong("session") + val web = WebView(this) + view = web + web.settings.apply { + javaScriptEnabled = true; domStorageEnabled = true + allowFileAccess = false; allowContentAccess = false + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; MSAppHost/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.26100" + } + WebViewCompat.addWebMessageListener(web, "droiddeckXbox", origins) { _, message, origin, mainFrame, _ -> + val payload = message.data + if (mainFrame && trusted(origin.toString()) && trusted(web.url.orEmpty()) && active == session && payload != null && payload.toByteArray().size <= 65_536) { + runCatching { child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload)) } + } + } + WebViewCompat.addDocumentStartJavaScript(web, + "if(window===window.top){Object.defineProperty(globalThis.external,'notify',{value:p=>{if(typeof p==='string')droiddeckXbox.postMessage(p)},configurable:false,writable:false})}", origins) + web.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = !trusted(request.url.toString()) + override fun onPageFinished(view: WebView, url: String) { + if (!trusted(url)) { finish(); return } + status.text = "Microsoft sign-in" + val uri = Uri.parse(url) + if (uri.host == "login.live.com" && uri.path == "/ppsecure/post.srf") { + view.evaluateJavascript("JSON.stringify(globalThis.ServerData ?? null)") { raw -> + val payload = runCatching { org.json.JSONArray("[$raw]").getString(0) }.getOrNull() + if (active == session && payload != null && payload != "null" && payload.length <= 65_536) runCatching { + child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload)) + } + } + } + } + } + host.addView(web, LinearLayout.LayoutParams(-1, 0, 1f)) + val headers = frame.getJSONObject("headers") + val values = headers.keys().asSequence().associateWith { key -> + require(key in allowedHeaders) + headers.getString(key).also { value -> require(value.length <= 4096 && value.all { it in ' '..'~' }) } + } + web.loadUrl(frame.getString("url"), values) + } + + private fun disposeView() { view?.let { host.removeView(it); it.stopLoading(); it.destroy() }; view = null } + override fun onDestroy() { + closed = true + val current = child; child = null + Thread({ runCatching { current?.close() } }, "xbox-login-close").start() + disposeView() + CookieManager.getInstance().removeAllCookies(null) + android.webkit.WebStorage.getInstance().deleteAllData() + super.onDestroy() + } + + companion object { + private val hosts = setOf("login.live.com", "account.live.com", "login.microsoftonline.com", "login.microsoft.com", "account.microsoft.com", "sisu.xboxlive.com") + private val origins = hosts.map { "https://$it" }.toSet() + private val allowedHeaders = setOf("cxh-capabilities", "cxh-correlationid", "cxh-msabinaryversion", "cxh-identityclientbinaryversion", "cxh-osversioninfo", "cxh-platform", "cxh-protocol", "cxh-source", "hostapp") + internal fun trusted(value: String): Boolean = runCatching { + val url = java.net.URI(value) + url.scheme == "https" && url.host in hosts && url.port == -1 && url.userInfo == null + }.getOrDefault(false) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt new file mode 100644 index 000000000..d738193ed --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.stores.xbox + +import java.io.File +import org.xmlpull.v1.XmlPullParser + +internal object XboxManifest { + data class Info(val storeId: String, val name: String, val publisher: String, val version: String, + val dependency: String, val related: List, val executables: List) + + fun read(folder: File): Info { + val manifests = folder.listFiles().orEmpty().filter { it.name.equals("MicrosoftGame.config", true) && it.isFile } + require(manifests.size == 1) { "Missing or ambiguous MicrosoftGame.config." } + val manifest = manifests.single() + require(manifest.length() <= 1_048_576) + val parser = android.util.Xml.newPullParser().apply { setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false) } + val identities = ArrayList>() + val storeIds = ArrayList() + val dependencies = ArrayList() + val related = ArrayList() + val entries = ArrayList() + fun attribute(name: String) = parser.getAttributeValue(null, name).orEmpty() + var root = false + var elements = 0 + manifest.inputStream().use { input -> + parser.setInput(input, null) + while (true) { + when (parser.nextToken()) { + XmlPullParser.END_DOCUMENT -> break + XmlPullParser.DOCDECL -> error("Xbox game manifests cannot contain a DTD.") + XmlPullParser.START_TAG -> { + require(++elements <= 10_000 && parser.depth <= 64) { "Xbox game manifest is too complex." } + if (parser.depth == 1) { require(!root && parser.name == "Game"); root = true } + when (parser.name) { + "Identity" -> identities.add(listOf(attribute("Name"), attribute("Publisher"), attribute("Version"))) + "StoreId" -> storeIds.add(parser.nextText().trim()) + "MainPackageDependency" -> dependencies.add(attribute("Name")) + "RelatedProduct" -> related.add(parser.nextText().trim()) + "Executable" -> { + val path = attribute("Name").replace('\\', '/') + if (path.endsWith(".exe", true) && attribute("TargetDeviceFamily") in listOf("", "PC", "Windows.Desktop") && + !attribute("IsDevOnly").equals("true", true)) entries.add(path) + } + } + } + } + } + } + require(root && identities.size <= 1 && storeIds.size <= 1 && dependencies.size <= 1) { "Invalid Xbox game manifest." } + val ids = related.distinct() + require(ids.size <= 128 && ids.all { it.matches(Regex("[A-Z0-9]{12}")) }) + val identity = identities.singleOrNull() + return Info(storeIds.singleOrNull().orEmpty(), identity?.get(0).orEmpty(), identity?.get(1).orEmpty(), identity?.get(2).orEmpty(), + dependencies.singleOrNull().orEmpty(), ids, entries) + } + + fun executable(folder: File): String { + for (path in read(folder).executables) { + require(!path.startsWith('/') && !path.contains(':') && path.split('/').none { it == ".." || it.isEmpty() }) + var file = folder + for (part in path.split('/')) { + val matches = file.listFiles().orEmpty().filter { it.name.equals(part, true) } + require(matches.size == 1) { "Missing or ambiguous Xbox launch path." } + file = matches.single() + } + require(file.canonicalPath.startsWith(folder.canonicalPath + File.separator)) + if (file.isFile) return file.relativeTo(folder).invariantSeparatorsPath + } + error("The Xbox package has no supported PC launch executable.") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt new file mode 100644 index 000000000..28d6c12d9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt @@ -0,0 +1,116 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.stores.* +import org.json.JSONObject +import java.io.File + +internal object XboxRuntime { + private val state = Any() + private val startup = Any() + private var child: Xodus.Child? = null + private var owner: String? = null + private var serviceSocket: File? = null + private var generation = 0L + private val views = XboxLaunchViews() + private val launches = XboxLaunchLeases { android.os.SystemClock.uptimeMillis() } + fun isActive(id: String): Boolean = launches.active(id) + fun released(token: String) { runCatching { launches.release(token)?.close() } } + fun renew(app: Context, id: String, token: String): Boolean = launches.renew(token, id, + StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running }) + + private data class Authorization(val account: String, val token: String, val runtime: Long, val credentials: String) + + fun prepare(app: Context, id: String): JSONObject { + check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" } + check(SessionState.running) { "No running DroidDeck session" } + val session = SessionState.sessionId ?: error("No running DroidDeck session") + val game = StoreInstallRoot.gameFolders(app).mapNotNull { folder -> + StoreGameSidecar.read(folder)?.takeIf { it.store == Store.XBOX && it.id == id && it.isInstalled }?.let { folder to it } + }.firstOrNull() ?: error("Xbox game is not installed") + val (account, token, current, credentials) = XboxCredentials.lifecycle(app) { epoch -> + val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing") + synchronized(state) { Authorization(account, launches.begin(id, account, session), generation, epoch) } + } + Thread({ + while (isActive(id)) { + Thread.sleep(10_000) + launches.stale(StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running }, + SessionState.suspended).forEach { runCatching { it.close() } } + } + }, "xbox-launch-expiry").start() + try { + val socket = service(app, account, current, credentials) + check(synchronized(state) { generation == current }) { "Xbox launch was cancelled" } + val market = game.second.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market() + val directory = views.create(app.filesDir) + val process = try { + Xodus.start(app, listOf("run", game.first.path, "droiddeck:prepare", "--exe", game.second.exe, + "--materialize-chain", "--market", market), launchDirectory = directory, credentialGeneration = credentials) + } catch (e: Exception) { XboxLaunchViews.remove(directory); throw e } + if (!launches.attach(token, process)) { process.close(); error("Xbox launch was cancelled") } + Xodus.diagnostics(process) + val result = java.util.concurrent.FutureTask { + val input = process.process.inputStream.buffered() + var line: String + do { line = XboxCredentials.readLine(input, 131_072) } while (!line.startsWith("DROIDDECK_LAUNCH_V1 ")) + JSONObject(line.removePrefix("DROIDDECK_LAUNCH_V1 ")) + } + Thread(result, "xbox-launch-view").start() + val view = result.get(90, java.util.concurrent.TimeUnit.SECONDS) + check(renew(app, id, token) && synchronized(state) { generation == current }) { "Xbox launch was cancelled" } + return JSONObject().put("ok", true).put("socket", socket.path).put("lease", token) + .put("exe", view.getString("exe")).put("playfab", view.opt("playfab")) + } catch (e: Exception) { released(token); throw e } + } + + internal fun newServiceSocket(files: File): File = + File(files, "xbox-runtime/s-${java.util.UUID.randomUUID().toString().replace("-", "")}/xodus.sock") + + private fun service(app: Context, account: String, current: Long, credentials: String): File = synchronized(startup) { + val existing = synchronized(state) { + check(generation == current) { "Xbox launch was cancelled" } + serviceSocket?.takeIf { child?.process?.isAlive == true && owner == account } + } + val socket = existing ?: newServiceSocket(app.filesDir) + if (existing == null) { + val old = synchronized(state) { child.also { child = null; owner = null; serviceSocket = null } } + old?.close() + socket.delete() + val service = Xodus.start(app, emptyList(), service = true, runtimeDirectory = socket.parentFile!!, credentialGeneration = credentials) + val accepted = synchronized(state) { + (generation == current).also { if (it) { child = service; owner = account; serviceSocket = socket } } + } + if (!accepted) { service.close(); error("Xbox launch was cancelled") } + try { + Xodus.diagnostics(service) + Thread({ runCatching { service.process.inputStream.use { input -> + val buffer = ByteArray(8192) + while (input.read(buffer) >= 0) { Thread.yield() } + } } }, "xbox-service-output").start() + val deadline = android.os.SystemClock.elapsedRealtime() + 90_000 + while (!socket.exists() && service.process.isAlive && SessionState.running && + synchronized(state) { generation == current } && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100) + check(socket.exists() && service.process.isAlive && SessionState.running) { + "Xbox account service did not become ready. Check your sign-in and network connection." + } + } catch (e: Exception) { + synchronized(state) { if (child === service) { child = null; owner = null; serviceSocket = null } } + service.close() + throw e + } + } + socket + } + + fun stop() { + val processes = synchronized(state) { + generation++ + val all = launches.clear() + listOfNotNull(child) + child = null; owner = null; serviceSocket = null + all + } + Thread({ processes.forEach { runCatching { it.close() } } }, "xbox-runtime-stop").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt new file mode 100644 index 000000000..6e7c30df8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -0,0 +1,144 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.os.Environment +import com.droiddeck.launcher.core.AddonPack +import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import java.io.Closeable +import java.io.File +import java.util.concurrent.TimeUnit + +internal object Xodus { + const val DIR = "/usr/local/lib/droiddeck-xbox" + fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US" + + class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null, + private val networkLink: LinuxNetworkLinkComponent) : Closeable { + @Synchronized fun send(frame: JSONObject) { + process.outputStream.write((frame.toString() + "\n").toByteArray()) + process.outputStream.flush() + } + override fun close() { + try { + credentials.close() + runCatching { process.outputStream.close() } + if (!process.waitFor(2, TimeUnit.SECONDS)) { + credentials.signalBackend(android.system.OsConstants.SIGINT) + if (!process.waitFor(2, TimeUnit.SECONDS)) { + credentials.signalBackend(android.system.OsConstants.SIGKILL) + if (!process.waitFor(1, TimeUnit.SECONDS)) { + val pid = com.droiddeck.launcher.core.HostProcess.pidOf(process) + if (pid > 0) android.os.Process.killProcess(pid) + } + } + } + } finally { + networkLink.stop() + runtimeDirectory?.let { directory -> + File(directory, "xodus.sock").delete() + directory.delete() + } + launchDirectory?.let(XboxLaunchViews::remove) + } + } + } + + @Synchronized private fun stage(app: Context): File { + check(AddonPack.XBOX.await(app)) { "Couldn't download the Xbox components. Check your connection and try again." } + val pack = AddonPack.XBOX.dir(app) + val root = File(LinuxRuntime.rootDir(app), DIR.removePrefix("/")) + val packaged = File(pack, "version").readText() + if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return pack + fun copy(source: File, relative: String) { + if (source.isDirectory) { source.listFiles().orEmpty().forEach { copy(it, if (relative.isEmpty()) it.name else "$relative/${it.name}") }; return } + if (relative == "version" || relative == AddonPack.COMPLETE) return + val file = File(root, relative) + file.parentFile?.mkdirs() + val staged = File(file.path + ".staged") + source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } } + staged.setExecutable(true, false) + check(staged.renameTo(file)) + } + copy(pack, "") + File(root, "version").writeText(packaged) + return pack + } + + fun start(app: Context, args: List, service: Boolean = false, runtimeDirectory: File? = null, launchDirectory: File? = null, credentialGeneration: String? = null): Child { + check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." } + val pack = stage(app) + val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}" + check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." } + val networkLink = LinuxNetworkLinkComponent(app, LinuxRuntime.rootDir(app)) + val credentials = XboxCredentials(app, credentialGeneration) + try { + networkLink.attach(app) + networkLink.publish() + networkLink.start() + val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() } + val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf( + "/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off", + "XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary, + ) + args) + val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app)) + if (launchDirectory != null) builder.environment()["TMPDIR"] = launchDirectory.path + builder.environment().apply { + remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH") + put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path) + put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path) + if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1") + LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } + } + val digest = java.security.MessageDigest.getInstance("SHA-256") + File(pack, binary.removePrefix("$DIR/")).inputStream().use { input -> + val buffer = ByteArray(65536) + while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) } + } + val process = builder.start() + credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest()) + return Child(process, credentials, runtimeDirectory, launchDirectory, networkLink) + } catch (e: Exception) { + runCatching { networkLink.stop() } + credentials.close() + throw e + } + } + + fun run(app: Context, args: List, timeout: Long = 90, credentialGeneration: String? = null): JSONObject { + start(app, args, credentialGeneration = credentialGeneration).use { child -> + val output = java.io.ByteArrayOutputStream() + var readError: Throwable? = null + val reader = Thread({ + try { + val buffer = ByteArray(8192) + child.process.inputStream.use { input -> + while (true) { + val count = input.read(buffer) + if (count < 0) break + check(output.size() + count <= 8_000_000) + output.write(buffer, 0, count) + } + } + } catch (t: Throwable) { readError = t; child.process.destroy() } + }, "xbox-result").apply { start() } + val diagnostic = diagnostics(child) + check(child.process.waitFor(timeout, TimeUnit.SECONDS)) { "Xbox request timed out. Try again." } + reader.join(3000); diagnostic.join(3000) + check(readError == null && !reader.isAlive) { "Xbox returned an invalid response." } + check(child.process.exitValue() == 0) { "Xbox request failed. Check your account and network connection." } + return JSONObject(output.toString("UTF-8")) + } + } + + fun diagnostics(child: Child): Thread = Thread({ + runCatching { + child.process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line -> + if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\."))) + com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $line") + } } + } + }, "xbox-diagnostics").apply { start() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt index bc9618c96..5e63d7b0f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -305,6 +307,7 @@ internal fun AppDialog( modifier: Modifier = Modifier, maxWidth: Dp? = null, width: Dp? = null, fitContent: Boolean = false, back: (() -> Unit)? = null, + scrollable: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { val pal = LocalPalette.current @@ -314,7 +317,7 @@ internal fun AppDialog( Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize().imePadding() - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = close), + .tapOutside(close), ) { AnimatedVisibility( visibleState = shown, @@ -339,10 +342,10 @@ internal fun AppDialog( .clip(Shape16) .background(pal.surfaceVariant.copy(alpha = 0.97f)) .border(1.dp, pal.signal.copy(alpha = 0.22f), Shape16) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} + .blockTouches() .controllerBack(back ?: close) .then(modifier) - .verticalScroll(rememberScrollState()) + .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) .padding(horizontal = 18.dp, vertical = 16.dp), content = content, ) @@ -492,7 +495,7 @@ private fun IconSuggestions(repo: String, selected: String?, onPick: (String) -> if (list != null && list.isEmpty()) return Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(44.dp)) { Small(stringResource(if (list == null) R.string.add_app_icon_searching else R.string.add_app_icon_suggestions)) - if (list != null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + if (list != null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState()).selectableGroup()) { list.forEachIndexed { i, url -> Rise(i) { SuggestedIcon(url, url == selected) { onPick(url) } } } } } @@ -509,7 +512,7 @@ private fun SuggestedIcon(url: String, selected: Boolean, onClick: () -> Unit) { contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(colors.surface) .glideBorder(hot, Shape12, pal.signal, pal.line2) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) + .hoverable(src).selectable(selected = selected, role = androidx.compose.ui.semantics.Role.RadioButton, interactionSource = src, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick), ) { AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(url).crossfade(true).build(), contentDescription = stringResource(R.string.add_app_icon), contentScale = ContentScale.Fit, modifier = Modifier.size(34.dp)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt index cec6c7a42..3d86510f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt @@ -40,6 +40,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.input.InputMode import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import android.content.Context @@ -502,7 +508,7 @@ private fun ArtChooserDialog( sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER } val first = remember { FocusRequester() } - LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } } + LaunchedEffect(options != null) { focusWithinFrames({ false }) { first } } val apply: (AddedGameArt.Option) -> Unit = { option -> scope.launch { val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) } @@ -624,14 +630,23 @@ private fun SgdbSearchDialog( } } LaunchedEffect(Unit) { search() } - AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) { + val firstItem = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val leaveField = { focusManager.clearFocus(); keyboardController?.hide() } + val keyboard = LocalInputModeManager.current.inputMode == InputMode.Keyboard + LaunchedEffect(picked?.id, matches?.size, images.size, busy) { + val ready = if (picked != null) images.isNotEmpty() else !matches.isNullOrEmpty() + if (keyboard && !busy && ready) focusSoon { firstItem } + } + AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp, modifier = Modifier.pointerInput(Unit) { detectTapGestures { leaveField() } }) { DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = { SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back) }) androidx.compose.material3.OutlinedTextField( query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.art_sgdb_search_label)) }, - keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }), + keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { leaveField(); search() }), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search), ) val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp @@ -643,24 +658,24 @@ private fun SgdbSearchDialog( val chosenGame = picked val list = matches.orEmpty() if (chosenGame == null) { - for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } } + list.forEachIndexed { i, match -> key(match.id) { PickerRow(null, match.name, null, focus = if (i == 0) firstItem else null) { leaveField(); showImages(match) } } } if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) { Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp)) } } else { Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - for (option in images) key(option.full) { + images.forEachIndexed { i, option -> key(option.full) { ArtOption( - slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null, + slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = if (i == 0) firstItem else null, description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName", ) { onPick(option) } - } + } } } if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) { Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp)) } - if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null } + if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { leaveField(); picked = null } } if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt new file mode 100644 index 000000000..a4227d4c2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt @@ -0,0 +1,717 @@ +package com.droiddeck.launcher.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import android.graphics.SurfaceTexture +import android.media.MediaPlayer +import android.net.Uri +import android.view.Surface +import android.view.TextureView +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.viewinterop.AndroidView +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.session.BootMovie +import com.droiddeck.launcher.session.BootMovieLibrary +import com.droiddeck.launcher.session.BootMovieReport +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SteamDeckRepo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +private enum class MovieTab { INSTALLED, IMPORT, REPO, SOUND } + +/** Downloads run here rather than in a tab, so switching tabs or closing the settings does not cancel one halfway. */ +private val installScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + +/** The downloads under way, by post id and percent; kept here so they are still known when the tab or the settings come back. */ +private val installProgress = mutableStateMapOf() + +/** What the movie list looks like right now: every movie and the one Steam will play. */ +private class MovieSnapshot(val entries: List, val selected: String) + +/** The movie list, read again every couple of seconds so a movie picked, added or removed inside Steam shows up here too. */ +@Composable +private fun rememberMovies(refresh: Int, onSelectionChanged: () -> Unit): MovieSnapshot? { + val app = LocalContext.current.applicationContext + var snapshot by remember { mutableStateOf(null) } + val changed by androidx.compose.runtime.rememberUpdatedState(onSelectionChanged) + LaunchedEffect(refresh) { + while (true) { + val before = snapshot?.selected + snapshot = withContext(Dispatchers.IO) { + BootMovie.syncFromSteam(app) + MovieSnapshot(BootMovieLibrary.list(app), BootMovie.choice(app)) + } + if (before != null && before != snapshot?.selected) changed() + delay(2_000) + } + } + return snapshot +} + +@Composable +private fun TabChip(label: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(8.dp) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.clip(shape) + .background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) + .border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal) + .hoverable(src) + .clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = label; this.selected = selected } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text(label, fontSize = 12.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** The "Boot movie" settings group: Installed movies, Import your own movies, SteamDeckRepo and Sound. */ +@Composable +internal fun BootMovieGroup(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) { + var tab by rememberSaveable { mutableStateOf(MovieTab.INSTALLED) } + SettingsGroup(stringResource(R.string.boot_movie)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)) { + TabChip(stringResource(R.string.boot_movie_tab_installed), tab == MovieTab.INSTALLED, Modifier.weight(1f)) { tab = MovieTab.INSTALLED } + TabChip(stringResource(R.string.boot_movie_tab_import), tab == MovieTab.IMPORT, Modifier.weight(1f)) { tab = MovieTab.IMPORT } + TabChip(stringResource(R.string.boot_movie_tab_repo), tab == MovieTab.REPO, Modifier.weight(1f)) { tab = MovieTab.REPO } + TabChip(stringResource(R.string.boot_movie_tab_sound), tab == MovieTab.SOUND, Modifier.weight(1f)) { tab = MovieTab.SOUND } + } + when (tab) { + MovieTab.INSTALLED -> InstalledTab(a) + MovieTab.IMPORT -> ImportTab(s, a) + MovieTab.REPO -> RepoTab(a) + MovieTab.SOUND -> SoundTab(s, a, host) + } + } +} + +@Composable +private fun InstalledTab(a: ModeSettingsActions) { + var browsing by remember { mutableStateOf(false) } + val snapshot = rememberMovies(0, a.onBootMovieChanged) + val current = snapshot?.let { snap -> snap.entries.firstOrNull { it.id == snap.selected }?.title } + val hint = current ?: stringResource(R.string.common_working) + SettingsRow(stringResource(R.string.boot_movie_installed), hint) { + SecondaryButton(stringResource(R.string.boot_movie_browse), onClick = { browsing = true }) + } + Text( + stringResource(R.string.boot_movie_note), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp), + ) + val app = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + var saving by remember { mutableStateOf(false) } + // The report is for bug reports, so it shows only while Debugging tools is on (read each time this tab opens). + val debugging = remember { com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(app) } + if (debugging) { + SettingsRow(stringResource(R.string.boot_movie_report), stringResource(R.string.boot_movie_report_hint)) { + SecondaryButton(stringResource(R.string.boot_movie_report_save), enabled = !saving, onClick = { + saving = true + scope.launch { + val file = withContext(Dispatchers.IO) { BootMovieReport.write(app) } + saving = false + Toast.makeText( + app, + file?.let { app.getString(R.string.boot_movie_report_saved, it.path) } ?: app.getString(R.string.boot_movie_report_failed), + Toast.LENGTH_LONG, + ).show() + } + }) + } + } + if (browsing) InstalledMoviesDialog(a) { browsing = false } +} + +@Composable +private fun MovieCover(entry: BootMovieLibrary.Entry, modifier: Modifier = Modifier) { + val app = LocalContext.current.applicationContext + val cover by androidx.compose.runtime.produceState(null, entry.id, entry.title) { + value = withContext(Dispatchers.IO) { BootMovieLibrary.cover(app, entry) } + } + Box(modifier.aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0E1218))) { + cover?.let { AsyncImage(model = it, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } + } +} + +@Composable +private fun movieSourceLabel(source: String): String = stringResource( + when (source) { + BootMovieLibrary.SOURCE_DROIDDECK -> R.string.boot_movie_source_droiddeck + BootMovieLibrary.SOURCE_REPO -> R.string.boot_movie_source_repo + BootMovieLibrary.SOURCE_IMPORT -> R.string.boot_movie_source_import + BootMovieLibrary.SOURCE_POINTSHOP -> R.string.boot_movie_source_pointshop + else -> R.string.boot_movie_source_steam + }, +) + +/** The installed movies as a list with covers; the one Steam plays is highlighted, and the list follows changes made in Steam. */ +@Composable +private fun InstalledMoviesDialog(a: ModeSettingsActions, onDismiss: () -> Unit) { + val app = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + var refresh by remember { mutableIntStateOf(0) } + val snapshot = rememberMovies(refresh, a.onBootMovieChanged) + var picked by remember { mutableStateOf(null) } + var removing by remember { mutableStateOf(null) } + var filter by rememberSaveable { mutableStateOf(null) } + val selected = picked ?: snapshot?.selected + LaunchedEffect(snapshot?.selected) { picked = null } + removing?.let { entry -> + RemoveMovieDialog( + entry, + onConfirm = { + removing = null + scope.launch { + withContext(Dispatchers.IO) { BootMovieLibrary.remove(app, entry.id) } + a.onBootMovieChanged() + refresh++ + } + }, + onDismiss = { removing = null }, + ) + } + val firstRow = remember { FocusRequester() } + val inputMode = LocalInputModeManager.current + LaunchedEffect(snapshot != null, filter) { + if (snapshot != null && inputMode.inputMode == InputMode.Keyboard) focusSoon { firstRow } + } + AppDialog(shown, close, "boot-movies", wide = false, scrollable = false) { + val filters = listOf( + null to stringResource(R.string.store_all), + BootMovieLibrary.SOURCE_DROIDDECK to movieSourceLabel(BootMovieLibrary.SOURCE_DROIDDECK), + BootMovieLibrary.SOURCE_STEAM to movieSourceLabel(BootMovieLibrary.SOURCE_STEAM), + BootMovieLibrary.SOURCE_POINTSHOP to movieSourceLabel(BootMovieLibrary.SOURCE_POINTSHOP), + BootMovieLibrary.SOURCE_REPO to movieSourceLabel(BootMovieLibrary.SOURCE_REPO), + BootMovieLibrary.SOURCE_IMPORT to movieSourceLabel(BootMovieLibrary.SOURCE_IMPORT), + ) + var filterOpen by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) { + Text( + stringResource(R.string.boot_movie_installed), fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), + ) + Box { + androidx.compose.runtime.CompositionLocalProvider(LocalChipMinWidth provides 100.dp) { + ValueChip(filters.first { it.first == filter }.second, filterOpen) { filterOpen = !filterOpen } + } + AnchoredMenu(filterOpen, onDismiss = { filterOpen = false }, compact = true) { firstItemFocus -> + filters.forEachIndexed { index, (source, label) -> + MenuItem(label, checked = filter == source, focusRequester = if (index == 0) firstItemFocus else null) { + filter = source + filterOpen = false + } + } + } + } + SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = MaterialTheme.colorScheme.error, onClick = close) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().weight(1f, fill = false)) { + itemsIndexed(snapshot?.entries.orEmpty().filter { filter == null || it.source == filter }, key = { _, entry -> entry.id }) { index, entry -> + ListRow(accent = entry.id == selected, tall = true, focus = if (index == 0) firstRow else null, onClick = { picked = entry.id; a.onBootMovie(entry.id) }) { + MovieCover(entry, Modifier.size(width = 96.dp, height = 54.dp)) + Column(Modifier.weight(1f)) { + Text(entry.title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(movieSourceLabel(entry.source), fontSize = 12.sp, color = if (entry.id == selected) LocalPalette.current.signal else MaterialTheme.colorScheme.onSurfaceVariant) + } + if (entry.removable) SmallIconButton(Icons.Filled.Close, stringResource(R.string.boot_movie_remove), MaterialTheme.colorScheme.error) { + removing = entry + } + } + } + } + } +} + +@Composable +private fun RemoveMovieDialog(entry: BootMovieLibrary.Entry, onConfirm: () -> Unit, onDismiss: () -> Unit) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.boot_movie_remove_confirm, entry.title)) }, + text = { Text(stringResource(R.string.boot_movie_remove_confirm_body)) }, + confirmButton = { androidx.compose.material3.TextButton(onClick = onConfirm) { Text(stringResource(R.string.boot_movie_remove)) } }, + dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, + ) +} + +@Composable +private fun ImportTab(s: ModeSettings, a: ModeSettingsActions) { + SettingsRow( + stringResource(R.string.boot_movie_custom), + stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS), + ) { + SecondaryButton( + when { + s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress) + s.bootMovieImporting -> stringResource(R.string.common_working) + else -> stringResource(R.string.boot_movie_import) + }, + enabled = !s.bootMovieImporting, + progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f }, + onClick = a.onImportBootMovie, + ) + } +} + +@Composable +private fun SoundTab(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) { + val app = LocalContext.current.applicationContext + var volume by remember { mutableIntStateOf(SessionPrefs.bootMovieVolume(app)) } + var seconds by remember { mutableIntStateOf(SessionPrefs.bootMovieSeconds(app)) } + val duration by androidx.compose.runtime.produceState(0, s.bootMovie) { + value = withContext(Dispatchers.IO) { BootMovieLibrary.durationSeconds(BootMovie.currentFile(app)) } + } + ToggleRow( + host, "boot-movie-sound", stringResource(R.string.boot_movie_sound), + stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound, + ) + Box(Modifier.padding(horizontal = 6.dp)) { + ValueSlider( + volume, 0..100, 5, s.bootMovieSound, stringResource(R.string.boot_movie_volume), { "$it%" }, + ) { volume = it; SessionPrefs.setBootMovieVolume(app, it) } + } + Box(Modifier.padding(horizontal = 6.dp)) { + val shownSeconds = if (seconds in 1 until duration) seconds else duration + ValueSlider( + shownSeconds, 1..duration.coerceAtLeast(2), 1, duration > 1, stringResource(R.string.boot_movie_length), + { value -> app.getString(R.string.boot_movie_seconds, value) }, + ) { next -> + val stored = if (next >= duration) 0 else next + seconds = stored + SessionPrefs.setBootMovieSeconds(app, stored) + } + } + Text( + stringResource(R.string.boot_movie_length_hint), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp), + ) +} + +@Composable +private fun RepoTab(a: ModeSettingsActions) { + val progress = installProgress + val app = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + var sort by rememberSaveable { mutableStateOf(SteamDeckRepo.Sort.TRENDING) } + var device by rememberSaveable { mutableStateOf(null) } + var query by rememberSaveable { mutableStateOf("") } + var page by rememberSaveable { mutableIntStateOf(1) } + var retry by remember { mutableIntStateOf(0) } + var searching by remember { mutableStateOf(false) } + var jumping by remember { mutableStateOf(false) } + var previewing by remember { mutableStateOf(null) } + var installed by remember { mutableStateOf(setOf()) } + val chosen = rememberMovies(0, a.onBootMovieChanged)?.selected + val inUse by androidx.compose.runtime.produceState(null, chosen) { + value = withContext(Dispatchers.IO) { + chosen?.takeIf { it.startsWith(BootMovieLibrary.PREFIX) }?.let { BootMovieLibrary.repoId(app, it.removePrefix(BootMovieLibrary.PREFIX)) } + } + } + LaunchedEffect(progress.size) { installed = withContext(Dispatchers.IO) { BootMovieLibrary.installedRepoIds(app) } } + val result by androidx.compose.runtime.produceState(null, sort, device, query, page, retry) { + value = null + value = withContext(Dispatchers.IO) { SteamDeckRepo.fetch(sort, device, query, page) } + } + val last by androidx.compose.runtime.produceState(null, sort, device, query, retry) { + value = null + value = withContext(Dispatchers.IO) { SteamDeckRepo.lastPage(sort, device, query) } + } + LaunchedEffect(last) { last?.let { if (page > it) page = it } } + val installPost: (SteamDeckRepo.Post) -> Unit = { post -> + if (progress.isEmpty()) { + progress[post.id] = 0 + installScope.launch { + val outcome = try { + withContext(Dispatchers.IO) { install(app, post) { progress[post.id] = it } } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + app.getString(R.string.boot_movie_repo_download_failed) + } finally { + progress.remove(post.id) + } + Toast.makeText(app, outcome ?: app.getString(R.string.boot_movie_repo_installed, post.title), Toast.LENGTH_LONG).show() + a.onBootMovieChanged() + } + } + } + val usePost: (SteamDeckRepo.Post) -> Unit = { post -> + scope.launch { + val name = withContext(Dispatchers.IO) { BootMovieLibrary.nameForRepo(app, post.id) } + if (name != null) a.onBootMovie(BootMovieLibrary.idOf(name)) + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(bottom = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + Box(Modifier.weight(1f)) { + ListRow(tall = true, onClick = { searching = true }) { + Icon(Icons.Filled.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Text( + query.ifEmpty { stringResource(R.string.boot_movie_repo_search) }, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.weight(1f), + ) + } + } + if (query.isNotEmpty()) SecondaryButton(stringResource(R.string.boot_movie_repo_clear), compact = true) { query = ""; page = 1 } + } + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TabChip(stringResource(R.string.boot_movie_repo_all), device == null) { device = null; page = 1 } + TabChip(stringResource(R.string.boot_movie_repo_deck), device == "steam_deck") { device = "steam_deck"; page = 1 } + TabChip(stringResource(R.string.boot_movie_repo_machine), device == "steam_machine") { device = "steam_machine"; page = 1 } + } + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SteamDeckRepo.Sort.values().forEach { option -> + TabChip( + stringResource( + when (option) { + SteamDeckRepo.Sort.TRENDING -> R.string.boot_movie_repo_trending + SteamDeckRepo.Sort.DOWNLOADS -> R.string.boot_movie_repo_downloads + SteamDeckRepo.Sort.LIKES -> R.string.boot_movie_repo_likes + SteamDeckRepo.Sort.NEWEST -> R.string.boot_movie_repo_newest + }, + ), + sort == option, + ) { sort = option; page = 1 } + } + } + when (val r = result) { + null -> Text(stringResource(R.string.common_working), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp)) + is SteamDeckRepo.Page.Failed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.boot_movie_repo_failed), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) + SecondaryButton(stringResource(R.string.boot_movie_repo_retry), compact = true) { retry++ } + } + is SteamDeckRepo.Page.Ok -> { + if (r.posts.isEmpty()) Text(stringResource(R.string.boot_movie_repo_empty), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp)) + r.posts.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + row.forEach { post -> + RepoCard( + post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(), + modifier = Modifier.weight(1f), onPreview = { previewing = post }, onInstall = { installPost(post) }, onUse = { usePost(post) }, + ) + } + if (row.size == 1) Box(Modifier.weight(1f)) + } + } + RepoPager(page, last, hasMore = r.posts.size >= SteamDeckRepo.PAGE_SIZE, onPage = { page = it }, onJump = { jumping = true }) + } + } + } + if (searching) TextEntryDialog( + stringResource(R.string.boot_movie_repo_search), query, number = false, confirm = stringResource(R.string.stores_search_open), + onSubmit = { value -> if (value != query) { query = value; page = 1 } }, onDismiss = { searching = false }, + ) + if (jumping) TextEntryDialog( + stringResource(R.string.boot_movie_repo_go_to_page), page.toString(), number = true, confirm = stringResource(R.string.common_ok), + onSubmit = { value -> value.toIntOrNull()?.let { n -> page = n.coerceIn(1, maxOf(1, last ?: n)) } }, onDismiss = { jumping = false }, + ) + previewing?.let { post -> + PreviewDialog( + post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(), + onInstall = { installPost(post) }, onUse = { usePost(post) }, onDismiss = { previewing = null }, + ) + } +} + +/** Downloads [post] and puts it among the installed movies; returns a message when it failed, null when it worked. */ +private fun install(app: android.content.Context, post: SteamDeckRepo.Post, onProgress: (Int) -> Unit): String? { + val folder = File(app.cacheDir, "boot-movie-download").apply { mkdirs() } + // Leftovers of a download that was cut short are cleared, and every download has a file of its own. + folder.listFiles()?.forEach { if (System.currentTimeMillis() - it.lastModified() > 60 * 60 * 1000L) it.delete() } + val file = File.createTempFile("movie-", ".webm", folder) + return try { + if (!SteamDeckRepo.download(post, file, onProgress)) return app.getString(R.string.boot_movie_repo_download_failed) + val cover = if (post.thumbnail.isNotEmpty()) SteamDeckRepo.bytes(post.thumbnail) else null + when (BootMovie.import(app, file, onProgress = onProgress, title = post.title, source = BootMovieLibrary.SOURCE_REPO, repoId = post.id, cover = cover, use = false)) { + is BootMovie.Import.Done -> null + BootMovie.Import.TooLong -> app.getString(R.string.boot_movie_too_long, BootMovie.MAX_CONVERT_SECONDS) + is BootMovie.Import.Unsupported -> app.getString(R.string.boot_movie_unsupported) + BootMovie.Import.Failed -> app.getString(R.string.boot_movie_import_failed) + } + } finally { + file.delete() + } +} + +@Composable +private fun RepoCard( + post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, + onPreview: () -> Unit, onInstall: () -> Unit, onUse: () -> Unit, +) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(10.dp) + val thumbShape = RoundedCornerShape(7.dp) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val previewLabel = stringResource(R.string.boot_movie_repo_preview) + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + modifier = modifier.clip(shape) + .background(if (inUse) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f)) + .border(if (inUse) 2.dp else 1.dp, if (inUse) pal.signal else pal.line, shape).padding(6.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(thumbShape).background(Color(0xFF0E1218)) + .glideBorder(hot, thumbShape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onPreview) + .controllerConfirm(onClick = onPreview) + .semantics { contentDescription = previewLabel }, + ) { + if (post.thumbnail.isNotEmpty()) AsyncImage(model = post.thumbnail, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) + } + } + Text(post.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(repoInfo(post), fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse) + } +} + +@Composable +private fun repoInfo(post: SteamDeckRepo.Post): String = + listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "") + .filter { it.isNotEmpty() }.joinToString(" · ") + +private const val AFTER_OPEN = 3 +private const val AFTER_INSTALL = 1 +private const val AFTER_USE = 2 + +/** Install and Use are different buttons in turn, and the button is disabled while installing; it takes focus back for the controller. */ +@Composable +private fun RepoAction( + installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit, + takeFocus: Boolean = false, +) { + val requester = remember { FocusRequester() } + val focused = remember { booleanArrayOf(false) } + var pending by remember { mutableIntStateOf(if (takeFocus) AFTER_OPEN else 0) } + val inputMode = LocalInputModeManager.current + LaunchedEffect(installed, inUse, busy, percent != null, pending) { + if (pending == 0) return@LaunchedEffect + if (inputMode.inputMode != InputMode.Keyboard) { pending = 0; return@LaunchedEffect } + focusWithinFrames({ focused[0] }) { requester } + when (pending) { + AFTER_OPEN -> pending = 0 + AFTER_INSTALL -> if (installed && focused[0]) pending = 0 + AFTER_USE -> if (inUse && focused[0]) pending = 0 + else -> Unit + } + } + val m = modifier.focusRequester(requester).onFocusChanged { focused[0] = it.hasFocus || it.isFocused } + if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = m, onClick = {}) + else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = m, onClick = { pending = AFTER_USE; onUse() }) + else SecondaryButton( + if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install), + enabled = !busy, compact = true, modifier = m, onClick = { pending = AFTER_INSTALL; onInstall() }, + ) +} + +/** A movie from SteamDeckRepo played straight from the site, so it can be checked before it is installed. */ +@Composable +private fun PreviewDialog( + post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, + onInstall: () -> Unit, onUse: () -> Unit, onDismiss: () -> Unit, +) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp + AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) { + DialogTitleRow(post.title, close) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f)) + } + Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse, takeFocus = true) + } +} + +@Composable +private fun MoviePreview(url: String, modifier: Modifier = Modifier) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(10.dp) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + var state by remember(url) { mutableIntStateOf(0) } + var ratio by remember(url) { mutableFloatStateOf(16f / 9f) } + val player = remember(url) { arrayOfNulls(1) } + DisposableEffect(url) { + onDispose { + runCatching { player[0]?.release() } + player[0] = null + } + } + val toggle = { runCatching { player[0]?.let { if (it.isPlaying) it.pause() else it.start() } }; Unit } + Box( + contentAlignment = Alignment.Center, + modifier = modifier.clip(shape).background(Color.Black) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) + .controllerConfirm(onClick = toggle), + ) { + AndroidView( + modifier = Modifier.aspectRatio(ratio), + factory = { viewContext -> + TextureView(viewContext).apply { + surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { + val mp = MediaPlayer() + player[0] = mp + try { + mp.setDataSource(viewContext, Uri.parse(url), mapOf("User-Agent" to "DroidDeck")) + mp.setSurface(Surface(texture)) + mp.isLooping = true + mp.setOnVideoSizeChangedListener { _, w, h -> if (w > 0 && h > 0) ratio = w.toFloat() / h } + mp.setOnPreparedListener { it.start(); state = 1 } + mp.setOnErrorListener { _, _, _ -> state = 2; true } + mp.prepareAsync() + } catch (e: Exception) { + state = 2 + } + } + + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) = Unit + + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { + runCatching { player[0]?.release() } + player[0] = null + return true + } + + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) = Unit + } + } + }, + ) + if (state == 0) Text(stringResource(R.string.common_working), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f)) + if (state == 2) Text(stringResource(R.string.boot_movie_repo_preview_failed), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f), modifier = Modifier.padding(12.dp)) + } +} + +/** A dialog with one text box, for a search term or a page number; the Android keyboard comes up with it. */ +@Composable +private fun TextEntryDialog( + title: String, initial: String, number: Boolean, confirm: String, onSubmit: (String) -> Unit, onDismiss: () -> Unit, +) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + var text by remember { mutableStateOf(initial) } + val submit = { onSubmit(text.trim()); close() } + AppDialog(shown, close, "boot-movie-entry", wide = false, maxWidth = 460.dp) { + DialogTitleRow(title, close) + AdbTextField( + value = text, onValueChange = { text = it }, label = "", keyboardType = if (number) KeyboardType.Number else KeyboardType.Text, + imeAction = ImeAction.Done, placeholder = title, focusOnShow = true, onDone = { submit() }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), modifier = Modifier.weight(1f), onClick = close) + SecondaryButton(confirm, modifier = Modifier.weight(1f)) { submit() } + } + } +} + +/** Page buttons around the current page, which opens a box to type another page into; first and last page show their numbers. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RepoPager(page: Int, last: Int?, hasMore: Boolean, onPage: (Int) -> Unit, onJump: () -> Unit) { + val reach = last ?: if (hasMore) Int.MAX_VALUE else page + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + SecondaryButton(stringResource(R.string.boot_movie_repo_previous), enabled = page > 1, compact = true) { onPage(page - 1) } + if (page - 3 > 1) SecondaryButton("1", compact = true) { onPage(1) } + for (n in maxOf(1, page - 3) until page) SecondaryButton("$n", compact = true) { onPage(n) } + TabChip("$page", true, Modifier.widthIn(min = 52.dp).heightIn(min = 44.dp), onJump) + for (n in page + 1..minOf(page + 3, reach)) SecondaryButton("$n", compact = true) { onPage(n) } + if (last != null && page + 3 < last) SecondaryButton("$last", compact = true) { onPage(last) } + SecondaryButton(stringResource(R.string.boot_movie_repo_next), enabled = page < reach, compact = true) { onPage(page + 1) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt new file mode 100644 index 000000000..e94f193e9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt @@ -0,0 +1,141 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import kotlin.math.atan2 +import kotlin.math.hypot +import kotlin.math.min + +/** A small colour square; pressing it opens the colour picker. */ +@Composable +internal fun ColorCube(color: Color, description: String, focus: FocusRequester? = null, onClick: () -> Unit) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(10.dp) + Box( + Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).size(44.dp).clip(shape).background(color) + .border(1.dp, pal.line2, shape).glideBorder(hot, shape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = description }, + ) +} + +/** Colour wheel (touch), hue / saturation / brightness sliders (controller and touch) and a hex box for one colour. */ +@Composable +internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Unit, onApply: (Int) -> Unit) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val start = remember { ThemeColors.toHsv(initial) } + var hue by remember { mutableStateOf(start.h) } + var sat by remember { mutableStateOf(start.s) } + var value by remember { mutableStateOf(start.v) } + var hexText by remember { mutableStateOf(ThemeColors.hex(initial)) } + val current = ThemeColors.fromHsv(hue, sat, value) + val first = remember { FocusRequester() } + LaunchedEffect(Unit) { focusSoon { first } } + fun picked(h: Float = hue, s: Float = sat, v: Float = value) { + hue = h.coerceIn(0f, 360f); sat = s.coerceIn(0f, 1f); value = v.coerceIn(0f, 1f) + hexText = ThemeColors.hex(ThemeColors.fromHsv(hue, sat, value)) + } + AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp) { + DialogTitleRow(title, close) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + ColorWheel(hue, sat, value) { h, s -> picked(h = h, s = s) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Swatch(Color(initial)) + Swatch(Color(current)) + } + } + ValueSlider(hue.toInt(), 0..360, step = 5, enabled = true, label = stringResource(R.string.theme_color_hue), format = { "$it°" }, + modifier = Modifier.focusRequester(first)) { picked(h = it.toFloat()) } + ValueSlider((sat * 100).toInt(), 0..100, step = 2, enabled = true, label = stringResource(R.string.theme_color_saturation), format = { "$it%" }) { picked(s = it / 100f) } + ValueSlider((value * 100).toInt(), 0..100, step = 2, enabled = true, label = stringResource(R.string.theme_color_brightness), format = { "$it%" }) { picked(v = it / 100f) } + AdbTextField( + value = hexText, + onValueChange = { text -> + hexText = text + ThemeColors.parseHex(text)?.let { argb -> ThemeColors.toHsv(argb).let { hsv -> hue = hsv.h; sat = hsv.s; value = hsv.v } } + }, + label = "", keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done, placeholder = stringResource(R.string.theme_color_hex), + onDone = { ThemeColors.parseHex(hexText)?.let { onApply(it); close() } }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), modifier = Modifier.weight(1f), onClick = close) + PrimaryButton(stringResource(R.string.common_ok), enabled = ThemeColors.parseHex(hexText) != null, modifier = Modifier.weight(1f)) { + ThemeColors.parseHex(hexText)?.let { onApply(it); close() } + } + } + } +} + +@Composable +private fun Swatch(color: Color) { + val shape = RoundedCornerShape(10.dp) + Box(Modifier.size(48.dp).clip(shape).background(color).border(1.dp, LocalPalette.current.line2, shape)) +} + +/** Hue around the circle and saturation out from the middle, at full brightness; brightness has its own slider. */ +@Composable +private fun ColorWheel(hue: Float, sat: Float, value: Float, onPick: (hue: Float, sat: Float) -> Unit) { + val rim = MaterialTheme.colorScheme.onBackground + val size = 168.dp + fun at(offset: Offset, side: Float) { + val center = side / 2f + val dx = offset.x - center + val dy = offset.y - center + val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat() + onPick((angle + 360f) % 360f, min(1f, hypot(dx, dy) / center)) + } + Canvas( + Modifier.size(size) + .pointerInput(Unit) { detectTapGestures { at(it, this.size.width.toFloat()) } } + .pointerInput(Unit) { detectDragGestures { change, _ -> at(change.position, this.size.width.toFloat()); change.consume() } }, + ) { + val radius = this.size.minDimension / 2f + drawCircle(Brush.sweepGradient(List(7) { Color.hsv(it * 60f, 1f, 1f) }), radius) + drawCircle(Brush.radialGradient(listOf(Color.White, Color.Transparent), radius = radius), radius) + val theta = Math.toRadians(hue.toDouble()) + val marker = Offset(radius + (kotlin.math.cos(theta) * sat * radius).toFloat(), radius + (kotlin.math.sin(theta) * sat * radius).toFloat()) + drawCircle(Color.Black.copy(alpha = 0.55f), 9.dp.toPx(), marker) + drawCircle(rim, 7.dp.toPx(), marker) + drawCircle(Color.hsv(hue, sat, value), 5.dp.toPx(), marker) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index ee6dd09d4..4b5cfce76 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -73,6 +74,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -151,6 +154,7 @@ fun ComponentsPage( onImport: () -> Unit, onBack: () -> Unit, games: List = emptyList(), + initialGameKey: String? = null, generalFexPreset: String = "", onGeneralFexPreset: (String) -> Unit = {}, requestInitialFocus: Boolean = true, @@ -169,7 +173,7 @@ fun ComponentsPage( val layer = if (comp in LAYERS) comp else GPU_TAB BackHandler(onBack = onBack) var scopeMenu by remember { mutableStateOf(false) } - var selectedGameKey by remember { mutableStateOf(null) } + var selectedGameKey by remember(initialGameKey) { mutableStateOf(initialGameKey) } var fexMenu by remember { mutableStateOf(false) } var gameProtonMenu by remember { mutableStateOf(false) } var gameProtonId by remember { mutableStateOf(null) } @@ -380,6 +384,7 @@ fun ComponentsPage( var stackAt by remember { mutableStateOf(Offset.Zero) } var pageAt by remember { mutableStateOf(Offset.Zero) } val landed = remember { mutableStateMapOf() } + val nightlyPicked = remember { mutableStateMapOf() } val rings = remember { mutableStateMapOf() } fun land(l: String) { landed[l] = (landed[l] ?: 0) + 1; rings[l] = (rings[l] ?: 0) + 1 } val arrivedSeen = remember { intArrayOf(Hops.arrived) } @@ -513,7 +518,7 @@ fun ComponentsPage( val availableTitle: @Composable (String) -> String = { l -> when (l) { PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null || it.update != null }) - else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size) + else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size) } } val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right -> @@ -591,12 +596,16 @@ fun ComponentsPage( ) { protonActions.onInstall(row.id) } } else -> { - val available = availableFor(l, catalog, snapshot) + val available = availableRows(l, catalog, snapshot) if (available.isEmpty()) EmptyLine(if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new)) - for (d in available) GetLine( - d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0), - progress = downloads[d.file], enabled = busy == null, - ) { onDownload(d) } + for ((stable, nightly) in available) { + val d = if (nightly != null && nightlyPicked[stable.file] == true) nightly else stable + GetLine( + d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0), + progress = downloads[d.file], enabled = busy == null, + channel = if (nightly != null) ({ ChannelToggle(d === nightly, enabled = busy == null) { nightlyPicked[stable.file] = d !== nightly } }) else null, + ) { onDownload(d) } + } } } } @@ -938,6 +947,14 @@ private fun availableFor(l: String, catalog: List, snapshot: Snapsh return catalog.filter { it.comp == l && ComponentsManager.safeName(it.file) !in stored } } +// The rows to show: each stable row carries its paired nightly (if any), and a paired nightly has no row of its own. +private fun availableRows(l: String, catalog: List, snapshot: Snapshot?): List> { + val available = availableFor(l, catalog, snapshot) + val pairs = ComponentsManager.nightlyPairs(available) + val paired = pairs.values.toSet() + return available.filter { it !in paired }.map { it to pairs[it] } +} + @Composable private fun LayerTitle(l: String): String = when (l) { GPU_TAB -> stringResource(R.string.comp_gpu_drivers) @@ -1329,7 +1346,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa modifier = Modifier.size(44.dp).clip(RoundedCornerShape(12.dp)) .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) .glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2) - .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick), ) { if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) @@ -1348,7 +1365,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) .background(if (hot) pal.signal else colors.surfaceVariant) .glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp) - .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 14.dp, vertical = 8.dp), ) { @@ -1428,7 +1445,7 @@ private fun ChoiceLine( modifier = Modifier.padding(end = 6.dp).size(44.dp).clip(ROW_SHAPE) .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) .glideBorder(delHot, ROW_SHAPE, colors.error) - .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) + .hoverable(delSrc).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), ) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } } @@ -1439,7 +1456,8 @@ private fun ChoiceLine( @Composable private fun GetLine( name: String, detail: String, progress: Int?, enabled: Boolean, - badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null, onGet: () -> Unit, + badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null, + channel: (@Composable () -> Unit)? = null, onGet: () -> Unit, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -1454,12 +1472,43 @@ private fun GetLine( if (progress != null) ProgressBit(progress) else { if (onCancel != null) FocusText(stringResource(R.string.proton_cancel_queued), pal.signal, onClick = onCancel) + channel?.invoke() SmallButton(action ?: stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet) } } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } +// Stable | Nightly in the same box as SmallButton: one focus stop, A (or a tap) flips it. +@Composable +private fun ChannelToggle(nightly: Boolean, enabled: Boolean, onFlip: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) && enabled + val state = stringResource(if (nightly) R.string.comp_channel_nightly else R.string.comp_channel_stable) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) + .background(colors.surfaceVariant) + .glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp) + .hoverable(src).toggleable(value = nightly, interactionSource = src, indication = null, enabled = enabled, role = Role.Switch, onValueChange = { onFlip() }) + .controllerConfirm(enabled = enabled, onClick = onFlip) + .semantics { stateDescription = state } + .padding(4.dp), + ) { + for ((label, on) in listOf(R.string.comp_channel_stable to !nightly, R.string.comp_channel_nightly to nightly)) { + Text( + stringResource(label), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (!enabled) colors.onSurfaceVariant else if (on) (if (hot) pal.onSignal else pal.signal) else colors.onSurfaceVariant, + modifier = Modifier.clip(RoundedCornerShape(7.dp)) + .background(if (on && enabled) (if (hot) pal.signal else pal.signal.copy(alpha = 0.16f)) else Color.Transparent) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } +} + @Composable private fun DetailLine(detail: String, badge: String?) { val colors = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt index 8f883c24c..075b7c2ba 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt @@ -13,11 +13,12 @@ import androidx.compose.ui.graphics.drawscope.Stroke @Composable fun CursorOverlay( - position: Offset, visible: Boolean, scale: Float, + positionOf: () -> Offset, visible: Boolean, scale: Float, image: ImageBitmap? = null, hotX: Int = 0, hotY: Int = 0, imageScale: Float = 1f, ) { if (!visible) return Canvas(modifier = Modifier.fillMaxSize()) { + val position = positionOf() if (image != null) { val w = (image.width * imageScale).toInt().coerceAtLeast(1) val h = (image.height * imageScale).toInt().coerceAtLeast(1) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt index b030632fc..0a603d5a5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt @@ -17,12 +17,15 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView import com.droiddeck.launcher.input.DeckControls import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PadState +import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionState import kotlin.math.atan2 import kotlin.math.hypot import kotlin.math.min @@ -49,13 +52,6 @@ class DeckControlsPanel( select(if (pad != null) selectedWithPad else selected) } - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) { - post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) } - } - } - override fun onDetachedFromWindow() { DeckControls.releaseAll() super.onDetachedFromWindow() @@ -85,13 +81,17 @@ class DeckControlsPanel( addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)) } } - val centre = FrameLayout(context).apply { - addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER)) + val centre = HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = false + overScrollMode = View.OVER_SCROLL_NEVER + addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44))) } addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) - addView(roundButton("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu, wide = true)) - addView(space(dp(8))) - addView(roundButton("•••", context.getString(R.string.drawer_open_qam), onQam, wide = true)) + if (SessionState.mode == SessionService.MODE_STEAM) { + addView(roundButton("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu, wide = true)) + addView(space(dp(8))) + addView(roundButton("•••", context.getString(R.string.drawer_open_qam), onQam, wide = true)) + } } private fun select(tab: Tab) { @@ -102,6 +102,17 @@ class DeckControlsPanel( view.background = if (on) filled(CORNER_PILL, ACCENT) else null view.setTextColor(if (on) ON_ACCENT else TEXT_DIM) } + tabViews[tab]?.let { selectedView -> + (selectedView.parent?.parent as? HorizontalScrollView)?.post { + val left = selectedView.left + val right = selectedView.right + val scroll = selectedView.parent?.parent as? HorizontalScrollView ?: return@post + when { + left < scroll.scrollX -> scroll.smoothScrollTo(left, 0) + right > scroll.scrollX + scroll.width -> scroll.smoothScrollTo(right - scroll.width, 0) + } + } + } content.removeAllViews() topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE content.addView( @@ -170,8 +181,10 @@ class DeckControlsPanel( place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER) place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER) place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW) - place(centreTap("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu), GamepadGeometry.Slot.STEAM) - place(centreTap("\u2022\u2022\u2022", context.getString(R.string.drawer_open_qam), onQam), GamepadGeometry.Slot.QAM) + if (SessionState.mode == SessionService.MODE_STEAM) { + place(centreTap("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu), GamepadGeometry.Slot.STEAM) + place(centreTap("\u2022\u2022\u2022", context.getString(R.string.drawer_open_qam), onQam), GamepadGeometry.Slot.QAM) + } place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU) place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) }, GamepadGeometry.Slot.PANELS) @@ -643,7 +656,7 @@ class DeckControlsPanel( private const val CLICK_MS = 300L private var selected = Tab.BOTH - private var selectedWithPad = Tab.GAMEPAD + private var selectedWithPad = Tab.BOTH private const val CORNER = 20f private const val CORNER_PILL = 22f diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt index e1068a53c..f6ce824ea 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt @@ -52,6 +52,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -142,8 +143,13 @@ internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click) }, beside = { - HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") } - HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") } + HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true } + } + if (s.desktopPresent) UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop) + } }, rows = { tab -> if (tab == SheetTab.FILES) DesktopFiles(s, a) @@ -366,6 +372,7 @@ private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) { @Composable private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) { + val canUninstall by rememberUpdatedState(enabled) val colors = MaterialTheme.colorScheme val label = stringResource(R.string.common_uninstall) val title = stringResource(R.string.desktop_uninstall_title) @@ -383,7 +390,7 @@ private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) { Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } - StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() } + StepChoice(label, danger = true, enabled = open && canUninstall) { Steps.fold(); onUninstall() } } }) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index bfdde089a..2a22c0a15 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -105,7 +106,7 @@ fun DriverPage( .glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line) .focusRequester(refreshFocus) .hoverable(refreshSrc) - .clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), + .clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), ) { if (checking) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) else Icon(Icons.Outlined.Refresh, contentDescription = stringResource(R.string.comp_check_drivers), tint = colors.onBackground, modifier = Modifier.size(20.dp)) @@ -185,7 +186,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f).then(modifier).focusRequester(mainFocus) .then(if (row.removable) Modifier.focusProperties { right = delFocus } else Modifier) - .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) + .hoverable(src).selectable(selected = selected, role = androidx.compose.ui.semantics.Role.RadioButton, interactionSource = src, indication = null, onClick = onSelect) .padding(horizontal = 11.dp, vertical = 8.dp), ) { Box( @@ -218,7 +219,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit .glideBorder(delHot, RoundedCornerShape(10.dp), colors.error) .focusRequester(delFocus) .focusProperties { left = mainFocus } - .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete), + .hoverable(delSrc).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = delSrc, indication = null, onClick = onDelete), ) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, row.name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) } } } @@ -235,7 +236,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier modifier = modifier.clip(shape) .background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent) .glideBorder(hot, shape, color) - .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = null, onClick = onClick) .padding(horizontal = 12.dp, vertical = 8.dp), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt index 816177bc1..bac30bd3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt @@ -27,7 +27,7 @@ internal fun FrameGenMenu( onPick(m); host.open = null } item(FrameGen.Mode.OFF, focus = firstItemFocus) - for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m)) + for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_DDFG, m)) val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } + FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) } lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index 85386ca93..eef6fd19c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -131,7 +131,7 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera .background(colors.surface) .glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp) .alpha(if (t.dim && !hot) 0.55f else 1f) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), ) { Box(modifier = Modifier.fillMaxWidth().shine(hot)) { Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 679348878..dcb5eece6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.runtime.key import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Row @@ -30,6 +31,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.ui.semantics.semantics import androidx.compose.foundation.shape.CircleShape @@ -110,7 +112,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o if (onAdd != null) Rise(0) { GamesHeader(gameCount, s.steamGamesX64, onAdd, Modifier.padding(bottom = 10.dp)) } Rise(0) { Row(verticalAlignment = Alignment.Bottom) { - GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) { + GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp), corner = { GameRemoveButton(current, s, a) }) { GameHeroCopy(current, if (narrow) 28.sp else 38.sp) GameActions(current, s, a, onEdit, launch) } @@ -132,7 +134,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()) .padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp), ) { - GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) { + GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp), corner = { GameRemoveButton(current, s, a) }) { GameHeroCopy(current, if (narrow) 24.sp else 32.sp) GameActions(current, s, a, onEdit, launch) } @@ -266,6 +268,21 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA } } +/** A skipped sync is not a success: "0 files" used to show for a signed-out store or cloud saves turned off. */ +internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.launcher.stores.CloudSaves.Result): String = when (r.result) { + "ok" -> when { + r.conflicts > 0 -> context.getString(R.string.cloud_conflict_files, r.conflicts) + r.files == 0 -> context.getString(R.string.cloud_up_to_date) + else -> context.getString(R.string.cloud_done, r.files) + } + "skipped" -> context.getString(when (r.reason) { + "not-signed-in" -> R.string.cloud_signed_out + "no-cloud-saves" -> R.string.cloud_none + else -> R.string.cloud_not_synced + }) + else -> context.getString(R.string.cloud_not_synced) +} + @Composable private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compose.ui.focus.FocusRequester) { val store = com.droiddeck.launcher.stores.Store.byId(g.source)?.takeIf { it != com.droiddeck.launcher.stores.Store.AMAZON } ?: return @@ -288,7 +305,7 @@ private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compo busy = if (up) "up" else "down" Thread({ val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id, force) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id, force) - com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, if (r.ok) context.getString(R.string.cloud_done, r.files) else r.reason, android.widget.Toast.LENGTH_SHORT).show() } + com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, cloudMessage(context, r), android.widget.Toast.LENGTH_SHORT).show() } }, "cloud-manual").start() } MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, focusRequester = first, @@ -300,7 +317,7 @@ private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compo com.droiddeck.launcher.stores.StoresState.post { enabled = written?.cloud ?: enabled } }, "cloud-switch").start() } - val ready = st != null && supported && busy == null + val ready = st != null && supported && enabled && busy == null val pending = stringResource(R.string.cloud_pending) val summary = when { st == null || busy != null -> pending @@ -439,6 +456,47 @@ private fun GamesDialogs(a: FrontEndActions, games: List) { } } +@Composable +private fun GameRemoveButton(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) { + val kind = com.droiddeck.launcher.frontend.GameRemoval.kind(g) + if (s.shortcutPicker || kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER) return + if (g.gameFiles?.isDirectory != true) return + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + var asking by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } + val shortcut = kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT + val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall) + val title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name) + val body = when { + shortcut -> stringResource(R.string.games_remove_body) + g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body) + else -> stringResource(R.string.games_uninstall_body) + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem("game:remove").downToFirstTile().size(44.dp).clip(Shape12) + .background(if (hot) colors.error.copy(alpha = 0.22f) else Color.Black.copy(alpha = 0.34f)) + .glideBorder(hot, Shape12, colors.error, pal.line2) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button) { asking = true } + .controllerConfirm { asking = true } + .semantics { contentDescription = label }, + ) { + androidx.compose.material3.Icon( + Icons.Outlined.Delete, contentDescription = null, + tint = if (hot) colors.error else Color.White, modifier = Modifier.size(18.dp), + ) + } + if (asking) ConfirmDialog( + title = title, + text = body, + confirm = label, + onConfirm = { a.onGameRemoved(g) }, + onDismiss = { asking = false }, + ) +} + @Composable private fun EditGameButton(onClick: () -> Unit) { val colors = MaterialTheme.colorScheme @@ -501,7 +559,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni .clip(Shape12) .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) .glideBorder(focused, Shape12, pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = onSelect) .controllerConfirm(onClick = onLaunch) .padding(horizontal = 10.dp, vertical = 6.dp), ) { @@ -570,7 +628,7 @@ private fun libraryLabel(g: Library.SteamGame): String = when { } @Composable -private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { +private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composable BoxScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit) { val colors = MaterialTheme.colorScheme Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) { val image = g.hero ?: g.art.takeIf { g.library != Library.EPIC_LAUNCHER } @@ -593,5 +651,6 @@ private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composa modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp), content = content, ) + Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index c5a0c6866..7136ac7f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -119,7 +121,7 @@ internal fun SideRail( Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().selectableGroup(), ) { val place: (String) -> Modifier = { key -> Modifier.onGloballyPositioned { @@ -192,7 +194,7 @@ private fun RailItem( .background(fill) .glideBorder(focused, Shape14, pal.signal) .hoverable(src) - .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .selectable(selected = current, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier), ) { if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index df8fdcbf6..1c4c9a44b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -145,12 +145,13 @@ class FrontEndState( /** Setup's "Safe mode" for the Mali bridge (SessionPrefs.vkBridgeSafe), on by default. */ val vkBridgeSafe: Boolean = true, val showPhantomGate: Boolean = false, - val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, + val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, val launcherFullscreen: Boolean = true, val downloadSource: String = DownloadSource.MODE_AUTO, val downloadMirror: String = "", val downloadPrefix: String = "", val animationsEnabled: Boolean = true, + val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK, val gameStoresEnabled: Boolean = true, val gameStoresSpeedTier: String = "fast", val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY, @@ -177,6 +178,7 @@ class FrontEndActions( val onAddedGamesChanged: (folders: List) -> Unit = {}, val onSteamGameArtChanged: (appId: Int) -> Unit = {}, val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> }, + val onGameRemoved: (Library.SteamGame) -> Unit = {}, val onAddedGamesArt: (Boolean) -> Unit = {}, val onSgdbKey: (String) -> Unit = {}, val onStopGameFileSync: () -> Unit = {}, @@ -203,6 +205,7 @@ class FrontEndActions( val onImportLossless: () -> Unit, val onProtons: () -> Unit, val onComponents: (focusContent: Boolean) -> Unit, + val onGameComponents: (Library.SteamGame) -> Unit = { onComponents(true) }, val onComponentsTab: (String) -> Unit = {}, val onSaveImport: (Library.SteamGame) -> Unit = {}, val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> }, @@ -223,6 +226,7 @@ class FrontEndActions( val onTheme: (String) -> Unit = {}, val onLanguage: (String) -> Unit = {}, val onAppScale: (Int) -> Unit = {}, + val onLauncherIcon: (String) -> Unit = {}, val onOrientation: (Int) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, @@ -404,6 +408,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com when (target) { "stores:downloads" -> { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" } "components" -> a.onComponents(true) + "setup:launcher" -> { SetupTabs.openLauncher = true; selected = "setup" } else -> selected = target } a.onNavigated() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c792988dd..9ce1eacce 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -86,6 +86,11 @@ import com.droiddeck.launcher.R private enum class CheckState { OK, WARN, BUSY } private const val STORAGE_TAB = 3 +private const val LAUNCHER_TAB = 2 + +internal object SetupTabs { + var openLauncher = false +} private const val GLASSES_TAB = 5 @Composable @@ -177,7 +182,8 @@ internal fun SetupPanel( onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false }, ) val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses)) - var tab by rememberSaveable { mutableStateOf(0) } + var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) } + SetupTabs.openLauncher = false val chipFocus = remember { List(tabs.size) { FocusRequester() } } var tabTurned by remember { mutableStateOf(false) } val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true } @@ -390,6 +396,7 @@ internal fun SetupPanel( } } } + if (s.theme == Themes.GRADIENT) GradientColorRows() ChoiceRow( host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent -> @@ -399,6 +406,11 @@ internal fun SetupPanel( ) }, s.appScale, onPick = a.onAppScale, ) + ChoiceRow( + host, "launcher-icon", stringResource(R.string.setup_launcher_icon), null, + com.droiddeck.launcher.LauncherIcon.options.map { (style, label) -> style to stringResource(label) }, + s.launcherIcon, onPick = a.onLauncherIcon, + ) ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, s.animationsEnabled, onChange = a.onAnimationsEnabled) LanguageRow(host, s.language, a.onLanguage) @@ -551,8 +563,9 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost val back = stringResource(R.string.hop_from_games) SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) { val at = Hops.sources["card:components"] - if (at == null) a.onComponents(true) - else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) } + val open = { if (game == null) a.onComponents(true) else a.onGameComponents(game) } + if (at == null) open() + else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open) } } add { m -> @@ -817,3 +830,21 @@ private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit } } } + +/** The two colours of the Gradient theme, each a small square that opens the colour picker. */ +@Composable +private fun GradientColorRows() { + val ctx = androidx.compose.ui.platform.LocalContext.current.applicationContext + val (primary, secondary) = Themes.gradientColors + var picking by remember { androidx.compose.runtime.mutableIntStateOf(0) } + val primaryLabel = stringResource(R.string.theme_gradient_primary) + val secondaryLabel = stringResource(R.string.theme_gradient_secondary) + SettingsRow(primaryLabel, null) { ColorCube(androidx.compose.ui.graphics.Color(primary), primaryLabel) { picking = 1 } } + SettingsRow(secondaryLabel, null) { ColorCube(androidx.compose.ui.graphics.Color(secondary), secondaryLabel) { picking = 2 } } + if (picking != 0) ColorPickerDialog( + if (picking == 1) primaryLabel else secondaryLabel, if (picking == 1) primary else secondary, + onDismiss = { picking = 0 }, + ) { argb -> + if (picking == 1) Themes.setGradient(ctx, argb, secondary) else Themes.setGradient(ctx, primary, argb) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index c6296300e..fb7d20485 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -59,16 +59,22 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import java.util.Locale internal val Shape14 = RoundedCornerShape(14.dp) +@Composable +internal fun headingUppercase(text: String): String = + text.uppercase(LocalConfiguration.current.locales[0] ?: Locale.ROOT) + @Composable internal fun Eyebrow(t: String) { val pal = LocalPalette.current @@ -76,7 +82,7 @@ internal fun Eyebrow(t: String) { LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal)) - Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal) + Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal) } } @@ -88,7 +94,7 @@ internal fun Eyebrow(t: String) { internal fun SectionTitle(t: String, detail: String?) { val colors = MaterialTheme.colorScheme Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) { - Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant) + Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant) if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground) Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line)) } @@ -179,10 +185,9 @@ internal fun PrimaryButton( .graphicsLayer { alpha = if (LaunchOrigin.flooding === placed) 0f else 1f; scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal } .clip(Shape12) .background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant))) - .shine(hot, 0.45f) .then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, Shape12)) - .glideBorder(hot, Shape12, pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) { + .then(if (hot) Modifier.border(2.dp, pal.signal, Shape12) else Modifier) + .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = null, enabled = enabled) { placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) } onClick() } @@ -245,7 +250,7 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo contentAlignment = Alignment.Center, modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2) .alpha(if (enabled) 1f else 0.5f) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp), ) { @@ -267,7 +272,7 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) Box( contentAlignment = Alignment.Center, modifier = Modifier.paneItem("cog").downToFirstTile().size(size).onGloballyPositioned { placed[0] = it }.clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current) { + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current) { placed[0]?.takeIf { it.isAttached }?.let { PageOrigin.mark(Origin(it.boundsInRoot(), cornerPx)) } spins++ onClick() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt index 7557eb249..f8f50a5eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -143,15 +143,15 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on } } otherId?.let { id -> + val valid = id.isNotEmpty() && GameEnvironment.validScope(id) + val open = { if (valid) { scope = id.toLong().toString(); otherId = null } } Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp)) { AdbTextField( id, { otherId = it.filter(Char::isDigit) }, stringResource(R.string.game_env_app_id), KeyboardType.Number, ImeAction.Done, modifier = Modifier.weight(1f), compact = true, - onDone = { if (GameEnvironment.validScope(id)) { scope = id.toLong().toString(); otherId = null } }, + onDone = open, ) - SecondaryButton(stringResource(R.string.game_env_open), enabled = id.isNotEmpty() && GameEnvironment.validScope(id)) { - scope = id.toLong().toString(); otherId = null - } + SecondaryButton(stringResource(R.string.game_env_open), enabled = valid, onClick = open) } } if (error) Small(stringResource(R.string.game_env_error), error = true) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt b/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt new file mode 100644 index 000000000..56643effe --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt @@ -0,0 +1,128 @@ +package com.droiddeck.launcher.ui + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R +import java.text.Collator + +internal enum class GestureActionGroup(@StringRes val title: Int) { + MOUSE(R.string.gesture_group_mouse), APP(R.string.gesture_group_app), + NAVIGATION(R.string.gesture_group_navigation), LETTERS(R.string.gesture_group_letters), + NUMBERS(R.string.gesture_group_numbers), FUNCTION(R.string.gesture_group_function), + MODIFIERS(R.string.gesture_group_modifiers), NUMPAD(R.string.gesture_group_numpad), + SYMBOLS(R.string.gesture_group_symbols), +} + +internal data class GestureAction(val id: String, val title: String, val group: GestureActionGroup) + +internal fun gestureActionGroup(id: String): GestureActionGroup { + if (id == "show_keyboard") return GestureActionGroup.APP + if (id in setOf("left_click", "right_click", "middle_click", "left_click_drag", "right_click_drag", + "middle_mouse_pan", "inverted_middle_mouse_pan", "scroll_wheel")) return GestureActionGroup.MOUSE + val key = id.removePrefix("key_") + return when { + key.startsWith("KP_") -> GestureActionGroup.NUMPAD + key.length == 1 && key[0] in 'A'..'Z' -> GestureActionGroup.LETTERS + key.length == 1 && key[0].isDigit() -> GestureActionGroup.NUMBERS + key.matches(Regex("F(?:[1-9]|1[0-2])")) -> GestureActionGroup.FUNCTION + key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_") || key.endsWith("_LOCK") -> GestureActionGroup.MODIFIERS + key in setOf("MINUS", "EQUAL", "BRACKET_LEFT", "BRACKET_RIGHT", "SEMICOLON", "APOSTROPHE", + "GRAVE", "TILDE", "BACKSLASH", "COMMA", "PERIOD", "SLASH") || id == "plus_minus" -> GestureActionGroup.SYMBOLS + else -> GestureActionGroup.NAVIGATION + } +} + +internal fun filterGestureActions(actions: List, query: String, group: GestureActionGroup?): List { + val words = query.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + val collator = Collator.getInstance() + return actions.filter { action -> + (group == null || action.group == group) && words.all { word -> + action.title.contains(word, ignoreCase = true) || action.id.removePrefix("key_").replace('_', ' ').contains(word, ignoreCase = true) + } + }.sortedWith(compareBy { it.group.ordinal }.thenComparator { a, b -> + // Function keys follow their numeric sequence; names use the UI locale. + if (a.group == GestureActionGroup.FUNCTION) { + a.id.removePrefix("key_F").toInt().compareTo(b.id.removePrefix("key_F").toInt()) + } else collator.compare(a.title, b.title) + }.thenBy { it.id }) +} + +@Composable +internal fun gestureActionTitle(action: String): String { + val named = when (action) { + "left_click" -> R.string.gesture_left_click + "right_click" -> R.string.gesture_right_click + "middle_click" -> R.string.gesture_middle_click + "show_keyboard" -> R.string.gesture_keyboard + "left_click_drag" -> R.string.gesture_left_drag + "right_click_drag" -> R.string.gesture_right_drag + "middle_mouse_pan" -> R.string.gesture_middle_pan + "inverted_middle_mouse_pan" -> R.string.gesture_middle_pan_inverted + "arrow_keys" -> R.string.gesture_arrows + "inverted_arrow_keys" -> R.string.gesture_arrows_inverted + "inverted_wasd" -> R.string.gesture_wasd_inverted + "scroll_wheel" -> R.string.gesture_scroll + else -> null + } + if (named != null) return stringResource(named) + return when (action) { + "wasd" -> "WASD" + "plus_minus" -> "+ / −" + "page_up_down" -> stringResource(R.string.gesture_key_page_up) + " / " + stringResource(R.string.gesture_key_page_down) + else -> gestureKeyTitle(action.removePrefix("key_")) + } +} + +@Composable +private fun gestureKeyTitle(key: String): String { + if (key.startsWith("KP_")) return stringResource(R.string.gesture_key_numpad, gestureKeyTitle(key.removePrefix("KP_"))) + val named = when (key) { + "ESC" -> R.string.gesture_key_escape + "BKSP" -> R.string.gesture_key_backspace + "ENTER" -> R.string.gesture_key_enter + "TAB" -> R.string.gesture_key_tab + "SPACE" -> R.string.gesture_key_space + "DEL" -> R.string.gesture_key_delete + "INSERT" -> R.string.gesture_key_insert + "HOME" -> R.string.gesture_key_home + "END" -> R.string.gesture_key_end + "PRIOR" -> R.string.gesture_key_page_up + "NEXT" -> R.string.gesture_key_page_down + "PRTSCN" -> R.string.gesture_key_print_screen + "UP" -> R.string.gesture_key_up + "DOWN" -> R.string.gesture_key_down + "LEFT" -> R.string.gesture_key_left_arrow + "RIGHT" -> R.string.gesture_key_right_arrow + "CAPS_LOCK" -> R.string.gesture_key_caps_lock + "NUM_LOCK" -> R.string.gesture_key_num_lock + "SCROLL_LOCK" -> R.string.gesture_key_scroll_lock + else -> null + } + if (named != null) return stringResource(named) + if (key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_")) { + val name = stringResource(when (key.substringBefore('_')) { + "CTRL" -> R.string.pckb_key_ctrl + "SHIFT" -> R.string.gesture_key_shift + else -> R.string.pckb_key_alt + }) + return stringResource(if (key.endsWith("_L")) R.string.gesture_key_left else R.string.gesture_key_right, name) + } + return when (key) { + "MINUS", "SUBTRACT" -> "−" + "EQUAL" -> "=" + "ADD" -> "+" + "MULTIPLY" -> "×" + "DIVIDE", "SLASH" -> "/" + "BRACKET_LEFT" -> "[" + "BRACKET_RIGHT" -> "]" + "SEMICOLON" -> ";" + "APOSTROPHE" -> "'" + "GRAVE" -> "`" + "TILDE" -> "~" + "BACKSLASH" -> "\\" + "COMMA" -> "," + "PERIOD" -> "." + else -> key.replace('_', ' ') + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GestureFeedback.kt b/app/src/main/java/com/droiddeck/launcher/ui/GestureFeedback.kt new file mode 100644 index 000000000..89233e924 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GestureFeedback.kt @@ -0,0 +1,35 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.TouchscreenGestures.Gesture + +@Composable +fun GestureFeedback(gesture: Gesture?) { + Box(Modifier.fillMaxSize()) { + if (gesture != null) Text(stringResource(when (gesture) { + Gesture.TAP -> R.string.gesture_tap + Gesture.DRAG -> R.string.gesture_drag + Gesture.LONG_PRESS -> R.string.gesture_long_press + Gesture.DOUBLE_TAP -> R.string.gesture_double_tap + Gesture.TWO_FINGER_TAP -> R.string.gesture_two_tap + Gesture.TWO_FINGER_DRAG -> R.string.gesture_two_drag + Gesture.TWO_FINGER_HOLD -> R.string.gesture_two_hold + Gesture.PINCH -> R.string.gesture_pinch + Gesture.THREE_FINGER_TAP -> R.string.gesture_three_tap + Gesture.THREE_FINGER_DRAG -> R.string.gesture_three_drag + Gesture.THREE_FINGER_HOLD -> R.string.gesture_three_hold + }), + Modifier.align(Alignment.TopCenter).padding(top = 24.dp) + .background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onSurface) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt index 0d0608730..92e0aef3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt @@ -79,6 +79,33 @@ internal object GlassesTab { const val VR = "vr" const val HARDWARE = "hardware" const val TRACKING = "tracking" + const val CONTROLLERS = "controllers" +} + +@Composable +internal fun VrControllerGroups(host: MenuHost, track: (String) -> Modifier = { Modifier }) { + val context = androidx.compose.ui.platform.LocalContext.current + androidx.compose.runtime.LaunchedEffect(Unit) { com.droiddeck.launcher.input.VrControllers.load(context) } + val c by com.droiddeck.launcher.input.VrControllers.settings.collectAsState() + val emulate = com.droiddeck.launcher.input.VrControllers.Emulate.entries + SettingsGroup(stringResource(R.string.vr_controllers_group)) { + ChoiceRow(host, "vr-emulate", stringResource(R.string.vr_controllers_emulate), stringResource(R.string.vr_controllers_emulate_hint), + emulate.map { it to stringResource(when (it) { + com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_off + com.droiddeck.launcher.input.VrControllers.Emulate.INDEX -> R.string.vr_controllers_index + com.droiddeck.launcher.input.VrControllers.Emulate.TOUCH -> R.string.vr_controllers_touch + com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_vive + }) }, c.emulate, chipModifier = track("vr-emulate")) { com.droiddeck.launcher.input.VrControllers.setEmulate(context, it) } + if (c.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ChoiceRow(host, "vr-sticks", + stringResource(R.string.vr_controllers_sticks), stringResource(R.string.vr_controllers_sticks_hint), + listOf(false to stringResource(R.string.vr_controllers_sticks_game), true to stringResource(R.string.vr_controllers_sticks_hands)), + c.sticksMoveHands, chipModifier = track("vr-sticks")) { com.droiddeck.launcher.input.VrControllers.setSticksMoveHands(context, it) } + SettingsRow(stringResource(R.string.vr_controllers_buttons), stringResource(when (c.emulate) { + com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_buttons_off + com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_buttons_vive + else -> R.string.vr_controllers_buttons_stick + }), hintLines = 6) {} + } } @Composable @@ -122,7 +149,8 @@ internal fun GlassesGroups( } } if (display) SettingsGroup(g.model) { - if (g.experimental) SettingsRow(stringResource(R.string.glasses_control), stringResource(R.string.glasses_xreal_experimental)) {} + if (g.experimental) SettingsRow(stringResource(R.string.glasses_control), stringResource( + if (g.supportsDisplayModeControl) R.string.glasses_xreal_experimental else R.string.glasses_pro2_experimental)) {} val status = if (g.hasDisplay) stringResource(R.string.glasses_status, g.width, g.height, kotlin.math.round(g.hz).toInt(), stringResource(if (g.sbs) R.string.glasses_mode_3d else R.string.glasses_mode_2d)) else null when { @@ -141,7 +169,7 @@ internal fun GlassesGroups( ToggleRow(host, "glasses-game", stringResource(R.string.glasses_game_on), stringResource(R.string.glasses_game_on_hint), s.gameOnGlasses, chipModifier = track("glasses-game")) { Glasses.setGameOnGlasses(it) } } - if (display && g.canControl && (inSession || g.sbs || g.sbsSupport == GlassesState.SBS_FAILS)) { + if (display && g.canControl && g.supportsDisplayModeControl && (inSession || g.sbs || g.sbsSupport == GlassesState.SBS_FAILS)) { SettingsGroup(stringResource(R.string.glasses_display_mode)) { if (g.sbsSupport == GlassesState.SBS_FAILS && !g.sbs) { ActionRow(stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_failed), @@ -150,7 +178,7 @@ internal fun GlassesGroups( g.sbs, chipModifier = track("glasses-3d")) { Glasses.set3d(it) } } } - if (display && g.canControl) { + if (display && g.canControl && g.supportsDisplayModeControl) { val profiles = if (inSession) listOf(GlassesDisplayModes.profile(g.sbs, false)) else listOf(GlassesDisplayProfile.TWO_D, GlassesDisplayProfile.THREE_D) for (profile in profiles) SettingsGroup(stringResource(if (profile == GlassesDisplayProfile.TWO_D) @@ -187,9 +215,9 @@ internal fun GlassesGroups( } if (vr && (g.canControl || g.hasDisplay)) SettingsGroup(stringResource(R.string.cardboard_steamvr)) { steamVr() - ToggleRow(host, "glasses-auto3d", stringResource(R.string.glasses_auto3d), stringResource(R.string.glasses_auto3d_hint), - s.auto3d, chipModifier = track("glasses-auto3d")) { Glasses.setAuto3d(it) } - if (g.canControl) { + if (g.supportsDisplayModeControl) ToggleRow(host, "glasses-auto3d", stringResource(R.string.glasses_auto3d), + stringResource(R.string.glasses_auto3d_hint), s.auto3d, chipModifier = track("glasses-auto3d")) { Glasses.setAuto3d(it) } + if (g.canControl && g.supportsDisplayModeControl) { GlassesResolutionRow(host, g, s, GlassesDisplayProfile.VR, track) if (steamVrRunning && tab != null) GlassesRefreshRow(host, g, s, track) } @@ -362,13 +390,3 @@ private fun GlassesRecenterAction(label: String, tag: String, tint: Int, opacity } } -@Composable -internal fun GlassesPlayingHint(model: String) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(56.dp)) - Spacer(Modifier.height(10.dp)) - Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.45f), fontSize = 15.sp) - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPhonePanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPhonePanel.kt new file mode 100644 index 000000000..625c30efb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPhonePanel.kt @@ -0,0 +1,85 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R + +/** Actions for the phone while the picture is on the glasses. Null hides the button. */ +internal class GlassesPhoneActions( + val onRecenter: (() -> Unit)?, + val onRecenterJoyCons: (() -> Unit)?, + val onSteamVrMenu: (() -> Unit)?, + val onSwitchSticks: (() -> Unit)?, + val controllers: String?, + val onNextControllers: (() -> Unit)?, + val onKeyboard: () -> Unit, + val onSessionMenu: () -> Unit, + val onStopSteamVr: (() -> Unit)?, + val brightness: Int, + val brightnessMax: Int, + val onBrightness: ((Int) -> Unit)?, +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun GlassesPhonePanel(model: String, status: String?, a: GlassesPhoneActions) { + Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.displayCutout).padding(16.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(max = 640.dp)) { + Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(40.dp)) + Spacer(Modifier.height(6.dp)) + Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.6f), fontSize = 15.sp) + if (status != null) Text(status, color = Color.White.copy(alpha = 0.4f), fontSize = 13.sp, textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + a.onRecenter?.let { PanelButton(stringResource(R.string.glasses_phone_recenter), "phone-recenter", it) } + a.onRecenterJoyCons?.let { PanelButton(stringResource(R.string.glasses_recenter_joycons_button), "phone-recenter-joycons", it) } + a.onSteamVrMenu?.let { PanelButton(stringResource(R.string.glasses_phone_steamvr_menu), "phone-steamvr-menu", it) } + a.onNextControllers?.let { next -> a.controllers?.let { + PanelButton(stringResource(R.string.glasses_phone_controllers, it), "phone-controllers", next) } } + a.onSwitchSticks?.let { PanelButton(stringResource(R.string.glasses_phone_switch_sticks), "phone-switch-sticks", it) } + PanelButton(stringResource(R.string.glasses_phone_keyboard), "phone-keyboard", a.onKeyboard) + PanelButton(stringResource(R.string.glasses_phone_session_menu), "phone-session-menu", a.onSessionMenu) + a.onStopSteamVr?.let { PanelButton(stringResource(R.string.glasses_phone_stop_steamvr), "phone-stop-steamvr", it) } + } + val setBrightness = a.onBrightness + if (setBrightness != null && a.brightnessMax > 0) { + Spacer(Modifier.height(14.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PanelButton("−", "phone-brightness-down") { setBrightness((a.brightness - 1).coerceAtLeast(0)) } + Text(stringResource(R.string.glasses_phone_brightness, a.brightness.coerceAtLeast(0), a.brightnessMax), + color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp) + PanelButton("+", "phone-brightness-up") { setBrightness((a.brightness + 1).coerceAtMost(a.brightnessMax)) } + } + } + } + } +} + +@Composable +private fun PanelButton(label: String, tag: String, onClick: () -> Unit) = + SecondaryButton(label, compact = true, modifier = Modifier.testTag(tag), onClick = onClick) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/HandCameraUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/HandCameraUi.kt index 86b2aa392..3aae52a76 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/HandCameraUi.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/HandCameraUi.kt @@ -1,9 +1,11 @@ package com.droiddeck.launcher.ui +import com.droiddeck.launcher.core.AddonPack import android.Manifest import android.content.Intent import android.net.Uri import android.provider.Settings +import android.text.format.Formatter import android.speech.tts.TextToSpeech import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -70,6 +72,7 @@ import com.droiddeck.launcher.handtracking.Advice import com.droiddeck.launcher.handtracking.BodyPose import com.droiddeck.launcher.handtracking.HandCamera import com.droiddeck.launcher.handtracking.HandTracking +import com.droiddeck.launcher.handtracking.TrackingPack import com.droiddeck.launcher.handtracking.Landmark import com.droiddeck.launcher.handtracking.TrackingCameras import kotlinx.coroutines.delay @@ -186,11 +189,27 @@ internal fun HandCameraRows(host: MenuHost, track: (String) -> Modifier, inSessi }) !s.permitted -> ActionRow(stringResource(R.string.hand_camera_access), stringResource(R.string.hand_camera_access_hint), stringResource(R.string.hand_camera_allow_button), { ask.launch(Manifest.permission.CAMERA) }) + !s.downloaded -> TrackingPackRow() !inSession -> ActionRow(stringResource(R.string.hand_camera_placement), stringResource(R.string.hand_camera_placement_hint), stringResource(R.string.hand_camera_check), { HandTracking.requestCheck() }) } } +@Composable +private fun TrackingPackRow() { + val context = LocalContext.current + val pack by TrackingPack.state.collectAsState() + val size = Formatter.formatShortFileSize(context, TrackingPack.size) + when (val now = pack) { + is AddonPack.State.Downloading -> ActionRow(stringResource(R.string.tracking_pack_title), + stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), {}, progress = now.progress) + AddonPack.State.Failed -> ActionRow(stringResource(R.string.tracking_pack_title), + stringResource(R.string.tracking_pack_failed), stringResource(R.string.tracking_pack_retry), { TrackingPack.ensure(context) }) + else -> ActionRow(stringResource(R.string.tracking_pack_title), + stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), { TrackingPack.ensure(context) }) + } +} + @Composable private fun PlacementCheck(settings: HandTracking.Settings, play: Boolean, onPlay: () -> Unit, onClose: () -> Unit) { val context = LocalContext.current diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt new file mode 100644 index 000000000..cfeeeb8c4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt @@ -0,0 +1,115 @@ +package com.droiddeck.launcher.ui + +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.itch.ItchApi +import com.droiddeck.launcher.stores.itch.ItchAuth +import com.droiddeck.launcher.stores.itch.ItchLibrary + +private fun copiedKey(ctx: Context): String? { + val clipboard = ctx.getSystemService(ClipboardManager::class.java) + val text = runCatching { clipboard?.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull() ?: return null + return ItchAuth.candidates(text).firstOrNull() +} + +@Composable +internal fun ItchKeyDialog(onDismiss: () -> Unit) { + val ctx = LocalContext.current + var key by remember { mutableStateOf(copiedKey(ctx).orEmpty()) } + var busy by remember { mutableStateOf(false) } + var rejected by remember { mutableStateOf(false) } + + val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME && key.isBlank()) { + copiedKey(ctx)?.let { key = it } + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + fun submit() { + val raw = key.trim() + if (raw.isEmpty() || busy) return + busy = true + rejected = false + Thread({ + val user = ItchAuth.verify(raw) + if (user == null) { + StoresState.post { busy = false; rejected = true } + return@Thread + } + ItchAuth.save(ctx, raw, user) + StoresState.post { busy = false; onDismiss() } + StoresState.refresh(ctx) + ItchLibrary.sync(ctx, true) + }, "itch-key").start() + } + + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + title = { Text(Store.ITCH.label) }, + text = { + Column { + AdbTextField( + value = key, + onValueChange = { key = it; rejected = false }, + label = stringResource(R.string.stores_itch_key), + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + focusOnShow = true, + onDone = { submit() }, + ) + Row(horizontalArrangement = Arrangement.Start) { + TextButton(onClick = { + ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE))) + }) { Text(stringResource(R.string.stores_itch_get_key)) } + } + if (rejected) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.stores_itch_key_rejected), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton(enabled = key.isNotBlank() && !busy, onClick = { submit() }) { + Text(stringResource(R.string.game_env_save)) + } + }, + dismissButton = { + TextButton(enabled = !busy, onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } + }, + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt index 01240610d..c428b8b97 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.focusGroup import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.hoverable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement @@ -715,7 +717,7 @@ internal fun SheetChips( horizontalArrangement = Arrangement.spacedBy(ChipGap), modifier = (if (equal) Modifier.fillMaxWidth() else Modifier) .focusProperties { enter = { chips.getOrNull(selected) ?: FocusRequester.Default } } - .focusGroup(), + .focusGroup().selectableGroup(), ) { labels.forEachIndexed { i, label -> val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = least)) @@ -743,7 +745,7 @@ private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: ( .background(if (under) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f)) .shownBar(on, pal.signal) .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .hoverable(src).selectable(selected = on, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 8.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt index 7e42aab51..2ddbdbe28 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt @@ -3,6 +3,9 @@ package com.droiddeck.launcher.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.runtime.LaunchedEffect import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -105,7 +108,7 @@ private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) { } @Composable -private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) { +private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) { val pal = LocalPalette.current val colors = MaterialTheme.colorScheme val src = remember { MutableInteractionSource() } @@ -113,7 +116,7 @@ private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) { val shape = RoundedCornerShape(8.dp) Box( contentAlignment = Alignment.Center, - modifier = Modifier.clip(shape) + modifier = Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(shape) .background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) .border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape) .glideBorder(hot, shape, pal.signal) @@ -140,6 +143,9 @@ internal fun LogoPositionDialog( val shown = rememberShown(onBack) val back = { shown.targetState = false } var value by remember { mutableStateOf(start ?: LogoPosition.DEFAULT) } + val startPin = remember { value.pin } + val first = remember { FocusRequester() } + LaunchedEffect(Unit) { focusSoon { first } } var failed by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) } val save: (LogoPosition.Value?) -> Unit = { next -> @@ -172,7 +178,7 @@ internal fun LogoPositionDialog( ) Text(stringResource(R.string.logo_pos_position), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin) { value = value.copy(pin = pin) } + for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin, if (pin == startPin) first else null) { value = value.copy(pin = pin) } } ValueSlider( value.widthPct, LogoPosition.MIN_PCT..LogoPosition.MAX_PCT, step = 1, enabled = true, label = stringResource(R.string.logo_pos_size), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt index ac9a0524a..0f4483c0a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt @@ -66,6 +66,22 @@ fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () -> ) } +@Composable +fun RuntimeActionsDialog(onRepair: () -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.runtime_actions_title)) }, + text = { Text(stringResource(R.string.runtime_actions_text)) }, + confirmButton = { TextButton(onClick = onRepair) { Text(stringResource(R.string.runtime_repair)) } }, + dismissButton = { + Row { + TextButton(onClick = onRemove) { Text(stringResource(R.string.store_remove)) } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } + } + }, + ) +} + @Composable fun ResetSettingsDialog(onConfirm: (gameProfiles: Boolean) -> Unit, onDismiss: () -> Unit) { var gameProfiles by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 0fb34d663..778673e92 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -81,10 +82,13 @@ class ModeSettings( val picturePosition: String? = null, val stretch16x9: Boolean? = null, val steamClientArch: String? = null, + /** Steam only: a game launched from the front end returns there when it closes; null hides the row. */ + val returnAfterGame: Boolean? = null, val steamRepairQueued: Boolean = false, val mangoapp: Boolean = true, val steamController: String? = null, val unfoldedControls: Boolean? = null, + val unfoldedControlsWithController: Boolean = false, val runSteamAtStartup: Boolean = false, val bootMovie: String? = null, val bootMovieSound: Boolean = true, @@ -98,6 +102,7 @@ class ModeSettings( val wifiDiscoveryBlocked: Boolean = false, val deckyInstalled: String? = null, val deckyLatestRelease: DeckyManager.Release? = null, + val deckyBranch: Int = DeckyManager.BRANCH_STABLE, val deckyChecking: Boolean = false, val deckyStage: String? = null, val deckyPercent: Int = -1, @@ -133,17 +138,20 @@ class ModeSettingsActions( val onSteamRepair: () -> Unit = {}, val onSteamClientArch: (String) -> Unit = {}, val onMangoapp: (Boolean) -> Unit = {}, + val onReturnAfterGame: (Boolean) -> Unit = {}, val onSteamController: (String) -> Unit = {}, val onUnfoldedControls: (Boolean) -> Unit = {}, + val onUnfoldedControlsWithController: (Boolean) -> Unit = {}, val onRunSteamAtStartup: (Boolean) -> Unit = {}, val onBootMovie: (String) -> Unit = {}, val onBootMovieSound: (Boolean) -> Unit = {}, val onImportBootMovie: () -> Unit = {}, - val onRemoveBootMovie: () -> Unit = {}, + val onBootMovieChanged: () -> Unit = {}, val onWifiDiscovery: (Boolean) -> Unit = {}, val onWifiDiscoverySettings: () -> Unit = {}, val onDeckyInstall: (DeckyManager.Release) -> Unit = {}, val onDeckyCheck: () -> Unit = {}, + val onDeckyBranch: (Int) -> Unit = {}, val onDeckyEnabled: (Boolean) -> Unit = {}, val onDeckyUninstall: () -> Unit = {}, val onPickDeckyPluginZip: () -> Unit = {}, @@ -250,7 +258,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) { ChoiceRow( host, "touch", stringResource(R.string.mode_touch), null, - listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, + listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, note = stringResource(R.string.mode_touch_note), onPick = a.onTouch, ) @@ -273,8 +281,10 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4), SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense), SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360), + SessionPrefs.CONTROLLER_NONE to stringResource(R.string.second_mode_none), ), s.steamController, - note = stringResource(R.string.mode_controller_note), + note = if (s.steamController == SessionPrefs.CONTROLLER_NONE) stringResource(R.string.common_applies_next_session) + else stringResource(R.string.mode_controller_note), onPick = a.onSteamController, ) if (steam && s.unfoldedControls != null) ToggleRow( @@ -282,6 +292,11 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.unfolded_controls_hint), s.unfoldedControls, onChange = a.onUnfoldedControls, ) + if (steam && s.unfoldedControls == true) ToggleRow( + host, "unfolded-controls-with-controller", stringResource(R.string.unfolded_controls_with_controller), + stringResource(R.string.unfolded_controls_with_controller_hint), + s.unfoldedControlsWithController, onChange = a.onUnfoldedControlsWithController, + ) if (steam) ChoiceRow( host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)), listOf( @@ -423,6 +438,19 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall) } } + ChoiceRow( + host, "decky-channel", stringResource(R.string.decky_channel), + stringResource(R.string.decky_channel_hint), + listOf( + DeckyManager.BRANCH_STABLE to stringResource(R.string.decky_channel_stable), + DeckyManager.BRANCH_PRERELEASE to stringResource(R.string.decky_channel_prerelease), + ) + if (s.deckyBranch == DeckyManager.BRANCH_TESTING) listOf( + DeckyManager.BRANCH_TESTING to stringResource(R.string.decky_channel_testing), + ) else emptyList(), + s.deckyBranch, + enabled = s.deckyStage == null && !s.deckySessionRunning, + onPick = a.onDeckyBranch, + ) if (s.deckyInstalled != null) ToggleRow( host, "decky-enabled", stringResource(R.string.mode_decky), if (s.deckyEnabled) stringResource(R.string.mode_decky_on) @@ -454,6 +482,11 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.mode_mangoapp_hint), s.mangoapp, onChange = a.onMangoapp, ) + if (s.returnAfterGame != null) ToggleRow( + host, "returnaftergame", stringResource(R.string.mode_return_after_game), + stringResource(R.string.mode_return_after_game_hint), + s.returnAfterGame, onChange = a.onReturnAfterGame, + ) SettingsRow( stringResource(R.string.mode_steam_repair), stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint), @@ -467,41 +500,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: onPick = a.onSteamClientArch, ) } - if (s.bootMovie != null) SettingsGroup(stringResource(R.string.boot_movie)) { - ChoiceRow( - host, "boot-movie", stringResource(R.string.boot_movie_video), stringResource(R.string.common_applies_next_session), - buildList { - add(BootMovie.DROIDDECK_30 to stringResource(R.string.boot_movie_droiddeck, 30)) - add(BootMovie.DROIDDECK_60 to stringResource(R.string.boot_movie_droiddeck, 60)) - s.bootMovieCustomName?.let { add(BootMovie.CUSTOM to it) } - }, s.bootMovie, - note = stringResource(R.string.boot_movie_note), onPick = a.onBootMovie, - ) - ToggleRow( - host, "boot-movie-sound", stringResource(R.string.boot_movie_sound), - stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound, - ) - SettingsRow( - stringResource(R.string.boot_movie_custom), - stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS), - ) { - Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) { - SecondaryButton( - when { - s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress) - s.bootMovieImporting -> stringResource(R.string.common_working) - else -> stringResource(R.string.boot_movie_import) - }, - enabled = !s.bootMovieImporting, - progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f }, - onClick = a.onImportBootMovie, - ) - if (s.bootMovieCustomName != null) SecondaryButton( - stringResource(R.string.boot_movie_remove), enabled = !s.bootMovieImporting, onClick = a.onRemoveBootMovie, - ) - } - } - } + if (s.bootMovie != null) BootMovieGroup(s, a, host) if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { val hint = when { s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off) @@ -563,6 +562,35 @@ internal fun ProtonOptions( GameEnvironmentRow() } +@Composable +internal fun DeckyZipWarningDialog(onConfirm: () -> Unit, onCancel: () -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + var left by remember { mutableIntStateOf(if (SessionPrefs.deckyZipWarningSeen(context)) 0 else DECKY_ZIP_WARNING_SECONDS) } + androidx.compose.runtime.LaunchedEffect(Unit) { + while (left > 0) { + kotlinx.coroutines.delay(1000L) + left-- + } + SessionPrefs.setDeckyZipWarningSeen(context) + } + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.decky_zip_warning_title)) }, + text = { Text(stringResource(R.string.decky_zip_warning_text)) }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = onConfirm, enabled = left == 0) { + Text( + if (left > 0) androidx.compose.ui.res.pluralStringResource(R.plurals.decky_zip_warning_wait, left, left) + else stringResource(R.string.decky_zip_warning_continue), + ) + } + }, + dismissButton = { androidx.compose.material3.TextButton(onClick = onCancel) { Text(stringResource(R.string.common_cancel)) } }, + ) +} + +private const val DECKY_ZIP_WARNING_SECONDS = 5 + @Composable private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) { val colors = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index 0567319f3..0fc576277 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -234,7 +234,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) { modifier = Modifier.fillMaxHeight() .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f)) - .clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) + .clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = onClick) .padding(horizontal = 12.dp), ) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 25b795921..04809ea5c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -111,9 +111,11 @@ class SecondScreenPresentation( keyboardHost = null closeTerminal() root.removeAllViews() - if (currentMode == SecondScreenMode.NONE) return + val mode = if (currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad) SecondScreenMode.NONE + else currentMode + if (mode == SecondScreenMode.NONE) return - when (currentMode) { + when (mode) { SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad() SecondScreenMode.TERMINAL -> renderTerminal() SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams()) @@ -155,16 +157,18 @@ class SecondScreenPresentation( } } controls.addView(keyboardButton) - controls.addView(Button(context).apply { - text = "STEAM" - contentDescription = context.getString(R.string.deck_open_steam_menu) - setOnClickListener { onSteamMenu() } - }) - controls.addView(Button(context).apply { - text = "…" - contentDescription = context.getString(R.string.drawer_open_qam) - setOnClickListener { onQam() } - }) + if (SessionState.mode == SessionService.MODE_STEAM) { + controls.addView(Button(context).apply { + text = "STEAM" + contentDescription = context.getString(R.string.deck_open_steam_menu) + setOnClickListener { onSteamMenu() } + }) + controls.addView(Button(context).apply { + text = "…" + contentDescription = context.getString(R.string.drawer_open_qam) + setOnClickListener { onQam() } + }) + } panel.addView(controls, FrameLayout.LayoutParams(-1, -2, Gravity.TOP)) root.addView(panel, weightParams()) val host = KeyboardHost(context) { sessionActivity.dispatchKeyEvent(it) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 3cb47b070..4a0adea2d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.material.icons.filled.Pause import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Icon +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -68,6 +69,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -78,21 +81,93 @@ import androidx.compose.ui.text.style.TextOverflow @Composable -fun HudText(text: String) { +fun HudText(text: String, graph: FloatArray = FloatArray(0), color: String = "mint", opacity: Int = 70) { + if (text.isEmpty() && graph.size < 2) return + val accent = hudInk(color) + val wash = (opacity.coerceIn(30, 100) / 100f) + val ink = accent.copy(alpha = (0.5f + wash * 0.5f).coerceIn(0.62f, 1f)) Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) { - Text( - text, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = Color.White, + Column( + horizontalAlignment = Alignment.End, modifier = Modifier .padding(12.dp) - .background(Color(0x8C000000)) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) + .background( + Brush.verticalGradient( + listOf(accent.copy(alpha = wash * 0.28f), Color.Black.copy(alpha = wash * 0.72f)), + ), + RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 5.dp), + ) { + if (text.isNotEmpty()) Text( + text, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = ink, + textAlign = TextAlign.End, + ) + if (graph.size >= 2) { + if (text.isNotEmpty()) Spacer(Modifier.height(4.dp)) + HudGraph(graph, accent, wash) + } + } } } +@Composable +private fun HudGraph(samples: FloatArray, accent: Color, wash: Float) { + Canvas(Modifier.width(148.dp).height(44.dp)) { + val maxMs = (samples.maxOrNull() ?: 16.7f).coerceAtLeast(16.7f) + val step = size.width / (samples.size - 1) + fun yOf(ms: Float) = size.height * (1f - (ms / maxMs).coerceIn(0f, 1f)) + val fillAlpha = wash * 0.5f + val fill = Path().apply { + moveTo(0f, size.height) + samples.forEachIndexed { i, ms -> lineTo(i * step, yOf(ms)) } + lineTo(size.width, size.height) + close() + } + drawPath( + fill, + Brush.verticalGradient(listOf(HUD_SLOW.copy(alpha = fillAlpha), accent.copy(alpha = fillAlpha * 0.45f))), + ) + val guide = yOf(16.7f) + drawLine(Color.White.copy(alpha = 0.32f), Offset(0f, guide), Offset(size.width, guide), strokeWidth = 1f) + for (i in 0 until samples.lastIndex) { + val tone = ((samples[i] - 8f) / 25f).coerceIn(0f, 1f) + drawLine( + mix(accent, HUD_SLOW, tone).copy(alpha = 0.55f + wash * 0.4f), + Offset(i * step, yOf(samples[i])), + Offset((i + 1) * step, yOf(samples[i + 1])), + strokeWidth = 2.5f, + cap = StrokeCap.Round, + ) + } + } +} + +private fun hudInk(color: String): Color = Color( + when (color) { + "white" -> 0xFFF2F5F8 + "sky" -> 0xFF66C0F4 + "amber" -> 0xFFFFB74D + "violet" -> 0xFFD0A2FF + else -> 0xFF3DDC84 + }.toInt(), +) + +private fun mix(from: Color, to: Color, t: Float): Color { + val u = t.coerceIn(0f, 1f) + return Color( + red = from.red + (to.red - from.red) * u, + green = from.green + (to.green - from.green) * u, + blue = from.blue + (to.blue - from.blue) * u, + alpha = 1f, + ) +} + +private val HUD_SLOW = Color(0xFFFF5C6C.toInt()) + private class LoadStage(@androidx.annotation.StringRes val label: Int, @androidx.annotation.StringRes val detail: Int) private fun loadStages(steam: Boolean): List = if (steam) listOf( @@ -122,6 +197,7 @@ fun LoadingOverlay( title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null, endedDetail: String? = null, onRetry: (() -> Unit)? = null, + onRepair: (() -> Unit)? = null, onShareLogs: (() -> Unit)? = null, onClose: (() -> Unit)? = null, flood: Color? = null, @@ -145,7 +221,7 @@ fun LoadingOverlay( modifier = Modifier .fillMaxSize() .background(colors.background) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, + .blockTouches(), ) { CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f)) Box( @@ -187,7 +263,7 @@ fun LoadingOverlay( textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp), ) - if (ended) EndedActions(onRetry, onShareLogs, onClose) + if (ended) EndedActions(onRetry, onRepair, onShareLogs, onClose) } if (!ended && hint.isNotEmpty()) Text( hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center, @@ -200,7 +276,7 @@ fun LoadingOverlay( } @Composable -private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) { +private fun EndedActions(onRetry: (() -> Unit)?, onRepair: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) { val first = remember { FocusRequester() } LaunchedEffect(Unit) { androidx.compose.runtime.withFrameNanos { } @@ -209,7 +285,11 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) { var firstUsed = false fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) } - if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry) + if (onRepair != null) PrimaryButton(stringResource(R.string.runtime_repair), modifier = claim(), onClick = onRepair) + if (onRetry != null) { + if (onRepair != null) SecondaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry) + else PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry) + } if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(), progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs) if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose) @@ -270,7 +350,6 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) { fun SessionPausedOverlay(title: String = stringResource(R.string.load_steam_paused), onResume: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val interactionSource = remember { MutableInteractionSource() } val resumeFocus = remember { FocusRequester() } LaunchedEffect(Unit) { androidx.compose.runtime.withFrameNanos { } @@ -280,7 +359,7 @@ fun SessionPausedOverlay(title: String = stringResource(R.string.load_steam_paus modifier = Modifier .fillMaxSize() .background(pal.background.copy(alpha = 0.94f)) - .clickable(interactionSource = interactionSource, indication = null) {}, + .blockTouches(), contentAlignment = Alignment.Center, ) { Column( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 42c7f6f49..8c1ca4a58 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -103,6 +103,7 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.border import androidx.compose.foundation.focusable +import androidx.compose.ui.focus.focusProperties import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -110,6 +111,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -195,6 +197,11 @@ class DrawerActions( val isHomeApp: Boolean, val androidApps: List, val hudOn: Boolean, + val hudFps: Boolean = true, + val hudBattery: Boolean = true, + val hudGraph: Boolean = true, + val hudColor: String = SessionPrefs.HUD_COLOR_MINT, + val hudOpacity: Int = SessionPrefs.HUD_OPACITY_DEFAULT, val frameGen: FrameGen.Mode, val lossless: Lossless.State, val oscMode: String, @@ -222,6 +229,11 @@ class DrawerActions( val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, val onHud: (Boolean) -> Unit, + val onHudFps: (Boolean) -> Unit = {}, + val onHudBattery: (Boolean) -> Unit = {}, + val onHudGraph: (Boolean) -> Unit = {}, + val onHudColor: (String) -> Unit = {}, + val onHudOpacity: (Int) -> Unit = {}, val onFrameGenPick: (FrameGen.Mode) -> Unit, val onImportLossless: () -> Unit, val onKeyboard: () -> Unit, @@ -245,6 +257,7 @@ class DrawerActions( val onLowLatencyController: (Boolean) -> Unit = {}, val onLowLatencyTouch: (Boolean) -> Unit = {}, val onTouch: (String) -> Unit, + val onGestureSettings: () -> Unit = {}, val onFexPreset: (String) -> Unit, val onUpscaler: (Int) -> Unit = {}, val onUpscaleSharpness: (Int) -> Unit = {}, @@ -265,6 +278,8 @@ class DrawerActions( val onPipAutoEnter: (Boolean) -> Unit = {}, val unfoldedControls: Boolean? = null, val onUnfoldedControls: (Boolean) -> Unit = {}, + val unfoldedControlsWithController: Boolean = false, + val onUnfoldedControlsWithController: (Boolean) -> Unit = {}, val onShareLogs: () -> Unit, val onStop: () -> Unit, val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null, @@ -301,14 +316,14 @@ class DrawerActions( @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) { - val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty(), a.glassesPresent) + val pages = sessionDrawerPages(a.secondScreenDisplays.isNotEmpty(), a.glassesPresent) val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) } val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val host = rememberMenuHost() val (glasses, glassesSettings) = rememberGlasses() - val glassesPages = glassesTabs(glasses) + val glassesPages = glassesTabs(glasses) + (GlassesTab.CONTROLLERS to stringResource(R.string.vr_controllers_tab)) var selectedGlassesTab by rememberSaveable { mutableStateOf(GlassesTab.DISPLAY) } val glassesTab = selectedGlassesTab.takeIf { key -> glassesPages.any { it.first == key } } ?: GlassesTab.DISPLAY var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } @@ -373,10 +388,15 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont focus.request(first) } } - if (open || veil > 0.01f) Box( - modifier = Modifier.fillMaxSize() - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() }, - ) + if (open || veil > 0.01f) { + val closeLabel = stringResource(R.string.common_close) + Box( + modifier = Modifier.fillMaxSize() + .focusProperties { canFocus = false } + .semantics { contentDescription = closeLabel } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClickLabel = closeLabel) { host.open = null; a.onClose() }, + ) + } androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize().exposeTestTags(), contentAlignment = Alignment.CenterEnd) { val short = maxHeight < 440.dp val sheetWidth = minOf(360.dp, maxWidth * 0.92f) @@ -390,7 +410,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont modifier = Modifier .fillMaxSize() .background(pal.background.copy(alpha = 0.97f)) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} + .blockTouches() .focusGroup() .controllerBack { if (host.open != null) host.open = null else a.onClose() @@ -462,6 +482,30 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont SettingsGroup(stringResource(R.string.drawer_page_display)) { ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn, chipModifier = focus.track(page, "hud"), onChange = a.onHud) + if (a.hudOn) { + ToggleRow(host, "hud-fps", stringResource(R.string.drawer_hud_fps), null, a.hudFps, + chipModifier = focus.track(page, "hud-fps"), onChange = a.onHudFps) + ToggleRow(host, "hud-battery", stringResource(R.string.drawer_hud_battery), null, a.hudBattery, + chipModifier = focus.track(page, "hud-battery"), onChange = a.onHudBattery) + ToggleRow(host, "hud-graph", stringResource(R.string.drawer_hud_graph), null, a.hudGraph, + chipModifier = focus.track(page, "hud-graph"), onChange = a.onHudGraph) + ChoiceRow( + host, "hud-color", stringResource(R.string.drawer_hud_color), null, + listOf( + SessionPrefs.HUD_COLOR_MINT to stringResource(R.string.drawer_hud_color_mint), + "white" to stringResource(R.string.drawer_hud_color_white), + "sky" to stringResource(R.string.drawer_hud_color_sky), + "amber" to stringResource(R.string.drawer_hud_color_amber), + "violet" to stringResource(R.string.drawer_hud_color_violet), + ), + a.hudColor, chipModifier = focus.track(page, "hud-color"), onPick = a.onHudColor, + ) + SliderRow( + stringResource(R.string.drawer_hud_opacity), null, a.hudOpacity, + SessionPrefs.HUD_OPACITY_RANGE, step = 10, format = { "$it%" }, + modifier = focus.track(page, "hud-opacity"), onChange = a.onHudOpacity, + ) + } val fgOpen = host.open == "fg" val fgLabel = FrameGen.label(LocalContext.current, a.frameGen) SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) { @@ -501,8 +545,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType) } ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null, - listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), + listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch) + ActionRow(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_settings_hint), + stringResource(R.string.ctrl_edit), a.onGestureSettings, + modifier = focus.track(page, "gesture-settings")) ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null, if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)) else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)), @@ -531,6 +578,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls), stringResource(R.string.unfolded_controls_hint), a.unfoldedControls, chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls) + if (a.steam && a.unfoldedControls == true) ToggleRow(host, "unfolded-controls-with-controller", + stringResource(R.string.unfolded_controls_with_controller), + stringResource(R.string.unfolded_controls_with_controller_hint), a.unfoldedControlsWithController, + chipModifier = focus.track(page, "unfolded-controls-with-controller"), + onChange = a.onUnfoldedControlsWithController) } SettingsGroup(stringResource(R.string.drawer_latency)) { ToggleRow(host, "low-latency-controller", stringResource(R.string.drawer_low_latency_controller), @@ -554,7 +606,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont SessionDrawerPage.SECOND_SCREEN -> { SecondScreenGroup(host, a) { key -> focus.track(page, key) } } - SessionDrawerPage.GLASSES -> { + SessionDrawerPage.GLASSES -> if (glassesTab == GlassesTab.CONTROLLERS) { + VrControllerGroups(host) { key -> focus.track(page, key) } + } else { GlassesGroups(host, glasses, glassesSettings, { key -> focus.track(page, key) }, onRecenter = a.onGlassesRecenter, tab = glassesTab, outputActive = a.glassesOutput, steamVrRunning = a.steamVr == SteamVr.Status.RUNNING, steamVr = { SteamVrRow(a) }) @@ -708,7 +762,7 @@ private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics. modifier = modifier.fillMaxWidth().heightIn(min = 52.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) .glideBorder(hot, shape, pal.signal) - .hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, onClick = onClick) + .hoverable(source).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = source, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp), ) { Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(22.dp)) @@ -745,7 +799,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }.testTag("drawer-stop") .clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f)) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 12.dp, end = 14.dp), ) { @@ -771,7 +825,7 @@ private fun SteamActionPill( .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(interactionSource) - .clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick) + .clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onConfirm ?: onClick), ) { Icon(icon, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(20.dp)) @@ -790,7 +844,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier contentAlignment = Alignment.Center, modifier = modifier.size(44.dp).clip(ring) .glideBorder(hot, ring, pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = null, onClick = onClick) .controllerConfirm(onClick = onClick) .semantics { contentDescription = description }, ) { @@ -811,7 +865,7 @@ private fun DrawerPageTabs(page: SessionDrawerPage, pages: List(340, easing = FastOutSlowInEasing) androidx.compose.foundation.layout.BoxWithConstraints( contentAlignment = Alignment.Center, - modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(), + modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds().selectableGroup(), ) { val tab = if (compact) 36f else 44f val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(24f, DRAWER_TAB_SPACING_DP) @@ -844,7 +898,7 @@ private fun DrawerPageTabs(page: SessionDrawerPage, pages: List() } SettingsGroup(stringResource(R.string.drawer_component_downloads)) { Text(stringResource(R.string.drawer_component_downloads_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)) @@ -1415,21 +1470,40 @@ internal fun ComponentDownloadsDrawerGroup( } AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = ComponentsManager.LABEL.getValue(comp)) { firstItemFocus -> - val firstAvailable = items.firstOrNull { ComponentsManager.safeName(it.file) !in installed && it.file !in downloads } - items.forEach { item -> + // A nightly paired with a stable build rides on that row; Stable/Nightly tabs beside the row pick which one. + val pairs = ComponentsManager.nightlyPairs(items) + val rows = items.filter { it !in pairs.values } + fun shown(row: ComponentsManager.CatalogItem) = pairs[row]?.takeIf { nightlyPicked[row.file] == true } ?: row + val firstAvailable = rows.firstOrNull { shown(it).let { s -> ComponentsManager.safeName(s.file) !in installed && s.file !in downloads } } + rows.forEach { row -> + val item = shown(row) + val nightly = pairs[row] val stored = ComponentsManager.safeName(item.file) in installed val progress = downloads[item.file] - MenuItem(item.file.removeSuffix(".wcp"), checked = stored, - enabled = !stored && progress == null, - detail = when { - stored -> stringResource(R.string.wincomp_downloaded) - progress != null -> if (progress < 0) stringResource(R.string.wincomp_downloading, item.file.removeSuffix(".wcp")) else "$progress%" - else -> item.release + " · " + stringResource(R.string.common_size_mb, item.size / 1048576.0) - }, - focusRequester = if (item == firstAvailable) firstItemFocus else null, - ) { - onDownload(item) - host.open = null + val choose = @Composable { rowModifier: Modifier -> + MenuItem(item.file.removeSuffix(".wcp"), checked = stored, + enabled = !stored && progress == null, + detail = when { + stored -> stringResource(R.string.wincomp_downloaded) + progress != null -> if (progress < 0) stringResource(R.string.wincomp_downloading, item.file.removeSuffix(".wcp")) else "$progress%" + else -> item.release + " · " + stringResource(R.string.common_size_mb, item.size / 1048576.0) + }, + focusRequester = if (row == firstAvailable) firstItemFocus else null, + modifier = rowModifier, + ) { + onDownload(item) + host.open = null + } + } + if (nightly == null) choose(Modifier) + else Row(verticalAlignment = Alignment.CenterVertically) { + choose(Modifier.weight(1f)) + TabStrip( + listOf(stringResource(R.string.comp_channel_stable), stringResource(R.string.comp_channel_nightly)), + selected = if (item === nightly) 1 else 0, + onSelect = { nightlyPicked[row.file] = it == 1 }, + bumpers = false, compact = true, modifier = Modifier.padding(start = 6.dp, end = 4.dp), + ) } } } @@ -1654,7 +1728,7 @@ private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modi .background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent) .glideBorder(hot, shape, accent) .hoverable(src) - .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 16.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 8db2cc793..0d8909d82 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -34,7 +34,11 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.hoverable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -141,6 +145,11 @@ internal fun Modifier.controllerBack(onBack: () -> Unit): Modifier = onPreviewKe } } +internal fun Modifier.blockTouches(): Modifier = pointerInput(Unit) { detectTapGestures { } } + +internal fun Modifier.tapOutside(onTap: () -> Unit): Modifier = + pointerInput(onTap) { detectTapGestures(onTap = { onTap() }) } + internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modifier = onPreviewKeyEvent { event -> val keyEvent = event.nativeKeyEvent val previous = keyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_L1 @@ -157,6 +166,7 @@ internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modif internal fun TabStrip( tabs: List, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, focusRequesters: List? = null, + bumpers: Boolean = true, compact: Boolean = false, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -164,12 +174,12 @@ internal fun TabStrip( val scroll = rememberScrollState() var tight by remember(tabs) { mutableStateOf(false) } LaunchedEffect(scroll.maxValue) { if (scroll.maxValue in 1 until Int.MAX_VALUE) tight = true } - val tabPadding = if (tight) 10.dp else 16.dp + val tabPadding = if (compact) 10.dp else if (tight) 10.dp else 16.dp Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) { - BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) } + if (bumpers) BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) } Row( modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape) - .horizontalScroll(scroll).padding(3.dp), + .horizontalScroll(scroll).selectableGroup().padding(3.dp), ) { tabs.forEachIndexed { i, label -> val on = i == selected @@ -180,7 +190,7 @@ internal fun TabStrip( val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() } if (on) LaunchedEffect(Unit) { reveal.bringIntoView() } Text( - label, fontSize = 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1, + label, fontSize = if (compact) 12.sp else 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1, color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, modifier = Modifier .bringIntoViewRequester(reveal) @@ -189,13 +199,13 @@ internal fun TabStrip( .clip(tabShape) .background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) .glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick) + .hoverable(src).selectable(selected = on, interactionSource = src, indication = null, role = Role.Tab, onClick = pick) .controllerConfirm(onClick = pick) - .padding(horizontal = tabPadding, vertical = 12.dp), + .padding(horizontal = tabPadding, vertical = if (compact) 6.dp else 12.dp), ) } } - BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) } + if (bumpers) BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) } } } @@ -359,7 +369,7 @@ fun MenuItem( .clip(RoundedCornerShape(8.dp)) .background(if (hot && enabled) Color.White.copy(alpha = 0.07f) else Color.Transparent) .alpha(if (enabled) 1f else 0.4f) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 10.dp, vertical = 9.dp), ) { @@ -392,7 +402,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo .background(colors.surfaceVariant) .glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2) .alpha(if (enabled) 1f else 0.5f) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 12.dp, vertical = 8.dp), ) { @@ -648,8 +658,8 @@ fun MultiRow( } @Composable -fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null) { - SettingsRow(label, hint) { SecondaryButton(button, progress = progress, onClick = onClick) } +fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null, modifier: Modifier = Modifier) { + SettingsRow(label, hint) { SecondaryButton(button, modifier = modifier, progress = progress, onClick = onClick) } } @Composable @@ -783,7 +793,7 @@ fun SegmentedTabs(options: List>, selected: T?, modifier: Mo .graphicsLayer { alpha = shown } .clip(pillShape).background(pal.signal), ) - Row { + Row(Modifier.selectableGroup()) { options.forEachIndexed { i, (value, label) -> val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value @@ -802,9 +812,8 @@ fun SegmentedTabs(options: List>, selected: T?, modifier: Mo modifier = tabModifier(value).width(segment).fillMaxHeight() .clip(pillShape) .glideBorder(hot, pillShape, if (chosen) pal.onSignal else pal.signal) - .semantics { role = Role.Tab; this.selected = chosen } .hoverable(src) - .clickable(interactionSource = src, indication = LocalIndication.current, onClick = pick) + .selectable(selected = chosen, role = Role.Tab, interactionSource = src, indication = LocalIndication.current, onClick = pick) .controllerConfirm(onClick = pick), ) { Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = ink, maxLines = 1, softWrap = false) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SpatialTrackingActivity.kt b/app/src/main/java/com/droiddeck/launcher/ui/SpatialTrackingActivity.kt index 76539855d..3b77e09f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SpatialTrackingActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SpatialTrackingActivity.kt @@ -29,13 +29,13 @@ import androidx.compose.ui.unit.dp import com.droiddeck.launcher.R import com.droiddeck.launcher.core.AppUiPrefs import com.droiddeck.launcher.glasses.CalibrationDistance +import com.droiddeck.launcher.handtracking.TrackingPack import com.droiddeck.launcher.glasses.Glasses import com.droiddeck.launcher.glasses.SpatialCalibrationProgress import com.droiddeck.launcher.glasses.SpatialTracking import com.droiddeck.launcher.glasses.SpatialTrackingStatus import com.droiddeck.launcher.glasses.StereoCalibrator import kotlinx.coroutines.delay -import org.opencv.android.OpenCVLoader import org.opencv.android.Utils import org.opencv.core.Mat import org.opencv.core.Size @@ -95,7 +95,7 @@ class SpatialTrackingActivity : ComponentActivity() { } override fun onPause() { SpatialTracking.diagnostics(false);super.onPause() } private fun printBoard() { - if(!OpenCVLoader.initLocal()) return + if(!TrackingPack.loadNative(this)) return val c=StereoCalibrator(this);val mat=Mat() c.board.generateImage(Size(1500.0,2100.0),mat,0,1) val bitmap=Bitmap.createBitmap(1500,2100,Bitmap.Config.ARGB_8888) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 5dafd2428..dfb6d2525 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -115,13 +115,14 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) drop = drop.takeIf { headset }, overlay = { lifted -> if (headset) DropSheet( - drop, glassesTabs(glasses), covered = lifted, + drop, glassesTabs(glasses) + (GlassesTab.CONTROLLERS to stringResource(R.string.vr_controllers_tab)), covered = lifted, button = { enabled -> PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy && glasses.usable, large = true, icon = Icons.Filled.PlayArrow, fill = true, modifier = Modifier.fillMaxWidth().testTag("play-steamvr")) { if (enabled) { drop.open = false; a.onPlaySteamVr() } } }, ) { tab -> - GlassesGroups(rememberMenuHost(), glasses, glassesSettings, + if (tab == GlassesTab.CONTROLLERS) VrControllerGroups(rememberMenuHost()) + else GlassesGroups(rememberMenuHost(), glasses, glassesSettings, onRecenter = GlassesMenuMirror::recenter, inSession = false, tab = tab) } }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt index 7c1e45ffa..8b1ec5340 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -781,17 +781,18 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack: } @Composable -private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) { +internal fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) { val ctx = LocalContext.current val pal = LocalPalette.current val colors = MaterialTheme.colorScheme val busy = sessionRunning || StorageState.cleaning var confirm by remember { mutableStateOf(null) } + var confirmAll by remember { mutableStateOf(false) } val unused = report.unusedComponents SubHeader(stringResource(R.string.storage_components), stringResource(R.string.storage_tools_detail, gb(report.components.sumOf { it.bytes }), gb(unused.sumOf { it.bytes })), onBack) { if (unused.isNotEmpty()) PrimaryButton(stringResource(R.string.storage_remove_unused, gb(unused.sumOf { it.bytes })), compact = true, enabled = !busy) { - StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { } + confirmAll = true } } ActionStatus() @@ -824,6 +825,19 @@ private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBac stringResource(if (c.packageFile != null) R.string.storage_component_confirm else R.string.storage_original_confirm, gb(c.bytes)), stringResource(R.string.storage_remove), onConfirm = { StorageState.removeComponent(ctx, c) }, onDismiss = { confirm = null }) } + if (confirmAll) { + val total = gb(unused.sumOf { it.bytes }) + val lines = unused.map { "• " + componentLabel(it) + " " + gb(it.bytes) }.joinToString("\n") + val warning = stringResource( + if (unused.any { it.packageFile != null }) R.string.storage_component_confirm else R.string.storage_original_confirm, total) + ConfirmDialog( + stringResource(R.string.storage_remove_unused, total), + lines + "\n\n" + warning, + stringResource(R.string.storage_remove), + onConfirm = { StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { } }, + onDismiss = { confirmAll = false }, + ) + } } @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt index e0d5099a7..1492362f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt @@ -303,7 +303,7 @@ internal fun SessionAchievementsPanel(label: String, state: AchievementsState, t val slide = remember { MutableTransitionState(false).apply { targetState = true } } BoxWithConstraints( Modifier.fillMaxSize().background(veil) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClose), + .tapOutside(onClose), contentAlignment = Alignment.CenterEnd, ) { val short = maxHeight < 440.dp @@ -312,7 +312,7 @@ internal fun SessionAchievementsPanel(label: String, state: AchievementsState, t (if (floating) Modifier.padding(12.dp).fillMaxHeight().width(minOf(420.dp, maxWidth * 0.92f)).clip(RoundedCornerShape(20.dp)) .background(pal.background).border(1.dp, pal.line2, RoundedCornerShape(20.dp)) else Modifier.fillMaxHeight().width(minOf(420.dp, maxWidth * 0.92f)).background(pal.background.copy(alpha = 0.97f))) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} + .blockTouches() .controllerBack(onClose) .then(if (kinds.isEmpty()) Modifier else Modifier.bumpers({ pick = (pick + kinds.size - 1) % kinds.size }, { pick = (pick + 1) % kinds.size })) .padding(if (short) 12.dp else 16.dp), @@ -370,7 +370,7 @@ internal fun DrawerAchievementsRow(progress: Pair?, modifier: Modifier modifier = modifier.fillMaxWidth().height(44.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface) .glideBorder(hot, shape, pal.signal, pal.line) - .clickable(interactionSource = interaction, indication = LocalIndication.current, onClick = onClick) + .clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = interaction, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 12.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index ec12d19af..e913dc6af 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -29,6 +29,8 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8)) Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD)) Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900)) + Store.XBOX.id -> SourceColours(Color(0xFF17381B), Color(0xFFC6EDC2), Color(0xFF70C850)) + Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF)) @@ -40,6 +42,8 @@ internal fun sourceLabel(source: String): String = when (source) { Store.GOG.id -> Store.GOG.label Store.EPIC.id -> Store.EPIC.label Store.AMAZON.id -> Store.AMAZON.label + Store.XBOX.id -> Store.XBOX.label + Store.ITCH.id -> Store.ITCH.label else -> stringResource(R.string.source_added) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt index d019bc009..3996d3042 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt @@ -1,6 +1,19 @@ package com.droiddeck.launcher.ui -internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int): Int? { +// `split` is the size of a leading section that starts its own rows; 0 means one continuous grid. +internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int, split: Int = 0): Int? { if (index !in 0 until count || columns < 1 || direction !in listOf(-1, 1)) return null - return (index + columns * direction).takeIf { it in 0 until count } + if (split !in 1 until count) return (index + columns * direction).takeIf { it in 0 until count } + val inFirst = index < split + val start = if (inFirst) 0 else split + val end = if (inFirst) split else count + val local = index - start + val within = start + local + columns * direction + if (within in start until end) return within + val column = local % columns + return when { + inFirst && direction > 0 -> split + minOf(column, count - split - 1) + !inFirst && direction < 0 && local < columns -> ((split - 1) / columns) * columns + column.coerceAtMost((split - 1) % columns) + else -> null + } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index f6bc614b9..b377f02ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,7 +51,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.droiddeck.launcher.R -import com.droiddeck.launcher.stores.CatalogItem import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreAchievements import com.droiddeck.launcher.stores.StoresState @@ -66,7 +67,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro val narrow = LocalNarrowPane.current val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700 val id = key.substringAfter(':') - val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } + val item = StoresState.detailItem(store, id) val installed = StoresState.installedGame(store, id) val rawDownload = StoresState.download("${store.id}:$id") val download = rawDownload?.takeIf { it.isActive } @@ -84,18 +85,24 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro .clip(Shape16).background(artBrush(hueOf(title))), ) { val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover - if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + // The card it came from keeps its own bitmap under the hero until the wide art is decoded. + val heroArt = cardArtRequest(art, item?.tallImageUrl) + if (heroArt != null) AsyncImage(model = heroArt, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent))) if (scores != null && !stacked) { Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f)))) HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp)) } Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) { + val passName = item?.includedWith.orEmpty() val state = when { installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder) download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location item?.owned == true -> stringResource(R.string.stores_eyebrow_library) + item?.subscription == true -> passName.ifBlank { "PC Game Pass" } + passName.isNotBlank() -> "Available with $passName" + item?.accessible == true -> stringResource(R.string.stores_eyebrow_library) else -> stringResource(R.string.stores_eyebrow_store) } val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: "" @@ -115,10 +122,22 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro Actions { when { installed != null -> { - ActionChip(stringResource(R.string.stores_in_steam), ok = true) val gone = StoresState.uninstalling["${store.id}:$id"] + PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id)), main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { + launchStoreGame(ctx, store, id, s, a) + } + ActionChip(stringResource(R.string.stores_in_steam), ok = true) + if (store == Store.XBOX) { + SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) { + runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) } + .onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." } + } + SecondaryButton("Manage content", enabled = download == null) { + com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed) + } + } if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {} - else UninstallButton(title, installed, enabled = download == null, modifier = leadOn) { + else UninstallButton(title, installed, enabled = download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id))) { StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() } } } @@ -138,14 +157,25 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key) } } - item?.owned == true && StoresState.isUnfinished(item) -> { + item?.installable == true && StoresState.isUnfinished(item) -> { InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) } ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } } - item?.owned == true -> InstallButton( - if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), - leadOn, - ) { StoresState.requestInstall(ctx, item) } + item?.installable == true -> { + InstallButton( + if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), + leadOn, + ) { StoresState.requestInstall(ctx, item) } + if (!item.accessible) { + SecondaryButton( + when { + item.isFree -> stringResource(R.string.stores_get_free) + item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice) + else -> stringResource(R.string.stores_view_on, store.shortLabel) + }, + ) { openStoreUrl(ctx, item) } + } + } item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } } @@ -156,7 +186,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro } if (StoresMotion.heroLands) hero() else Rise(1) { hero() } if (scores != null) Rise(2) { - val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id } + val listed = (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty() val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty() val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty() @@ -164,10 +194,10 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro } val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty() .takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty() - val achievements = remember(store, id, item?.owned, installed) { + val achievements = remember(store, id, item?.accessible, installed) { when { StoreAchievements.forSource(store.id) == null -> null - item?.owned == true -> StoreAchievements.Game.of(item) + item?.accessible == true -> StoreAchievements.Game.of(item) installed != null -> StoreAchievements.Game.of(installed.sidecar) else -> null } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index e61130501..2bf7dcac0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.geometry.Size @@ -83,6 +84,7 @@ import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.StoreLanguage import com.droiddeck.launcher.stores.StoresState import com.droiddeck.launcher.stores.download.DownloadEntry import com.droiddeck.launcher.stores.download.DownloadQueue @@ -182,10 +184,7 @@ private fun DownloadList(s: FrontEndState, a: FrontEndActions) { private fun InterruptedRow(item: com.droiddeck.launcher.stores.CatalogItem) { val ctx = LocalContext.current ArtStrip(item.imageUrl ?: item.tallImageUrl, item.title) { - Text(item.title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - SourceChip(item.store.id, small = true) - Text(stringResource(R.string.stores_dl_interrupted), fontSize = 12.sp, color = Color(0xFFFFC24D), maxLines = 1) - Spacer(Modifier.weight(1f)) + StripLabel(item.title, item.store.id, stringResource(R.string.stores_dl_interrupted), Color(0xFFFFC24D)) RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { StoresState.requestInstall(ctx, item) } CancelAction { StoresState.clearUnfinished(ctx, item) } } @@ -207,14 +206,20 @@ private fun ArtStrip(cover: String?, name: String, content: @Composable androidx } } +@Composable +private fun androidx.compose.foundation.layout.RowScope.StripLabel(title: String, store: String, status: String, statusColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) { + Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + SourceChip(store, small = true) + Text(status, fontSize = 12.sp, color = statusColor, maxLines = 1) + } +} + @Composable private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, bloom: Boolean = false) { val ctx = LocalContext.current ArtStrip(d.cover, d.name) { - Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - SourceChip(d.store.id, small = true) - Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 12.sp, color = Color.White.copy(alpha = 0.72f), maxLines = 1) - Spacer(Modifier.weight(1f)) + StripLabel(d.name, d.store.id, stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), Color.White.copy(alpha = 0.72f)) if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) { RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) } } @@ -480,7 +485,7 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) { } } -internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk = +internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color, onArrange: () -> Unit): StepAsk = StepAsk( anchor = at, side = StepSide.Left, accent = accent, items = 2, maxWidth = 440.dp, handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) }, @@ -491,6 +496,13 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State + val options = listOf("") + StoreLanguage.KNOWN + options.forEachIndexed { i, code -> + MenuItem( + if (code.isEmpty()) stringResource(R.string.stores_install_language_system) else StoreLanguage.displayName(code), + checked = language == code, + focusRequester = if (i == 0) first else null, + ) { + language = code + SessionPrefs.setStoresInstallLanguage(context, code) + langOpen = false + } + } + } + } + } Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) } - val context = androidx.compose.ui.platform.LocalContext.current var gogAchievements by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(com.droiddeck.launcher.stores.gog.GogComet.enabled(context)) } + androidx.compose.runtime.LaunchedEffect(Unit) { + if (gogAchievements) com.droiddeck.launcher.core.AddonPack.COMET.ensure(context) + } SettingsRow(stringResource(R.string.gog_achievements), stringResource(R.string.gog_achievements_hint)) { ToggleSwitch(gogAchievements, label = stringResource(R.string.gog_achievements)) { gogAchievements = it diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt index 565b5cc9a..662e8236f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt @@ -25,7 +25,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,8 +45,12 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.stores.CatalogItem import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLanguage import com.droiddeck.launcher.stores.StoresState import com.droiddeck.launcher.stores.formatBytes +import com.droiddeck.launcher.stores.gog.GogDownloadManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { @@ -55,7 +62,19 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { val remembered = remember { SessionPrefs.storesInstallTarget(ctx) } val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0) val focus = remember { List(targets.size) { FocusRequester() } } + val askLanguage = StoreLanguage.selectable(item.store) + val preferredLanguage = StoreLanguage.preferredCode(ctx) + var languages by remember { mutableStateOf(StoreLanguage.fallbackList(item.store)) } + var chosen by remember(preferredLanguage) { mutableStateOf(StoreLanguage.pick(languages, preferredLanguage)) } + var menuOpen by remember { mutableStateOf(false) } LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus[defaultIndex] } } + LaunchedEffect(item.key) { + if (item.store != com.droiddeck.launcher.stores.Store.GOG) return@LaunchedEffect + val listed = withContext(Dispatchers.IO) { GogDownloadManager.listLanguages(ctx, item.id) } + if (listed.isEmpty()) return@LaunchedEffect + languages = listed + chosen = StoreLanguage.pick(listed, chosen) + } AppDialog(shown, close, "installWhere", wide = false, maxWidth = if (targets.size > 2) 600.dp else 440.dp) { Rise(0) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { @@ -65,11 +84,27 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { ) Text( stringResource(R.string.common_cancel), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, - modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp), + modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).controllerConfirm(onClick = close).padding(horizontal = 10.dp, vertical = 6.dp), ) } } - Rise(1) { + if (askLanguage) Rise(1) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.stores_install_language), fontSize = 13.sp, color = colors.onSurfaceVariant) + Box(Modifier.weight(1f)) { + ValueChip(StoreLanguage.displayName(chosen).ifEmpty { chosen }, menuOpen, modifier = Modifier.fillMaxWidth()) { menuOpen = !menuOpen } + AnchoredMenu(menuOpen, { menuOpen = false }, title = stringResource(R.string.stores_install_language), compact = true) { first -> + languages.forEachIndexed { i, code -> + MenuItem(StoreLanguage.displayName(code).ifEmpty { code }, checked = code == chosen, focusRequester = if (i == 0) first else null) { + chosen = code + menuOpen = false + } + } + } + } + } + } + Rise(if (askLanguage) 2 else 1) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { targets.forEachIndexed { i, t -> val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item) @@ -89,7 +124,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { warning = isLow, modifier = Modifier.weight(1f), ) { SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath) - StoresState.install(ctx, item, t.root) + StoresState.install(ctx, item, t.root, if (askLanguage) chosen else null) close() } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index e9c0e39ac..175cbecd8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.outlined.Folder @@ -24,10 +25,12 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.positionInRoot import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLanguage import com.droiddeck.launcher.stores.epic.EpicClient import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -37,6 +40,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import android.view.KeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.draw.drawWithContent @@ -56,6 +60,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -65,6 +71,8 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import kotlin.math.roundToInt +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -90,6 +98,7 @@ import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material.icons.filled.ViewModule import com.droiddeck.launcher.stores.sortCatalogItems import com.droiddeck.launcher.stores.retainCatalogOrder import androidx.compose.foundation.layout.widthIn @@ -115,6 +124,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -136,7 +146,9 @@ import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.stores.CatalogItem import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreTab import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.arrangeStoreTabs import com.droiddeck.launcher.stores.download.DownloadStage import com.droiddeck.launcher.stores.download.DownloadState import com.droiddeck.launcher.stores.formatBytes @@ -148,32 +160,103 @@ import kotlinx.coroutines.suspendCancellableCoroutine private const val DOWNLOADS = "downloads" -private val TABS = listOf("store", "library", "installed") +private val TABS = listOf("library", "store") -internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) TABS.drop(1) else TABS +internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) TABS.take(1) else TABS internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library" -private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean) +private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean, val accountsLoaded: Boolean) @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) @Composable internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { val ctx = LocalContext.current + com.droiddeck.launcher.stores.xbox.XboxContent.plan?.let { content -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }, + title = { Text("Xbox content") }, + text = { Column { + if (content.error != null) Text(content.error) + else if (content.offers.isEmpty()) Text("No licensed PC content packages are available for this game.") + content.offers.forEach { offer -> + androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.install(ctx, offer.item) }) { + Text(offer.item.title + if (offer.installed) " · Update / verify" else " · Install") + } + } + } }, + confirmButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }) { Text(stringResource(android.R.string.ok)) } }, + ) + } + com.droiddeck.launcher.stores.xbox.XboxBackend.packageChoice?.let { pending -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }, + title = { Text("Choose PC content") }, + text = { Column { pending.choices.forEach { choice -> + androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.selectPackage(ctx, choice) }) { + Text(choice.getString("title") + " (" + choice.getString("kind") + ")") + } + } } }, + confirmButton = {}, + dismissButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }) { Text(stringResource(android.R.string.cancel)) } }, + ) + } val colors = MaterialTheme.colorScheme val narrow = LocalNarrowPane.current val padH = if (narrow) 14.dp else 22.dp - var chip by rememberSaveable { mutableStateOf(Store.GOG.id) } + val tabs = remember { + androidx.compose.runtime.mutableStateListOf().apply { + addAll(arrangeStoreTabs(SessionPrefs.storesTabOrder(ctx), SessionPrefs.storesTabHidden(ctx))) + } + } + var chip by rememberSaveable { mutableStateOf(tabs.firstOrNull { it.shown }?.store?.id ?: Store.GOG.id) } + var arranging by remember { mutableStateOf(false) } + val shownStores = tabs.filter { it.shown }.map { it.store } + fun commitTabs() { + SessionPrefs.setStoresTabOrder(ctx, tabs.map { it.store.id }) + SessionPrefs.setStoresTabHidden(ctx, tabs.filterNot { it.shown }.map { it.store.id }.toSet()) + } + fun shiftTab(store: Store, delta: Int) { + val from = tabs.indexOfFirst { it.store == store } + val to = from + delta + if (from < 0 || to !in tabs.indices) return + tabs.add(to, tabs.removeAt(from)) + commitTabs() + } + fun moveTab(store: Store, to: Int) { + val from = tabs.indexOfFirst { it.store == store } + if (from < 0) return + val target = to.coerceIn(0, tabs.lastIndex) + if (target == from) return + tabs.add(target, tabs.removeAt(from)) + commitTabs() + } + fun toggleTab(store: Store) { + val i = tabs.indexOfFirst { it.store == store } + if (i < 0) return + val show = !tabs[i].shown + if (!show && tabs.count { it.shown } <= 1) return + tabs[i] = tabs[i].copy(shown = show) + commitTabs() + } + LaunchedEffect(tabs.map { it.store to it.shown }, chip) { + val picked = Store.byId(chip) ?: return@LaunchedEffect + if (tabs.none { it.store == picked && it.shown }) chip = tabs.firstOrNull { it.shown }?.store?.id ?: chip + } var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") } var query by rememberSaveable { mutableStateOf("") } var openGame by rememberSaveable { mutableStateOf(null) } val liveState = androidx.compose.runtime.rememberUpdatedState(s) LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } } val store = Store.byId(chip) + LaunchedEffect(tabs.toList()) { + if (chip != DOWNLOADS && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS + } val shownTab = tabFor(store, tab) val pane = PaneKey( chip, if (store == null) "" else shownTab, store == null || (StoresState.isSignedIn(store) && StoresState.expired[store] != true), + store == null || StoresState.accountsLoaded, ) val grids = remember { HashMap() } val grid = grids.getOrPut(pane) { LazyGridState() } @@ -214,43 +297,26 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } } val pal = LocalPalette.current - fun openModes(st: Store, at: Rect?) { - if (chip != st.id) { pickChip(st.id); return } - if (at == null || !StoresState.isSignedIn(st) || StoresState.expired[st] == true) { move("first"); return } - val modes = tabsFor(st) - val current = if (chip == st.id) shownTab else null - var picked = false - StoresMotion.ask(StepAsk( - anchor = at, side = StepSide.Right, accent = storePalette(pal, st).signal, items = modes.size, - onDismiss = { move(if (picked) "first" else "item:storechip:${st.id}") }, - handle = { ChipLabel(st, MaterialTheme.colorScheme.onBackground) }, - ) { - StoreTheme(st) { - val library = StoresState.library[st].orEmpty() - val installedCount = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(st, StoresState.installed, library).size } - StepList { - modes.forEachIndexed { i, m -> - val label = when (m) { - "store" -> stringResource(R.string.stores_tab_store) - "installed" -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedCount) - else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size) - } - val focusHere = if (current != null) m == current else i == 0 - StepChoice( - label, selected = m == current, enabled = open, - modifier = Modifier.fillMaxWidth().stepItem(i).then(if (focusHere) Modifier.focusRequester(first) else Modifier), - ) { - picked = true - if (chip != st.id) { chip = st.id; openGame = null; query = "" } - tab = m - StoresMotion.fold() - } - } - } - } - }) + fun cycleStore(step: Int) { + val ids = tabs.filter { it.shown }.map { it.store.id } + if (ids.isEmpty()) return + val at = ids.indexOf(chip) + pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size]) + } + fun openModes(st: Store) { + if (chip != st.id) pickChip(st.id) else move("first") } BackHandler(enabled = openGame != null) { closeGame() } + BackHandler(enabled = arranging) { arranging = false } + LaunchedEffect(arranging) { + val focus = ff ?: return@LaunchedEffect + if (!arranging || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect + repeat(45) { + androidx.compose.runtime.withFrameNanos { } + val req = tabs.firstOrNull()?.let { focus.items["storechip:${it.store.id}"] } ?: return@repeat + if (runCatching { req.requestFocus() }.isSuccess) return@LaunchedEffect + } + } val scroll = rememberScrollState() val detailScroll = rememberScrollState() LaunchedEffect(chip, tab) { scroll.scrollTo(0); grid.scrollToItem(0) } @@ -326,14 +392,32 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier Column( modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) .bumpers( - onPrevious = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + t.size - 1) % t.size]) } }, - onNext = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + 1) % t.size]) } }, + onPrevious = { + val picked = Store.byId(focusedChip) + if (arranging) { if (picked != null) shiftTab(picked, -1) } + else if (openGame == null) cycleStore(-1) + }, + onNext = { + val picked = Store.byId(focusedChip) + if (arranging) { if (picked != null) shiftTab(picked, 1) } + else if (openGame == null) cycleStore(1) + }, ), ) { Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { StoreTheme(Store.byId(focusedChip ?: chip)) { - Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, - onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) } + Rise(0) { + StoreChips( + chip, focusedChip, s.storeDownloadsActive, tabs, arranging, + onStore = { st -> openModes(st) }, + onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, + onFocusChip = { focusedChip = it }, + onArrangeDone = { arranging = false }, + onMoveTab = { st, to -> moveTab(st, to) }, + onToggleTab = { toggleTab(it) }, + onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal) { arranging = true }) }, + ) + } } } val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() } @@ -389,8 +473,11 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier when { k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) } st == null -> {} + !k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { + androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) + } !k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) } - else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }) + else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) }) } } } @@ -459,7 +546,10 @@ private fun InstallWhere(item: CatalogItem) { val ctx = LocalContext.current val pal = LocalPalette.current val from = remember(item) { StoresMotion.recentInstall() } - if (from == null) { InstallWhereDialog(item) { StoresState.pendingInstall = null }; return } + if (from == null || StoreLanguage.selectable(item.store)) { + InstallWhereDialog(item) { StoresState.pendingInstall = null } + return + } val title = stringResource(R.string.stores_install_title, item.title) val internalLabel = stringResource(R.string.stores_target_internal) val label = StoresMotion.installLabel @@ -541,53 +631,149 @@ private fun SignInFloodLayer(hostAt: Offset) { } +private class ChipDrag { + var store by mutableStateOf(null) + var from by mutableIntStateOf(-1) + var target by mutableIntStateOf(-1) + var x by mutableFloatStateOf(0f) +} + +@Composable +private fun StoreDot(store: Store, signedIn: Boolean) { + val c = sourceColours(store.id) + Box( + Modifier.size(12.dp) + .onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() } + .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot) + .background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape), + ) +} + +@Composable +private fun RowScope.ArrangeStoreChip( + tab: StoreTab, index: Int, last: Int, gapPx: Float, drag: ChipDrag, + onFocusChip: (String?) -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit, +) { + val store = tab.store + val box = remember(store) { mutableFloatStateOf(0f) } + val pitch = box.floatValue + gapPx + val held = drag.store == store + val lift by animateFloatAsState(if (held) 1f else 0f, Motion.sp(0.7f, Spring.StiffnessMediumLow), label = "chipLift") + val shift by animateFloatAsState( + when { + drag.store == null || held -> 0f + drag.from < index && drag.target >= index -> -pitch + drag.from > index && drag.target <= index -> pitch + else -> 0f + }, + Motion.sp(0.6f, Spring.StiffnessMediumLow), label = "chipShift", + ) + val pill = remember { RoundedCornerShape(99.dp) } + val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true + fun reset() { + drag.store = null + drag.from = -1 + drag.target = -1 + drag.x = 0f + } + fun commit() { + val from = drag.from + val to = drag.target + reset() + if (from >= 0 && to >= 0 && to != from) onMoveTab(store, to) + } + StoreChip( + label = store.shortLabel, on = false, under = false, key = store.id, mark = sourceColours(store.id).dot, lift = lift, + modifier = Modifier.weight(1f) + .zIndex(if (held) 2f else 0f) + .onSizeChanged { box.floatValue = it.width.toFloat() } + .graphicsLayer { + translationX = if (held) drag.x else shift + shadowElevation = 14.dp.toPx() * lift + shape = pill + clip = false + } + .pointerInput(store, index) { + detectDragGestures( + onDragStart = { + drag.store = store + drag.from = index + drag.target = index + drag.x = 0f + }, + onDragEnd = { commit() }, + onDragCancel = { reset() }, + ) { change, amount -> + change.consume() + val step = box.floatValue + gapPx + if (step <= 0f) return@detectDragGestures + drag.x = (drag.x + amount.x).coerceIn(-drag.from * step, (last - drag.from) * step) + drag.target = (drag.from + (drag.x / step).roundToInt()).coerceIn(0, last) + } + } + .alpha(if (tab.shown) 1f else 0.32f), + lead = { StoreDot(store, signedIn) }, + onFocused = onFocusChip, + ) { onToggleTab(store) } +} + @Composable private fun StoreChips( - selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, - onFocusChip: (String?) -> Unit, + selected: String, focused: String?, active: Int, tabs: List, arranging: Boolean, + onStore: (Store) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, onFocusChip: (String?) -> Unit, + onArrangeDone: () -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit, ) { val pal = LocalPalette.current - val keys = Store.entries.map { it.id } + DOWNLOADS + val keys = tabs.filter { it.shown }.map { it.store.id } + DOWNLOADS val bounds = remember { androidx.compose.runtime.mutableStateMapOf() } val rootBounds = remember { HashMap() } val highlighted = focused ?: selected val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode - val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard + val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard && !arranging val tint = androidx.compose.animation.animateColorAsState( Store.byId(highlighted)?.let { sourceColours(it.id).dot } ?: pal.signal, Motion.tw(160), label = "chipTint", ) + val gap = 8.dp + val gapPx = with(androidx.compose.ui.platform.LocalDensity.current) { gap.toPx() } + val drag = remember { ChipDrag() } Box(Modifier.fillMaxWidth()) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - for (store in Store.entries) { - val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true - StoreChip( - label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, - modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() }, - lead = { - val c = sourceColours(store.id) - Box( - Modifier.size(12.dp) - .onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() } - .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot), - ) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) } - }, - description = store.label + if (signedIn) "" else " " + stringResource(R.string.stores_chip_signed_out), - onFocused = onFocusChip, - ) { onStore(store, rootBounds[store.id]) } + Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + for (tab in tabs) { + val store = tab.store + if (arranging) { + val index = tabs.indexOfFirst { it.store == store } + key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) } + } else if (tab.shown) { + val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true + key(store) { + StoreChip( + label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, + modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() }, + lead = { StoreDot(store, signedIn) }, + description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), + onFocused = onFocusChip, + ) { onStore(store) } + } + } } - StoreChip( - label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, - modifier = Modifier.weight(1f).onGloballyPositioned { - bounds[DOWNLOADS] = it.boundsInParent() - StoresMotion.downloadsChip = it.boundsInRoot() - }, - lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, - trail = if (active > 0) { { CountPill(active) } } else null, - onFocused = onFocusChip, - ) { onDownloads() } - var cogAt by remember { mutableStateOf(null) } - Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { - IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } + if (arranging) { + IconChip(Icons.Filled.Check, stringResource(R.string.stores_tabs_arrange_done)) { onArrangeDone() } + } else { + StoreChip( + label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, + modifier = Modifier.onGloballyPositioned { + bounds[DOWNLOADS] = it.boundsInParent() + StoresMotion.downloadsChip = it.boundsInRoot() + }, + lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, + trail = if (active > 0) { { CountPill(active) } } else null, + description = stringResource(R.string.stores_tab_downloads), + onFocused = onFocusChip, + ) { onDownloads() } + var cogAt by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { + IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } + } } } ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown) @@ -596,8 +782,8 @@ private fun StoreChips( @Composable private fun StoreChip( - label: String, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit, - trail: (@Composable () -> Unit)? = null, description: String? = null, + label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit, + trail: (@Composable () -> Unit)? = null, description: String? = null, lift: Float = 0f, onFocused: (String?) -> Unit = {}, onClick: () -> Unit, ) { val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current @@ -622,10 +808,10 @@ private fun StoreChip( if (f.isFocused && inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) onFocused(key) else if (!f.isFocused) onFocused(null) } - .heightIn(min = 44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(shape) - .background(if (under) Color.Transparent else colors.surface) + .heightIn(min = 44.dp).graphicsLayer { scaleX = scale * (1f + 0.08f * lift); scaleY = scale * (1f + 0.08f * lift) }.clip(shape) + .background(if (under) Color.Transparent else lerp(colors.surface, pal.signal, 0.18f * lift)) .shownBar(on, mark) - .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line) + .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else lerp(pal.line, pal.signal, lift)) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .controllerConfirm(onClick = onClick) .then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier) @@ -634,7 +820,7 @@ private fun StoreChip( androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) { Box(Modifier.pulseRing(picked, mark), contentAlignment = Alignment.Center) { lead() } } - Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (label != null) Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis) trail?.invoke() } } @@ -724,11 +910,16 @@ private fun SignInButton(store: Store, large: Boolean = false) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val placed = remember { arrayOfNulls(1) } + var askKey by remember { mutableStateOf(false) } + if (askKey) ItchKeyDialog { askKey = false } Box(Modifier.onGloballyPositioned { placed[0] = it }) { PrimaryButton(stringResource(R.string.stores_signin), main = true, large = large, icon = if (large) Icons.AutoMirrored.Filled.Login else null) { - val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() - if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store) - else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot) + if (store == Store.ITCH) askKey = true + else { + val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() + if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store) + else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot) + } } } } @@ -746,20 +937,22 @@ internal fun StoreLogo(store: Store, size: Dp) { private fun Storefront( store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions, grid: LazyGridState, - onQuery: (String) -> Unit, onOpen: (String) -> Unit, + onQuery: (String) -> Unit, onOpen: (String) -> Unit, onTab: (String) -> Unit, ) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val library = StoresState.library[store] ?: emptyList() val shelves = StoresState.shelves[store] + val found = StoresState.found[store] ?: emptyList() val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) } val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } } val q = query.trim().lowercase() - fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) - fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned + val foundKeys = remember(found) { found.map { it.key }.toSet() } + fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys + fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.accessible fun shelf(l: List?) = l?.filter(::shown) - val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } } - val installedOnly = tab == "installed" + val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } } + LaunchedEffect(store, q) { StoresState.search(ctx, store, q) } val loaded = StoresState.library.containsKey(store) val showGrid = tab != "store" || q.isNotEmpty() val sort = s.storesSort @@ -773,63 +966,45 @@ private fun Storefront( val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L } lookup } - val isSizeSort = (tab == "library" || tab == "installed") && (sort == "sizeAsc" || sort == "sizeDesc") - val candidates = remember(library, installedItems, everything, tab, q, s.storesShowMature) { - when (tab) { - "library" -> library.filter(::matches) - "installed" -> installedItems.filter(::matches) - else -> everything.filter { matches(it) && shown(it) } - } + val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc") + val candidates = remember(library, everything, tab, q, s.storesShowMature) { + if (tab == "library") library.filter(::matches) else everything.filter { matches(it) && shown(it) } } val candidateKeys = remember(candidates) { candidates.map { it.key } } var resolvedSizeSortKey by remember(store, tab) { mutableStateOf?>(null) } val sizeSortPending = isSizeSort && resolvedSizeSortKey != candidateKeys var stableVisible by remember(store, tab) { mutableStateOf(emptyList()) } - val resolvedSizes = when { - !isSizeSort -> emptyMap() - installedOnly -> com.droiddeck.launcher.stores.StoreSizes.onDisk.toMap() - else -> com.droiddeck.launcher.stores.StoreSizes.known.toMap() - } - val sizeFor = remember(resolvedSizes, installedOnly) { + val resolvedSizes = if (isSizeSort) com.droiddeck.launcher.stores.StoreSizes.known.toMap() else emptyMap() + val sizeFor = remember(resolvedSizes) { val resolved = resolvedSizes - val lookup: (CatalogItem) -> Long = { item -> - resolved[item.key] ?: if (installedOnly) 0L else item.sizeBytes - } + val lookup: (CatalogItem) -> Long = { item -> resolved[item.key] ?: item.sizeBytes } lookup } - val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor) { - if (tab == "library" || tab == "installed") - sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor) + val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor, installedKeys) { + if (tab == "library") installedFirst(sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor), installedKeys) else candidates } val visible = remember(sizeSortPending, candidates, stableVisible, sortedVisible) { if (sizeSortPending) retainCatalogOrder(candidates, stableVisible) else sortedVisible } + val installedShown = if (tab == "library") visible.count { it.id in installedKeys } else 0 + val split = installedShown.takeIf { it in 1 until visible.size } ?: 0 SideEffect { if (!sizeSortPending && stableVisible != sortedVisible) stableVisible = sortedVisible } LaunchedEffect(store, tab, sort, candidates, candidateKeys) { if (!isSizeSort || resolvedSizeSortKey == candidateKeys) return@LaunchedEffect val requests = candidates.map { item -> - val future: CompletableFuture = if (tab == "installed") { - StoresState.installedGame(store, item.id)?.let { - com.droiddeck.launcher.stores.StoreSizes.resolveDisk(ctx, item.key, it) - } ?: CompletableFuture.completedFuture(null) - } else { - com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item) - } - item to future + item to com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item) } CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await() requests.forEach { (item, future) -> val bytes = future.getNow(null) ?: return@forEach - if (tab == "installed") com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key] = bytes - else if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes + if (item.accessible && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes } resolvedSizeSortKey = candidateKeys } val empty = stringResource(when { - installedOnly -> R.string.stores_installed_empty tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading else -> R.string.stores_nothing_matches }) @@ -838,9 +1013,14 @@ private fun Storefront( val density = androidx.compose.ui.platform.LocalDensity.current val focus = LocalFrontFocus.current val scope = rememberCoroutineScope() - val headerItems = 1 + (if (sizeSortPending) 1 else 0) + - (if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) - val listMode = s.storesView == "list" && (tab == "library" || tab == "installed") + // A bar only where the tab has nothing else on it: shelves, the shelf of owned games, or a grid still loading. + val gridEmpty = if (showGrid) visible.isEmpty() else (shelves == null || shelves.isEmpty) && library.isEmpty() + val barLine = StoresState.status[store]?.takeIf { gridEmpty && syncCount(it) != null } + val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (barLine != null) 1 else 0) + + (if (StoresState.problems[store] != null) 1 else 0) + val listMode = s.storesView == "list" && tab == "library" + val capsules = s.storesView == "capsules" && tab == "library" + val tileWidth = if (capsules) CapsuleWidth else CardWidth val card: @Composable (CatalogItem) -> Unit = { item -> val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced firstPlaced = true @@ -852,14 +1032,14 @@ private fun Storefront( else -> 0 } val index = visible.indexOfFirst { it.key == item.key } - val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction) + val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split) when { direction == 0 || index < 0 -> false next == null -> direction > 0 else -> { if (key.action == KeyEvent.ACTION_DOWN) { val target = "card:${visible[next].key}" - val targetIndex = headerItems + next + val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2 val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } if (!visibleTarget || (focus.attached[target] ?: 0) == 0 || runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { @@ -872,12 +1052,12 @@ private fun Storefront( } } if (listMode) GameListRow(item, store, installedKeys, onOpen, first, navigation) - else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation) + else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation, capsule = capsules) } LazyVerticalGrid( - columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { + columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { val gap = with(density) { 10.dp.roundToPx() } - val minimum = with(density) { (if (listMode) CardWidth * 2 else CardWidth).roundToPx() } + val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() } columns = ((it.width + gap) / (minimum + gap)).toInt().coerceAtLeast(1) }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -885,21 +1065,19 @@ private fun Storefront( ) { item(key = "header", span = { GridItemSpan(maxLineSpan) }) { Rise(1) { + val tabs = tabsFor(store) + val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size) + else stringResource(R.string.stores_tab_library) ModeHeader( - when { - tab == "store" -> stringResource(R.string.stores_tab_store) - !loaded -> stringResource(if (installedOnly) R.string.stores_tab_installed else R.string.stores_tab_library) - installedOnly -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedItems.size) - else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size) - }, + libraryLabel, query, onQuery, - placeholder = when { - tab == "store" -> stringResource(R.string.stores_search_catalog, store.label) - installedOnly -> stringResource(R.string.stores_search_installed) - else -> stringResource(R.string.stores_search_library) - }, + placeholder = if (tab == "store") stringResource(R.string.stores_search_catalog, store.label) else stringResource(R.string.stores_search_library), + lead = if (tabs.size < 2) null else { { + val labels = tabs.map { if (it == "store") stringResource(R.string.stores_tab_store) else libraryLabel } + HeaderTabs(labels, tabs.indexOf(tab).coerceAtLeast(0)) { onTab(tabs[it]) } + } }, ) { - if (tab == "library" || tab == "installed") { + if (tab == "library") { SortButton(s, a) ViewButton(s, a) } @@ -922,11 +1100,17 @@ private fun Storefront( Text(stringResource(R.string.store_section_loading), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - StoresState.status[store]?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } } + barLine?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } } StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } } when (tab) { - "library", "installed" -> if (!loaded) Unit - else storeGrid(visible, empty, card) + "library" -> if (!loaded) Unit + else if (split == 0) storeGrid(visible, empty, card) + else { + item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) } + storeGrid(visible.take(split), empty, card) + item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) } + storeGrid(visible.drop(split), empty, card) + } else -> { if (q.isNotEmpty()) storeGrid(visible, empty, card) else { @@ -956,14 +1140,49 @@ private suspend fun CompletableFuture.await(): T = } @Composable -internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) { +private fun SectionLabel(title: String) { + Text( + headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp), + ) +} + +@Composable +private fun HeaderTabs(labels: List, selected: Int, onSelect: (Int) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(8.dp) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.selectableGroup()) { + labels.forEachIndexed { i, label -> + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pick = { onSelect(i) } + Text( + label, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, + color = if (i == selected || hot) colors.onBackground else colors.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.clip(shape).glideBorder(hot, shape, pal.signal) + .hoverable(src).selectable(selected = i == selected, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick) + .controllerConfirm(onClick = pick) + .padding(horizontal = 4.dp, vertical = 6.dp), + ) + } + } +} + +@Composable +internal fun ModeHeader( + title: String, query: String, onQuery: (String) -> Unit, placeholder: String, + lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit, +) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current var searching by remember { mutableStateOf(query.isNotEmpty()) } + var focusOnOpen by remember { mutableStateOf(false) } val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen") Column(Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) { - Text( + if (lead != null) lead() + else Text( title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, modifier = Modifier.padding(start = 4.dp, end = 4.dp), ) @@ -973,11 +1192,11 @@ internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, Box(Modifier.width(w).graphicsLayer { alpha = (open * 1.6f).coerceAtMost(1f) }) { AdbTextField( value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search, - placeholder = placeholder, compact = true, focusOnShow = true, - onFocusChange = { focused -> if (!focused && query.isEmpty()) searching = false }, + placeholder = placeholder, compact = true, focusOnShow = focusOnOpen, + onFocusChange = { focused -> if (!focused && query.isEmpty()) { searching = false; focusOnOpen = false } }, ) } - } else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true } + } else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true; focusOnOpen = true } } trailing() } @@ -985,19 +1204,13 @@ internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, } } -@Composable -private fun ChipLabel(store: Store, ink: Color) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box(Modifier.size(12.dp).clip(CircleShape).background(sourceColours(store.id).dot)) - Text(store.shortLabel, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1) - } -} - internal fun storePalette(base: Palette, store: Store?): Palette { val (accent, deep) = when (store) { Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1) Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD) Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800) + Store.XBOX -> Color(0xFF70C850) to Color(0xFF107C10) + Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B) null -> return base } val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White @@ -1101,12 +1314,13 @@ private fun SortButton(s: FrontEndState, a: FrontEndActions) { @Composable private fun ViewButton(s: FrontEndState, a: FrontEndActions) { - val list = s.storesView == "list" + val views = SessionPrefs.STORES_VIEWS + val next = views[(views.indexOf(s.storesView) + 1) % views.size] HeaderIconButton( - if (list) stringResource(R.string.stores_view_cards) else stringResource(R.string.stores_view_list), - if (list) Icons.Filled.GridView else Icons.Filled.ViewList, + stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }), + when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView }, "view", - ) { a.onStoresView(if (list) "cards" else "list") } + ) { a.onStoresView(next) } } @Composable @@ -1151,7 +1365,7 @@ private fun GameListRow( modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).then(navigation).paneItem("card:${item.key}") .then(if (first) Modifier.firstTile() else Modifier) .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open) + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open) .padding(horizontal = 10.dp, vertical = 8.dp), ) { CardArt(item, Modifier.width(96.dp).height(54.dp).clip(RoundedCornerShape(8.dp))) @@ -1218,6 +1432,11 @@ private fun Shelf(title: String, items: List?, card: @Composable (C } } +internal fun installedFirst(items: List, installed: Set): List { + val (on, off) = items.partition { it.id in installed } + return on + off +} + private fun LazyGridScope.storeGrid(games: List, empty: String, card: @Composable (CatalogItem) -> Unit) { if (games.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -1228,9 +1447,13 @@ private fun LazyGridScope.storeGrid(games: List, empty: String, car } private val CardWidth = 112.dp +private val CapsuleWidth = 92.dp @Composable -private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, first: Boolean = false, navigation: Modifier = Modifier) { +private fun GameCard( + item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, + first: Boolean = false, navigation: Modifier = Modifier, capsule: Boolean = false, +) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -1243,7 +1466,8 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front val placed = remember { arrayOfNulls(1) } val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } val open = { - placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) } + val art = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, art)) } onOpen(item.key) } val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() } @@ -1252,47 +1476,76 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front modifier = Modifier.fillMaxWidth() .onGloballyPositioned { c -> placed[0] = c - if (item.owned && item.sizeBytes <= 0) { + if (item.accessible && item.sizeBytes <= 0) { val b = c.boundsInWindow() if (b.bottom > -ahead && b.top < screen + ahead && b.width > 0f) com.droiddeck.launcher.stores.StoreSizes.request(ctx, item) } } .then(navigation).paneItem("card:${item.key}").then(if (first) Modifier.firstTile() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale } .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), + .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), ) { - CardFace(item, download, hot) { CardMarker(item, installed, download) } + CardFace(item, download, hot, capsule) { CardMarker(item, installed, download, compact = capsule) } } } +private fun syncCount(line: String): Pair? { + val match = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line) ?: return null + val done = match.groupValues[1].toIntOrNull() ?: return null + val total = match.groupValues[2].toIntOrNull()?.takeIf { it > 0 } ?: return null + return done to total +} + @Composable private fun SyncBar(line: String) { - val count = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line) - val done = count?.groupValues?.get(1)?.toIntOrNull() - val total = count?.groupValues?.get(2)?.toIntOrNull()?.takeIf { it > 0 } + val count = syncCount(line) ?: return + val done = count.first + val total = count.second Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Box(Modifier.weight(1f)) { - if (done != null && total != null) androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp)) - else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(3.dp)) - } - if (done != null && total != null) Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + Box(Modifier.weight(1f)) { androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp)) } + Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) } } private val CardStrip = 26.dp +@Composable +internal fun cardArtRequest(url: String?, cover: String?): coil.request.ImageRequest? { + val ctx = LocalContext.current + val under = cover?.takeIf { it != url } + return remember(url, under) { + url?.let { coil.request.ImageRequest.Builder(ctx).data(it).apply { if (under != null) placeholderMemoryCacheKey(under) }.build() } + } +} + @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable -private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, marker: @Composable () -> Unit) { - val url = item.imageUrl ?: item.tallImageUrl +private fun CardFace( + item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false, + marker: @Composable () -> Unit, +) { + val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl + val art = cardArtRequest(url, item.tallImageUrl) val blurs = android.os.Build.VERSION.SDK_INT >= 31 + var landscape by remember(url) { mutableStateOf(false) } BoxWithConstraints(Modifier.fillMaxWidth()) { - val total = maxWidth * 9f / 16f + CardStrip + val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) { - if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + if (art != null && capsule && landscape) { + // Stores without portrait covers (itch) would lose most of the art to a portrait crop. + AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp)) + Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f))) + AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip)) + } else if (art != null) AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), + onSuccess = { r -> + val drawn = r.result.drawable + if (capsule && drawn.intrinsicWidth > 0 && drawn.intrinsicHeight > 0) landscape = drawn.intrinsicWidth > drawn.intrinsicHeight * 0.95f + }, + ) Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) { - if (url != null) AsyncImage( - model = url, contentDescription = null, contentScale = ContentScale.Crop, + if (art != null) AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().wrapContentHeight(Alignment.Bottom, unbounded = true).height(total).blur(24.dp), ) Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.45f else 0.7f))) @@ -1317,7 +1570,7 @@ private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores. } @Composable -private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?) { +private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?, compact: Boolean = false) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val small = 10.sp @@ -1325,13 +1578,14 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi installed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { val ctx = LocalContext.current val game = StoresState.installedGame(item.store, item.id) - if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) } - com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } + if (game != null && !compact) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) } + if (!compact) com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.dp)) } download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) - item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) - item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } + item.accessible && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + item.accessible && compact -> Unit + item.accessible -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1) item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { StateChip("-${item.discountPercent}%", ChipTone.DEAL, small = true) @@ -1344,9 +1598,10 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi @Composable internal fun CardArt(item: CatalogItem, modifier: Modifier) { val url = item.imageUrl ?: item.tallImageUrl + val art = cardArtRequest(url, item.tallImageUrl) val colors = MaterialTheme.colorScheme Box(modifier.background(colors.surfaceVariant).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) { - if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) else Text(item.title, fontSize = 10.sp, fontWeight = FontWeight.Black, color = Color.White, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(6.dp)) Spacer(Modifier.matchParentSize().background(androidx.compose.ui.graphics.Brush.verticalGradient(0.6f to Color.Transparent, 1f to colors.surface.copy(alpha = 0.85f)))) } @@ -1407,9 +1662,16 @@ internal fun openUrl(ctx: android.content.Context, url: String) { internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) { val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id } - if (game == null) { - android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show() + if (game != null) { + com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) } return } - com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) } + val app = ctx.applicationContext + Thread({ + val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull() + StoresState.post { + if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show() + else com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(installed) } + } + }, "store-play").start() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 86980362f..0f3843fca 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -6,6 +6,10 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import android.content.Context import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance @@ -29,6 +33,30 @@ object Themes { const val PAPER = "paper" const val ICON_BLUE = "blue" const val ELECTRIC = "electric" + const val GRADIENT = "gradient" + + const val DEFAULT_PRIMARY = 0xFF8B5CF6.toInt() + const val DEFAULT_SECONDARY = 0xFF22D3EE.toInt() + + /** The two colours of the custom Gradient theme: primary, then secondary. Changing them restyles the app at once. */ + var gradientColors by mutableStateOf(DEFAULT_PRIMARY to DEFAULT_SECONDARY) + private set + + fun loadGradient(context: Context) { + gradientColors = SessionPrefs.gradientColors(context) + } + + fun setGradient(context: Context, primary: Int, secondary: Int) { + SessionPrefs.setGradientColors(context, primary, secondary) + gradientColors = primary to secondary + } + + /** The theme the person chose, with the custom colours read first; for code that is not in a composition. */ + fun current(context: Context): Palette { + val id = SessionPrefs.theme(context) + if (id == GRADIENT) loadGradient(context) + return byId(id) + } val all: List = listOf( Palette( @@ -56,12 +84,16 @@ object Themes { ELECTRIC, R.string.theme_electric, R.string.theme_electric_detail, background = Color(0xFF070A10), surface = Color(0xFF0F1522), surfaceVariant = Color(0xFF16203A), line = Color(0xFF1E2A47), line2 = Color(0xFF2A3A5E), onBackground = Color(0xFFEEF3FF), onSurfaceVariant = Color(0xFF8B9AB8), - primary = Color(0xFF2E86FF), primary2 = Color(0xFF136CE0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF2E86FF), + primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF), good = Color(0xFF4CD37F), ), + ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail), ) - fun byId(id: String): Palette = all.firstOrNull { it.id == id } ?: all[0] + fun byId(id: String): Palette { + if (id == GRADIENT) return ThemeColors.palette(gradientColors.first, gradientColors.second, R.string.theme_gradient, R.string.theme_gradient_detail) + return all.firstOrNull { it.id == id } ?: all[0] + } } val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt new file mode 100644 index 000000000..b4b57737e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt @@ -0,0 +1,157 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** Colour maths for the custom gradient theme: hue, saturation and brightness in, a readable palette out. */ +internal object ThemeColors { + /** Hue 0..360, saturation and brightness 0..1. */ + class Hsv(val h: Float, val s: Float, val v: Float) + + fun toHsv(argb: Int): Hsv { + val r = (argb shr 16 and 0xFF) / 255f + val g = (argb shr 8 and 0xFF) / 255f + val b = (argb and 0xFF) / 255f + val top = max(r, max(g, b)) + val delta = top - min(r, min(g, b)) + val hue = when { + delta == 0f -> 0f + top == r -> 60f * (((g - b) / delta) % 6f) + top == g -> 60f * ((b - r) / delta + 2f) + else -> 60f * ((r - g) / delta + 4f) + } + return Hsv(if (hue < 0f) hue + 360f else hue, if (top == 0f) 0f else delta / top, top) + } + + fun fromHsv(h: Float, s: Float, v: Float): Int = + Color.hsv(h.mod(360f), s.coerceIn(0f, 1f), v.coerceIn(0f, 1f)).toArgb() + + fun hex(argb: Int): String = "#%06X".format(argb and 0xFFFFFF) + + /** `#RRGGBB`, `RRGGBB` or `#RGB`; null for anything else. */ + fun parseHex(text: String): Int? { + val digits = text.trim().removePrefix("#") + if (!digits.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null + val full = when (digits.length) { + 3 -> digits.map { "$it$it" }.joinToString("") + 6 -> digits + else -> return null + } + return 0xFF000000.toInt() or full.toInt(16) + } + + private const val MIN_LUMINANCE = 0.10f + + /** The colour, made brighter (then paler, for a deep blue that brightness alone cannot lift) when it is too dark to show on the dark background. */ + fun readable(argb: Int): Int { + if (Color(argb).luminance() >= MIN_LUMINANCE) return argb or 0xFF000000.toInt() + val hsv = toHsv(argb) + var v = hsv.v + var s = hsv.s + var out = argb + while (v < 1f && Color(out).luminance() < MIN_LUMINANCE) { + v = min(1f, v + 0.04f) + out = fromHsv(hsv.h, s, v) + } + while (s > 0f && Color(out).luminance() < MIN_LUMINANCE) { + s = max(0f, s - 0.04f) + out = fromHsv(hsv.h, s, v) + } + return out + } + + /** A dark neutral tinted with the primary's hue; plain greys stay grey. */ + fun tint(primary: Int, saturation: Float, brightness: Float): Color { + val hsv = toHsv(primary) + val strength = (hsv.s / 0.35f).coerceIn(0f, 1f) + return Color(fromHsv(hsv.h, saturation * strength, brightness)) + } + + private val DARK_INK = Color(0xFF03111F) + + private fun contrast(a: Color, b: Color): Float { + val x = a.luminance() + val y = b.luminance() + return (max(x, y) + 0.05f) / (min(x, y) + 0.05f) + } + + /** Dark or white text, whichever reads better on the worse of the two gradient colours. */ + internal fun inkFor(first: Int, second: Int): Color { + fun worst(ink: Color) = min(contrast(ink, Color(first)), contrast(ink, Color(second))) + return if (worst(DARK_INK) >= worst(Color.White)) DARK_INK else Color.White + } + + private const val MIN_CONTRAST = 4.5f + + /** The colour nudged just enough, lighter for dark text and darker for white text, to give [ink] a readable contrast on it. */ + private fun fitTo(ink: Color, argb: Int): Int { + fun short(color: Int) = contrast(ink, Color(color)) < MIN_CONTRAST + if (!short(argb)) return argb + val hsv = toHsv(argb) + var v = hsv.v + var s = hsv.s + var out = argb + if (ink == DARK_INK) { + while (v < 1f && short(out)) { v = min(1f, v + 0.04f); out = fromHsv(hsv.h, s, v) } + while (s > 0f && short(out)) { s = max(0f, s - 0.04f); out = fromHsv(hsv.h, s, v) } + } else { + while (v > 0f && short(out)) { v = max(0f, v - 0.04f); out = fromHsv(hsv.h, s, v) } + } + return out + } + + private fun gradientColor(first: Int, second: Int, fraction: Float): Int { + fun channel(shift: Int): Int { + val a = first shr shift and 0xFF + val b = second shr shift and 0xFF + return (a + (b - a) * fraction).roundToInt().coerceIn(0, 255) + } + return (0xFF shl 24) or (channel(16) shl 16) or (channel(8) shl 8) or channel(0) + } + + private fun gradientFits(ink: Color, first: Int, second: Int): Boolean = + (0..32).all { step -> + val color = gradientColor(first, second, step / 32f) + contrast(ink, Color(color)) >= MIN_CONTRAST + 0.02f + } + + private fun moveTowardContrast(ink: Color, argb: Int): Int { + val hsv = toHsv(argb) + return if (ink == DARK_INK) { + when { + hsv.v < 1f -> fromHsv(hsv.h, hsv.s, min(1f, hsv.v + 0.04f)) + hsv.s > 0f -> fromHsv(hsv.h, max(0f, hsv.s - 0.04f), hsv.v) + else -> argb + } + } else { + fromHsv(hsv.h, hsv.s, max(0f, hsv.v - 0.04f)) + } + } + + fun palette(primary: Int, secondary: Int, label: Int, detail: Int): Palette { + val seen = readable(primary) + val seenSecond = readable(secondary) + val ink = inkFor(seen, seenSecond) + var first = fitTo(ink, seen) + var second = fitTo(ink, seenSecond) + while (!gradientFits(ink, first, second)) { + val nextFirst = moveTowardContrast(ink, first) + val nextSecond = moveTowardContrast(ink, second) + if (nextFirst == first && nextSecond == second) break + first = nextFirst + second = nextSecond + } + return Palette( + Themes.GRADIENT, label, detail, + background = tint(first, 0.50f, 0.045f), surface = tint(first, 0.45f, 0.075f), surfaceVariant = tint(first, 0.42f, 0.115f), + line = tint(first, 0.40f, 0.16f), line2 = tint(first, 0.36f, 0.22f), + onBackground = tint(first, 0.05f, 0.97f), onSurfaceVariant = tint(first, 0.16f, 0.68f), + primary = Color(first), primary2 = Color(second), onPrimary = ink, signal = Color(first), + good = Color(0xFF4CD37F), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt new file mode 100644 index 000000000..db975a199 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt @@ -0,0 +1,330 @@ +package com.droiddeck.launcher.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Mouse +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.GestureKeys +import com.droiddeck.launcher.input.GesturePrefs +import com.droiddeck.launcher.input.TouchGestureConfig +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_LEFT_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_RIGHT_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_MIDDLE_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_SHOW_KEYBOARD +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_KEY_TILDE +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.PAN_ACTIONS +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ZOOM_ACTIONS +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_HOLD +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_DRAG_MOVEMENT_DIRECT +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_DRAG_MOVEMENT_RELATIVE + +@Composable +fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit) { + val context = LocalContext.current + // Keep an open editor attached to the game it was opened for. + val targetGame = remember { game } + val initialDefaults = remember { GesturePrefs.read(context) } + val initialGame = remember { targetGame?.takeIf { GesturePrefs.hasOverride(context, it) }?.let { GesturePrefs.read(context, it) } } + var defaults by remember { mutableStateOf(initialDefaults) } + var gameDraft by remember { mutableStateOf(initialGame) } + var forGame by rememberSaveable { mutableStateOf(targetGame != null) } + val config = if (forGame) gameDraft ?: defaults else defaults + val change: (TouchGestureConfig) -> Unit = { if (forGame) gameDraft = it else defaults = it } + var tab by rememberSaveable { mutableIntStateOf(0) } + val tabs = listOf(R.string.gesture_tab_one, R.string.gesture_tab_two, R.string.gesture_tab_three, R.string.gesture_tab_options) + val mouse = listOf(ACTION_LEFT_CLICK, ACTION_RIGHT_CLICK, ACTION_MIDDLE_CLICK) + val actions = mouse + ACTION_SHOW_KEYBOARD + GestureKeys.codes.keys.map { "key_$it" } + ACTION_KEY_TILDE + val p = LocalPalette.current + + GestureDialogFrame(onDismiss, + footer = { + Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } + Spacer(Modifier.width(12.dp)) + Button(onClick = { + if (defaults != initialDefaults) GesturePrefs.save(context, null, defaults) + if (targetGame != null && gameDraft != initialGame) { + gameDraft?.let { GesturePrefs.save(context, targetGame, it) } ?: GesturePrefs.clear(context, targetGame) + } + onSaved() + }, modifier = Modifier.testTag("gesture-save")) { Text(stringResource(R.string.gesture_save_changes)) } + } + }) { + ScrollableTabRow(tab, edgePadding = 12.dp, containerColor = p.surface, divider = { HorizontalDivider(color = p.line) }) { + tabs.forEachIndexed { index, title -> + Tab(tab == index, onClick = { tab = index }, text = { Text(stringResource(title)) }, selectedContentColor = p.onBackground, + unselectedContentColor = p.onSurfaceVariant, modifier = Modifier.testTag("gesture-tab-$index")) + } + } + key(tab) { + Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default")) + if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game")) + } + if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) } + } + when (tab) { + 0 -> { + GestureSection(R.string.gesture_tab_one) + GestureCard(R.string.gesture_tap, config.tapEnabled, { change(config.copy(tapEnabled = it)) }) { + GestureActionField("tap", R.string.gesture_tap, config.tapAction, actions) { change(config.copy(tapAction = it)) } + } + GestureCard(R.string.gesture_drag, config.dragEnabled, { change(config.copy(dragEnabled = it)) }) { + GestureActionField("drag", R.string.gesture_drag, config.dragAction, PAN_ACTIONS) { change(config.copy(dragAction = it)) } + } + GestureCard(R.string.gesture_long_press, config.longPressEnabled, { change(config.copy(longPressEnabled = it)) }) { + GestureActionField("long-press", R.string.gesture_long_press, config.longPressAction, actions) { change(config.copy(longPressAction = it)) } + GestureHoldOptions(config.longPressAction, config.longPressMouseBehavior, config.longPressDelay, { change(config.copy(longPressMouseBehavior = it)) }, { change(config.copy(longPressDelay = it)) }) + } + GestureCard(R.string.gesture_double_tap, config.doubleTapEnabled, { change(config.copy(doubleTapEnabled = it)) }) { + GestureDelay(stringResource(R.string.gesture_double_delay), config.doubleTapDelay) { change(config.copy(doubleTapDelay = it)) } + } + } + 1 -> { + GestureSection(R.string.gesture_tab_two) + GestureCard(R.string.gesture_two_tap, config.twoFingerTapEnabled, { change(config.copy(twoFingerTapEnabled = it)) }) { + GestureActionField("two-tap", R.string.gesture_two_tap, config.twoFingerTapAction, actions) { change(config.copy(twoFingerTapAction = it)) } + } + GestureCard(R.string.gesture_two_drag, config.twoFingerDragEnabled, { change(config.copy(twoFingerDragEnabled = it)) }) { + GestureActionField("two-drag", R.string.gesture_two_drag, config.twoFingerDragAction, PAN_ACTIONS) { change(config.copy(twoFingerDragAction = it)) } + } + GestureCard(R.string.gesture_two_hold, config.twoFingerHoldEnabled, { change(config.copy(twoFingerHoldEnabled = it)) }) { + GestureActionField("two-hold", R.string.gesture_two_hold, config.twoFingerHoldAction, actions) { change(config.copy(twoFingerHoldAction = it)) } + GestureHoldOptions(config.twoFingerHoldAction, config.twoFingerHoldMouseBehavior, config.twoFingerHoldDelay, { change(config.copy(twoFingerHoldMouseBehavior = it)) }, { change(config.copy(twoFingerHoldDelay = it)) }) + } + GestureCard(R.string.gesture_pinch, config.pinchEnabled, { change(config.copy(pinchEnabled = it)) }) { + GestureActionField("pinch", R.string.gesture_pinch, config.pinchAction, ZOOM_ACTIONS) { change(config.copy(pinchAction = it)) } + } + } + 2 -> { + GestureSection(R.string.gesture_tab_three) + GestureCard(R.string.gesture_three_tap, config.threeFingerTapEnabled, { change(config.copy(threeFingerTapEnabled = it)) }) { + GestureActionField("three-tap", R.string.gesture_three_tap, config.threeFingerTapAction, actions) { change(config.copy(threeFingerTapAction = it)) } + } + GestureCard(R.string.gesture_three_drag, config.threeFingerDragEnabled, { change(config.copy(threeFingerDragEnabled = it)) }) { + GestureActionField("three-drag", R.string.gesture_three_drag, config.threeFingerDragAction, PAN_ACTIONS) { change(config.copy(threeFingerDragAction = it)) } + } + GestureCard(R.string.gesture_three_hold, config.threeFingerHoldEnabled, { change(config.copy(threeFingerHoldEnabled = it)) }) { + GestureActionField("three-hold", R.string.gesture_three_hold, config.threeFingerHoldAction, actions) { change(config.copy(threeFingerHoldAction = it)) } + GestureHoldOptions(config.threeFingerHoldAction, config.threeFingerHoldMouseBehavior, config.threeFingerHoldDelay, { change(config.copy(threeFingerHoldMouseBehavior = it)) }, { change(config.copy(threeFingerHoldDelay = it)) }) + } + } + 3 -> { + GestureSection(R.string.gesture_tab_options) + GestureSettingsCard(R.string.gesture_movement) { + GestureChoiceChips(config.mouseDragMovementMode, listOf(MOUSE_DRAG_MOVEMENT_DIRECT to stringResource(R.string.gesture_direct), MOUSE_DRAG_MOVEMENT_RELATIVE to stringResource(R.string.gesture_relative))) { change(config.copy(mouseDragMovementMode = it)) } + } + GestureSettingsCard(R.string.gesture_recognition) { + Text(stringResource(R.string.gesture_threshold, config.gestureThreshold), style = MaterialTheme.typography.bodyMedium) + Slider(config.gestureThreshold.toFloat(), { change(config.copy(gestureThreshold = it.toInt())) }, valueRange = 10f..100f, steps = 17) + } + GestureSettingsCard(R.string.gesture_feedback) { + GestureToggle(R.string.gesture_debug, config.showGestureDebugOverlay) { change(config.copy(showGestureDebugOverlay = it)) } + } + OutlinedButton(onClick = { change(TouchGestureConfig()) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.gesture_reset)) } + } + } + } + } + } +} + +@Composable +private fun GestureDialogFrame(onDismiss: () -> Unit, + footer: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) { + val p = LocalPalette.current + // The app window does not opt into RTL, so these dialogs follow their resource locale explicitly. + val direction = if (LocalConfiguration.current.layoutDirection == android.view.View.LAYOUT_DIRECTION_RTL) LayoutDirection.Rtl else LayoutDirection.Ltr + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + CompositionLocalProvider(LocalLayoutDirection provides direction) { + Box(Modifier.fillMaxSize().systemBarsPadding().imePadding().padding(12.dp), contentAlignment = Alignment.Center) { + Surface(Modifier.widthIn(max = 1040.dp).fillMaxWidth().fillMaxHeight(), shape = RoundedCornerShape(22.dp), color = p.background, + contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) { + Column { + Row(Modifier.fillMaxWidth().padding(end = 4.dp), horizontalArrangement = Arrangement.End) { + IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) } + } + content() + HorizontalDivider(color = p.line) + footer() + } + } + } + } + } +} + +@Composable +private fun GestureSection(@StringRes title: Int) { + Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) +} + +@Composable +private fun GestureCard(@StringRes title: Int, checked: Boolean, onToggle: (Boolean) -> Unit, + content: @Composable ColumnScope.() -> Unit) { + val p = LocalPalette.current + Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = p.surface, + border = BorderStroke(1.dp, if (checked) p.line2 else p.line)) { + Column { + Row(Modifier.fillMaxWidth().toggleable(checked, role = Role.Switch, onValueChange = onToggle) + .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f).padding(end = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + } + Switch(checked, onCheckedChange = null) + } + if (checked) Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content) + } + } +} + +@Composable +private fun GestureActionField(id: String, @StringRes gesture: Int, selected: String, choices: List, onChoose: (String) -> Unit) { + var open by remember { mutableStateOf(false) } + val p = LocalPalette.current + Surface(onClick = { open = true }, modifier = Modifier.fillMaxWidth().testTag("gesture-choice-$id"), shape = RoundedCornerShape(10.dp), + color = p.surfaceVariant, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) { + Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(stringResource(R.string.gesture_action_label), color = p.onSurfaceVariant, style = MaterialTheme.typography.labelSmall) + Text(gestureActionTitle(selected), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + } + Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(19.dp), tint = p.onSurfaceVariant) + } + } + if (open) GestureActionPicker(choices, selected, onDismiss = { open = false }) { open = false; onChoose(it) } +} + +@Composable +internal fun GestureActionPicker(choices: List, selected: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) { + var query by rememberSaveable { mutableStateOf("") } + var category by remember { mutableStateOf(null) } + val catalog = choices.distinct().map { GestureAction(it, gestureActionTitle(it), gestureActionGroup(it)) } + val groups = GestureActionGroup.entries.filter { group -> catalog.any { it.group == group } } + val shown = filterGestureActions(catalog, query, category) + val p = LocalPalette.current + GestureDialogFrame(onDismiss) { + OutlinedTextField(query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).testTag("gesture-action-search"), + singleLine = true, shape = RoundedCornerShape(12.dp), placeholder = { Text(stringResource(R.string.gesture_search_actions)) }, + leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = { + if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { Icon(Icons.Default.Close, stringResource(R.string.gesture_clear_search)) } + }) + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GestureFilterChip(category == null, onClick = { category = null }, label = { Text(stringResource(R.string.gesture_all_actions)) }) + if (groups.size > 1) groups.forEach { group -> + GestureFilterChip(category == group, onClick = { category = group }, label = { Text(stringResource(group.title)) }, modifier = Modifier.testTag("gesture-category-${group.name}")) + } + } + if (shown.isEmpty()) Box(Modifier.weight(1f).fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.gesture_no_actions), color = p.onSurfaceVariant) + } else LazyColumn(Modifier.weight(1f).fillMaxWidth().testTag("gesture-action-list"), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + groups.forEach { group -> + val entries = shown.filter { it.group == group } + if (entries.isNotEmpty()) { + item(key = group.name) { Text(stringResource(group.title), style = MaterialTheme.typography.labelLarge, color = p.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp, bottom = 5.dp)) } + items(entries, key = { it.id }) { action -> + val isSelected = action.id == selected + Surface(shape = RoundedCornerShape(12.dp), color = if (isSelected) p.primary.copy(alpha = 0.12f) else p.surface, + border = BorderStroke(1.dp, if (isSelected) p.primary else p.line)) { + Row(Modifier.fillMaxWidth().selectable(isSelected, role = Role.RadioButton, onClick = { onChoose(action.id) }) + .testTag("gesture-action-${action.id}").padding(horizontal = 14.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(when (group) { GestureActionGroup.MOUSE -> Icons.Default.Mouse; GestureActionGroup.APP -> Icons.Default.TouchApp; else -> Icons.Default.Keyboard }, + null, tint = if (isSelected) p.primary else p.onSurfaceVariant, modifier = Modifier.size(22.dp)) + Text(action.title, Modifier.weight(1f).padding(horizontal = 14.dp), style = MaterialTheme.typography.bodyLarge) + RadioButton(isSelected, onClick = null) + } + } + } + } + } + } + } +} + +@Composable +private fun GestureHoldOptions(action: String, behavior: String, delay: Int, onBehavior: (String) -> Unit, onDelay: (Int) -> Unit) { + if (TouchGestureConfig.isMouseButtonAction(action)) { + Text(stringResource(R.string.gesture_mouse_behavior), style = MaterialTheme.typography.labelMedium, color = LocalPalette.current.onSurfaceVariant) + GestureChoiceChips(behavior, listOf(MOUSE_BEHAVIOR_HOLD to stringResource(R.string.gesture_hold_button), MOUSE_BEHAVIOR_CLICK to stringResource(R.string.gesture_click_once)), onBehavior) + } + GestureDelay(stringResource(R.string.gesture_hold_delay), delay, onDelay) +} + +@Composable +private fun GestureFilterChip(selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit, modifier: Modifier = Modifier) { + val p = LocalPalette.current + FilterChip(selected, onClick, label, modifier = modifier, colors = FilterChipDefaults.filterChipColors( + containerColor = p.surface, labelColor = p.onSurfaceVariant, + selectedContainerColor = p.primary.copy(alpha = 0.16f), selectedLabelColor = p.onBackground)) +} + +@Composable +private fun GestureChoiceChips(selected: String, options: List>, choose: (String) -> Unit) { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { (id, label) -> GestureFilterChip(selected == id, onClick = { choose(id) }, label = { Text(label) }) } + } +} + +@Composable +private fun GestureSettingsCard(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) { + val p = LocalPalette.current + Surface(shape = RoundedCornerShape(16.dp), color = p.surface, border = BorderStroke(1.dp, p.line)) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + content() + } + } +} + +@Composable +private fun GestureToggle(@StringRes title: Int, checked: Boolean, change: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth().toggleable(checked, role = Role.Switch, onValueChange = change).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(title), Modifier.weight(1f).padding(end = 12.dp), style = MaterialTheme.typography.bodyMedium) + Switch(checked, null) + } +} + +@Composable +private fun GestureDelay(label: String, value: Int, change: (Int) -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.gesture_ms, value), Modifier.background(LocalPalette.current.surfaceVariant, RoundedCornerShape(6.dp)).padding(horizontal = 10.dp, vertical = 5.dp), style = MaterialTheme.typography.labelLarge) + } + Slider(value.toFloat().coerceIn(100f, 1000f), { change((it / 50).toInt() * 50) }, valueRange = 100f..1000f, steps = 17) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt b/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt index 44c53a670..3a122445c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/TrackingRecoveryView.kt @@ -16,7 +16,7 @@ import com.droiddeck.launcher.session.SessionPrefs internal class TrackingRecoveryView(context: Context) : View(context) { private var recovery: TrackingRecovery? = null private var stereo = false - private var palette = Themes.byId(SessionPrefs.theme(context)) + private var palette = Themes.current(context) private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private var blocks = emptyList() @@ -26,7 +26,7 @@ internal class TrackingRecoveryView(context: Context) : View(context) { importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES } - fun update(state: TrackingRecovery?, stereo: Boolean, palette: Palette = Themes.byId(SessionPrefs.theme(context))) { + fun update(state: TrackingRecovery?, stereo: Boolean, palette: Palette = Themes.current(context)) { if (recovery == state && this.stereo == stereo && this.palette == palette) return recovery = state this.stereo = stereo diff --git a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt index 581606f7c..4ba3a20cc 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt @@ -57,7 +57,8 @@ object SelfInstaller { } @Suppress("DEPRECATION") val flags = if (Build.VERSION.SDK_INT >= 28) { - PackageManager.GET_SIGNING_CERTIFICATES + // Android 9 and 10 only collect an archive's certificates when GET_SIGNATURES is set. + PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES } else { PackageManager.GET_SIGNATURES } @@ -75,15 +76,18 @@ object SelfInstaller { throw IOException(context.getString(R.string.selfinst_downgrade, code, BuildConfig.VERSION_CODE)) } - if (Build.VERSION.SDK_INT >= 28) { - val signing = info.signingInfo ?: throw IOException(context.getString(R.string.selfinst_no_signing)) - val digests = signing.apkContentsSigners.orEmpty().map { cert -> - MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()) - .joinToString("") { "%02x".format(it) } - } - if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) { - throw IOException(context.getString(R.string.selfinst_wrong_signer)) - } + val digests = archiveSignerDigests(info) + if (digests.isEmpty()) throw IOException(context.getString(R.string.selfinst_no_signing)) + if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) { + throw IOException(context.getString(R.string.selfinst_wrong_signer)) + } + } + + @Suppress("DEPRECATION") + internal fun archiveSignerDigests(info: android.content.pm.PackageInfo): List { + val certs = (if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else null) ?: info.signatures + return certs.orEmpty().map { cert -> + MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 21c6d9b98..952bd2f6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -312,7 +312,7 @@ public final class WaylandCompositor { public static native void nativeSendSceneInput(int type, int a, int b); - public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_WINFG = 1; + public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_DDFG = 1; public static native void nativeSetFrameGenEngine(int kind); @@ -322,7 +322,7 @@ public final class WaylandCompositor { public static native void nativeSetFrameGenTuning(float flowScale, float refreshHz); - public static native void nativeSetWinFgTuning(int model, int perfPreset); + public static native void nativeSetDdFgTuning(int model, int perfPreset); public static native int nativeFrameGenProblem(); public static native String nativeFrameGenCapsReason(); diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light.xml new file mode 100644 index 000000000..275b9dfdd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background_light.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background_light.png new file mode 100644 index 000000000..0ed586fce Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background_light.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground_light.png new file mode 100644 index 000000000..e89aa93dd Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground_light.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background_light.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background_light.png new file mode 100644 index 000000000..f1bc876a2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background_light.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground_light.png new file mode 100644 index 000000000..ebb44b431 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground_light.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background_light.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background_light.png new file mode 100644 index 000000000..c7853a5a6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background_light.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground_light.png new file mode 100644 index 000000000..2e96d0934 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground_light.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background_light.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background_light.png new file mode 100644 index 000000000..f8588e220 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background_light.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png new file mode 100644 index 000000000..134c0842a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background_light.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background_light.png new file mode 100644 index 000000000..8cf80ad86 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background_light.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground_light.png new file mode 100644 index 000000000..7cc49e91c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground_light.png differ diff --git a/app/src/main/res/values-ar/gestures.xml b/app/src/main/res/values-ar/gestures.xml new file mode 100644 index 000000000..6e4aa5468 --- /dev/null +++ b/app/src/main/res/values-ar/gestures.xml @@ -0,0 +1,91 @@ + + + الإيماءات + إعدادات إيماءات اللمس + خصّص إيماءات الفأرة ولوحة المفاتيح. اختر الإيماءات كوضع للمس لاستخدامها. + نقرة + سحب بإصبع واحد + ضغط مطوّل + نقر مزدوج (نقرتان بالزر الأيسر) + نقر بإصبعين + سحب بإصبعين + ضغط مستمر بإصبعين + نقر بثلاثة أصابع + سحب بثلاثة أصابع + ضغط مستمر بثلاثة أصابع + ضمّ الإصبعين أو إبعادهما للتكبير والتصغير + إبقاء الزر مضغوطًا + النقر مرة واحدة + مهلة الضغط المستمر + الفاصل بين النقرتين + استعادة إعدادات الإيماءات الأصلية + إلغاء + اختيار إجراء + نقر بالزر الأيسر + نقر بالزر الأيمن + نقر بالزر الأوسط + إظهار لوحة المفاتيح + نقر أيسر وسحب + نقر أيمن وسحب + تحريك العرض بزر الفأرة الأوسط + تحريك العرض بزر الفأرة الأوسط (معكوس) + مفاتيح الأسهم + مفاتيح الأسهم (معكوسة) + WASD (معكوس) + عجلة الفأرة + عتبة الإيماءات: %1$d بكسل + إظهار الإيماءة المكتشفة + إصبع واحد + إصبعان + ثلاثة أصابع + خيارات + الافتراضية + هذه اللعبة + استخدام الإعدادات الافتراضية + حفظ التغييرات + إغلاق + الإجراء + البحث عن إجراءات الفأرة أو مفاتيح لوحة المفاتيح + مسح البحث + الكل + لا توجد إجراءات مطابقة. جرّب اسم مفتاح أو إجراء آخر. + الفأرة + إجراءات التطبيق + التنقل + الأحرف + الأرقام + مفاتيح الوظائف + مفاتيح التعديل + لوحة الأرقام + الرموز + حركة الفأرة + مباشر + نسبي + التعرّف على الإيماءات + الاستجابة المرئية + سلوك زر الفأرة + %1$d مللي ثانية + لوحة الأرقام %1$s + هروب + مسافة للخلف + إدخال + Tab + مسافة + حذف + إدراج + Home + End + صفحة لأعلى + صفحة لأسفل + طباعة الشاشة + سهم لأعلى + سهم لأسفل + سهم لليسار + سهم لليمين + قفل الأحرف الكبيرة + قفل الأرقام + قفل التمرير + %1$s الأيسر + %1$s الأيمن + Shift + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8b5c45a08..d60a59fb9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -30,7 +30,7 @@ توليد الإطارات اختر وضع توليد الإطارات إيقاف - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG تكيّفي %1$d يتطلب Lossless Scaling على Steam @@ -176,6 +176,8 @@ %1$s عبر FEX هذا البرنامج مبني لأجهزة كمبيوتر %1$s ويعمل عبر FEX، الذي لم يتم إعداده بعد. افتح Steam مرة واحدة أثناء تسجيل الدخول: سيجعله DroidDeck يُنزّل FEX وSteam Linux Runtime اللازمة، ثم يُفتح هذا البرنامج. هذا البرنامج + تعذّر تنزيل عميل Steam. قد تكون شبكتك أو VPN تحظر خوادم Steam؛ تحقّق من الاتصال وحاول مرة أخرى. + تعذّر إعداد عميل Steam. تحقّق من الاتصال والمساحة الفارغة، ثم حاول مرة أخرى. %1$s مبني لأجهزة كمبيوتر x86 ويعمل عبر FEX، الذي لم يتم تثبيته بعد. شغّل Steam مرة واحدة أثناء تسجيل الدخول ودعه يُنزّل FEX وSteam Linux Runtime، ثم شغّل هذا البرنامج مجددًا. معرّف تطبيق على Flathub أو صفحته على Flathub. يُعَدّ Flatpak أولًا إن لم يكن مُعدًّا بعد. الملف @@ -510,7 +512,9 @@ هل تريد حذف هذه النسخة الأصلية؟ هذه النسخة الأصلية من %1$s تعود إلى إصدار سابق من %2$s. يتم دائمًا الاحتفاظ بالنسخة الأصلية للإصدار الحالي. في الإصدارات الليلية · %1$d - حدّث لعرض الحزم في الإصدارات الليلية. + مستقر + ليلي + حدّث لعرض الحزم في إصدارات Nightlies وDroidDeck. لا جديد منذ آخر فحص. استيراد ملف ‎.wcp استعادة النسخة الأصلية لـ Proton هذا @@ -546,7 +550,7 @@ لـ Adreno فقط. تقطّع أقل، مع استهلاك أكبر للبطارية والحرارة. إصلاحات الجلسة Turnip: التصيير عبر sysmem - أسرع لـ Steam ولمعظم الألعاب. مطلوب على Adreno 710/720/722. + أسرع لعميل Steam؛ في الألعاب يختار برنامج التشغيل بين العرض المقسّم إلى مربعات والعرض في ذاكرة النظام لكل مرحلة. مطلوب على Adreno 710/720/722. تجاوز مساعد xalia في Steam droiddeck-ntsync (تجريبي) بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux، للاختبار. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية. @@ -588,6 +592,16 @@ الصفحة السابقة الصفحة التالية واجهة الأداء + معدل الإطارات + درجة حرارة البطارية + مخطط الأداء + اللون + نعناعي + أبيض + سماوي + كهرماني + بنفسجي + العتامة وضع التحجيم حدة التحجيم مؤثرات الشاشة @@ -741,6 +755,9 @@ %1$d%% (افتراضي) المظهر مظهر المشغّل + أيقونة التطبيق + داكنة + فاتحة استخدام كشاشة رئيسية يمكن أن يكون DroidDeck تطبيق الشاشة الرئيسية لا يُعرَض DroidDeck أبدًا كتطبيق للشاشة الرئيسية @@ -1059,6 +1076,9 @@ لعبة مُضافة لعبة مخصّصة تخزين الألعاب + يحذف ملفاتها ويزيلها من Steam. + إزالة %1$s؟ + يحذف الاختصار وتبقى ملفات اللعبة. أفضل زوج لهذا GPU، يُحافَظ على تحديثه اختر زوجًا بنفسك جارٍ البحث عن برامج التشغيل… @@ -1440,6 +1460,14 @@ خلفية سوداء ونص أبيض، والأزرق لكل عنصر محدَّد أو مُركَّز عليه أو مضغوط. كحلي كهربائي أزرق أكثر سطوعًا، على أسطح تميل إلى الكحلي بدلاً من الرمادي. + تدرج لوني + لونك الأساسي والثانوي، ممزوجان على الأزرار وعناصر التمييز. + الأساسي + الثانوي + درجة اللون + التشبع + السطوع + لون سداسي (Hex) Esc Ins Del @@ -1846,7 +1874,8 @@ تم الاحتفاظ بأصل النسخة المثبَّتة: فهو طريق العودة. غير مخزَّن تم حذف أصل %2$s من %1$s. - التنزيلات تأتي فقط من إصدارات Nightlies + التنزيلات تأتي فقط من إصدارات Nightlies وDroidDeck + فشل التنزيل: ربما استُبدل هذا الإصدار الليلي بإصدار أحدث. حدّث القائمة وحاول مرة أخرى. قائمة الحزم هذه أقدم من إضافة المجاميع الاختبارية - حدِّثها وحاوِل مرة أخرى عدم تطابق المجموع الاختباري @@ -2005,9 +2034,10 @@ المتاجر إعدادات المتاجر الإعدادات + ترتيب التبويبات + تم (لم يتم تسجيل الدخول) المتجر - المثبَّتة المكتبة الكل التنزيلات @@ -2019,10 +2049,8 @@ البقاء مسجَّل الدخول تسجيل الخروج البحث في كتالوج %1$s… - البحث في الألعاب المثبَّتة… البحث في مكتبتك… البحث - لا شيء مثبَّت جارٍ التحميل لا توجد ألعاب جارٍ تحميل المتجر… @@ -2041,7 +2069,19 @@ عرض على %1$s شراء · %1$s مثبَّتة + مثبَّتة + غير مثبَّت في المكتبة + مفتاح API + الحصول على مفتاح + تم رفض المفتاح + ليس في مكتبة itch.io الخاصة بك + لا يوجد تنزيل لنظام Windows لهذه اللعبة + لم يتم العثور على لعبة Windows في التنزيل + تعذر استخراج التنزيل (رمز الخروج %1$d) + لم يستجب itch.io + أرشيفات %1$s غير مدعومة + استجاب itch.io بـ HTTP %1$d مجاني استئناف تحديث @@ -2055,6 +2095,7 @@ تاريخ الإصدار عرض القائمة عرض البطاقات + عرض الأغلفة إظهار المحتوى للبالغين Epic EOS @@ -2090,6 +2131,9 @@ آخر مزامنة %1$s لم تتم المزامنة بعد %1$d ملفات + محدَّث بالفعل + سجّل الدخول إلى المتجر لمزامنة الحفظ + لم تتم مزامنة الحفظ مثبَّتة في مكتبتك متجر @@ -2221,7 +2265,7 @@ الحجم حفظ العودة إلى الافتراضي - يُرسم فوق خلفية اللعبة كما يفعل Steam. قد يختلف الموضع الدقيق ببضع بكسلات. + قد يختلف هذا الموضع بضع نقاط بكسل. لا توجد لهذه اللعبة شعار بعد. اختر واحدًا من الشعار لتراه هنا. تعذّر حفظ موضع الشعار. حاول مرة أخرى. الخلفية @@ -2401,7 +2445,7 @@ تم تعذّر حفظ تخطيط اللمس تم حفظ تخطيط اللمس في Steam - التطبيق %1$d + التطبيق %1$s التسمية (اختياري) لم يتم العثور على أيقونات Steam @@ -2500,6 +2544,15 @@ مجال رؤية SteamVR القيمة الأقل تقرّب SteamVR وتملأ النظارة؛ والأعلى تُظهر المزيد مما حولك. جارٍ اللعب على %1$s + إعادة التوسيط + قائمة SteamVR + العصي: اليدان ⇄ اللعبة + وحدات التحكم: %1$s + لوحة المفاتيح + قائمة الجلسة + إيقاف SteamVR + السطوع %1$d / %2$d + البطارية %1$d%% · %2$d °C معايرة التتبّع يقلّل الانجراف ويضبط المرجع الأفقي. تُطبَّق المعايرة المحفوظة على هذه النظارة. @@ -2554,6 +2607,7 @@ في انتظار اتصال USB Ethernet الخاص بالنظارة. قد لا يتيحه هذا الهاتف للتطبيقات. انقطع اتصال XREAL أو أعاد استجابة غير مدعومة. جارٍ إعادة المحاولة… دعم تجريبي لـ 1S. استخدم وضع Follow وعطّل تثبيت الصورة من قائمة النظارة. لا تزال اتجاهات التتبّع وأوضاع العرض بحاجة إلى اختبار على الجهاز. + تتبّع تجريبي لحركة الرأس لنظارة Pro 2. لا يُدعم بعد ضبط السطوع ومستوى الصوت أو تبديل وضع العرض. Cardboard VR وضع Cardboard عرض للعين اليسرى وآخر لليمنى لنظارة واقع افتراضي للهاتف. @@ -2687,33 +2741,61 @@ تحرير مساحة اللعب على أي حال + %d مثبَّتة %d مثبَّتة + %d مثبَّتة + %d مثبَّتة + %d مثبَّتة %d مثبَّتة + %d على بطاقة SD %d على بطاقة SD + %d على بطاقة SD + %d على بطاقة SD + %d على بطاقة SD %d على بطاقة SD + %d أداة %d أداة - %d أدوات + %d أداة + %d أدوات + %d أداة + %d أداة + مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة + مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة + مجلدات بادئة فارغة وذاكرة شيدرات من %d ألعاب + مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة + بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. بادئة %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + بادئات %d ألعاب تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + %d لعبة %d لعبة + %d لعبة + %d ألعاب + %d لعبة %d لعبة Proton الافتراضي في DroidDeck خيار Proton 11 في DroidDeck + تستخدمه %d لعبة تستخدمه %d لعبة + تستخدمه %d لعبة + تستخدمه %d ألعاب + تستخدمه %d لعبة تستخدمه %d لعبة المكوّنات @@ -2756,17 +2838,25 @@ يحذف بيئات التشغيل التي لا يحتاجها أي تطبيق مثبَّت وينظّف مخزن كائنات Flatpak. تبقى التطبيقات المثبَّتة تعمل. يحذف التطبيق (%1$s) وبيئات التشغيل التي لا يستخدمها غيره. تبقى بياناته في مجلدك الرئيسي. + %d حزمة %d حزمة - %d حزم + %d حزمة + %d حزم + %d حزمة + %d حزمة + %d تطبيق %d تطبيق - %d تطبيقات + %d تطبيق + %d تطبيقات + %d تطبيقًا + %d تطبيق فيديو الإقلاع الفيديو DroidDeck · %1$d إطار/ث - معدل 60 إطارًا/ث أكثر سلاسة لكنه يستهلك المعالج أكثر أثناء بدء Steam. فيديو البدء المختار في إعدادات Steam نفسها له الأولوية. + معدل 60 إطارًا/ث أكثر سلاسة لكنه يستهلك المعالج أكثر أثناء بدء Steam. فيديو مختار في إعدادات Steam نفسها يظهر هنا أيضًا. الصوت فيديو خاص بك MP4 أو MOV أو MKV أو WebM. تُحوَّل الصيغ الأخرى والملفات الأكبر من %1$d ميغابايت إلى WebM بدقة 720p على الجهاز، حتى %2$d ثانية. @@ -2780,6 +2870,51 @@ جارٍ تحويل %1$s لـ Steam. أبقِ DroidDeck مفتوحًا. لا يستطيع هذا الجهاز فك ترميز هذا الفيديو. تعذّر استيراد الفيديو + إزالة %1$s؟ + سيُحذف الفيديو من DroidDeck ومن مجلد فيديوهات Steam. + المثبّتة + استيراد + SteamDeckRepo + الصوت + الفيديوهات المثبّتة + استعراض + DroidDeck + مستورد + SteamDeckRepo + Steam + متجر النقاط + تقرير فيديو الإقلاع + يحفظ ما يعرفه DroidDeck وSteam عن فيديوهات الإقلاع في ملف نصي يمكنك إرفاقه ببلاغ خطأ. + حفظ + تم الحفظ في %1$s + تعذّر حفظ التقرير. + مستوى الصوت + المدة + %1$d ث + عدد الثواني التي يتم تشغيلها من الفيديو. أي جزء أطول من ذلك يُقطع في النهاية. + البحث في SteamDeckRepo + كل الأجهزة + Steam Deck + Steam Machine + الرائجة + الأكثر تنزيلًا + الأكثر إعجابًا + الأحدث + تعذّر الوصول إلى SteamDeckRepo + إعادة المحاولة + لم يُعثر على فيديوهات + تثبيت + استخدام + قيد الاستخدام + تم تثبيت %1$s + تعذّر تنزيل هذا الفيديو + السابق + التالي + الصفحة %1$d + مسح + الانتقال إلى صفحة + معاينة + تعذر تشغيل المعاينة كاميرات Luma Ultra - تجريبية تتبع موضع Joy-Con استخدم كاميرات النظارات الذكية لتحديد موضع وحدات تحكم Joy-Con المقترنة. يتم التحكم في الدوران والأزرار من خلال وحدات تحكم Joy-Con. يتم تقدير الموضع عندما تكون معصميك مخفية. يحل هذا محل وحدات التحكم التي يتم تشغيلها باليدين ويوقف تتبع كاميرا الهاتف مؤقتًا. وجّه وحدات تحكم Joy-Con للأمام عند توصيل مركز التتبع أو إعادة ضبطه. @@ -2953,7 +3088,6 @@ جارٍ انتظار استقرار تتبع الرأس؛ أعد ضبط مركز التتبع إذا طُلب منك ذلك. المراقبة بالكاميرات قديمة جدًا؛ في انتظار زوج جديد تناسب %1$.2f بكسل. قم بقياس المسافة التي حددتها من الكاميرا اليسرى إلى مركز هدف المعايرة، ثم قم بالتحقق من صحتها. - OpenCV غير متاح: %1$s تمت استعادة %1$d من المشاهدات المحفوظة. قم بحساب المعايرة لإعادة المحاولة، أو ابدأ مجموعة جديدة لالتقاط المزيد. ملتقطة %1$d / 24 مشاهدة. حافظ على هدف المعايرة ثابتًا؛ حرك النظارات الذكية للعرض التالي. رُفض الالتقاط: تظهر %1$d / 12 زاوية مطلوبة في كلتا الكاميرتين. أظهر جزءًا أكبر من لوحة المعايرة، وابقَ ثابتًا ثم حاول مرة أخرى. diff --git a/app/src/main/res/values-b+es+419/gestures.xml b/app/src/main/res/values-b+es+419/gestures.xml new file mode 100644 index 000000000..4f5f49779 --- /dev/null +++ b/app/src/main/res/values-b+es+419/gestures.xml @@ -0,0 +1,91 @@ + + + Gestos + Ajustes de gestos táctiles + Personaliza los gestos de ratón y teclado. Selecciona Gestos como modo táctil para usarlos. + Toque + Arrastre con un dedo + Pulsación prolongada + Doble toque (doble clic izquierdo) + Toque con dos dedos + Arrastre con dos dedos + Pulsación sostenida con dos dedos + Toque con tres dedos + Arrastre con tres dedos + Pulsación sostenida con tres dedos + Pellizcar para ampliar + Mantener el botón pulsado + Hacer clic una vez + Tiempo de pulsación sostenida + Intervalo de doble toque + Restablecer los ajustes originales de gestos + Cancelar + Elegir acción + Clic izquierdo + Clic derecho + Clic central + Mostrar teclado + Clic izquierdo y arrastre + Clic derecho y arrastre + Desplazamiento con el botón central + Desplazamiento con el botón central (invertido) + Teclas de dirección + Teclas de dirección (invertidas) + WASD (invertido) + Rueda del ratón + Umbral de gestos: %1$d px + Mostrar gesto reconocido + Un dedo + Dos dedos + Tres dedos + Opciones + Predeterminados + Este juego + Usar valores predeterminados + Guardar cambios + Cerrar + ACCIÓN + Buscar acciones de ratón o teclas del teclado + Borrar búsqueda + Todas + No hay acciones coincidentes. Prueba con otro nombre de tecla o acción. + Ratón + Acciones de la aplicación + Navegación + Letras + Números + Teclas de función + Teclas modificadoras + Teclado numérico + Símbolos + Movimiento del ratón + Directo + Relativo + Reconocimiento de gestos + Respuesta visual + Comportamiento del botón del ratón + %1$d ms + Teclado numérico %1$s + Escape + Retroceso + Intro + Tab + Espacio + Supr + Insertar + Inicio + Fin + Re Pág + Av Pág + Impr Pant + Flecha arriba + Flecha abajo + Flecha izquierda + Flecha derecha + Bloq Mayús + Bloq Num + Bloq Despl + %1$s izquierdo + %1$s derecho + Mayús + diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 729f2aa74..b924266f4 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -30,7 +30,7 @@ Generación de fotogramas Elige el modo de generación de fotogramas Desactivada - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptativo %1$d Requiere Lossless Scaling en Steam @@ -176,6 +176,8 @@ %1$s con FEX Este programa está hecho para PC %1$s y se ejecuta con FEX, que aún no está listo. Abre Steam una vez con tu sesión iniciada: DroidDeck hará que descargue FEX y el Steam Linux Runtime que necesita, y después se abrirá. Este programa + No se pudo descargar el cliente de Steam. Es posible que tu red o VPN esté bloqueando los servidores de Steam; revisa la conexión e inténtalo de nuevo. + No se pudo configurar el cliente de Steam. Revisa la conexión y el espacio libre, y vuelve a intentarlo. %1$s está hecho para PC x86 y se ejecuta con FEX, que aún no está instalado. Abre Steam una vez con tu sesión iniciada y deja que descargue FEX y el Steam Linux Runtime; después vuelve a iniciarlo. Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está. Archivo @@ -497,7 +499,9 @@ ¿Eliminar este original? Este original de %1$s es de una versión anterior de %2$s. El original de la versión actual siempre se conserva. En las Nightlies · %1$d - Actualiza para listar los paquetes de las Nightlies. + Estable + Nocturna + Actualiza para listar los paquetes de Nightlies y DroidDeck. Nada nuevo desde la última comprobación. Importar .wcp Restaurar el original de este Proton @@ -533,7 +537,7 @@ Solo Adreno. Menos microcortes, más consumo de batería y calor. Correcciones de sesión Turnip: renderizado sysmem - Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722. + Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722. Omitir el asistente xalia de Steam droiddeck-ntsync (experimental) Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. @@ -575,6 +579,16 @@ Página anterior Página siguiente HUD de rendimiento + Tasa de fotogramas + Temperatura de la batería + Gráfico de rendimiento + Color + Menta + Blanco + Celeste + Ámbar + Violeta + Opacidad Modo de escalado Nitidez del escalado Efectos de pantalla @@ -752,6 +766,9 @@ %1$d%% (predeterminada) Tema Apariencia del launcher + Ícono de la app + Oscuro + Claro Usar como pantalla de inicio DroidDeck puede ser la app de inicio DroidDeck nunca se ofrece como app de inicio @@ -1012,6 +1029,7 @@ Esperando la conexión Ethernet por USB de las gafas. Puede que este teléfono no la ofrezca a las apps. La conexión con las XREAL se interrumpió o devolvió una respuesta no compatible. Reintentando… Compatibilidad experimental con las 1S. Usa el modo Follow y desactiva la estabilización en el menú de las gafas. Aún falta probar en el dispositivo las direcciones del seguimiento y los modos de pantalla. + Seguimiento de cabeza experimental para Pro 2. Los ajustes de brillo y volumen y el cambio de modo de pantalla aún no son compatibles. En lugar de View/Select. Animaciones Los sticks aparecen donde tocas y se ocultan al soltar @@ -1097,6 +1115,9 @@ Juego agregado Juego personalizado Almacenamiento de juegos + Elimina sus archivos y la quita de Steam. + ¿Quitar %1$s? + Elimina su acceso directo. Los archivos del juego se conservan. El mejor par para esta GPU, siempre actualizado Elige un par manualmente Buscando controladores… @@ -1448,6 +1469,14 @@ Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o presionado. Azul marino eléctrico Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris. + Degradado + Tus propios colores primario y secundario, combinados en botones y resaltados. + Primario + Secundario + Tono + Saturación + Brillo + Color hexadecimal Esc Insert Supr @@ -1822,7 +1851,8 @@ El original de la versión instalada se conserva: es el camino de vuelta. No guardado Se eliminó el original de %1$s de %2$s. - Las descargas solo proceden de las versiones Nightlies + Las descargas solo proceden de las versiones de Nightlies y DroidDeck + Falló la descarga: es posible que esta compilación nocturna se haya reemplazado por una más nueva. Actualiza la lista e inténtalo de nuevo. Esta lista de paquetes es anterior a las sumas de verificación - actualízala e inténtalo de nuevo La suma de verificación no coincide Lineal @@ -1972,9 +2002,10 @@ Tiendas Ajustes de las tiendas Ajustes + Organizar pestañas + Listo (sin iniciar sesión) Tienda - Instalados Biblioteca Todos Descargas @@ -1986,10 +2017,8 @@ Mantener la sesión Cerrar sesión Buscar en el catálogo de %1$s… - Buscar juegos instalados… Buscar en tu biblioteca… Buscar - Nada instalado Cargando No hay juegos Cargando la tienda… @@ -2008,6 +2037,8 @@ Ver en %1$s Comprar · %1$s Instalado + Instalado + No instalada En la biblioteca Gratis Reanudar @@ -2022,6 +2053,7 @@ Fecha de lanzamiento Vista de lista Vista de tarjetas + Vista de portadas Mostrar contenido para adultos Epic EOS @@ -2043,6 +2075,9 @@ Última sincronización %1$s Aún sin sincronizar %1$d archivos + Ya está al día + Inicia sesión en la tienda para sincronizar las partidas + No se sincronizaron las partidas instalado en tu biblioteca tienda @@ -2182,7 +2217,7 @@ Tamaño Guardar Volver al valor predeterminado - Se dibuja sobre el fondo del juego, igual que en Steam. La posición exacta puede variar unos píxeles. + Este punto exacto puede variar unos pocos píxeles. Este juego aún no tiene logotipo. Elige uno en Logotipo para verlo aquí. No se pudo guardar la posición del logotipo. Inténtalo de nuevo. Fondo @@ -2448,6 +2483,15 @@ Campo de visión de SteamVR Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor. Jugando en %1$s + Recentrar + Menú de SteamVR + Sticks: manos ⇄ juego + Controles: %1$s + Teclado + Menú de sesión + Detener SteamVR + Brillo %1$d / %2$d + Batería %1$d%% · %2$d °C Calibración del seguimiento Reduce la deriva y alinea la referencia horizontal. Estas gafas usan la calibración guardada. @@ -2565,32 +2609,39 @@ Jugar igualmente %d instalado + %d instalados %d instalados %d en la tarjeta SD + %d en la tarjeta SD %d en la tarjeta SD %d herramienta + %d herramientas %d herramientas Carpetas de prefijo vacías y caché de shaders de %d juego + Carpetas de prefijo vacías y cachés de shaders de %d juegos Carpetas de prefijo vacías y cachés de shaders de %d juegos El prefijo de %d juego aún contiene archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. + Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. %d juego + %d juegos %d juegos Proton predeterminado de DroidDeck Opción Proton 11 de DroidDeck Usado por %d juego + Usado por %d juegos Usado por %d juegos Componentes @@ -2634,16 +2685,18 @@ Quita la app (%1$s) y cualquier runtime que no use nada más. Sus datos en tu carpeta personal se quedan. %d paquete + %d paquetes %d paquetes %d app + %d apps %d apps Video de arranque Video DroidDeck · %1$d fps - 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un video de inicio elegido en la configuración de Steam tiene prioridad. + 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un video elegido en la configuración de Steam también aparece aquí. Sonido Tu propio video MP4, MOV, MKV o WebM. Otros formatos y archivos de más de %1$d MB se convierten a WebM 720p en el dispositivo, hasta %2$d segundos. @@ -2657,6 +2710,61 @@ Convirtiendo %1$s para Steam. Mantén DroidDeck abierto. Este dispositivo no puede decodificar ese video. No se pudo importar ese video + ¿Quitar %1$s? + Esto elimina el video de DroidDeck y de la carpeta de videos de Steam. + Instalados + Importar + SteamDeckRepo + Sonido + Videos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Tienda de puntos + Informe de películas de arranque + Guarda en un archivo de texto lo que DroidDeck y Steam saben de las películas de arranque, para adjuntarlo a un informe de error. + Guardar + Guardado en %1$s + No se pudo guardar el informe. + Volumen + Duración + %1$d s + Cuántos segundos del video se reproducen. Todo lo que dure más se corta al final. + Buscar en SteamDeckRepo + Todos los dispositivos + Steam Deck + Steam Machine + Tendencias + Más descargados + Más gustados + Más recientes + No se pudo conectar con SteamDeckRepo + Reintentar + No se encontraron videos + Instalar + Usar + En uso + %1$s instalado + No se pudo descargar ese video + Anterior + Siguiente + Página %1$d + Borrar + Ir a la página + Vista previa + No se pudo reproducir la vista previa + Clave de API + Obtener una clave + Clave rechazada + No está en tu biblioteca de itch.io + Este juego no tiene descarga para Windows + No se encontró un juego de Windows en la descarga + No se pudo extraer la descarga (salida %1$d) + itch.io no respondió + Los archivos %1$s no son compatibles + itch.io respondió HTTP %1$d Cámaras Luma Ultra — Experimental Seguimiento de posición de los Joy-Con Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento. @@ -2830,7 +2938,6 @@ Esperando un seguimiento de cabeza estable; restablece el centro de seguimiento si se solicita. La observación con cámara es demasiado antigua; esperando un par nuevo Ajustar %1$.2f px. Mida la distancia seleccionada desde la cámara izquierda hasta el centro del objetivo de calibración y luego valide. - OpenCV no disponible: %1$s %1$d vistas guardadas restauradas. Calcule la calibración para volver a intentarlo o comience un nuevo conjunto para capturar más. Capturado %1$d / 24 vistas. Mantenga fijo el objetivo de calibración; Mueva las gafas inteligentes para la siguiente vista. Captura rechazada: %1$d / 12 esquinas requeridas visibles en ambas cámaras. Muestre más del objetivo de calibración, quédese quieto y vuelva a intentarlo. diff --git a/app/src/main/res/values-bg/gestures.xml b/app/src/main/res/values-bg/gestures.xml new file mode 100644 index 000000000..7c05b9b60 --- /dev/null +++ b/app/src/main/res/values-bg/gestures.xml @@ -0,0 +1,91 @@ + + + Жестове + Настройки на сензорните жестове + Настройте жестовете за мишка и клавиатура. За да ги използвате, изберете сензорен режим „Жестове“. + Докосване + Плъзгане с един пръст + Продължително натискане + Двойно докосване (двойно ляво щракване) + Докосване с два пръста + Плъзгане с два пръста + Задържане с два пръста + Докосване с три пръста + Плъзгане с три пръста + Задържане с три пръста + Мащабиране със събиране или раздалечаване на пръстите + Задържане на бутона натиснат + Еднократно щракване + Закъснение за задържане + Интервал за двойно докосване + Възстановяване на първоначалните настройки на жестовете + Отказ + Избор на действие + Ляво щракване + Дясно щракване + Средно щракване + Показване на клавиатурата + Ляво щракване и плъзгане + Дясно щракване и плъзгане + Преместване със средния бутон + Преместване със средния бутон (обърнато) + Клавиши със стрелки + Клавиши със стрелки (обърнати) + WASD (обърнато) + Колелце на мишката + Праг на жестовете: %1$d пиксела + Показване на разпознатия жест + Един пръст + Два пръста + Три пръста + Опции + Стандартни настройки + Тази игра + Използване на подразбиращите се + Запазване на промените + Затвори + ДЕЙСТВИЕ + Търсене на действия с мишката или клавиши + Изчистване на търсенето + Всички + Няма съвпадащи действия. Опитайте друго име на клавиш или действие. + Мишка + Действия на приложението + Навигация + Букви + Цифри + Функционални клавиши + Модифициращи клавиши + Цифрова клавиатура + Символи + Движение на мишката + Директно + Относително + Разпознаване на жестове + Визуална обратна връзка + Поведение на бутона на мишката + %1$d мс + Цифрова клавиатура %1$s + Esc + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Стрелка нагоре + Стрелка надолу + Стрелка наляво + Стрелка надясно + Caps Lock + Num Lock + Scroll Lock + Ляв %1$s + Десен %1$s + Shift + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 303feb9f4..f6d820e9e 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -30,9 +30,9 @@ Генериране на кадри Изберете режима на генериране на кадри Изключено - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× - LSFG Adaptive %1$d + LSFG Адаптивно %1$d Изисква Lossless Scaling в Steam Инсталирайте Lossless Scaling в Steam или импортирайте Lossless.dll Импортиране на Lossless.dll… @@ -115,7 +115,7 @@ Изключва проверките за самопроменящ се код. Игри с JIT и DRM може да спрат да работят. Екстремно + TSO Екстремни настройки с включен основен TSO. - Extreme-gn + Екстремно-gn Производителност с TSC и подсказки за метаданни. Пробвайте преди Екстремно. Denuvo Производителност с пълни проверки на кода и скрит hypervisor бит. @@ -176,6 +176,8 @@ %1$s през FEX Тази програма е компилирана за %1$s компютри и се стартира през FEX, който още не е настроен. Отворете Steam веднъж, докато сте влезли: DroidDeck ще изтегли FEX и необходимата Steam Linux Runtime, след което тя ще се отвори. Тази програма + Клиентът на Steam не можа да бъде изтеглен. Мрежата или VPN връзката ви може да блокира сървърите на Steam; проверете връзката и опитайте отново. + Клиентът на Steam не можа да бъде настроен. Проверете връзката и свободното място, след което опитайте отново. %1$s е компилирана за x86 компютри и се стартира през FEX, който още не е инсталиран. Стартирайте Steam веднъж, докато сте влезли, и го оставете да изтегли FEX и Steam Linux Runtime, след което стартирайте отново. ID на приложение в Flathub или страницата му в Flathub. Flatpak се настройва първо, ако още не е. Файл @@ -299,7 +301,7 @@ Инсталирано · %1$s Налична актуализация · %1$s Актуално · %1$s - Loader + Зареждач Инсталиране на последната версия Стартира заедно със Steam. Докато е включено, други приложения на това устройство може да могат да контролират Steam. Изключено. Портът за отдалечено дебъгване на Steam остава затворен. @@ -502,7 +504,9 @@ Изтриване на този оригинал? Този оригинал на %1$s е от по-стара версия на %2$s. Оригиналът на текущата версия винаги се запазва. В Nightlies · %1$d - Обновете, за да видите пакетите в Nightlies. + Стабилна + Нощна + Обновете, за да видите пакетите в Nightlies и DroidDeck. Нищо ново от последната проверка. Импортиране на .wcp Възстановяване на оригинала на този Proton @@ -538,7 +542,7 @@ Само за Adreno. По-малко засечки, по-голямо използване на батерията и повече топлина. Корекции на сесията Turnip: рендиране sysmem - По-бързо за Steam и повечето игри. Задължително на Adreno 710/720/722. + По-бързо за клиента на Steam; в игрите драйверът избира за всеки проход между рендериране на плочки и в системната памет. Задължително на Adreno 710/720/722. Пропускане на помощника xalia на Steam droiddeck-ntsync (експериментално) Заместител на потребителско ниво за драйвера ntsync на Linux, за тестване. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия. @@ -580,6 +584,16 @@ Предишна страница Следваща страница HUD за производителност + Честота на кадрите + Температура на батерията + Графика на производителността + Цвят + Мента + Бяло + Небесно + Кехлибарено + Виолетово + Прозрачност Режим на мащабиране Острота на мащабиране Ефекти на екрана @@ -729,6 +743,9 @@ %1$d%% (по подразбиране) Тема Облик на лаунчъра + Икона на приложението + Тъмна + Светла Използване като начален екран DroidDeck може да бъде приложението за начален екран DroidDeck никога не се предлага като приложение за начален екран @@ -1035,6 +1052,9 @@ Добавена игра Персонализирана игра Хранилище за игри + Изтрива файловете ѝ и я премахва от Steam. + Премахване на %1$s? + Изтрива пряката пътека. Файловете на играта остават. Най-добрата двойка за този GPU, поддържана актуална Изберете двойка сами Проверка за драйвери… @@ -1097,7 +1117,7 @@ Сесията на Linux все още не е готова за терминал. Steam Игри - Desktop + Работен плот Магазин Компоненти Приложения @@ -1384,6 +1404,14 @@ Черен фон, бял текст, синьото за всичко избрано, на фокус или натиснато. Яркосиньо По-яркото синьо, върху повърхности, по-близки до тъмносиньо, вместо сиво. + Градиент + Вашите основен и вторичен цвят, смесени в бутоните и акцентите. + Основен + Вторичен + Нюанс + Наситеност + Яркост + Hex цвят Esc Ins Del @@ -1762,7 +1790,8 @@ Оригиналът на инсталираната версия е запазен: той е пътят назад. Не е запазен Изтрит е оригиналът %1$s на %2$s. - Изтеглянията идват само от версиите Nightlies + Изтеглянията идват само от версиите на Nightlies и DroidDeck + Изтеглянето не бе успешно: тази нощна версия може да е заменена с по-нова. Обновете списъка и опитайте отново. Този списък с пакети е отпреди въвеждането на контролни суми - обнови го и опитай отново Несъответствие в контролната сума @@ -1921,9 +1950,10 @@ Магазини Настройки на магазините Настройки + Подреждане на раздели + Готово (не е вписан) Магазин - Инсталирани Библиотека Всички Изтегляния @@ -1935,10 +1965,8 @@ Остани вписан Изход Търсене в каталога на %1$s… - Търсене в инсталираните игри… Търсене в твоята библиотека… Търсене - Нищо инсталирано Зареждане Няма игри Зареждане на магазина… @@ -1957,7 +1985,19 @@ Виж в %1$s Купи · %1$s Инсталирана + Инсталирана + Не е инсталирано В библиотеката + API ключ + Вземи ключ + Ключът е отказан + Не е в библиотеката ви в itch.io + Няма изтегляне за Windows за тази игра + В изтеглянето не е намерена игра за Windows + Изтеглянето не можа да бъде разархивирано (код %1$d) + itch.io не отговори + Архивите %1$s не се поддържат + itch.io отговори HTTP %1$d Безплатна Продължи Обнови @@ -1971,6 +2011,7 @@ Дата на издаване Изглед на списък Изглед с карти + Изглед с обложки Показвай съдържание за възрастни Epic EOS @@ -2006,6 +2047,9 @@ Последна синхронизация %1$s Още не е синхронизирано %1$d файла + Вече е актуално + Влезте в магазина, за да синхронизирате записите + Записите не бяха синхронизирани инсталирана в библиотеката ти магазин @@ -2137,7 +2181,7 @@ Размер Запази Обратно по подразбиране - Рисува се върху фона на играта, както прави Steam. Точната позиция може да се различава с няколко пиксела. + Точното място може да се различава с няколко пиксела. Тази игра още няма лого. Избери такова в Лого, за да го видиш тук. Позицията на логото не можа да се запази. Опитай отново. Фон @@ -2309,7 +2353,7 @@ Готово Сензорното разположение не можа да се запази Сензорното разположение е запазено в Steam - Приложение %1$d + Приложение %1$s Етикет (незадължително) Иконите на Steam не бяха намерени @@ -2400,6 +2444,15 @@ Зрително поле на SteamVR По-ниско приближава SteamVR и запълва очилата; по-високо показва повече около вас. Играете на %1$s + Центриране отново + Меню на SteamVR + Стикове: ръце ⇄ игра + Контролери: %1$s + Клавиатура + Меню на сесията + Спиране на SteamVR + Яркост %1$d / %2$d + Батерия %1$d%% · %2$d °C Калибриране на проследяването Намалява дрейфа и подравнява хоризонта. За тези очила се прилага запазено калибриране. @@ -2454,6 +2507,7 @@ Изчаква се USB Ethernet връзката на очилата. Този телефон може да не я предоставя на приложенията. Връзката с XREAL беше прекъсната или върна неподдържан отговор. Нов опит… Експериментална поддръжка на 1S. Използвайте режим Follow и изключете стабилизацията в менюто на очилата. Посоките на проследяване и режимите на показване все още трябва да се тестват на устройство. + Експериментално проследяване на движенията на главата за Pro 2. Регулирането на яркостта и силата на звука и превключването на режима на показване все още не се поддържат. Cardboard VR Режим Cardboard Изображения за лявото и дясното око за VR очила за телефон. @@ -2666,7 +2720,7 @@ Начално видео Видео DroidDeck · %1$d кадъра/с - 60 кадъра/с изглежда по-гладко, но натоварва повече процесора, докато Steam се стартира. Начално видео, избрано в настройките на Steam, има предимство. + 60 кадъра/с изглежда по-гладко, но натоварва повече процесора, докато Steam се стартира. Видео, избрано в настройките на Steam, също се показва тук. Звук Собствено видео MP4, MOV, MKV или WebM. Други формати и файлове над %1$d MB се конвертират на устройството до WebM 720p, до %2$d секунди. @@ -2680,6 +2734,51 @@ %1$s се конвертира за Steam. Не затваряйте DroidDeck. Устройството не може да декодира това видео. Видеото не можа да бъде импортирано + Да се премахне ли %1$s? + Това изтрива видеото от DroidDeck и от папката с видеа на Steam. + Инсталирани + Импортиране + SteamDeckRepo + Звук + Инсталирани видеа + Преглед + DroidDeck + Импортирано + SteamDeckRepo + Steam + Магазин за точки + Отчет за стартовите филми + Записва в текстов файл какво знаят DroidDeck и Steam за стартовите филми, за да го прикачите към доклад за грешка. + Запази + Записано в %1$s + Отчетът не можа да бъде записан. + Сила на звука + Продължителност + %1$d с + Колко секунди от филма се възпроизвеждат. Всичко по-дълго се отрязва в края. + Търсене в SteamDeckRepo + Всички устройства + Steam Deck + Steam Machine + Популярни + Най-изтеглени + Най-харесвани + Най-нови + SteamDeckRepo не може да бъде достигнат + Опитайте отново + Не са намерени видеа + Инсталиране + Използване + Използва се + %1$s е инсталирано + Видеото не можа да бъде изтеглено + Назад + Напред + Страница %1$d + Изчисти + Към страница + Преглед + Прегледът не можа да бъде пуснат Luma Ultra камери — Експериментално Проследяване на позицията на Joy-Con Използвайте камерите на интелигентните очила, за да позиционирате сдвоените си контролери Joy-Con. Въртенето и натискането на бутоните идват от контролерите Joy-Con. Позицията се оценява, когато китките ви са скрити. Заменя контролерите, управлявани с голи ръце, и поставя на пауза проследяването с камерата на телефона. Насочете контролерите Joy-Con напред, когато свързвате или нулирате центъра за проследяване. @@ -2853,7 +2952,6 @@ Изчакване на стабилно проследяване на главата; нулиране на центъра за проследяване, ако е поискано. Наблюдението на камерата е твърде старо; в очакване на нов чифт Побиране на %1$.2f пиксела. Измерете избраното разстояние от лявата камера до центъра на целта за калибриране, след което потвърдете. - OpenCV не е наличен: %1$s %1$d запазените изгледи са възстановени. Изчислете калибрирането, за да опитате отново, или започнете нов набор, за да заснемете повече. Заснето %1$d / 24 прегледа. Дръжте целта за калибриране фиксирана; преместете смарт очилата за следващия изглед. Заснемането е отхвърлено: %1$d / 12 задължителни ъгъла, видими и в двете камери. Покажете още от целта за калибриране, задръжте неподвижно и опитайте отново. diff --git a/app/src/main/res/values-cs/gestures.xml b/app/src/main/res/values-cs/gestures.xml new file mode 100644 index 000000000..4de0b6119 --- /dev/null +++ b/app/src/main/res/values-cs/gestures.xml @@ -0,0 +1,91 @@ + + + Gesta + Nastavení dotykových gest + Přizpůsobte gesta myši a klávesnice. Chcete-li je používat, vyberte dotykový režim Gesta. + Klepnutí + Tažení jedním prstem + Dlouhé stisknutí + Dvojité klepnutí (dvojklik levým tlačítkem) + Klepnutí dvěma prsty + Tažení dvěma prsty + Podržení dvěma prsty + Klepnutí třemi prsty + Tažení třemi prsty + Podržení třemi prsty + Přiblížení sevřením či rozevřením prstů + Držet tlačítko stisknuté + Jednou kliknout + Prodleva podržení + Interval dvojitého klepnutí + Obnovit původní nastavení gest + Zrušit + Vybrat akci + Levé kliknutí + Pravé kliknutí + Prostřední kliknutí + Zobrazit klávesnici + Levé kliknutí a tažení + Pravé kliknutí a tažení + Posouvání prostředním tlačítkem myši + Posouvání prostředním tlačítkem myši (obráceně) + Šipky + Šipky (obráceně) + WASD (obráceně) + Kolečko myši + Práh gest: %1$d px + Zobrazovat rozpoznané gesto + Jeden prst + Dva prsty + Tři prsty + Možnosti + Výchozí + Tato hra + Použít výchozí + Uložit změny + Zavřít + AKCE + Hledat akce myši nebo klávesy + Vymazat hledání + Vše + Žádné odpovídající akce. Zkuste jiný název klávesy nebo akce. + Myš + Akce aplikace + Navigace + Písmena + Číslice + Funkční klávesy + Modifikační klávesy + Numerická klávesnice + Symboly + Pohyb myši + Přímé + Relativní + Rozpoznávání gest + Vizuální odezva + Chování tlačítka myši + %1$d ms + Numerická klávesnice %1$s + Esc + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Šipka nahoru + Šipka dolů + Šipka vlevo + Šipka vpravo + Caps Lock + Num Lock + Scroll Lock + Levý %1$s + Pravý %1$s + Shift + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index feb7fdf84..74c7e327f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -30,9 +30,9 @@ Generování snímků Vyberte režim generování snímků Vypnuto - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× - LSFG Adaptive %1$d + LSFG Adaptivní %1$d Vyžaduje Lossless Scaling na Steamu Nainstalujte Lossless Scaling ve Steamu nebo naimportujte Lossless.dll Importovat Lossless.dll… @@ -176,6 +176,8 @@ %1$s přes FEX Tento program je sestaven pro PC %1$s a běží přes FEX, který ještě není nastaven. Jednou spusťte přihlášený Steam: DroidDeck tím stáhne FEX a Steam Linux Runtime, který potřebuje, a pak se tento program otevře. Tento program + Klienta Steam se nepodařilo stáhnout. Vaše síť nebo VPN možná blokuje servery Steamu; zkontrolujte připojení a zkuste to znovu. + Klienta Steam se nepodařilo nastavit. Zkontrolujte připojení a volné místo a zkuste to znovu. %1$s je sestaven pro PC x86 a běží přes FEX, který ještě není nainstalován. Jednou spusťte přihlášený Steam a nechte ho stáhnout FEX a Steam Linux Runtime, pak to spusťte znovu. ID aplikace Flathub nebo její stránka na Flathub. Flatpak se nejprve nastaví, pokud ještě není. Soubor @@ -506,7 +508,9 @@ Smazat tento originál? Tento originál %1$s je z dřívějšího sestavení %2$s. Originál aktuálního sestavení je vždy zachován. Na Nightlies · %1$d - Obnovte pro zobrazení balíčků na Nightlies. + Stabilní + Noční + Obnovte pro zobrazení balíčků na Nightlies a DroidDeck. Od poslední kontroly nic nového. Importovat .wcp Obnovit originál tohoto Protonu @@ -542,7 +546,7 @@ Jen Adreno. Méně zadrhávání, více spotřeby baterie a tepla. Opravy relace Turnip: vykreslování sysmem - Rychlejší pro Steam a většinu her. Vyžadováno na Adreno 710/720/722. + Rychlejší pro klienta Steam; ve hrách ovladač pro každý průchod volí dlaždicové vykreslování, nebo vykreslování v systémové paměti. Vyžadováno na Adreno 710/720/722. Vynechat Steamova pomocníka xalia droiddeck-ntsync (experimentální) Uživatelská náhrada linuxového ovladače ntsync, pro testování. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace. @@ -584,6 +588,16 @@ Předchozí stránka Další stránka HUD výkonu + Snímková frekvence + Teplota baterie + Graf výkonu + Barva + Máta + Bílá + Nebeská + Ambrová + Fialová + Průhlednost Režim škálování Ostrost škálování Efekty obrazovky @@ -735,6 +749,9 @@ %1$d %% (výchozí) Téma Vzhled spouštěče + Ikona aplikace + Tmavá + Světlá Použít jako domovskou obrazovku DroidDeck může být domovská aplikace DroidDeck se nikdy nenabízí jako domovská aplikace @@ -1047,6 +1064,9 @@ Přidaná hra Vlastní hra Úložiště her + Smaže její soubory a odebere ji ze Steamu. + Odebrat %1$s? + Smaže zástupce. Soubory hry zůstanou. Nejlepší dvojice pro tento GPU, udržovaná aktuální Vyberte dvojici sami Hledání ovladačů… @@ -1412,6 +1432,14 @@ Černé pozadí, bílé písmo, modrá pro vše vybrané, zaměřené nebo stisknuté. Elektrická modř Jasnější modrá na povrchách, které jsou spíše tmavomodré než šedé. + Přechod + Vaše vlastní primární a sekundární barva, prolnuté na tlačítkách a zvýrazněních. + Primární + Sekundární + Odstín + Sytost + Jas + Barva hex Esc Ins Del @@ -1804,7 +1832,8 @@ Původní verze nainstalovaného sestavení je zachována: je to cesta zpět. Není uloženo Odstraněna původní verze %2$s z %1$s. - Stahování pochází jen z vydání Nightlies + Stahování pochází jen z vydání Nightlies a DroidDeck + Stažení selhalo: tento noční build mohl být nahrazen novějším. Obnovte seznam a zkuste to znovu. Tento seznam balíčků předchází kontrolním součtům - obnovte ho a zkuste to znovu Neshoda kontrolního součtu @@ -1963,9 +1992,10 @@ Obchody Nastavení obchodů Nastavení + Uspořádat karty + Hotovo (nepřihlášen) Obchod - Nainstalované Knihovna Vše Stahování @@ -1977,10 +2007,8 @@ Zůstat přihlášen Odhlásit se Hledat v katalogu %1$s… - Hledat v nainstalovaných hrách… Hledat ve vaší knihovně… Hledat - Nic není nainstalováno Načítání Žádné hry Načítání obchodu… @@ -1999,7 +2027,19 @@ Zobrazit na %1$s Koupit · %1$s Nainstalováno + Nainstalováno + Není nainstalováno V knihovně + Klíč API + Získat klíč + Klíč odmítnut + Není ve vaší knihovně itch.io + Pro tuto hru není ke stažení verze pro Windows + Ve stahování nebyla nalezena hra pro Windows + Stažený soubor nelze rozbalit (kód %1$d) + itch.io neodpovědělo + Archivy %1$s nejsou podporovány + itch.io odpovědělo HTTP %1$d Zdarma Pokračovat Obnovit @@ -2013,6 +2053,7 @@ Datum vydání Zobrazení seznamu Zobrazení karet + Zobrazení obalů Zobrazovat obsah pro dospělé Epic EOS @@ -2048,6 +2089,9 @@ Poslední synchronizace %1$s Ještě nesynchronizováno %1$d souborů + Už je aktuální + Pro synchronizaci uložených pozic se přihlaste do obchodu + Uložené pozice nebyly synchronizovány nainstalováno ve vaší knihovně obchod @@ -2179,7 +2223,7 @@ Velikost Uložit Zpět na výchozí - Kreslí se přes pozadí hry, jako to dělá Steam. Přesná poloha se může lišit o pár pixelů. + Přesná poloha se může lišit o několik pixelů. Tato hra zatím nemá logo. Vyber ho v Logo, ať se tady zobrazí. Polohu loga se nepodařilo uložit. Zkus to znovu. Pozadí @@ -2355,7 +2399,7 @@ Hotovo Dotykové rozložení se nepodařilo uložit Dotykové rozložení uloženo ve Steamu - Aplikace %1$d + Aplikace %1$s Popisek (nepovinné) Ikony Steamu nebyly nalezeny @@ -2450,6 +2494,15 @@ Zorné pole SteamVR Nižší hodnota přiblíží SteamVR a vyplní brýle; vyšší ukáže víc z okolí. Hraje se na %1$s + Znovu vycentrovat + Nabídka SteamVR + Páčky: ruce ⇄ hra + Ovladače: %1$s + Klávesnice + Nabídka relace + Zastavit SteamVR + Jas %1$d / %2$d + Baterie %1$d%% · %2$d °C Kalibrace sledování Sníží drift a vyrovná vodorovnou referenci. Pro tyto brýle se používá uložená kalibrace. @@ -2504,6 +2557,7 @@ Čeká se na připojení USB Ethernet brýlí. Tento telefon ho možná aplikacím nezpřístupňuje. Připojení XREAL bylo přerušeno nebo vrátilo nepodporovanou odpověď. Zkouší se to znovu… Experimentální podpora 1S. V nabídce brýlí použijte režim Follow a vypněte stabilizaci. Směry sledování a režimy zobrazení je ještě třeba otestovat na zařízení. + Experimentální sledování pohybu hlavy pro Pro 2. Nastavení jasu, hlasitosti a přepínání režimů zobrazení zatím nejsou podporovány. Cardboard VR Režim Cardboard Obraz pro levé a pravé oko pro VR brýle pro telefon. @@ -2734,7 +2788,7 @@ Úvodní video Video DroidDeck · %1$d fps - 60 fps vypadá plynuleji, ale při spouštění Steamu víc zatěžuje procesor. Úvodní video vybrané přímo v nastavení Steamu má přednost. + 60 fps vypadá plynuleji, ale při spouštění Steamu víc zatěžuje procesor. Video vybrané přímo v nastavení Steamu se zde také zobrazí. Zvuk Vlastní video MP4, MOV, MKV nebo WebM. Jiné formáty a soubory nad %1$d MB se v zařízení převedou na WebM 720p, nejvýše %2$d sekund. @@ -2748,6 +2802,51 @@ Převádí se %1$s pro Steam. Nechte DroidDeck otevřený. Toto zařízení video nedokáže dekódovat. Video se nepodařilo importovat + Odebrat %1$s? + Tím se video smaže z DroidDecku i ze složky videí Steamu. + Nainstalované + Importovat + SteamDeckRepo + Zvuk + Nainstalovaná videa + Procházet + DroidDeck + Importované + SteamDeckRepo + Steam + Obchod s body + Zpráva o úvodních videích + Uloží do textového souboru, co DroidDeck a Steam vědí o úvodních videích, aby šel přiložit k hlášení chyby. + Uložit + Uloženo do %1$s + Zprávu se nepodařilo uložit. + Hlasitost + Délka + %1$d s + Kolik sekund filmu se přehraje. Cokoli delšího se na konci odřízne. + Hledat v SteamDeckRepo + Všechna zařízení + Steam Deck + Steam Machine + Populární + Nejstahovanější + Nejoblíbenější + Nejnovější + SteamDeckRepo se nepodařilo kontaktovat + Zkusit znovu + Nenalezena žádná videa + Nainstalovat + Použít + Používá se + %1$s nainstalováno + Video se nepodařilo stáhnout + Předchozí + Další + Strana %1$d + Vymazat + Přejít na stránku + Náhled + Náhled se nepodařilo přehrát Kamery Luma Ultra — Experimentální Sledování polohy Joy-Conů Použijte kamery chytrých brýlí k umístění spárovaných ovladačů Joy-Con. Otáčení a vstupy tlačítek pocházejí z ovladačů Joy-Con. Poloha se odhaduje, když máte skrytá zápěstí. Nahrazuje ovladače ovládané holýma rukama a pozastavuje sledování fotoaparátem telefonu. Při připojování nebo resetování centra sledování namiřte ovladače Joy-Con dopředu. @@ -2921,7 +3020,6 @@ Čekání na stabilní sledování hlavy; resetování centra sledování je možné, pokud je požadováno. Pozorování kamerou je příliš staré; čekání na nový pár Přizpůsobit %1$.2f pixelům. Změřte vybranou vzdálenost od levé kamery ke středu kalibračního cíle a poté potvrďte. - OpenCV není k dispozici: %1$s Bylo obnoveno %1$d uložených zobrazení. Vypočítejte kalibraci a zkuste to znovu, nebo spusťte novou sadu a zachyťte více. Pořízeno %1$d / 24 zobrazení. Udržujte kalibrační cíl pevný; posuňte chytré brýle pro další pohled. Snímek zamítnut: %1$d / 12 požadovaných rohů viditelných v obou kamerách. Ukažte více z kalibračního cíle, nehýbejte se a zkuste to znovu. diff --git a/app/src/main/res/values-da/gestures.xml b/app/src/main/res/values-da/gestures.xml new file mode 100644 index 000000000..fdac523a6 --- /dev/null +++ b/app/src/main/res/values-da/gestures.xml @@ -0,0 +1,91 @@ + + + Bevægelser + Indstillinger for berøringsbevægelser + Tilpas muse- og tastaturbevægelser. Vælg Bevægelser som berøringstilstand for at bruge dem. + Tryk + Træk med én finger + Langt tryk + Dobbelttryk (dobbelt venstreklik) + Tryk med to fingre + Træk med to fingre + Hold med to fingre + Tryk med tre fingre + Træk med tre fingre + Hold med tre fingre + Knib for at zoome + Hold knappen nede + Klik én gang + Forsinkelse før hold + Interval for dobbelttryk + Gendan de oprindelige bevægelsesindstillinger + Annuller + Vælg handling + Venstreklik + Højreklik + Midterklik + Vis tastatur + Venstreklik og træk + Højreklik og træk + Panorering med midterste museknap + Panorering med midterste museknap (omvendt) + Piletaster + Piletaster (omvendt) + WASD (omvendt) + Musehjul + Bevægelsestærskel: %1$d px + Vis genkendt bevægelse + Én finger + To fingre + Tre fingre + Indstillinger + Standarder + Dette spil + Brug standardindstillinger + Gem ændringer + Luk + HANDLING + Søg efter musehandlinger eller tastaturtaster + Ryd søgning + Alle + Ingen matchende handlinger. Prøv et andet navn på en tast eller handling. + Mus + Apphandlinger + Navigation + Bogstaver + Tal + Funktionstaster + Modifikationstaster + Numerisk tastatur + Symboler + Musebevægelse + Direkte + Relativ + Genkendelse af bevægelser + Visuel feedback + Museknappens funktion + %1$d ms + Numerisk tastatur %1$s + Escape + Tilbage + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pil op + Pil ned + Pil til venstre + Pil til højre + Caps Lock + Num Lock + Scroll Lock + Venstre %1$s + Højre %1$s + Skift + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6186aa6a6..062bcb4b5 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -30,7 +30,7 @@ Billedgenerering Vælg metode til billedgenerering Fra - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Kræver Lossless Scaling på Steam @@ -176,6 +176,8 @@ %1$s via FEX Dette program er bygget til %1$s-pc’er og køres via FEX, som ikke er sat op endnu. Åbn Steam én gang, mens du er logget ind: DroidDeck downloader så FEX og den Steam Linux Runtime, det kræver, hvorefter dette åbner. Dette program + Steam-klienten kunne ikke downloades. Dit netværk eller din VPN blokerer muligvis Steams servere; tjek forbindelsen, og prøv igen. + Steam-klienten kunne ikke konfigureres. Tjek forbindelsen og den ledige lagerplads, og prøv igen. %1$s er bygget til x86-pc’er og køres via FEX, som ikke er installeret endnu. Start Steam én gang, mens du er logget ind, og lad det downloade FEX og Steam Linux Runtime, og start så dette igen. Et Flathub-app-id eller dens Flathub-side. Flatpak sættes op først, hvis det ikke allerede er. Fil @@ -502,7 +504,9 @@ Slet denne original? Denne original af %1$s er fra en tidligere build af %2$s. Den aktuelle builds original bevares altid. På Nightlies · %1$d - Opdater for at se pakker på Nightlies. + Stabil + Nightly + Opdater for at se pakker på Nightlies og DroidDeck. Intet nyt siden sidste kontrol. Importer .wcp Gendan denne Protons original @@ -527,7 +531,7 @@ Klientgrænseflade Threaded GL Kan gøre Steam-menuer mere lydhøre. - Zink: lazy descriptors + Zink: dovne deskriptorer Anbefalet til drivere uden descriptor buffers. Spring GL-fejlkontrol over Slår validering af GL fra for hvert kald. @@ -538,7 +542,7 @@ Kun Adreno. Færre hak, mere batteribrug og varme. Sessionsrettelser Turnip: sysmem-rendering - Hurtigere til Steam og de fleste spil. Påkrævet på Adreno 710/720/722. + Hurtigere til Steam-klienten; i spil vælger driveren flise- eller systemhukommelses-rendering for hvert pass. Påkrævet på Adreno 710/720/722. Spring Steams xalia-hjælper over droiddeck-ntsync (eksperimentel) En stand-in til ntsync-driveren i Linux-kernen, i brugerrum, til test. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session. @@ -549,7 +553,7 @@ Deaktiverer Protons gamepad-navigationshjælper, som koster CPU i hvert spil. Kør proot uden seccomp Kan afhjælpe fejl om manglende systemkald på nogle kerner. Kan reducere ydelsen. - proot fast path + proot-hurtigvej Kræver prootets seccomp-filter. Hurtigere filopslag og mindre hak. Slå fra, hvis et program ikke kan finde sine filer. Sessionsidentitet @@ -580,6 +584,16 @@ Forrige side Næste side Ydelses-HUD + Billedhastighed + Batteritemperatur + Ydelsesgraf + Farve + Mint + Hvid + Himmelblå + Ravgul + Violet + Gennemsigtighed Skaleringstilstand Skaleringsskarphed Skærmeffekter @@ -729,6 +743,9 @@ %1$d%% (standard) Tema Launcherens udseende + Appikon + Mørk + Lys Brug som startskærm DroidDeck kan være Home-appen DroidDeck tilbydes aldrig som Home-app @@ -942,7 +959,7 @@ 100% er standardstørrelsen Stick-klik Dobbelttryk og hold en stick nede for L3 eller R3 - Adaptive sticks + Adaptive styrepinde Spilvibration Steam-knap QAM-knap @@ -1035,6 +1052,9 @@ Tilføjet spil Brugerdefineret spil Spillager + Sletter dets filer og fjerner det fra Steam. + Fjern %1$s? + Sletter genvejen. Spillets filer bliver. Det bedste par til denne GPU, holdt opdateret Vælg et par selv Søger efter drivere… @@ -1384,6 +1404,14 @@ Sort baggrund, hvid skrift, blå til alt, der er valgt, fokuseret eller trykket. Elektrisk marineblå Den blå er klarere, på overflader der trækker mod marineblåt i stedet for gråt. + Gradient + Dine egne primære og sekundære farver, blandet på knapper og fremhævninger. + Primær + Sekundær + Farvetone + Mætning + Lysstyrke + Hex-farve Esc Ins Del @@ -1762,7 +1790,8 @@ Originalen af den installerede version beholdes: den er vejen tilbage. Ikke gemt Slettede %1$s-originalen af %2$s. - Downloads kommer kun fra Nightlies-udgivelserne + Downloads kommer kun fra Nightlies- og DroidDeck-udgivelserne + Download mislykkedes: denne nightly kan være erstattet af en nyere build. Opdater listen, og prøv igen. Denne pakkeliste er ældre end tjeksummer - opdater den, og forsøg igen Tjeksum stemmer ikke @@ -1921,9 +1950,10 @@ Butikker Butiksindstillinger Indstillinger + Arranger faner + Færdig (ikke logget ind) Butik - Installeret Bibliotek Alle Downloads @@ -1935,10 +1965,8 @@ Bliv logget ind Log ud Søg i %1$s-kataloget… - Søg i installerede spil… Søg i dit bibliotek… Søg - Intet installeret Indlæser Ingen spil Indlæser butikken… @@ -1957,7 +1985,19 @@ Se på %1$s Køb · %1$s Installeret + Installeret + Ikke installeret I biblioteket + API-nøgle + Hent en nøgle + Nøgle afvist + Ikke i dit itch.io-bibliotek + Ingen Windows-download til dette spil + Der blev ikke fundet et Windows-spil i downloaden + Downloaden kunne ikke udpakkes (kode %1$d) + itch.io svarede ikke + %1$s-arkiver understøttes ikke + itch.io svarede HTTP %1$d Gratis Genoptag Opdater @@ -1971,6 +2011,7 @@ Udgivelsesdato Listevisning Kortvisning + Omslagsvisning Vis indhold for voksne Epic EOS @@ -2006,6 +2047,9 @@ Senest synkroniseret %1$s Ikke synkroniseret endnu %1$d filer + Allerede opdateret + Log ind i butikken for at synkronisere gemte spil + Gemte spil blev ikke synkroniseret installeret i dit bibliotek butik @@ -2137,7 +2181,7 @@ Størrelse Gem Tilbage til standard - Tegnes oven på spillets baggrund, som Steam gør. Den præcise placering kan afvige et par pixels. + Præcis placering kan afvige med nogle få pixel. Dette spil har endnu ikke et logo. Vælg et under Logo for at se det her. Logopladeringen kunne ikke gemmes. Prøv igen. Baggrund @@ -2309,7 +2353,7 @@ Udført Berøringslayoutet kunne ikke gemmes Berøringslayout gemt i Steam - App %1$d + App %1$s Etiket (valgfrit) Steams ikoner blev ikke fundet @@ -2400,6 +2444,15 @@ SteamVR-synsfelt Lavere bringer SteamVR tættere på og fylder brillen; højere viser mere omkring dig. Spiller på %1$s + Centrér igen + SteamVR-menu + Pinde: hænder ⇄ spil + Controllere: %1$s + Tastatur + Sessionsmenu + Stop SteamVR + Lysstyrke %1$d / %2$d + Batteri %1$d%% · %2$d °C Sporingskalibrering Mindsker drift og justerer den vandrette reference. Gemt kalibrering anvendes på denne brille. @@ -2454,6 +2507,7 @@ Venter på brillens USB-Ethernet-forbindelse. Denne telefon gør den muligvis ikke tilgængelig for apps. XREAL-forbindelsen blev afbrudt eller returnerede et svar, der ikke understøttes. Prøver igen… Eksperimentel understøttelse af 1S. Brug Follow-tilstand, og slå stabilisering fra i brillens menu. Sporingsretninger og visningstilstande mangler stadig test på enheden. + Eksperimentel hovedsporing til Pro 2. Justering af lysstyrke og lydstyrke samt skift af visningstilstand understøttes endnu ikke. Cardboard VR Cardboard-tilstand Billeder til venstre og højre øje til en VR-brille til telefonen. @@ -2666,7 +2720,7 @@ Opstartsvideo Video DroidDeck · %1$d fps - 60 fps ser mere flydende ud, men bruger mere CPU, mens Steam starter. En opstartsvideo valgt i Steams egne indstillinger har forrang. + 60 fps ser mere flydende ud, men bruger mere CPU, mens Steam starter. En video valgt i Steams egne indstillinger vises også her. Lyd Din egen video MP4, MOV, MKV eller WebM. Andre formater og filer over %1$d MB konverteres til 720p-WebM på enheden, op til %2$d sekunder. @@ -2680,6 +2734,51 @@ Konverterer %1$s til Steam. Hold DroidDeck åben. Enheden kan ikke afkode videoen. Kunne ikke importere videoen + Fjern %1$s? + Dette sletter videoen fra DroidDeck og fra Steams videomappe. + Installeret + Importér + SteamDeckRepo + Lyd + Installerede videoer + Gennemse + DroidDeck + Importeret + SteamDeckRepo + Steam + Pointbutik + Rapport om startfilm + Gemmer, hvad DroidDeck og Steam ved om startfilm, i en tekstfil, som du kan vedhæfte en fejlrapport. + Gem + Gemt i %1$s + Rapporten kunne ikke gemmes. + Lydstyrke + Længde + %1$d s + Hvor mange sekunder af filmen der afspilles. Alt, hvad der er længere, klippes af til sidst. + Søg i SteamDeckRepo + Alle enheder + Steam Deck + Steam Machine + Populære + Mest downloadet + Mest likede + Nyeste + Kunne ikke få forbindelse til SteamDeckRepo + Prøv igen + Ingen videoer fundet + Installér + Brug + I brug + %1$s installeret + Kunne ikke downloade videoen + Forrige + Næste + Side %1$d + Ryd + Gå til side + Forhåndsvis + Forhåndsvisningen kunne ikke afspilles Luma Ultra-kameraer — Eksperimentel Joy-Con-positionssporing Brug smartglass-kameraerne til at placere dine parrede Joy-Con-controllere. Rotation og knapinput kommer fra Joy-Con-controllerne. Positionen estimeres, når dine håndled er skjulte. Erstatter controllere, der betjenes med bare hænder, og sætter telefonkameraets sporing på pause. Ret Joy-Con-controllerne fremad, når du tilslutter eller nulstiller sporingscentret. @@ -2853,7 +2952,6 @@ Venter på stabil hovedsporing; nulstil sporingscenter, hvis det anmodes om. Kameraobservation er for gammel; venter på et nyt par Tilpas %1$.2f px. Mål din valgte afstand fra venstre kamera til kalibreringsmålets centrum, og valider derefter. - OpenCV utilgængelig: %1$s %1$d gemte visninger gendannet. Beregn kalibrering for at prøve igen, eller start et nyt sæt for at fange mere. Optaget %1$d / 24 visninger. Hold kalibreringsmålet fast; flyt de smarte briller til næste visning. Optagelse afvist: %1$d / 12 påkrævede hjørner synlige i begge kameraer. Vis mere af kalibreringsmålet, hold stille, og prøv derefter igen. diff --git a/app/src/main/res/values-de/gestures.xml b/app/src/main/res/values-de/gestures.xml new file mode 100644 index 000000000..47532cd74 --- /dev/null +++ b/app/src/main/res/values-de/gestures.xml @@ -0,0 +1,91 @@ + + + Gesten + Touchgesten-Einstellungen + Maus- und Tastaturgesten anpassen. Wähle Gesten als Touchmodus, um sie zu verwenden. + Tippen + Mit einem Finger ziehen + Lange drücken + Doppeltippen (doppelter Linksklick) + Mit zwei Fingern tippen + Mit zwei Fingern ziehen + Mit zwei Fingern halten + Mit drei Fingern tippen + Mit drei Fingern ziehen + Mit drei Fingern halten + Zum Zoomen Finger zusammen- oder auseinanderziehen + Taste gedrückt halten + Einmal klicken + Halteverzögerung + Doppeltipp-Intervall + Ursprüngliche Gesteneinstellungen wiederherstellen + Abbrechen + Aktion auswählen + Linksklick + Rechtsklick + Mittelklick + Tastatur einblenden + Linksklick und ziehen + Rechtsklick und ziehen + Mit mittlerer Maustaste schwenken + Mit mittlerer Maustaste schwenken (umgekehrt) + Pfeiltasten + Pfeiltasten (umgekehrt) + WASD (umgekehrt) + Mausrad + Gestenschwelle: %1$d px + Erkannte Geste anzeigen + Ein Finger + Zwei Finger + Drei Finger + Optionen + Standardwerte + Dieses Spiel + Standardeinstellungen verwenden + Änderungen speichern + Schließen + AKTION + Mausaktionen oder Tastaturtasten suchen + Suche leeren + Alle + Keine passenden Aktionen. Versuche einen anderen Tasten- oder Aktionsnamen. + Maus + App-Aktionen + Navigation + Buchstaben + Zahlen + Funktionstasten + Modifikatortasten + Ziffernblock + Symbole + Mausbewegung + Direkt + Relativ + Gestenerkennung + Visuelle Rückmeldung + Verhalten der Maustaste + %1$d ms + Ziffernblock %1$s + Esc + Rücktaste + Eingabetaste + Tab + Leertaste + Entf + Einfg + Pos1 + Ende + Bild auf + Bild ab + Druck + Pfeil nach oben + Pfeil nach unten + Pfeil nach links + Pfeil nach rechts + Feststelltaste + Num-Feststelltaste + Rollen-Feststelltaste + Linke %1$s + Rechte %1$s + Umschalt + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dfaa30fed..d46e0c575 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -30,7 +30,7 @@ Frame-Generierung Modus der Frame-Generierung auswählen Aus - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Benötigt Lossless Scaling auf Steam @@ -176,6 +176,8 @@ %1$s über FEX Dieses Programm ist für %1$s-PCs gebaut und läuft über FEX, das noch nicht eingerichtet ist. Öffne Steam einmal, während du angemeldet bist: DroidDeck lässt es FEX und die nötige Steam Linux Runtime herunterladen, danach lässt sich das Programm öffnen. Dieses Programm + Der Steam-Client konnte nicht heruntergeladen werden. Dein Netzwerk oder VPN blockiert möglicherweise die Server von Steam; prüfe die Verbindung und versuche es erneut. + Der Steam-Client konnte nicht eingerichtet werden. Prüfe die Verbindung und den freien Speicher und versuche es erneut. %1$s ist für x86-PCs gebaut und läuft über FEX, das noch nicht installiert ist. Starte Steam einmal, während du angemeldet bist, und lass es FEX und die Steam Linux Runtime herunterladen. Starte es danach erneut. Eine Flathub-App-ID oder ihre Flathub-Seite. Flatpak wird zuerst eingerichtet, falls noch nicht geschehen. Datei @@ -499,7 +501,9 @@ Dieses Original löschen? Dieses %1$s-Original stammt aus einem früheren Build von %2$s. Das Original des aktuellen Builds wird immer behalten. In den Nightlies · %1$d - Aktualisiere, um die Pakete in den Nightlies aufzulisten. + Stabil + Nightly + Aktualisiere, um die Pakete in den Nightlies und bei DroidDeck aufzulisten. Nichts Neues seit der letzten Prüfung. .wcp importieren Original dieses Protons wiederherstellen @@ -535,7 +539,7 @@ Nur Adreno. Weniger Ruckler, mehr Akkuverbrauch und Wärme. Sitzungskorrekturen Turnip: Sysmem-Rendering - Schneller für Steam und die meisten Spiele. Erforderlich bei Adreno 710/720/722. + Schneller für den Steam-Client; in Spielen wählt der Treiber pro Durchgang zwischen Kachel- und Systemspeicher-Rendering. Erforderlich bei Adreno 710/720/722. Steams xalia-Helfer überspringen droiddeck-ntsync (experimentell) Ein Userspace-Ersatz für den Linux-Treiber ntsync, zum Testen. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung. @@ -577,6 +581,16 @@ Vorherige Seite Nächste Seite Leistungs-HUD + Bildrate + Akkutemperatur + Leistungsdiagramm + Farbe + Minze + Weiß + Himmelblau + Bernstein + Violett + Deckkraft Skalierungsmodus Skalierungsschärfe Bildschirmeffekte @@ -718,6 +732,9 @@ %1$d %% (Standard) Design Aussehen des Launchers + App-Symbol + Dunkel + Hell Als Startbildschirm verwenden DroidDeck kann die Startbildschirm-App sein DroidDeck wird nie als Startbildschirm-App angeboten @@ -1024,6 +1041,9 @@ Hinzugefügtes Spiel Eigenes Spiel Spielspeicher + Löscht seine Dateien und entfernt es aus Steam. + %1$s entfernen? + Löscht die Verknüpfung. Die Spieldateien bleiben. Das beste Paar für diese GPU, stets aktuell Wähle selbst ein Paar Suche nach Treibern… @@ -1373,6 +1393,14 @@ Schwarzer Grund, weiße Schrift, das Blau für alles Ausgewählte, Fokussierte oder Gedrückte. Elektrisches Marineblau Das Blau heller, auf Flächen, die eher zu Marineblau als zu Grau tendieren. + Farbverlauf + Eigene Primär- und Sekundärfarbe, als Verlauf auf Schaltflächen und Hervorhebungen. + Primär + Sekundär + Farbton + Sättigung + Helligkeit + Hex-Farbe Esc Einfg Entf @@ -1751,7 +1779,8 @@ Das Original des installierten Builds bleibt erhalten: Es ist der Weg zurück. Nicht gespeichert Das %1$s-Original von %2$s wurde gelöscht. - Downloads kommen nur aus den Nightlies-Releases + Downloads kommen nur aus den Nightlies- und DroidDeck-Releases + Download fehlgeschlagen: Dieser Nightly-Build wurde vielleicht durch einen neueren ersetzt. Aktualisiere die Liste und versuche es erneut. Diese Paketliste stammt aus der Zeit vor Prüfsummen - aktualisiere sie und versuche es erneut Prüfsumme stimmt nicht überein @@ -1910,9 +1939,10 @@ Stores Store-Einstellungen Einstellungen + Tabs anordnen + Fertig (nicht angemeldet) Store - Installiert Bibliothek Alle Downloads @@ -1924,10 +1954,8 @@ Angemeldet bleiben Abmelden Katalog von %1$s durchsuchen… - Installierte Spiele durchsuchen… Deine Bibliothek durchsuchen… Suchen - Nichts installiert Wird geladen Keine Spiele Store wird geladen… @@ -1946,7 +1974,19 @@ Auf %1$s ansehen Kaufen · %1$s Installiert + Installiert + Nicht installiert In der Bibliothek + API-Schlüssel + Schlüssel holen + Schlüssel abgelehnt + Nicht in deiner itch.io-Bibliothek + Kein Windows-Download für dieses Spiel + Im Download wurde kein Windows-Spiel gefunden + Download konnte nicht entpackt werden (Code %1$d) + itch.io hat nicht geantwortet + %1$s-Archive werden nicht unterstützt + itch.io antwortete mit HTTP %1$d Kostenlos Fortsetzen Aktualisieren @@ -1960,6 +2000,7 @@ Erscheinungsdatum Listenansicht Kartenansicht + Coveransicht Inhalte für Erwachsene anzeigen Epic EOS @@ -1995,6 +2036,9 @@ Zuletzt synchronisiert %1$s Noch nicht synchronisiert %1$d Dateien + Bereits aktuell + Im Store anmelden, um Spielstände zu synchronisieren + Spielstände wurden nicht synchronisiert installiert in deiner Bibliothek Store @@ -2123,7 +2167,7 @@ Größe Speichern Auf Standard zurücksetzen - Wird wie bei Steam über dem Spielehintergrund gezeichnet. Die Position kann um einige Pixel abweichen. + Die genaue Position kann um einige Pixel abweichen. Für dieses Spiel gibt es noch kein Logo. Wähle unter Logo eines aus, um es hier zu sehen. Die Logoposition ließ sich nicht speichern. Versuche es erneut. Hintergrund @@ -2295,7 +2339,7 @@ Fertig Das Touch-Layout konnte nicht gespeichert werden Touch-Layout in Steam gespeichert - App %1$d + App %1$s Bezeichnung (optional) Steams Symbole wurden nicht gefunden @@ -2386,6 +2430,15 @@ SteamVR-Sichtfeld Niedriger holt SteamVR näher heran und füllt die Brille; höher zeigt mehr von deiner Umgebung. Spiel läuft auf %1$s + Neu zentrieren + SteamVR-Menü + Sticks: Hände ⇄ Spiel + Controller: %1$s + Tastatur + Sitzungsmenü + SteamVR beenden + Helligkeit %1$d / %2$d + Akku %1$d%% · %2$d °C Tracking-Kalibrierung Verringert Drift und richtet die waagerechte Referenz aus. Für diese Brille wird eine gespeicherte Kalibrierung verwendet. @@ -2440,6 +2493,7 @@ Warte auf die USB-Ethernet-Verbindung der Brille. Dieses Smartphone stellt sie Apps möglicherweise nicht bereit. Die XREAL-Verbindung wurde unterbrochen oder hat eine nicht unterstützte Antwort geliefert. Neuer Versuch … Experimentelle Unterstützung für die 1S. Nutze den Follow-Modus und deaktiviere die Stabilisierung im Menü der Brille. Tracking-Richtungen und Anzeigemodi müssen noch auf Geräten getestet werden. + Experimentelles Kopftracking für Pro 2. Das Einstellen von Helligkeit und Lautstärke sowie das Umschalten des Anzeigemodus werden noch nicht unterstützt. Cardboard VR Cardboard-Modus Bilder für linkes und rechtes Auge für eine Smartphone-VR-Brille. @@ -2666,7 +2720,7 @@ Bootvideo Video DroidDeck · %1$d fps - 60 fps wirkt flüssiger, braucht aber mehr CPU, während Steam startet. Ein in Steam selbst gewähltes Startvideo hat Vorrang. + 60 fps wirkt flüssiger, braucht aber mehr CPU, während Steam startet. Ein in Steam selbst gewähltes Video erscheint hier ebenfalls. Ton Eigenes Video MP4, MOV, MKV oder WebM. Andere Formate und Dateien über %1$d MB werden auf dem Gerät in 720p-WebM umgewandelt, bis %2$d Sekunden. @@ -2680,6 +2734,51 @@ %1$s wird für Steam umgewandelt. DroidDeck geöffnet lassen. Dieses Gerät kann das Video nicht dekodieren. Das Video konnte nicht importiert werden + %1$s entfernen? + Dadurch wird das Video aus DroidDeck und aus dem Videoordner von Steam gelöscht. + Installiert + Importieren + SteamDeckRepo + Ton + Installierte Videos + Durchsuchen + DroidDeck + Importiert + SteamDeckRepo + Steam + Punkteshop + Bericht zu Startfilmen + Speichert in einer Textdatei, was DroidDeck und Steam über Startfilme wissen, zum Anhängen an einen Fehlerbericht. + Speichern + Gespeichert unter %1$s + Der Bericht konnte nicht gespeichert werden. + Lautstärke + Länge + %1$d s + Wie viele Sekunden des Films abgespielt werden. Alles, was länger ist, wird am Ende abgeschnitten. + SteamDeckRepo durchsuchen + Alle Geräte + Steam Deck + Steam Machine + Im Trend + Meistgeladen + Beliebteste + Neueste + SteamDeckRepo ist nicht erreichbar + Erneut versuchen + Keine Videos gefunden + Installieren + Verwenden + In Verwendung + %1$s installiert + Das Video konnte nicht heruntergeladen werden + Zurück + Weiter + Seite %1$d + Leeren + Zur Seite + Vorschau + Die Vorschau konnte nicht abgespielt werden Luma Ultra-Kameras – Experimentell Joy-Con-Positionsverfolgung Positionieren Sie Ihre gekoppelten Joy-Con-Controller mithilfe der Kameras Ihrer Smartbrille. Drehung und Tasteneingaben erfolgen über die Joy-Con-Controller. Die Position wird geschätzt, wenn Ihre Handgelenke verdeckt sind. Ersetzt Controller, die mit bloßen Händen bedient werden, und pausiert die Verfolgung per Smartphone-Kamera. Richten Sie die Joy-Con-Controller nach vorn, wenn Sie die Verbindung herstellen oder das Tracking-Center zurücksetzen. @@ -2853,7 +2952,6 @@ Warten auf stabiles Head-Tracking; Tracking-Zentrum bei Bedarf zurücksetzen. Kamerabeobachtung ist zu alt; warte auf ein neues Paar %1$.2f px anpassen. Messen Sie den ausgewählten Abstand von der linken Kamera zum Zentrum des Kalibrierungsziels und bestätigen Sie ihn anschließend. - OpenCV nicht verfügbar: %1$s %1$d gespeicherte Ansichten wiederhergestellt. Berechnen Sie die Kalibrierung, um es erneut zu versuchen, oder starten Sie einen neuen Satz, um mehr zu erfassen. Aufgenommen %1$d / 24 Aufrufe. Halten Sie das Kalibrierungsziel fest; Bewegen Sie die Datenbrille für die nächste Ansicht. Aufnahme abgelehnt: %1$d / 12 erforderliche Ecken in beiden Kameras sichtbar. Zeigen Sie mehr vom Kalibrierungsziel, halten Sie still und versuchen Sie es dann erneut. diff --git a/app/src/main/res/values-el/gestures.xml b/app/src/main/res/values-el/gestures.xml new file mode 100644 index 000000000..22d20ba93 --- /dev/null +++ b/app/src/main/res/values-el/gestures.xml @@ -0,0 +1,91 @@ + + + Χειρονομίες + Ρυθμίσεις χειρονομιών αφής + Προσαρμόστε τις χειρονομίες ποντικιού και πληκτρολογίου. Επιλέξτε Χειρονομίες ως λειτουργία αφής για να τις χρησιμοποιήσετε. + Πάτημα + Σύρσιμο με ένα δάχτυλο + Παρατεταμένο πάτημα + Διπλό πάτημα (διπλό αριστερό κλικ) + Πάτημα με δύο δάχτυλα + Σύρσιμο με δύο δάχτυλα + Κράτημα με δύο δάχτυλα + Πάτημα με τρία δάχτυλα + Σύρσιμο με τρία δάχτυλα + Κράτημα με τρία δάχτυλα + Μεγέθυνση με τσίμπημα + Διατήρηση του κουμπιού πατημένου + Ένα κλικ + Καθυστέρηση κρατήματος + Διάστημα διπλού πατήματος + Επαναφορά αρχικών ρυθμίσεων χειρονομιών + Ακύρωση + Επιλογή ενέργειας + Αριστερό κλικ + Δεξί κλικ + Μεσαίο κλικ + Εμφάνιση πληκτρολογίου + Αριστερό κλικ και σύρσιμο + Δεξί κλικ και σύρσιμο + Μετατόπιση με το μεσαίο κουμπί + Μετατόπιση με το μεσαίο κουμπί (αντίστροφη) + Πλήκτρα βέλους + Πλήκτρα βέλους (αντίστροφα) + WASD (αντίστροφα) + Ροδέλα ποντικιού + Όριο χειρονομιών: %1$d px + Εμφάνιση αναγνωρισμένης χειρονομίας + Ένα δάχτυλο + Δύο δάχτυλα + Τρία δάχτυλα + Επιλογές + Προεπιλογές + Αυτό το παιχνίδι + Χρήση προεπιλογών + Αποθήκευση αλλαγών + Κλείσιμο + ΕΝΕΡΓΕΙΑ + Αναζήτηση ενεργειών ποντικιού ή πλήκτρων + Εκκαθάριση αναζήτησης + Όλα + Δεν βρέθηκαν ενέργειες. Δοκιμάστε άλλο όνομα πλήκτρου ή ενέργειας. + Ποντίκι + Ενέργειες εφαρμογής + Πλοήγηση + Γράμματα + Αριθμοί + Πλήκτρα λειτουργιών + Πλήκτρα τροποποίησης + Αριθμητικό πληκτρολόγιο + Σύμβολα + Κίνηση ποντικιού + Απευθείας + Σχετική + Αναγνώριση χειρονομιών + Οπτική ανάδραση + Συμπεριφορά κουμπιού ποντικιού + %1$d ms + Αριθμητικό πληκτρολόγιο %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Βέλος πάνω + Βέλος κάτω + Βέλος αριστερά + Βέλος δεξιά + Caps Lock + Num Lock + Scroll Lock + Αριστερό %1$s + Δεξί %1$s + Shift + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index bb3ed7830..dd6458776 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -30,7 +30,7 @@ Δημιουργία καρέ Επιλέξτε τη λειτουργία δημιουργίας καρέ Ανενεργό - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG προσαρμοστικό %1$d Απαιτεί το Lossless Scaling στο Steam @@ -115,7 +115,7 @@ Απενεργοποιεί τους ελέγχους αυτο-τροποποιούμενου κώδικα. Παιχνίδια με JIT και DRM μπορεί να πάθουν βλάβη. Ακραίο + TSO Ρυθμίσεις Ακραίο με το κύριο TSO ενεργοποιημένο. - Extreme-gn + Ακραίο-gn Απόδοση με υποδείξεις TSC και μεταδεδομένων. Δοκιμάστε το πριν το Ακραίο. Denuvo Απόδοση με πλήρεις ελέγχους κώδικα και το bit υπερεπόπτη κρυμμένο. @@ -176,6 +176,8 @@ %1$s μέσω FEX Αυτό το πρόγραμμα είναι χτισμένο για υπολογιστές %1$s και εκτελείται μέσω FEX, το οποίο δεν έχει ρυθμιστεί ακόμα. Ανοίξτε το Steam μία φορά ενώ είστε συνδεδεμένοι: το DroidDeck κάνει λήψη του FEX και του Steam Linux Runtime που χρειάζεται, και μετά αυτό ανοίγει. Αυτό το πρόγραμμα + Δεν ήταν δυνατή η λήψη του πελάτη Steam. Το δίκτυο ή το VPN σας ίσως αποκλείει τους διακομιστές του Steam· ελέγξτε τη σύνδεση και δοκιμάστε ξανά. + Δεν ήταν δυνατή η ρύθμιση του πελάτη Steam. Ελέγξτε τη σύνδεση και τον ελεύθερο χώρο αποθήκευσης και δοκιμάστε ξανά. Το %1$s είναι χτισμένο για υπολογιστές x86 και εκτελείται μέσω FEX, το οποίο δεν έχει εγκατασταθεί ακόμα. Εκκινήστε το Steam μία φορά ενώ είστε συνδεδεμένοι και αφήστε το να κάνει λήψη του FEX και του Steam Linux Runtime, μετά εκκινήστε αυτό ξανά. Ένα ID εφαρμογής Flathub ή η σελίδα της στο Flathub. Το Flatpak ρυθμίζεται πρώτα, αν δεν έχει ρυθμιστεί ακόμα. Αρχείο @@ -299,7 +301,7 @@ Εγκατεστημένο · %1$s Διαθέσιμη ενημέρωση · %1$s Ενημερωμένο · %1$s - Loader + Φορτωτής Εγκατάσταση τελευταίας έκδοσης Ξεκινά με το Steam. Όσο είναι ενεργό, άλλες εφαρμογές σε αυτή τη συσκευή μπορεί να είναι σε θέση να ελέγξουν το Steam. Ανενεργό. Η θύρα απομακρυσμένου εντοπισμού σφαλμάτων του Steam παραμένει κλειστή. @@ -502,7 +504,9 @@ Διαγραφή αυτού του πρωτοτύπου; Αυτό το πρωτότυπο %1$s είναι από προηγούμενο build του %2$s. Το πρωτότυπο του τρέχοντος build διατηρείται πάντα. Στα Nightlies · %1$d - Ανανεώστε για να εμφανιστούν τα πακέτα στα Nightlies. + Σταθερή + Νυχτερινή + Ανανεώστε για να εμφανιστούν τα πακέτα στα Nightlies και στο DroidDeck. Τίποτα νέο από τον τελευταίο έλεγχο. Εισαγωγή .wcp Επαναφορά του πρωτοτύπου αυτού του Proton @@ -527,7 +531,7 @@ Διεπαφή πελάτη Νηματοποιημένο GL Μπορεί να κάνει τα μενού του Steam πιο αποκρίσιμα. - Zink: lazy descriptors + Zink: καθυστερημένοι περιγραφείς Προτείνεται για προγράμματα οδήγησης χωρίς descriptor buffers. Παράβλεψη ελέγχων σφαλμάτων GL Απενεργοποιεί την επικύρωση GL ανά κλήση. @@ -538,7 +542,7 @@ Μόνο Adreno. Λιγότερα κολλήματα, μεγαλύτερη χρήση μπαταρίας και θερμότητα. Διορθώσεις συνεδρίας Turnip: απόδοση sysmem - Ταχύτερο για το Steam και τα περισσότερα παιχνίδια. Απαιτείται σε Adreno 710/720/722. + Ταχύτερο για το πρόγραμμα-πελάτη Steam· στα παιχνίδια ο οδηγός επιλέγει σε κάθε πέρασμα απόδοση με πλακίδια ή στη μνήμη συστήματος. Απαιτείται σε Adreno 710/720/722. Παράβλεψη του βοηθού xalia του Steam droiddeck-ntsync (πειραματικό) Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux, για δοκιμές. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία. @@ -575,11 +579,21 @@ Steam Επιφάνεια εργασίας Μενού Steam - Quick access + Γρήγορη πρόσβαση Άνοιγμα Quick Access Menu Προηγούμενη σελίδα Επόμενη σελίδα HUD απόδοσης + Ρυθμός καρέ + Θερμοκρασία μπαταρίας + Γράφημα απόδοσης + Χρώμα + Μέντα + Λευκό + Γαλάζιο + Κεχριμπαρί + Βιολετί + Διαφάνεια Λειτουργία κλιμάκωσης Οξύτητα κλιμάκωσης Εφέ οθόνης @@ -729,6 +743,9 @@ %1$d%% (Προεπιλογή) Θέμα Εμφάνιση εκκινητή + Εικονίδιο εφαρμογής + Σκούρο + Ανοιχτό Χρήση ως αρχική οθόνη Το DroidDeck μπορεί να είναι η εφαρμογή αρχικής οθόνης Το DroidDeck δεν προτείνεται ποτέ ως εφαρμογή αρχικής οθόνης @@ -1035,6 +1052,9 @@ Προστέθηκε παιχνίδι Προσαρμοσμένο παιχνίδι Αποθήκευση παιχνιδιών + Διαγράφει τα αρχεία του και το αφαιρεί από το Steam. + Αφαίρεση του %1$s; + Διαγράφει τη συντόμευση. Τα αρχεία του παιχνιδιού παραμένουν. Το καλύτερο ζεύγος για αυτή τη GPU, ενημερωμένο Επιλέξτε ένα ζεύγος εσείς Έλεγχος για προγράμματα οδήγησης… @@ -1059,7 +1079,7 @@ Προετοιμασία του runtime Linux Συνεδρία Εκκίνηση οθόνης και ήχου - Steam client + Πρόγραμμα-πελάτης Steam Έλεγχος του Steam και των εργαλείων συμβατότητάς του Άνοιγμα Steam Αναμονή για το πρώτο καρέ του Steam @@ -1384,6 +1404,14 @@ Μαύρο φόντο, λευκά γράμματα, το μπλε για ό,τι είναι επιλεγμένο, εστιασμένο ή πατημένο. Ηλεκτρικό ναυτικό μπλε Το μπλε πιο έντονο, σε επιφάνειες που τείνουν προς το ναυτικό μπλε αντί για γκρι. + Διαβάθμιση + Δικά σας πρωτεύον και δευτερεύον χρώμα, σε μίξη στα κουμπιά και τις επισημάνσεις. + Πρωτεύον + Δευτερεύον + Απόχρωση + Κορεσμός + Φωτεινότητα + Χρώμα hex Esc Ins Del @@ -1428,8 +1456,8 @@ Καρφίτσωμα τρέχοντος φακέλου Δεν υπάρχουν ακόμα αγαπημένα - καρφιτσώστε έναν φάκελο με το μενού ⋮ του για να έρχεστε εδώ γρήγορα. Κατάργηση από τα αγαπημένα - 0 bytes - bytes + 0 μπάιτ + μπάιτ Τοποθεσία Τύπος Μέγεθος @@ -1762,7 +1790,8 @@ Το αρχικό της εγκατεστημένης έκδοσης διατηρείται: είναι ο δρόμος της επιστροφής. Δεν είναι αποθηκευμένο Διαγράφηκε το αρχικό %1$s του %2$s. - Οι λήψεις προέρχονται μόνο από τις κυκλοφορίες Nightlies + Οι λήψεις προέρχονται μόνο από τις κυκλοφορίες Nightlies και DroidDeck + Η λήψη απέτυχε: αυτή η νυχτερινή έκδοση ίσως αντικαταστάθηκε από νεότερη. Ανανεώστε τη λίστα και δοκιμάστε ξανά. Αυτή η λίστα πακέτων προηγείται των αθροισμάτων ελέγχου - ανανεώστε την και δοκιμάστε ξανά Αναντιστοιχία αθροίσματος ελέγχου @@ -1785,7 +1814,7 @@ Μόνο όξυνση, το χρώμα παραμένει ανεπηρέαστο. Φιλμικό Καμπύλη σε στιλ φιλμ, ελαφρώς απαλή. - Arcade + Αρκέιντ Έντονο και δυναμικό. Ρετρό CRT Ο σωλήνας CRT και τα αναλογικά περάσματα σήματος μαζί. @@ -1921,9 +1950,10 @@ Καταστήματα Ρυθμίσεις καταστημάτων Ρυθμίσεις + Τακτοποίηση καρτελών + Έγινε (μη συνδεδεμένος) Κατάστημα - Εγκατεστημένα Βιβλιοθήκη Όλα Λήψεις @@ -1935,10 +1965,8 @@ Παραμονή συνδεδεμένου Αποσύνδεση Αναζήτηση στον κατάλογο %1$s… - Αναζήτηση εγκατεστημένων παιχνιδιών… Αναζήτηση στη βιβλιοθήκη σας… Αναζήτηση - Τίποτα εγκατεστημένο Φόρτωση Δεν υπάρχουν παιχνίδια Φόρτωση του καταστήματος… @@ -1957,7 +1985,19 @@ Προβολή στο %1$s Αγορά · %1$s Εγκατεστημένο + Εγκατεστημένο + Δεν είναι εγκατεστημένο Στη βιβλιοθήκη + Κλειδί API + Λήψη κλειδιού + Το κλειδί απορρίφθηκε + Δεν είναι στη βιβλιοθήκη σας στο itch.io + Δεν υπάρχει λήψη για Windows για αυτό το παιχνίδι + Δεν βρέθηκε παιχνίδι Windows στη λήψη + Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d) + Το itch.io δεν απάντησε + Τα αρχεία %1$s δεν υποστηρίζονται + Το itch.io απάντησε HTTP %1$d Δωρεάν Συνέχιση Ανανέωση @@ -1971,6 +2011,7 @@ Ημερομηνία κυκλοφορίας Προβολή λίστας Προβολή καρτών + Προβολή εξωφύλλων Εμφάνιση περιεχομένου για ενήλικες Epic EOS @@ -2006,6 +2047,9 @@ Τελευταίος συγχρονισμός %1$s Δεν έχει συγχρονιστεί ακόμα %1$d αρχεία + Ήδη ενημερωμένο + Συνδεθείτε στο κατάστημα για συγχρονισμό αποθηκεύσεων + Οι αποθηκεύσεις δεν συγχρονίστηκαν εγκατεστημένο στη βιβλιοθήκη σας κατάστημα @@ -2066,7 +2110,7 @@ Επανάληψη Εκκαθάριση Εκκαθάριση ολοκληρωμένων - Manifest + Μανιφέστο Λήψη Επαλήθευση Εγκατάσταση @@ -2137,7 +2181,7 @@ Μέγεθος Αποθήκευση Επαναφορά στην προεπιλογή - Σχεδιάζεται πάνω από το φόντο του παιχνιδιού, όπως κάνει το Steam. Η ακριβής θέση μπορεί να διαφέρει μερικά pixel. + Ακριβώς αυτή η θέση μπορεί να διαφέρει κατά μερικά pixel. Αυτό το παιχνίδι δεν έχει λογότυπο ακόμη. Διάλεξε ένα στο Λογότυπο για να το δεις εδώ. Η θέση του λογότυπου δεν αποθηκεύτηκε. Δοκίμασε ξανά. Φόντο @@ -2247,7 +2291,7 @@ Καθρέφτης Δικό σου prefix Ιστότοπος - Prefix + Πρόθεμα ΕΦΑΡΜΟΓΕΣ LINUX Flathub Εφαρμογές επιφάνειας εργασίας για την επιφάνεια εργασίας Linux, ανοίγουν από την Επιφάνεια εργασίας. @@ -2309,7 +2353,7 @@ Τέλος Δεν ήταν δυνατή η αποθήκευση της διάταξης αφής Η διάταξη αφής αποθηκεύτηκε στο Steam - Εφαρμογή %1$d + Εφαρμογή %1$s Ετικέτα (προαιρετικό) Τα εικονίδια του Steam δεν βρέθηκαν @@ -2400,6 +2444,15 @@ Οπτικό πεδίο SteamVR Χαμηλότερο: το SteamVR έρχεται πιο κοντά και γεμίζει τα γυαλιά· υψηλότερο: βλέπετε περισσότερα γύρω σας. Παίζετε στα %1$s + Επανακεντράρισμα + Μενού SteamVR + Μοχλοί: χέρια ⇄ παιχνίδι + Χειριστήρια: %1$s + Πληκτρολόγιο + Μενού συνεδρίας + Διακοπή SteamVR + Φωτεινότητα %1$d / %2$d + Μπαταρία %1$d%% · %2$d °C Βαθμονόμηση παρακολούθησης Μειώνει την ολίσθηση και ευθυγραμμίζει την οριζόντια αναφορά. Σε αυτά τα γυαλιά εφαρμόζεται αποθηκευμένη βαθμονόμηση. @@ -2454,6 +2507,7 @@ Αναμονή για τη σύνδεση USB Ethernet των γυαλιών. Αυτό το τηλέφωνο ίσως δεν τη διαθέτει στις εφαρμογές. Η σύνδεση XREAL διακόπηκε ή επέστρεψε μη υποστηριζόμενη απόκριση. Νέα προσπάθεια… Πειραματική υποστήριξη για 1S. Χρησιμοποιήστε τη λειτουργία Follow και απενεργοποιήστε τη σταθεροποίηση στο μενού των γυαλιών. Οι κατευθύνσεις παρακολούθησης και οι λειτουργίες οθόνης χρειάζονται ακόμα δοκιμή σε συσκευή. + Πειραματική παρακολούθηση κεφαλής για Pro 2. Η ρύθμιση της φωτεινότητας και της έντασης ήχου και η αλλαγή λειτουργίας οθόνης δεν υποστηρίζονται ακόμη. Cardboard VR Λειτουργία Cardboard Εικόνες για το αριστερό και το δεξί μάτι, για γυαλιά VR κινητού. @@ -2625,7 +2679,7 @@ Απέτυχε: %1$s %1$s κοινά Εγκατεστημένα παιχνίδια - Prefix %1$s · shaders %2$s · workshop %3$s + Πρόθεμα %1$s · σκιαστές %2$s · εργαστήριο %3$s Εκκαθάριση shaders Διαγραμμένα παιχνίδια Εφαρμογή Steam %1$d @@ -2666,7 +2720,7 @@ Βίντεο εκκίνησης Βίντεο DroidDeck · %1$d fps - Τα 60 fps φαίνονται πιο ομαλά, αλλά επιβαρύνουν περισσότερο τον επεξεργαστή όσο ξεκινά το Steam. Ένα βίντεο εκκίνησης που επιλέχθηκε στις ρυθμίσεις του Steam έχει προτεραιότητα. + Τα 60 fps φαίνονται πιο ομαλά, αλλά επιβαρύνουν περισσότερο τον επεξεργαστή όσο ξεκινά το Steam. Ένα βίντεο που επιλέχθηκε στις ρυθμίσεις του Steam εμφανίζεται κι εδώ. Ήχος Δικό σας βίντεο MP4, MOV, MKV ή WebM. Άλλες μορφές και αρχεία πάνω από %1$d MB μετατρέπονται στη συσκευή σε WebM 720p, έως %2$d δευτερόλεπτα. @@ -2680,6 +2734,51 @@ Μετατροπή του %1$s για το Steam. Αφήστε το DroidDeck ανοιχτό. Η συσκευή δεν μπορεί να αποκωδικοποιήσει αυτό το βίντεο. Δεν ήταν δυνατή η εισαγωγή του βίντεο + Αφαίρεση του %1$s; + Το βίντεο θα διαγραφεί από το DroidDeck και από τον φάκελο βίντεο του Steam. + Εγκατεστημένα + Εισαγωγή + SteamDeckRepo + Ήχος + Εγκατεστημένα βίντεο + Περιήγηση + DroidDeck + Εισαγμένο + SteamDeckRepo + Steam + Κατάστημα πόντων + Αναφορά ταινιών εκκίνησης + Αποθηκεύει σε αρχείο κειμένου ό,τι γνωρίζουν το DroidDeck και το Steam για τις ταινίες εκκίνησης, για να το επισυνάψετε σε αναφορά σφάλματος. + Αποθήκευση + Αποθηκεύτηκε στο %1$s + Δεν ήταν δυνατή η αποθήκευση της αναφοράς. + Ένταση + Διάρκεια + %1$d δευτ. + Πόσα δευτερόλεπτα της ταινίας αναπαράγονται. Ό,τι είναι μεγαλύτερο κόβεται στο τέλος. + Αναζήτηση στο SteamDeckRepo + Όλες οι συσκευές + Steam Deck + Steam Machine + Τάσεις + Περισσότερες λήψεις + Περισσότερα likes + Νεότερα + Δεν ήταν δυνατή η σύνδεση με το SteamDeckRepo + Επανάληψη + Δεν βρέθηκαν βίντεο + Εγκατάσταση + Χρήση + Σε χρήση + Το %1$s εγκαταστάθηκε + Δεν ήταν δυνατή η λήψη αυτού του βίντεο + Προηγούμενη + Επόμενη + Σελίδα %1$d + Εκκαθάριση + Μετάβαση στη σελίδα + Προεπισκόπηση + Η προεπισκόπηση δεν μπόρεσε να αναπαραχθεί Κάμερες Luma Ultra — Πειραματικό Παρακολούθηση θέσης Joy-Con Χρησιμοποιήστε τις κάμερες των έξυπνων γυαλιών για να τοποθετήσετε τα συνδεδεμένα χειριστήρια Joy-Con. Η περιστροφή και οι εντολές κουμπιών προέρχονται από τα χειριστήρια Joy-Con. Η θέση εκτιμάται όταν οι καρποί σας είναι κρυμμένοι. Αντικαθιστά τα χειριστήρια που λειτουργούν με γυμνά χέρια και διακόπτει την παρακολούθηση της κάμερας του τηλεφώνου. Στρέψτε τα χειριστήρια Joy-Con προς τα εμπρός κατά τη σύνδεση ή την επαναφορά του κέντρου παρακολούθησης. @@ -2853,7 +2952,6 @@ Αναμονή για σταθερή παρακολούθηση κεφαλής. Επαναφέρετε το κέντρο παρακολούθησης εάν ζητηθεί Η παρατήρηση με κάμερα είναι πολύ παλιά. περιμένοντας ένα φρέσκο ζευγάρι Προσαρμογή %1$.2f px. Μετρήστε την επιλεγμένη απόσταση από την αριστερή κάμερα μέχρι το κέντρο στόχου βαθμονόμησης και, στη συνέχεια, επικυρώστε. - Το OpenCV δεν είναι διαθέσιμο: %1$s Αποκαταστάθηκαν οι αποθηκευμένες προβολές %1$d. Υπολογίστε τη βαθμονόμηση για να προσπαθήσετε ξανά ή ξεκινήστε ένα νέο σύνολο για να καταγράψετε περισσότερα. Λήψη %1$d / 24 προβολές. Διατηρήστε σταθερό τον στόχο βαθμονόμησης. μετακινήστε τα έξυπνα γυαλιά για την επόμενη προβολή. Η λήψη απορρίφθηκε: %1$d / 12 απαιτούμενες γωνίες ορατές και στις δύο κάμερες. Εμφάνιση μεγαλύτερου μέρους του στόχου βαθμονόμησης, κρατήστε ακίνητο και μετά δοκιμάστε ξανά. diff --git a/app/src/main/res/values-es/gestures.xml b/app/src/main/res/values-es/gestures.xml new file mode 100644 index 000000000..4f5f49779 --- /dev/null +++ b/app/src/main/res/values-es/gestures.xml @@ -0,0 +1,91 @@ + + + Gestos + Ajustes de gestos táctiles + Personaliza los gestos de ratón y teclado. Selecciona Gestos como modo táctil para usarlos. + Toque + Arrastre con un dedo + Pulsación prolongada + Doble toque (doble clic izquierdo) + Toque con dos dedos + Arrastre con dos dedos + Pulsación sostenida con dos dedos + Toque con tres dedos + Arrastre con tres dedos + Pulsación sostenida con tres dedos + Pellizcar para ampliar + Mantener el botón pulsado + Hacer clic una vez + Tiempo de pulsación sostenida + Intervalo de doble toque + Restablecer los ajustes originales de gestos + Cancelar + Elegir acción + Clic izquierdo + Clic derecho + Clic central + Mostrar teclado + Clic izquierdo y arrastre + Clic derecho y arrastre + Desplazamiento con el botón central + Desplazamiento con el botón central (invertido) + Teclas de dirección + Teclas de dirección (invertidas) + WASD (invertido) + Rueda del ratón + Umbral de gestos: %1$d px + Mostrar gesto reconocido + Un dedo + Dos dedos + Tres dedos + Opciones + Predeterminados + Este juego + Usar valores predeterminados + Guardar cambios + Cerrar + ACCIÓN + Buscar acciones de ratón o teclas del teclado + Borrar búsqueda + Todas + No hay acciones coincidentes. Prueba con otro nombre de tecla o acción. + Ratón + Acciones de la aplicación + Navegación + Letras + Números + Teclas de función + Teclas modificadoras + Teclado numérico + Símbolos + Movimiento del ratón + Directo + Relativo + Reconocimiento de gestos + Respuesta visual + Comportamiento del botón del ratón + %1$d ms + Teclado numérico %1$s + Escape + Retroceso + Intro + Tab + Espacio + Supr + Insertar + Inicio + Fin + Re Pág + Av Pág + Impr Pant + Flecha arriba + Flecha abajo + Flecha izquierda + Flecha derecha + Bloq Mayús + Bloq Num + Bloq Despl + %1$s izquierdo + %1$s derecho + Mayús + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c8b7026a6..d71362d88 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -30,7 +30,7 @@ Generación de fotogramas Elige el modo de generación de fotogramas Desactivada - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptativo %1$d Requiere Lossless Scaling en Steam @@ -176,6 +176,8 @@ %1$s con FEX Este programa está hecho para PC %1$s y se ejecuta con FEX, que aún no está listo. Abre Steam una vez con tu sesión iniciada: DroidDeck hará que descargue FEX y el Steam Linux Runtime que necesita, y después se abrirá. Este programa + No se ha podido descargar el cliente de Steam. Puede que tu red o VPN esté bloqueando los servidores de Steam; comprueba la conexión y vuelve a intentarlo. + No se ha podido configurar el cliente de Steam. Comprueba la conexión y el espacio libre y vuelve a intentarlo. %1$s está hecho para PC x86 y se ejecuta con FEX, que aún no está instalado. Abre Steam una vez con tu sesión iniciada y deja que descargue FEX y el Steam Linux Runtime; después vuelve a iniciarlo. Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está. Archivo @@ -305,6 +307,8 @@ Cliente Superposición de rendimiento Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión. + Volver a DroidDeck al cerrar un juego + Cuando se cierra un juego que iniciaste desde DroidDeck u otro lanzador, Steam se cierra correctamente (primero termina de sincronizar las partidas en la nube) y vuelves allí. Desactivado: te quedas en Steam. Reparar el cliente de Steam Vuelve a descargar el cliente de Steam la próxima vez que se inicie Steam. Úsalo si Steam se queda en la pantalla de carga. Se conservan los juegos, la sesión iniciada y los ajustes. El cliente de Steam se volverá a descargar la próxima vez que se inicie Steam. @@ -501,7 +505,9 @@ ¿Eliminar este original? Este original de %1$s es de una versión anterior de %2$s. El original de la versión actual siempre se conserva. En las Nightlies · %1$d - Actualiza para listar los paquetes de las Nightlies. + Estable + Nocturna + Actualiza para listar los paquetes de Nightlies y DroidDeck. Nada nuevo desde la última comprobación. Importar .wcp Restaurar el original de este Proton @@ -537,7 +543,7 @@ Solo Adreno. Menos tirones, más consumo de batería y calor. Correcciones de sesión Turnip: renderizado sysmem - Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722. + Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722. Omitir el asistente xalia de Steam droiddeck-ntsync (experimental) Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. @@ -579,6 +585,16 @@ Página anterior Página siguiente HUD de rendimiento + Tasa de fotogramas + Temperatura de la batería + Gráfico de rendimiento + Color + Menta + Blanco + Celeste + Ámbar + Violeta + Opacidad Modo de escalado Nitidez del escalado Efectos de pantalla @@ -729,6 +745,9 @@ %1$d%% (predeterminada) Tema Apariencia del launcher + Icono de la app + Oscuro + Claro Usar como pantalla de inicio DroidDeck puede ser la app de inicio DroidDeck nunca se ofrece como app de inicio @@ -1038,6 +1057,9 @@ Juego añadido Juego personalizado Almacenamiento de juegos + Elimina sus archivos y lo quita de Steam. + ¿Quitar %1$s? + Elimina su acceso directo. Los archivos del juego se conservan. El mejor par para esta GPU, siempre actualizado Elige un par manualmente Buscando controladores… @@ -1395,6 +1417,14 @@ Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o pulsado. Azul marino eléctrico Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris. + Degradado + Tus propios colores primario y secundario, combinados en botones y resaltados. + Primario + Secundario + Tono + Saturación + Brillo + Color hexadecimal Esc Insert Supr @@ -1780,7 +1810,8 @@ El original de la versión instalada se conserva: es el camino de vuelta. No guardado Se eliminó el original de %1$s de %2$s. - Las descargas solo proceden de las versiones Nightlies + Las descargas solo proceden de las versiones de Nightlies y DroidDeck + Error en la descarga: puede que esta compilación nocturna se haya sustituido por una más reciente. Actualiza la lista y vuelve a intentarlo. Esta lista de paquetes es anterior a las sumas de verificación - actualízala e inténtalo de nuevo La suma de verificación no coincide @@ -1939,9 +1970,10 @@ Tiendas Ajustes de las tiendas Ajustes + Organizar pestañas + Listo (sin iniciar sesión) Tienda - Instalados Biblioteca Todos Descargas @@ -1953,10 +1985,8 @@ Mantener la sesión Cerrar sesión Buscar en el catálogo de %1$s… - Buscar juegos instalados… Buscar en tu biblioteca… Buscar - Nada instalado Cargando No hay juegos Cargando la tienda… @@ -1975,7 +2005,19 @@ Ver en %1$s Comprar · %1$s Instalado + Instalado + No instalada En la biblioteca + Clave de API + Obtener una clave + Clave rechazada + No está en tu biblioteca de itch.io + No hay descarga para Windows de este juego + No se ha encontrado un juego de Windows en la descarga + No se ha podido extraer la descarga (código %1$d) + itch.io no respondió + Los archivos %1$s no son compatibles + itch.io respondió HTTP %1$d Gratis Reanudar Actualizar @@ -1989,6 +2031,7 @@ Fecha de lanzamiento Vista de lista Vista de tarjetas + Vista de portadas Mostrar contenido para adultos Epic EOS @@ -2024,6 +2067,9 @@ Última sincronización %1$s Aún sin sincronizar %1$d archivos + Ya está al día + Inicia sesión en la tienda para sincronizar las partidas + No se sincronizaron las partidas instalado en tu biblioteca tienda @@ -2152,7 +2198,7 @@ Tamaño Guardar Volver al valor predeterminado - Se dibuja sobre el fondo del juego, igual que en Steam. La posición exacta puede variar unos píxeles. + Este punto exacto puede variar unos pocos píxeles. Este juego aún no tiene logotipo. Elige uno en Logotipo para verlo aquí. No se pudo guardar la posición del logotipo. Inténtalo de nuevo. Fondo @@ -2326,7 +2372,7 @@ Hecho No se pudo guardar la disposición táctil Disposición táctil guardada en Steam - App %1$d + App %1$s Etiqueta (opcional) No se encontraron los iconos de Steam @@ -2408,6 +2454,15 @@ Campo de visión de SteamVR Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor. Jugando en %1$s + Recentrar + Menú de SteamVR + Sticks: manos ⇄ juego + Mandos: %1$s + Teclado + Menú de sesión + Detener SteamVR + Brillo %1$d / %2$d + Batería %1$d%% · %2$d °C Calibración del seguimiento Reduce la deriva y alinea la referencia horizontal. Estas gafas usan la calibración guardada. @@ -2462,6 +2517,7 @@ Esperando la conexión Ethernet por USB de las gafas. Puede que este teléfono no la ofrezca a las apps. La conexión con las XREAL se interrumpió o devolvió una respuesta no compatible. Reintentando… Compatibilidad experimental con las 1S. Usa el modo Follow y desactiva la estabilización en el menú de las gafas. Aún falta probar en el dispositivo las direcciones del seguimiento y los modos de pantalla. + Seguimiento de cabeza experimental para Pro 2. Los ajustes de brillo y volumen y el cambio de modo de pantalla aún no son compatibles. Desarrollador Distribuidor Lanzamiento @@ -2613,32 +2669,39 @@ Jugar igualmente %d instalado + %d instalados %d instalados %d en la tarjeta SD + %d en la tarjeta SD %d en la tarjeta SD %d herramienta + %d herramientas %d herramientas Carpetas de prefijo vacías y caché de shaders de %d juego + Carpetas de prefijo vacías y cachés de shaders de %d juegos Carpetas de prefijo vacías y cachés de shaders de %d juegos El prefijo de %d juego aún contiene archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. + Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. %d juego + %d juegos %d juegos Proton predeterminado de DroidDeck Opción Proton 11 de DroidDeck Usado por %d juego + Usado por %d juegos Usado por %d juegos Componentes @@ -2682,16 +2745,18 @@ Quita la app (%1$s) y cualquier runtime que no use nada más. Sus datos en tu carpeta personal se quedan. %d paquete + %d paquetes %d paquetes %d app + %d apps %d apps Vídeo de arranque Vídeo DroidDeck · %1$d fps - 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un vídeo de inicio elegido en los ajustes de Steam tiene prioridad. + 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un vídeo elegido en los ajustes de Steam también aparece aquí. Sonido Tu propio vídeo MP4, MOV, MKV o WebM. Otros formatos y archivos de más de %1$d MB se convierten a WebM 720p en el dispositivo, hasta %2$d segundos. @@ -2705,6 +2770,51 @@ Convirtiendo %1$s para Steam. Mantén DroidDeck abierto. Este dispositivo no puede decodificar ese vídeo. No se pudo importar ese vídeo + ¿Quitar %1$s? + Esto elimina el vídeo de DroidDeck y de la carpeta de vídeos de Steam. + Instalados + Importar + SteamDeckRepo + Sonido + Vídeos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Tienda de puntos + Informe de películas de arranque + Guarda en un archivo de texto lo que DroidDeck y Steam saben de las películas de arranque, para adjuntarlo a un informe de error. + Guardar + Guardado en %1$s + No se pudo guardar el informe. + Volumen + Duración + %1$d s + Cuántos segundos de la película se reproducen. Lo que dure más se corta al final. + Buscar en SteamDeckRepo + Todos los dispositivos + Steam Deck + Steam Machine + Tendencias + Más descargados + Más valorados + Más recientes + No se pudo acceder a SteamDeckRepo + Reintentar + No se encontraron vídeos + Instalar + Usar + En uso + %1$s instalado + No se pudo descargar ese vídeo + Anterior + Siguiente + Página %1$d + Borrar + Ir a la página + Vista previa + No se pudo reproducir la vista previa Cámaras Luma Ultra — Experimental Seguimiento de posición de los Joy-Con Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento. @@ -2878,7 +2988,6 @@ Esperando un seguimiento de cabeza estable; restablece el centro de seguimiento si se solicita. La observación con cámara es demasiado antigua; esperando un par nuevo Ajustar %1$.2f px. Mida la distancia seleccionada desde la cámara izquierda hasta el centro del objetivo de calibración y luego valide. - OpenCV no disponible: %1$s %1$d vistas guardadas restauradas. Calcule la calibración para volver a intentarlo o comience un nuevo conjunto para capturar más. Capturado %1$d / 24 vistas. Mantenga fijo el objetivo de calibración; Mueva las gafas inteligentes para la siguiente vista. Captura rechazada: %1$d / 12 esquinas requeridas visibles en ambas cámaras. Muestre más del objetivo de calibración, quédese quieto y vuelva a intentarlo. diff --git a/app/src/main/res/values-fi/gestures.xml b/app/src/main/res/values-fi/gestures.xml new file mode 100644 index 000000000..c92357b99 --- /dev/null +++ b/app/src/main/res/values-fi/gestures.xml @@ -0,0 +1,91 @@ + + + Eleet + Kosketuseleiden asetukset + Mukauta hiiri- ja näppäimistöeleitä. Ota ne käyttöön valitsemalla kosketustilaksi Eleet. + Napautus + Veto yhdellä sormella + Pitkä painallus + Kaksoisnapautus (vasemman painikkeen kaksoisnapsautus) + Napautus kahdella sormella + Veto kahdella sormella + Pito kahdella sormella + Napautus kolmella sormella + Veto kolmella sormella + Pito kolmella sormella + Zoomaa nipistämällä + Pidä painike painettuna + Napsauta kerran + Pidon viive + Kaksoisnapautuksen aikaväli + Palauta alkuperäiset eleasetukset + Peruuta + Valitse toiminto + Vasen napsautus + Oikea napsautus + Keskinapsautus + Näytä näppäimistö + Vasen napsautus ja veto + Oikea napsautus ja veto + Panorointi hiiren keskipainikkeella + Panorointi hiiren keskipainikkeella (käänteinen) + Nuolinäppäimet + Nuolinäppäimet (käänteiset) + WASD (käänteinen) + Hiiren rulla + Elekynnys: %1$d px + Näytä tunnistettu ele + Yksi sormi + Kaksi sormea + Kolme sormea + Asetukset + Oletukset + Tämä peli + Käytä oletuksia + Tallenna muutokset + Sulje + TOIMINTO + Etsi hiiritoimintoja tai näppäimiä + Tyhjennä haku + Kaikki + Vastaavia toimintoja ei löytynyt. Kokeile toista näppäimen tai toiminnon nimeä. + Hiiri + Sovellustoiminnot + Siirtyminen + Kirjaimet + Numerot + Funktionäppäimet + Muunnosnäppäimet + Numeronäppäimistö + Symbolit + Hiiren liike + Suora + Suhteellinen + Eleiden tunnistus + Visuaalinen palaute + Hiiripainikkeen toiminta + %1$d ms + Numeronäppäimistö %1$s + Esc + Askelpalautin + Enter + Tab + Space + Delete + Insert + Home + End + Sivu ylös + Sivu alas + Print Screen + Nuoli ylös + Nuoli alas + Nuoli vasemmalle + Nuoli oikealle + Caps Lock + Num Lock + Scroll Lock + Vasen %1$s + Oikea %1$s + Vaihto + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 7c12aaa94..a6e749372 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -30,7 +30,7 @@ Kehysten generointi Valitse kehysten generoinnin tila Pois - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Mukautuva %1$d Vaatii Lossless Scalingin Steamista @@ -115,7 +115,7 @@ Poistaa itseään muokkaavan koodin tarkistukset käytöstä. JIT-pelit ja DRM voivat hajota. Äärimmäinen + TSO Äärimmäiset asetukset, pääasiallinen TSO käytössä. - Extreme-gn + Äärimmäinen-gn Suorituskyky TSC- ja metadatavihjeillä. Kokeile ennen Äärimmäistä. Denuvo Suorituskyky täysin koodintarkistuksin ja hypervisor-bitti piilotettuna. @@ -176,6 +176,8 @@ %1$s FEXin kautta Tämä ohjelma on tehty %1$s-tietokoneille ja käynnistyy FEXin kautta, jota ei ole vielä määritetty. Avaa Steam kerran kirjautuneena: DroidDeck lataa sen myötä FEXin ja tarvittavan Steam Linux Runtimen, jonka jälkeen tämä avautuu. Tämä ohjelma + Steam-asiakasohjelmaa ei voitu ladata. Verkkosi tai VPN saattaa estää Steamin palvelimet; tarkista yhteys ja yritä uudelleen. + Steam-asiakasohjelmaa ei voitu ottaa käyttöön. Tarkista yhteys ja vapaa tallennustila ja yritä uudelleen. %1$s on tehty x86-tietokoneille ja käynnistyy FEXin kautta, jota ei ole vielä asennettu. Käynnistä Steam kerran kirjautuneena ja anna sen ladata FEX ja Steam Linux Runtime, sitten käynnistä tämä uudelleen. Flathub-sovellustunnus tai sen Flathub-sivu. Flatpak otetaan käyttöön ensin, jos sitä ei ole vielä otettu. Tiedosto @@ -299,7 +301,7 @@ Asennettu · %1$s Päivitys saatavilla · %1$s Ajan tasalla · %1$s - Loader + Lataaja Asenna uusin Käynnistyy Steamin kanssa. Päällä ollessaan muut tämän laitteen sovellukset voivat pystyä ohjaamaan Steamia. Pois. Steamin etävianmääritysportti pysyy suljettuna. @@ -502,7 +504,9 @@ Poistetaanko tämä alkuperäinen? Tämä %1$s:n alkuperäinen on aiemmasta %2$s-versiosta. Nykyisen version alkuperäinen säilytetään aina. Nightliesissä · %1$d - Päivitä nähdäksesi Nightliesin paketit. + Vakaa + Yöversio + Päivitä nähdäksesi Nightliesin ja DroidDeckin paketit. Ei mitään uutta viimeisimmän tarkistuksen jälkeen. Tuo .wcp Palauta tämän Protonin alkuperäinen @@ -538,7 +542,7 @@ Vain Adreno. Vähemmän nykäyksiä, enemmän akun kulutusta ja lämpöä. Istunnon korjaukset Turnip: sysmem-renderöinti - Nopeampi Steamille ja useimmille peleille. Pakollinen Adreno 710/720/722:lla. + Nopeampi Steam-asiakasohjelmalle; peleissä ajuri valitsee jokaiselle vaiheelle ruuduittaisen tai järjestelmämuistiin tehtävän renderöinnin. Pakollinen Adreno 710/720/722:lla. Ohita Steamin xalia-avustaja droiddeck-ntsync (kokeellinen) Käyttäjätilainen korvike Linuxin ntsync-ajurille, testausta varten. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa. @@ -580,6 +584,16 @@ Edellinen sivu Seuraava sivu Suorituskyky-HUD + Kuvataajuus + Akun lämpötila + Suorituskykykäyrä + Väri + Minttu + Valkoinen + Taivaansininen + Meripihka + Violetti + Peittävyys Skaalaustila Skaalauksen terävyys Näyttötehosteet @@ -729,6 +743,9 @@ %1$d %% (Oletus) Teema Käynnistimen ulkoasu + Sovelluskuvake + Tumma + Vaalea Käytä Aloitusnäyttönä DroidDeck voi olla kotisovellus DroidDeckiä ei koskaan tarjota kotisovellukseksi @@ -1035,6 +1052,9 @@ Lisätty peli Mukautettu peli Pelien tallennustila + Poistaa sen tiedostot ja poistaa sen Steamista. + Poistetaanko %1$s? + Poistaa pikakuvakkeen. Pelin tiedostot jäävät. Parhaiten tähän GPU:hun sopiva pari, pidetään ajan tasalla Valitse pari itse Tarkistetaan ajureita… @@ -1384,6 +1404,14 @@ Musta pohja, valkoinen teksti, sininen kaikessa valitussa, kohdistetussa tai painetussa. Sähköinen laivastonsininen Sininen kirkkaampana pinnoilla, jotka taittuvat harmaan sijaan laivastonsiniseen. + Liukuväri + Omat ensisijainen ja toissijainen värisi, sulautettuina painikkeisiin ja korostuksiin. + Ensisijainen + Toissijainen + Sävy + Kylläisyys + Kirkkaus + Hex-väri Esc Ins Del @@ -1762,7 +1790,8 @@ Asennetun version alkuperäinen säilytetään: se on tie takaisin. Ei tallennettu Poistettu %2$s:n %1$s-alkuperäinen. - Lataukset tulevat vain Nightlies-julkaisuista + Lataukset tulevat vain Nightlies- ja DroidDeck-julkaisuista + Lataus epäonnistui: tämä yöversio on ehkä korvattu uudemmalla. Päivitä luettelo ja yritä uudelleen. Tämä pakettiluettelo on tarkistussummia vanhempi - päivitä se ja yritä uudelleen Tarkistussumma ei täsmää @@ -1921,9 +1950,10 @@ Kaupat Kauppojen asetukset Asetukset + Järjestä välilehdet + Valmis (ei kirjautunut) Kauppa - Asennetut Kirjasto Kaikki Lataukset @@ -1935,10 +1965,8 @@ Pysy kirjautuneena Kirjaudu ulos Hae %1$s-luettelosta… - Hae asennetuista peleistä… Hae kirjastostasi… Hae - Ei mitään asennettuna Latautuu Ei pelejä Ladataan kauppaa… @@ -1957,7 +1985,19 @@ Näytä %1$s:ssä Osta · %1$s Asennettu + Asennettu + Ei asennettu Kirjastossa + API-avain + Hae avain + Avain hylätty + Ei itch.io-kirjastossasi + Ei Windows-latausta tälle pelille + Latauksesta ei löytynyt Windows-peliä + Latausta ei voitu purkaa (koodi %1$d) + itch.io ei vastannut + %1$s-arkistot eivät ole tuettuja + itch.io vastasi HTTP %1$d Ilmainen Jatka Päivitä @@ -1971,6 +2011,7 @@ Julkaisupäivä Luettelonäkymä Korttinäkymä + Kansinäkymä Näytä aikuisille tarkoitettu sisältö Epic EOS @@ -2006,6 +2047,9 @@ Viimeisin synkronointi %1$s Ei synkronoitu vielä %1$d tiedostoa + Jo ajan tasalla + Kirjaudu kauppaan synkronoidaksesi tallennukset + Tallennuksia ei synkronoitu asennettu kirjastossasi kauppa @@ -2137,7 +2181,7 @@ Koko Tallenna Takaisin oletukseen - Piirretään pelin taustan päälle, kuten Steam tekee. Tarkka sijainti voi poiketa muutaman pikselin. + Tämä tarkka kohta voi vaihdella muutamalla pikselillä. Tällä pelillä ei ole vielä logoa. Valitse sellainen kohdasta Logo, niin näet sen tässä. Logon sijaintia ei voitu tallentaa. Yritä uudelleen. Tausta @@ -2309,7 +2353,7 @@ Valmis Kosketusasettelua ei voitu tallentaa Kosketusasettelu tallennettu Steamiin - Sovellus %1$d + Sovellus %1$s Nimike (valinnainen) Steamin kuvakkeita ei löytynyt @@ -2400,6 +2444,15 @@ SteamVR:n näkökenttä Pienempi tuo SteamVR:n lähemmäs ja täyttää lasit; suurempi näyttää enemmän ympäriltäsi. Pelataan laitteella %1$s + Keskitä uudelleen + SteamVR-valikko + Sauvat: kädet ⇄ peli + Ohjaimet: %1$s + Näppäimistö + Istuntovalikko + Pysäytä SteamVR + Kirkkaus %1$d / %2$d + Akku %1$d%% · %2$d °C Seurannan kalibrointi Vähentää ryömintää ja kohdistaa vaakatason. Tallennettu kalibrointi on käytössä näissä laseissa. @@ -2454,6 +2507,7 @@ Odotetaan lasien USB-Ethernet-yhteyttä. Tämä puhelin ei ehkä tarjoa sitä sovelluksille. XREAL-yhteys katkesi tai palautti vastauksen, jota ei tueta. Yritetään uudelleen… Kokeellinen 1S-tuki. Käytä Follow-tilaa ja poista vakautus käytöstä lasien valikosta. Seurannan suunnat ja näyttötilat vaativat vielä testausta laitteella. + Kokeellinen päänliikkeiden seuranta Pro 2:lle. Kirkkauden ja äänenvoimakkuuden säätöä sekä näyttötilan vaihtamista ei vielä tueta. Cardboard VR Cardboard-tila Vasemman ja oikean silmän kuvat puhelimen VR-laseille. @@ -2666,7 +2720,7 @@ Käynnistysvideo Video DroidDeck · %1$d fps - 60 fps näyttää sulavammalta, mutta kuormittaa suoritinta enemmän Steamin käynnistyessä. Steamin omista asetuksista valittu käynnistysvideo on etusijalla. + 60 fps näyttää sulavammalta, mutta kuormittaa suoritinta enemmän Steamin käynnistyessä. Steamin omista asetuksista valittu video näkyy myös täällä. Ääni Oma video MP4, MOV, MKV tai WebM. Muut muodot ja yli %1$d Mt:n tiedostot muunnetaan laitteella 720p-WebM-muotoon, enintään %2$d sekuntia. @@ -2680,6 +2734,51 @@ Muunnetaan %1$s Steamille. Pidä DroidDeck auki. Laite ei pysty purkamaan videota. Videon tuonti epäonnistui + Poistetaanko %1$s? + Tämä poistaa videon DroidDeckistä ja Steamin videokansiosta. + Asennetut + Tuo + SteamDeckRepo + Ääni + Asennetut videot + Selaa + DroidDeck + Tuotu + SteamDeckRepo + Steam + Pisteputiikki + Käynnistyselokuvaraportti + Tallentaa tekstitiedostoon, mitä DroidDeck ja Steam tietävät käynnistyselokuvista, liitettäväksi virheilmoitukseen. + Tallenna + Tallennettu: %1$s + Raportin tallennus epäonnistui. + Äänenvoimakkuus + Pituus + %1$d s + Kuinka monta sekuntia elokuvasta toistetaan. Sitä pidempi osa leikataan pois lopusta. + Hae SteamDeckReposta + Kaikki laitteet + Steam Deck + Steam Machine + Trendaavat + Ladatuimmat + Tykätyimmät + Uusimmat + SteamDeckRepoon ei saatu yhteyttä + Yritä uudelleen + Videoita ei löytynyt + Asenna + Käytä + Käytössä + %1$s asennettu + Videota ei voitu ladata + Edellinen + Seuraava + Sivu %1$d + Tyhjennä + Siirry sivulle + Esikatselu + Esikatselua ei voitu toistaa Luma Ultra -kamerat — Kokeellinen Joy-Con-paikan seuranta Käytä älylasien kameroita pariliitettyjen Joy-Con-ohjainten asemointiin. Kierto- ja painikesyötteet tulevat Joy-Con-ohjaimista. Sijainti arvioidaan, kun ranteesi ovat piilossa. Korvaa paljain käsin käytettävät ohjaimet ja keskeyttää puhelimen kameran seurannan. Osoita Joy-Con-ohjaimet eteenpäin, kun yhdistät tai nollaat seurantakeskuksen. @@ -2853,7 +2952,6 @@ Odotetaan vakaata pään seurantaa; nollaa seurantakeskus pyydettäessä. Kamerahavainto on liian vanha; uutta paria odotellessa Sovita %1$.2f px. Mittaa valitsemasi etäisyys vasemmasta kamerasta kalibrointikohteen keskustaan ​​ja vahvista sitten. - OpenCV ei saatavilla: %1$s %1$d tallennettua näkymää palautettu. Laske kalibrointi yrittääksesi uudelleen tai aloita uusi sarja, jos haluat ottaa lisää. Kuvattu %1$d / 24 katselukertaa. Pidä kalibrointitavoite kiinteänä; siirrä älylaseja seuraavaa näkymää varten. Kuvaus hylätty: %1$d / 12 vaadittua kulmaa näkyvissä molemmissa kameroissa. Näytä lisää kalibrointikohdetta, pidä paikallaan ja yritä sitten uudelleen. diff --git a/app/src/main/res/values-fr/gestures.xml b/app/src/main/res/values-fr/gestures.xml new file mode 100644 index 000000000..031725fa1 --- /dev/null +++ b/app/src/main/res/values-fr/gestures.xml @@ -0,0 +1,91 @@ + + + Gestes + Paramètres des gestes tactiles + Personnalisez les gestes de souris et de clavier. Sélectionnez Gestes comme mode tactile pour les utiliser. + Appui + Glissement à un doigt + Appui prolongé + Double appui (double clic gauche) + Appui à deux doigts + Glissement à deux doigts + Maintien à deux doigts + Appui à trois doigts + Glissement à trois doigts + Maintien à trois doigts + Pincer pour zoomer + Maintenir le bouton enfoncé + Cliquer une fois + Délai de maintien + Intervalle du double appui + Rétablir les paramètres des gestes d\'origine + Annuler + Choisir une action + Clic gauche + Clic droit + Clic du milieu + Afficher le clavier + Clic gauche et glissement + Clic droit et glissement + Déplacement avec le bouton du milieu + Déplacement avec le bouton du milieu (inversé) + Touches fléchées + Touches fléchées (inversées) + WASD (inversé) + Molette de la souris + Seuil des gestes : %1$d px + Afficher le geste reconnu + Un doigt + Deux doigts + Trois doigts + Options + Valeurs par défaut + Ce jeu + Utiliser les valeurs par défaut + Enregistrer les modifications + Fermer + ACTION + Rechercher des actions de souris ou des touches du clavier + Effacer la recherche + Tout + Aucune action correspondante. Essayez un autre nom de touche ou d\'action. + Souris + Actions de l\'application + Navigation + Lettres + Chiffres + Touches de fonction + Touches de modification + Pavé numérique + Symboles + Mouvement de la souris + Direct + Relatif + Reconnaissance des gestes + Retour visuel + Comportement du bouton de souris + %1$d ms + Pavé numérique %1$s + Échap + Retour arrière + Entrée + Tab + Espace + Suppr + Inser + Début + Fin + Page précédente + Page suivante + Impr. écran + Flèche haut + Flèche bas + Flèche gauche + Flèche droite + Verr. maj. + Verr. num. + Arrêt défil. + %1$s gauche + %1$s droite + Maj + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e429ead98..27954b6c4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -30,7 +30,7 @@ Génération d\'images Sélectionnez le mode de génération d\'images à utiliser Désactivé - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptatif %1$d Nécessite Lossless Scaling sur Steam @@ -176,6 +176,8 @@ %1$s via FEX Ce programme est conçu pour les PC %1$s et s\'exécute via FEX, qui n\'est pas encore configuré. Ouvrez Steam une fois en étant connecté : DroidDeck lui fait télécharger FEX et le Steam Linux Runtime nécessaires, puis ce programme s\'ouvre. Ce programme + Impossible de télécharger le client Steam. Votre réseau ou votre VPN bloque peut-être les serveurs de Steam ; vérifiez la connexion et réessayez. + Impossible de configurer le client Steam. Vérifiez la connexion et l\'espace libre, puis réessayez. %1$s est conçu pour les PC x86 et s\'exécute via FEX, qui n\'est pas encore installé. Lancez Steam une fois en étant connecté et laissez-le télécharger FEX et le Steam Linux Runtime, puis relancez ce programme. ID d\'application Flathub ou sa page Flathub. Si Flatpak n\'est pas encore configuré, il le sera d\'abord. Fichier @@ -305,6 +307,8 @@ Client Superposition de performance Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session. + Revenir à DroidDeck après un jeu + Quand un jeu lancé depuis DroidDeck ou un autre lanceur se ferme, Steam se ferme proprement (après la synchronisation des sauvegardes cloud) et vous y revenez. Désactivé : vous restez dans Steam. Réparer le client Steam Retélécharge le client Steam au prochain démarrage de Steam. Utile si Steam reste bloqué sur son écran de chargement. Les jeux, la connexion et les paramètres sont conservés. Le client Steam sera retéléchargé au prochain démarrage de Steam. @@ -501,7 +505,9 @@ Supprimer cette version originale ? Cette version originale de %1$s provient d\'une version antérieure de %2$s. La version originale de la version actuelle sera toujours conservée. Dans Nightlies · %1$d - Actualiser pour lister les paquets dans Nightlies. + Stable + Nightly + Actualiser pour lister les paquets dans Nightlies et DroidDeck. Aucun nouvel élément depuis la dernière vérification. Importer .wcp Restaurer les fichiers originaux de ce Proton @@ -537,7 +543,7 @@ Uniquement pour Adreno. Moins de saccades, mais plus de consommation d\'énergie et de chaleur. Correctifs de session Turnip : rendu sysmem - Steam et la plupart des jeux seront plus rapides. Requis pour Adreno 710/720/722. + Le client Steam sera plus rapide ; en jeu, le pilote choisit à chaque passe entre rendu par tuiles et rendu en mémoire système. Requis pour Adreno 710/720/722. Ignorer l\'assistant xalia de Steam droiddeck-ntsync (expérimental) Un substitut en espace utilisateur du pilote Linux ntsync, pour les tests. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session. @@ -579,6 +585,16 @@ Page précédente Page suivante HUD de performance + Fréquence d\'images + Température de la batterie + Graphique de performance + Couleur + Menthe + Blanc + Ciel + Ambre + Violet + Opacité Mode de mise à l\'échelle Netteté de la mise à l\'échelle Effets d\'écran @@ -729,6 +745,9 @@ %1$d%% (par défaut) Thème Apparence du lanceur + Icône de l\'app + Sombre + Clair Utiliser comme écran d\'accueil DroidDeck peut être utilisé comme application d\'écran d\'accueil DroidDeck ne sera pas proposé comme application d\'écran d\'accueil @@ -1038,6 +1057,9 @@ Jeux ajoutés Jeu personnalisé Stockage des jeux + Supprime ses fichiers et le retire de Steam. + Retirer %1$s ? + Supprime son raccourci. Les fichiers du jeu restent. Sélectionne la meilleure combinaison de pilotes pour ce GPU et les maintient à jour Choisir manuellement la combinaison de pilotes Vérification des pilotes… @@ -1395,6 +1417,14 @@ Fond noir, texte blanc, et le bleu pour tout élément sélectionné, ciblé ou pressé. Marine électrique Un bleu plus vif, sur des surfaces qui tirent vers le marine plutôt que vers le gris. + Dégradé + Vos propres couleurs primaire et secondaire, fondues sur les boutons et les éléments en surbrillance. + Primaire + Secondaire + Teinte + Saturation + Luminosité + Couleur hex Échap Inser Suppr @@ -1780,7 +1810,8 @@ L\'original de la version installée est conservé : c\'est le moyen d\'y revenir. Non enregistré Original %1$s de %2$s supprimé. - Les téléchargements proviennent uniquement des versions Nightlies + Les téléchargements proviennent uniquement des versions Nightlies et DroidDeck + Échec du téléchargement : cette version nightly a peut-être été remplacée par une plus récente. Actualisez la liste et réessayez. Cette liste de paquets est antérieure aux sommes de contrôle - actualisez-la et réessayez Somme de contrôle incorrecte @@ -1939,9 +1970,10 @@ Boutiques Paramètres des boutiques Paramètres + Organiser les onglets + Terminé (non connecté) Boutique - Installés Bibliothèque Tous Téléchargements @@ -1953,10 +1985,8 @@ Rester connecté Se déconnecter Rechercher dans le catalogue %1$s… - Rechercher dans les jeux installés… Rechercher dans votre bibliothèque… Rechercher - Aucun jeu installé Chargement Aucun jeu Chargement de la boutique… @@ -1975,7 +2005,19 @@ Voir sur %1$s Acheter · %1$s Installé + Installé + Non installé Dans la bibliothèque + Clé d\'API + Obtenir une clé + Clé refusée + Absent de votre bibliothèque itch.io + Pas de téléchargement Windows pour ce jeu + Aucun jeu Windows trouvé dans le téléchargement + Impossible d\'extraire le téléchargement (code %1$d) + itch.io n\'a pas répondu + Les archives %1$s ne sont pas prises en charge + itch.io a répondu HTTP %1$d Gratuit Reprendre Actualiser @@ -1989,6 +2031,7 @@ Date de sortie Vue liste Vue cartes + Vue jaquettes Afficher le contenu pour adultes Epic EOS @@ -2024,6 +2067,9 @@ Dernière synchro : %1$s Pas encore synchronisé %1$d fichiers + Déjà à jour + Connectez-vous à la boutique pour synchroniser les sauvegardes + Les sauvegardes n’ont pas été synchronisées installé dans votre bibliothèque boutique @@ -2152,7 +2198,7 @@ Taille Enregistrer Rétablir par défaut - Dessiné sur l\'arrière-plan du jeu, comme le fait Steam. La position exacte peut varier de quelques pixels. + Cet emplacement exact peut varier de quelques pixels. Ce jeu n\'a pas encore de logo. Choisissez-en un dans Logo pour le voir ici. Impossible d\'enregistrer la position du logo. Réessayez. Arrière-plan @@ -2326,7 +2372,7 @@ Terminé Impossible d\'enregistrer la disposition tactile Disposition tactile enregistrée sur Steam - Appli %1$d + Appli %1$s Libellé (facultatif) Les icônes de Steam n\'ont pas été trouvées @@ -2419,6 +2465,15 @@ Champ de vision SteamVR Plus bas rapproche SteamVR et remplit les lunettes ; plus haut montre davantage autour de vous. En jeu sur %1$s + Recentrer + Menu SteamVR + Sticks : mains ⇄ jeu + Manettes : %1$s + Clavier + Menu de session + Arrêter SteamVR + Luminosité %1$d / %2$d + Batterie %1$d%% · %2$d °C Calibrage du suivi Réduit la dérive et aligne la référence horizontale. Le calibrage enregistré est appliqué à ces lunettes. @@ -2473,6 +2528,7 @@ En attente de la connexion Ethernet USB des lunettes. Ce téléphone ne la rend peut-être pas accessible aux applications. La connexion XREAL a été interrompue ou a renvoyé une réponse non prise en charge. Nouvelle tentative… Prise en charge expérimentale des 1S. Utilisez le mode Follow et désactivez la stabilisation dans le menu des lunettes. Le sens du suivi et les modes d\'affichage restent à tester sur l\'appareil. + Suivi expérimental des mouvements de la tête pour Pro 2. Le réglage de la luminosité et du volume ainsi que le changement de mode d\'affichage ne sont pas encore pris en charge. Développeur Éditeur Sortie @@ -2613,32 +2669,39 @@ Jouer quand même %d installé + %d installés %d installés %d sur la carte SD + %d sur la carte SD %d sur la carte SD %d outil + %d outils %d outils Dossiers de préfixe vides et cache de shaders de %d jeu + Dossiers de préfixe vides et caches de shaders de %d jeux Dossiers de préfixe vides et caches de shaders de %d jeux Le préfixe de %d jeu contient encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez. + Les préfixes de %d jeux contiennent encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez. Les préfixes de %d jeux contiennent encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez. %d jeu + %d jeux %d jeux Proton par défaut de DroidDeck Option Proton 11 de DroidDeck Utilisé par %d jeu + Utilisé par %d jeux Utilisé par %d jeux Composants @@ -2682,16 +2745,18 @@ Supprime l\'application (%1$s) et tout environnement d\'exécution que rien d\'autre n\'utilise. Ses données dans votre dossier personnel restent. %d paquet + %d paquets %d paquets %d application + %d applications %d applications Vidéo de démarrage Vidéo DroidDeck · %1$d i/s - 60 i/s est plus fluide mais sollicite davantage le processeur pendant le démarrage de Steam. Une vidéo de démarrage choisie dans les paramètres de Steam est prioritaire. + 60 i/s est plus fluide mais sollicite davantage le processeur pendant le démarrage de Steam. Une vidéo choisie dans les paramètres de Steam apparaît aussi ici. Son Votre propre vidéo MP4, MOV, MKV ou WebM. Les autres formats et les fichiers de plus de %1$d Mo sont convertis en WebM 720p sur l\'appareil, jusqu\'à %2$d secondes. @@ -2705,6 +2770,51 @@ Conversion de %1$s pour Steam. Gardez DroidDeck ouvert. Cet appareil ne peut pas décoder cette vidéo. Impossible d\'importer cette vidéo + Supprimer %1$s ? + Cela supprime la vidéo de DroidDeck et du dossier de vidéos de Steam. + Installées + Importer + SteamDeckRepo + Son + Vidéos installées + Parcourir + DroidDeck + Importée + SteamDeckRepo + Steam + Boutique de points + Rapport sur les vidéos de démarrage + Enregistre dans un fichier texte ce que DroidDeck et Steam savent des vidéos de démarrage, à joindre à un rapport de bogue. + Enregistrer + Enregistré dans %1$s + Impossible d\'enregistrer le rapport. + Volume + Durée + %1$d s + Nombre de secondes du film qui sont lues. Tout ce qui dépasse est coupé à la fin. + Rechercher dans SteamDeckRepo + Tous les appareils + Steam Deck + Steam Machine + Tendances + Les plus téléchargées + Les plus aimées + Les plus récentes + Impossible de joindre SteamDeckRepo + Réessayer + Aucune vidéo trouvée + Installer + Utiliser + Utilisé + %1$s installée + Impossible de télécharger cette vidéo + Précédent + Suivant + Page %1$d + Effacer + Aller à la page + Aperçu + Impossible de lire cet aperçu Caméras Luma Ultra — Expérimental Suivi de la position des Joy-Con Utilisez les caméras des lunettes connectées pour positionner vos manettes Joy-Con appariées. La rotation et les entrées des boutons proviennent des manettes Joy-Con. La position est estimée lorsque vos poignets sont cachés. Remplace les manettes utilisées à mains nues et interrompt le suivi par la caméra du téléphone. Pointez les manettes Joy-Con vers l\'avant lors de la connexion ou de la réinitialisation du centre de suivi. @@ -2878,7 +2988,6 @@ Attente d\'un suivi de tête stable ; réinitialisation du centre de suivi si demandé. L\'observation par caméra est trop ancienne ; en attendant une nouvelle paire Ajuster %1$.2f px. Mesurez la distance sélectionnée entre la caméra gauche et le centre de la cible de calibrage, puis validez. - OpenCV indisponible : %1$s %1$d vues enregistrées restaurées. Calculez l’étalonnage pour réessayer ou démarrez un nouvel ensemble pour en capturer davantage. Capturé %1$d / 24 vues. Gardez la cible d\'étalonnage fixe ; déplacez les lunettes intelligentes pour la vue suivante. Capture rejetée : %1$d / 12 coins requis visibles dans les deux caméras. Affichez davantage la cible d\'étalonnage, restez immobile, puis réessayez. diff --git a/app/src/main/res/values-hi/gestures.xml b/app/src/main/res/values-hi/gestures.xml new file mode 100644 index 000000000..12bdd6381 --- /dev/null +++ b/app/src/main/res/values-hi/gestures.xml @@ -0,0 +1,91 @@ + + + जेस्चर + टच जेस्चर सेटिंग + माउस और कीबोर्ड जेस्चर बदलें। इस्तेमाल करने के लिए टच मोड में जेस्चर चुनें। + टैप + एक उँगली से खींचना + देर तक दबाना + दो बार टैप (दो बार बायाँ क्लिक) + दो उँगलियों से टैप + दो उँगलियों से खींचना + दो उँगलियों से दबाए रखना + तीन उँगलियों से टैप + तीन उँगलियों से खींचना + तीन उँगलियों से दबाए रखना + पिंच करके ज़ूम करें + बटन दबाए रखें + एक बार क्लिक करें + दबाए रखने का विलंब + दो टैप के बीच का अंतराल + जेस्चर की मूल सेटिंग वापस लाएँ + रद्द करें + कार्रवाई चुनें + बायाँ क्लिक + दायाँ क्लिक + बीच का क्लिक + कीबोर्ड दिखाएं + बायाँ क्लिक करके खींचना + दायाँ क्लिक करके खींचना + माउस के बीच वाले बटन से दृश्य खिसकाना + माउस के बीच वाले बटन से दृश्य खिसकाना (उल्टा) + तीर वाली कुंजियाँ + तीर वाली कुंजियाँ (उल्टी) + WASD (उल्टा) + माउस व्हील + जेस्चर की सीमा: %1$d पिक्सेल + पहचाना गया जेस्चर दिखाएँ + एक उँगली + दो उँगलियाँ + तीन उँगलियाँ + विकल्प + डिफ़ॉल्ट + यह गेम + डिफ़ॉल्ट इस्तेमाल करें + बदलाव सेव करें + बंद करें + कार्रवाई + माउस की कार्रवाइयाँ या कीबोर्ड की कुंजियाँ खोजें + खोज मिटाएँ + सभी + कोई मिलती-जुलती कार्रवाई नहीं मिली। किसी दूसरी कुंजी या कार्रवाई का नाम आज़माएँ। + माउस + ऐप की कार्रवाइयाँ + नेविगेशन + अक्षर + अंक + फ़ंक्शन कुंजियाँ + मॉडिफ़ायर कुंजियाँ + नंबर पैड + चिह्न + माउस की गति + डायरेक्ट + सापेक्ष + जेस्चर की पहचान + दृश्य प्रतिक्रिया + माउस बटन का व्यवहार + %1$d मिलीसेकंड + नंबर पैड %1$s + एस्केप + बैकस्पेस + एंटर + Tab + Space + डिलीट + इंसर्ट + Home + End + पेज अप + पेज डाउन + प्रिंट स्क्रीन + ऊपर का तीर + नीचे का तीर + बायाँ तीर + दायाँ तीर + कैप्स लॉक + नम लॉक + स्क्रॉल लॉक + बायाँ %1$s + दायाँ %1$s + शिफ़्ट + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 556e2b538..c7d0f6005 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -30,7 +30,7 @@ फ़्रेम जनरेशन फ़्रेम जनरेशन मोड चुनें बंद - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG एडैप्टिव %1$d Steam पर Lossless Scaling की ज़रूरत है @@ -176,6 +176,8 @@ FEX के ज़रिए %1$s यह प्रोग्राम %1$s पीसी के लिए बना है और FEX के ज़रिए चलता है, जो अभी सेट नहीं है। साइन इन रहते हुए Steam एक बार खोलें: DroidDeck इससे FEX और ज़रूरी Steam Linux Runtime डाउनलोड करवा लेगा, फिर यह खुल जाएगा। यह प्रोग्राम + Steam क्लाइंट डाउनलोड नहीं हो सका। हो सकता है आपका नेटवर्क या VPN Steam के सर्वर ब्लॉक कर रहा हो; कनेक्शन जांचें और फिर से कोशिश करें। + Steam क्लाइंट सेट अप नहीं हो सका। कनेक्शन और खाली स्टोरेज जांचें, फिर से कोशिश करें। %1$s x86 पीसी के लिए बना है और FEX के ज़रिए चलता है, जो अभी इंस्टॉल नहीं है। साइन इन रहते हुए Steam एक बार शुरू करें और इसे FEX और Steam Linux Runtime डाउनलोड करने दें, फिर इसे फिर से शुरू करें। एक Flathub ऐप ID या उसका Flathub पेज। अगर Flatpak अभी सेट नहीं है, तो पहले वह सेट होगा। फ़ाइल @@ -502,7 +504,9 @@ यह ओरिजिनल हटाएं? यह %1$s ओरिजिनल %2$s के पहले वाले बिल्ड से है। मौजूदा बिल्ड का ओरिजिनल हमेशा रखा जाता है। Nightlies पर · %1$d - Nightlies के पैकेज सूचीबद्ध करने के लिए रीफ़्रेश करें। + स्थिर + नाइटली + Nightlies और DroidDeck के पैकेज सूचीबद्ध करने के लिए रीफ़्रेश करें। पिछली जांच के बाद कुछ नया नहीं है। .wcp इंपोर्ट करें इस Proton का ओरिजिनल वापस लाएं @@ -538,7 +542,7 @@ सिर्फ़ Adreno के लिए। कम हिचकोले, लेकिन ज़्यादा बैटरी खर्च और गर्मी। सेशन फ़िक्स Turnip: sysmem रेंडरिंग - Steam और ज़्यादातर गेम के लिए तेज़। Adreno 710/720/722 पर ज़रूरी। + Steam क्लाइंट के लिए तेज़; गेम में ड्राइवर हर पास के लिए टाइल या सिस्टम मेमोरी रेंडरिंग चुनता है। Adreno 710/720/722 पर ज़रूरी। Steam के xalia हेल्पर को छोड़ें droiddeck-ntsync (एक्सपेरिमेंटल) जांच के लिए Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है। @@ -580,6 +584,16 @@ पिछला पेज अगला पेज परफ़ॉर्मेंस HUD + फ़्रेम दर + बैटरी तापमान + परफ़ॉर्मेंस ग्राफ़ + रंग + मिंट + सफ़ेद + स्काई + एम्बर + वायलेट + ओपैसिटी स्केलिंग मोड स्केलिंग शार्पनेस स्क्रीन इफ़ेक्ट @@ -729,6 +743,9 @@ %1$d%% (डिफ़ॉल्ट) थीम लॉन्चर की दिखावट + ऐप आइकन + गहरा + हल्का होम स्क्रीन के तौर पर इस्तेमाल करें DroidDeck होम ऐप हो सकता है DroidDeck को होम ऐप के तौर पर कभी नहीं दिखाया जाता @@ -1035,6 +1052,9 @@ जोड़ा गया गेम कस्टम गेम गेम स्टोरेज + इसकी फ़ाइलें हटाता है और इसे Steam से हटा देता है। + %1$s हटाएं? + इसका शॉर्टकट हटाता है। गेम की फ़ाइलें बनी रहती हैं। इस GPU के लिए सबसे बेहतर जोड़ी, हमेशा नवीनतम खुद एक जोड़ी चुनें ड्राइवर देखे जा रहे हैं… @@ -1384,6 +1404,14 @@ काली ज़मीन, सफ़ेद टाइप, चुने, फ़ोकस या दबाए गए हर चीज़ के लिए ब्लू रंग। इलेक्ट्रिक नेवी ब्लू रंग ज़्यादा चमकीला, उन सतहों पर जो ग्रे के बजाय नेवी की ओर झुकती हैं। + ग्रेडिएंट + आपके अपने प्राथमिक और द्वितीयक रंग, बटनों और हाइलाइट पर मिश्रित। + प्राथमिक + द्वितीयक + रंगत + संतृप्ति + चमक + हेक्स रंग Esc Ins Del @@ -1762,7 +1790,8 @@ इंस्टॉल किए बिल्ड का ओरिजिनल रखा गया है: यह वापस जाने का रास्ता है। सेव नहीं है %2$s का %1$s ओरिजिनल हटाया गया। - डाउनलोड सिर्फ़ Nightlies रिलीज़ से आते हैं + डाउनलोड सिर्फ़ Nightlies और DroidDeck रिलीज़ से आते हैं + डाउनलोड विफल: हो सकता है यह नाइटली बिल्ड किसी नए बिल्ड से बदल दिया गया हो। सूची रीफ़्रेश करें और फिर से कोशिश करें। यह पैकेज लिस्ट चेकसम से पुरानी है - इसे रीफ़्रेश करें और फिर कोशिश करें चेकसम मेल नहीं खाया @@ -1894,7 +1923,7 @@ फ़िल्टर शार्पनेस फ़्रेम रेट %1$s स्केलिंग को %2$s पर सेट करता है। - Match screen (%1$d×%2$d) + स्क्रीन से मिलाएँ (%1$d×%2$d) Follow screen (खुलने या बंद होने पर आकार बदलता है) Steam डिवाइस जानकारी दिखाएं @@ -1921,9 +1950,10 @@ स्टोर स्टोर सेटिंग सेटिंग + टैब व्यवस्थित करें + हो गया (साइन इन नहीं है) स्टोर - इंस्टॉल किए लाइब्रेरी सभी डाउनलोड @@ -1935,10 +1965,8 @@ साइन इन रहें साइन आउट करें %1$s के कैटलॉग में सर्च करें… - इंस्टॉल किए गेम में सर्च करें… अपनी लाइब्रेरी में सर्च करें… सर्च - कुछ इंस्टॉल नहीं है लोड हो रहा है कोई गेम नहीं स्टोर लोड हो रहा है… @@ -1957,7 +1985,19 @@ %1$s पर देखें खरीदें · %1$s इंस्टॉल है + इंस्टॉल है + इंस्टॉल नहीं है लाइब्रेरी में + API कुंजी + कुंजी पाएं + कुंजी अस्वीकृत + आपकी itch.io लाइब्रेरी में नहीं + इस गेम के लिए Windows डाउनलोड नहीं है + डाउनलोड में कोई Windows गेम नहीं मिला + डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d) + itch.io ने जवाब नहीं दिया + %1$s आर्काइव समर्थित नहीं हैं + itch.io ने HTTP %1$d लौटाया फ़्री फिर से शुरू करें रीफ़्रेश करें @@ -1971,6 +2011,7 @@ रिलीज़ तिथि सूची दृश्य कार्ड दृश्य + कवर व्यू वयस्क कंटेंट दिखाएं Epic EOS @@ -2006,6 +2047,9 @@ आख़िरी सिंक %1$s अभी सिंक नहीं हुआ %1$d फ़ाइलें + पहले से अप टू डेट + सेव सिंक करने के लिए स्टोर में साइन इन करें + सेव सिंक नहीं हुए इंस्टॉल है आपकी लाइब्रेरी में स्टोर @@ -2137,7 +2181,7 @@ आकार सहेजें डिफ़ॉल्ट पर लौटाएँ - गेम की पृष्ठभूमि के ऊपर बनता है, जैसे Steam करता है। सटीक स्थिति कुछ पिक्सेल अलग हो सकती है। + यह सटीक जगह कुछ पिक्सल तक अलग हो सकती है। इस गेम का लोगो अभी नहीं है। यहाँ देखने के लिए लोगो में से कोई चुनें। लोगो की स्थिति सहेजी नहीं जा सकी। फिर कोशिश करें। बैकग्राउंड @@ -2309,7 +2353,7 @@ हो गया टच लेआउट सेव नहीं हो सका टच लेआउट Steam में सेव हो गया - ऐप %1$d + ऐप %1$s लेबल (वैकल्पिक) Steam के आइकॉन नहीं मिले @@ -2400,6 +2444,15 @@ SteamVR फ़ील्ड ऑफ़ व्यू कम करने पर SteamVR पास आता है और चश्मा भर देता है; ज़्यादा करने पर आपके आस-पास ज़्यादा दिखता है। %1$s पर खेल रहे हैं + फिर से केंद्रित करें + SteamVR मेनू + स्टिक: हाथ ⇄ गेम + कंट्रोलर: %1$s + कीबोर्ड + सत्र मेनू + SteamVR रोकें + चमक %1$d / %2$d + बैटरी %1$d%% · %2$d °C ट्रैकिंग कैलिब्रेशन ड्रिफ़्ट कम करता है और क्षैतिज संदर्भ को सीध में लाता है। इस चश्मे पर सेव किया गया कैलिब्रेशन लागू है। @@ -2454,6 +2507,7 @@ चश्मे के USB Ethernet कनेक्शन का इंतज़ार है। हो सकता है यह फ़ोन इसे ऐप्स को उपलब्ध न कराए। XREAL कनेक्शन टूट गया या उसने ऐसा जवाब दिया जो समर्थित नहीं है। फिर से कोशिश हो रही है… 1S के लिए प्रायोगिक सपोर्ट। चश्मे के मेनू में Follow मोड चुनें और स्टेबिलाइज़ेशन बंद करें। ट्रैकिंग की दिशाएं और डिस्प्ले मोड अभी डिवाइस पर टेस्ट होने बाकी हैं। + Pro 2 के लिए प्रायोगिक हेड ट्रैकिंग। चमक और वॉल्यूम बदलना और डिस्प्ले मोड स्विच करना अभी समर्थित नहीं है। Cardboard VR Cardboard मोड फ़ोन VR व्यूअर के लिए बाईं और दाईं आंख के व्यू। @@ -2666,7 +2720,7 @@ बूट वीडियो वीडियो DroidDeck · %1$d fps - 60 fps ज़्यादा स्मूथ दिखता है, लेकिन Steam शुरू होते समय CPU ज़्यादा इस्तेमाल करता है। Steam की अपनी सेटिंग्स में चुना गया स्टार्टअप वीडियो प्राथमिकता लेता है। + 60 fps ज़्यादा स्मूथ दिखता है, लेकिन Steam शुरू होते समय CPU ज़्यादा इस्तेमाल करता है। Steam की अपनी सेटिंग्स में चुना गया वीडियो यहाँ भी दिखता है। आवाज़ अपना वीडियो MP4, MOV, MKV या WebM. दूसरे फ़ॉर्मैट और %1$d MB से बड़ी फ़ाइलें डिवाइस पर 720p WebM में बदली जाती हैं, %2$d सेकंड तक। @@ -2680,6 +2734,51 @@ %1$s को Steam के लिए बदला जा रहा है। DroidDeck खुला रखें। यह डिवाइस उस वीडियो को डिकोड नहीं कर सकता। वीडियो इंपोर्ट नहीं हो सका + %1$s हटाएं? + इससे वीडियो DroidDeck और Steam के वीडियो फ़ोल्डर से मिट जाएगा। + इंस्टॉल किए गए + इंपोर्ट करें + SteamDeckRepo + आवाज़ + इंस्टॉल किए गए वीडियो + ब्राउज़ करें + DroidDeck + इंपोर्ट किया गया + SteamDeckRepo + Steam + पॉइंट्स शॉप + बूट वीडियो रिपोर्ट + बूट वीडियो के बारे में DroidDeck और Steam की जानकारी एक टेक्स्ट फ़ाइल में सहेजता है, जिसे आप बग रिपोर्ट के साथ जोड़ सकते हैं। + सहेजें + %1$s में सहेजा गया + रिपोर्ट सहेजी नहीं जा सकी। + वॉल्यूम + अवधि + %1$d सेक + वीडियो के कितने सेकंड चलेंगे। इससे लंबा हिस्सा अंत में काट दिया जाता है। + SteamDeckRepo में खोजें + सभी डिवाइस + Steam Deck + Steam Machine + ट्रेंडिंग + सबसे ज़्यादा डाउनलोड किए गए + सबसे ज़्यादा पसंद किए गए + सबसे नए + SteamDeckRepo से कनेक्ट नहीं हो सका + फिर कोशिश करें + कोई वीडियो नहीं मिला + इंस्टॉल करें + इस्तेमाल करें + इस्तेमाल में + %1$s इंस्टॉल हो गया + वह वीडियो डाउनलोड नहीं हो सका + पिछला + अगला + पेज %1$d + साफ़ करें + पेज पर जाएँ + पूर्वावलोकन + यह पूर्वावलोकन चलाया नहीं जा सका Luma Ultra कैमरे — एक्सपेरिमेंटल Joy-Con पोजीशन ट्रैकिंग अपने पेयर्ड Joy-Con कंट्रोलर्स को सही जगह पर लगाने के लिए स्मार्ट ग्लास के कैमरे का इस्तेमाल करें। रोटेशन और बटन इनपुट Joy-Con कंट्रोलर्स से आते हैं। जब आपकी कलाइयां छिपी होती हैं, तो पोजीशन का अंदाज़ा लगाया जाता है। नंगे हाथों से चलने वाले कंट्रोलर्स को बदलता है और फ़ोन-कैमरा ट्रैकिंग को पॉज़ करता है। ट्रैकिंग सेंटर को कनेक्ट या रीसेट करते समय Joy-Con कंट्रोलर्स को आगे की ओर पॉइंट करें। @@ -2853,7 +2952,6 @@ स्थिर हेड ट्रैकिंग की प्रतीक्षा कर रहा है; अनुरोध किए जाने पर ट्रैकिंग सेंटर को रीसेट करें कैमरा अवलोकन बहुत पुराना है; एक नई जोड़ी का इंतजार है फ़िट %1$.2f पीएक्स। बाएं कैमरे से अंशांकन लक्ष्य केंद्र तक अपनी चयनित दूरी को मापें, फिर सत्यापित करें। - OpenCV अनुपलब्ध: %1$s %1$d सहेजे गए दृश्य पुनर्स्थापित किए गए। पुनः प्रयास करने के लिए अंशांकन की गणना करें, या अधिक कैप्चर करने के लिए एक नया सेट प्रारंभ करें। कैप्चर किया गया %1$d / 24 बार देखा गया। अंशांकन लक्ष्य निश्चित रखें; अगले दृश्य के लिए स्मार्ट चश्मा हटाएँ। कैप्चर अस्वीकृत: %1$d / दोनों कैमरों में 12 आवश्यक कोने दिखाई दे रहे हैं। अंशांकन लक्ष्य को और अधिक दिखाएं, स्थिर रखें, फिर पुनः प्रयास करें। diff --git a/app/src/main/res/values-hu/gestures.xml b/app/src/main/res/values-hu/gestures.xml new file mode 100644 index 000000000..e5279c2e2 --- /dev/null +++ b/app/src/main/res/values-hu/gestures.xml @@ -0,0 +1,91 @@ + + + Gesztusok + Érintési gesztusok beállításai + Szabd testre az egér- és billentyűzetgesztusokat. Használatukhoz válaszd a Gesztusok érintési módot. + Koppintás + Húzás egy ujjal + Hosszú nyomás + Dupla koppintás (dupla bal kattintás) + Koppintás két ujjal + Húzás két ujjal + Nyomva tartás két ujjal + Koppintás három ujjal + Húzás három ujjal + Nyomva tartás három ujjal + Csippentéses nagyítás + Gomb nyomva tartása + Egyszeri kattintás + Nyomva tartás késleltetése + Dupla koppintás időköze + Eredeti gesztusbeállítások visszaállítása + Mégse + Művelet kiválasztása + Bal kattintás + Jobb kattintás + Középső kattintás + Billentyűzet megjelenítése + Bal kattintás és húzás + Jobb kattintás és húzás + Pásztázás középső egérgombbal + Pásztázás középső egérgombbal (fordított) + Nyílbillentyűk + Nyílbillentyűk (fordított) + WASD (fordított) + Egérgörgő + Gesztusküszöb: %1$d px + Felismert gesztus megjelenítése + Egy ujj + Két ujj + Három ujj + Beállítások + Alapértelmezések + Ez a játék + Alapbeállítások használata + Módosítások mentése + Bezárás + MŰVELET + Egérműveletek vagy billentyűk keresése + Keresés törlése + Összes + Nincs megfelelő művelet. Próbálj másik billentyű- vagy műveletnevet. + Egér + Alkalmazásműveletek + Navigáció + Betűk + Számok + Funkcióbillentyűk + Módosítóbillentyűk + Numerikus billentyűzet + Szimbólumok + Egérmozgás + Közvetlen + Relatív + Gesztusfelismerés + Vizuális visszajelzés + Egérgomb működése + %1$d ms + Numerikus billentyűzet %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Fel nyíl + Le nyíl + Balra nyíl + Jobbra nyíl + Caps Lock + Num Lock + Scroll Lock + Bal %1$s + Jobb %1$s + Shift + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index d60f27fa9..c146857a5 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -30,7 +30,7 @@ Képkocka-generálás Válassza ki a képkocka-generálás módját Ki - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptív %1$d A Lossless Scaling szükséges a Steamen @@ -176,6 +176,8 @@ %1$s a FEX-en keresztül Ez a program %1$s PC-khez készült, és a FEX-en keresztül fut, amely még nincs beállítva. Nyissa meg egyszer a Steamet bejelentkezve: a DroidDeck ekkor letölti a FEX-et és a szükséges Steam Linux Runtime-ot, majd ez megnyílik. Ez a program + A Steam-klienst nem sikerült letölteni. Lehet, hogy a hálózat vagy a VPN blokkolja a Steam szervereit; ellenőrizd a kapcsolatot, és próbáld újra. + A Steam-klienst nem sikerült beállítani. Ellenőrizd a kapcsolatot és a szabad tárhelyet, majd próbáld újra. A %1$s x86 PC-khez készült, és a FEX-en keresztül fut, amely még nincs telepítve. Indítsa el egyszer a Steamet bejelentkezve, és hagyja letölteni a FEX-et és a Steam Linux Runtime-ot, majd indítsa el ezt újra. Egy Flathub-alkalmazásazonosító vagy a Flathub-oldala. A Flatpak előbb beállításra kerül, ha még nincs beállítva. Fájl @@ -502,7 +504,9 @@ Törli ezt az eredetit? Ez a %1$s eredeti a %2$s egy korábbi buildjéből származik. Az aktuális build eredetije mindig megmarad. A Nightliesen · %1$d - Frissítsen a Nightliesen lévő csomagok listázásához. + Stabil + Nightly + Frissítsen a Nightlies és a DroidDeck csomagjainak listázásához. Nincs semmi új az utolsó ellenőrzés óta. .wcp importálása Ennek a Protonnak az eredetijének visszaállítása @@ -538,7 +542,7 @@ Csak Adreno. Kevesebb akadás, nagyobb akkumulátorhasználat és hőtermelés. Munkamenet-javítások Turnip: sysmem renderelés - Gyorsabb a Steamhez és a legtöbb játékhoz. Adreno 710/720/722-n szükséges. + Gyorsabb a Steam kliensnek; játékokban a meghajtó menetenként választ a csempés és a rendszermemóriás renderelés között. Adreno 710/720/722-n szükséges. A Steam xalia segítőjének kihagyása droiddeck-ntsync (kísérleti) A Linux ntsync illesztőprogram felhasználói szintű helyettesítője, teszteléshez. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes. @@ -580,6 +584,16 @@ Előző oldal Következő oldal Teljesítmény HUD + Képkockasebesség + Akkuhőmérséklet + Teljesítménygrafikon + Szín + Menta + Fehér + Égszínkék + Borostyán + Lila + Átlátszatlanság Méretezési mód Méretezési élesség Képernyőeffektek @@ -729,6 +743,9 @@ %1$d%% (Alapértelmezett) Téma Az indítóalkalmazás megjelenése + Alkalmazásikon + Sötét + Világos Használat kezdőképernyőként A DroidDeck lehet a kezdőképernyő alkalmazás A DroidDeck soha nem jelenik meg kezdőképernyő alkalmazásként @@ -1012,7 +1029,7 @@ Indítási beállítások Indítás Játékfájlok - Proton prefix + Proton-prefix Mentések keresése… Nem található mentésmappa név szerint. Az exportálás a teljes felhasználói mappát veszi. %1$d mentésmappa @@ -1035,6 +1052,9 @@ Hozzáadott játék Egyéni játék Játéktárhely + Törli a fájljait, és eltávolítja a Streamből. + %1$s eltávolítása? + Törli a parancsikont. A játék fájljai megmaradnak. A legjobb páros ehhez a GPU-hoz, naprakészen tartva Válasszon párost saját kezűleg Illesztőprogramok keresése… @@ -1384,6 +1404,14 @@ Fekete alap, fehér betűk, a kék minden kijelölt, fókuszált vagy lenyomott elemhez. Elektromos tengerészkék Fényesebb kék, szürke helyett tengerészkék felületeken. + Színátmenet + Saját elsődleges és másodlagos színed, átmenettel a gombokon és a kiemeléseken. + Elsődleges + Másodlagos + Színárnyalat + Telítettség + Fényerő + Hex szín Esc Ins Del @@ -1762,7 +1790,8 @@ A telepített build eredetije megmarad: ez a visszaút. Nincs tárolva A %2$s %1$s eredetije törölve. - A letöltések csak a Nightlies kiadásokból érkeznek + A letöltések csak a Nightlies és a DroidDeck kiadásokból érkeznek + A letöltés sikertelen: lehet, hogy ezt a nightly buildet egy újabb váltotta. Frissítse a listát, és próbálja újra. Ez a csomaglista régebbi, mint az ellenőrzőösszegek – frissítse, és próbálja újra Az ellenőrzőösszeg nem egyezik @@ -1921,9 +1950,10 @@ Áruházak Áruházbeállítások Beállítások + Fülek rendezése + Kész (nincs bejelentkezve) Áruház - Telepítve Könyvtár Összes Letöltések @@ -1935,10 +1965,8 @@ Bejelentkezve marad Kijelentkezés Keresés a %1$s katalógusban… - Keresés a telepített játékok között… Keresés a könyvtárában… Keresés - Nincs telepítve semmi Betöltés Nincs játék Az áruház betöltése… @@ -1957,7 +1985,19 @@ Megtekintés itt: %1$s Megvétel · %1$s Telepítve + Telepítve + Nincs telepítve A könyvtárban + API-kulcs + Kulcs beszerzése + A kulcs elutasítva + Nincs az itch.io-könyvtáradban + Ehhez a játékhoz nincs Windows-letöltés + A letöltésben nem található Windows-játék + A letöltés nem bontható ki (kód: %1$d) + Az itch.io nem válaszolt + A(z) %1$s archívumok nem támogatottak + Az itch.io HTTP %1$d választ adott Ingyenes Folytatás Frissítés @@ -1971,6 +2011,7 @@ Megjelenés dátuma Listanézet Kártyanézet + Borítónézet Felnőtt tartalom megjelenítése Epic EOS @@ -2006,6 +2047,9 @@ Utolsó szinkronizálás: %1$s Még nincs szinkronizálva %1$d fájl + Már naprakész + Jelentkezz be az áruházba a mentések szinkronizálásához + A mentések nem lettek szinkronizálva telepítve a könyvtárában áruház @@ -2137,7 +2181,7 @@ Méret Mentés Vissza az alapértelmezettre - A játék háttere fölött rajzolódik ki, ahogy a Steam is teszi. A pontos pozíció néhány pixellel eltérhet. + Ez a pontos hely néhány képpontnyit eltérhet. Ennek a játéknak még nincs logója. Válassz egyet a Logó alatt, hogy itt lásd. A logó pozícióját nem sikerült menteni. Próbáld újra. Háttér @@ -2309,7 +2353,7 @@ Kész Az érintőelrendezést nem sikerült elmenteni Az érintőelrendezés elmentve a Steambe - %1$d. alkalmazás + %1$s. alkalmazás Felirat (nem kötelező) A Steam ikonjai nem találhatók @@ -2400,6 +2444,15 @@ SteamVR látómező Kisebb érték közelebb hozza a SteamVR-t és kitölti a szemüveget; nagyobb többet mutat a környezetéből. Játék ezen: %1$s + Középre igazítás + SteamVR menü + Karok: kezek ⇄ játék + Kontrollerek: %1$s + Billentyűzet + Munkamenet menü + SteamVR leállítása + Fényerő %1$d / %2$d + Akkumulátor %1$d%% · %2$d °C Követés kalibrálása Csökkenti a sodródást, és beállítja a vízszintes referenciát. A szemüveg a mentett kalibrációt használja. @@ -2454,6 +2507,7 @@ Várakozás a szemüveg USB-s Ethernet-kapcsolatára. Lehet, hogy ez a telefon nem teszi elérhetővé az alkalmazások számára. Az XREAL-kapcsolat megszakadt, vagy nem támogatott választ adott. Újrapróbálkozás… Kísérleti 1S-támogatás. Használja a Follow módot, és kapcsolja ki a stabilizálást a szemüveg menüjében. A követési irányokat és a megjelenítési módokat még tesztelni kell az eszközön. + Kísérleti fejkövetés a Pro 2-höz. A fényerő és a hangerő beállítása, valamint a megjelenítési mód váltása még nem támogatott. Cardboard VR Cardboard mód Bal és jobb szem képe telefonos VR-szemüveghez. @@ -2666,7 +2720,7 @@ Indítóvideó Videó DroidDeck · %1$d fps - A 60 fps simább, de jobban terheli a processzort a Steam indulása közben. A Steam saját beállításaiban választott indítóvideó elsőbbséget élvez. + A 60 fps simább, de jobban terheli a processzort a Steam indulása közben. A Steam saját beállításaiban választott videó itt is megjelenik. Hang Saját videó MP4, MOV, MKV vagy WebM. Más formátumokat és a %1$d MB-nál nagyobb fájlokat az eszköz 720p WebM-re alakítja, legfeljebb %2$d másodpercig. @@ -2680,6 +2734,51 @@ %1$s átalakítása a Steamhez. Hagyd nyitva a DroidDecket. Ez az eszköz nem tudja dekódolni a videót. Nem sikerült importálni a videót + Eltávolítod: %1$s? + Ezzel törlődik a videó a DroidDeckből és a Steam videómappájából. + Telepítve + Importálás + SteamDeckRepo + Hang + Telepített videók + Tallózás + DroidDeck + Importált + SteamDeckRepo + Steam + Pontbolt + Indítóvideó-jelentés + Szöveges fájlba menti, amit a DroidDeck és a Steam tud az indítóvideókról, hibajelentéshez csatolható. + Mentés + Mentve: %1$s + A jelentést nem sikerült menteni. + Hangerő + Hossz + %1$d mp + A videó hány másodperce játszódik le. Ami ennél hosszabb, azt a végén levágjuk. + Keresés a SteamDeckRepo-ban + Minden eszköz + Steam Deck + Steam Machine + Népszerű + Legtöbbet letöltött + Legkedveltebb + Legújabb + Nem érhető el a SteamDeckRepo + Újra + Nem található videó + Telepítés + Használat + Használatban + %1$s telepítve + Nem sikerült letölteni a videót + Előző + Következő + %1$d. oldal + Törlés + Ugrás az oldalra + Előnézet + Az előnézetet nem sikerült lejátszani Luma Ultra kamerák — Kísérleti Joy-Con pozíciókövetés Használd az okosszemüveg kameráit a párosított Joy-Con kontrollerek pozicionálásához. A forgatás és a gombbemenetek a Joy-Con kontrollerekről származnak. A pozíció akkor becsült, amikor a csuklód rejtve van. Helyettesíti a puszta kézzel működtetett kontrollereket, és szünetelteti a telefonkamera követést. Irányítsd a Joy-Con kontrollereket előre a követőközpont csatlakoztatásakor vagy visszaállításakor. @@ -2851,7 +2950,6 @@ Várakozás stabil fejkövetésre; szükség esetén állítsa vissza a követőközpontot. A kamerás megfigyelés túl régi; friss párra vár Fit %1$.2f px. Mérje meg a kiválasztott távolságot a bal oldali kamera és a kalibrációs célközép között, majd érvényesítse. - Az OpenCV nem érhető el: %1$s %1$d mentett nézet visszaállítva. Számítsa ki a kalibrációt az újrapróbálkozáshoz, vagy indítson új sorozatot, hogy többet rögzítsen. Rögzített %1$d / 24 megtekintés. Tartsa rögzítve a kalibrációs célt; mozgassa az okosszemüveget a következő nézethez. Rögzítés elutasítva: %1$d / 12 szükséges sarok mindkét kamerán látható. Mutasson többet a kalibrációs célból, tartsa mozdulatlanul, majd próbálja újra. diff --git a/app/src/main/res/values-in/gestures.xml b/app/src/main/res/values-in/gestures.xml new file mode 100644 index 000000000..3bf4b33e5 --- /dev/null +++ b/app/src/main/res/values-in/gestures.xml @@ -0,0 +1,91 @@ + + + Gestur + Setelan gestur layar sentuh + Sesuaikan gestur mouse dan keyboard. Pilih Gestur sebagai mode sentuh untuk menggunakannya. + Ketuk + Seret dengan satu jari + Tekan lama + Ketuk dua kali (klik kiri dua kali) + Ketuk dengan dua jari + Seret dengan dua jari + Tahan dengan dua jari + Ketuk dengan tiga jari + Seret dengan tiga jari + Tahan dengan tiga jari + Cubit untuk memperbesar + Tetap tekan tombol + Klik sekali + Jeda sebelum menahan + Interval ketuk dua kali + Pulihkan setelan gestur awal + Batal + Pilih tindakan + Klik kiri + Klik kanan + Klik tengah + Tampilkan keyboard + Klik kiri dan seret + Klik kanan dan seret + Geser dengan tombol tengah mouse + Geser dengan tombol tengah mouse (terbalik) + Tombol panah + Tombol panah (terbalik) + WASD (terbalik) + Roda mouse + Ambang gestur: %1$d px + Tampilkan gestur yang dikenali + Satu jari + Dua jari + Tiga jari + Opsi + Default + Game ini + Gunakan default + Simpan perubahan + Tutup + TINDAKAN + Cari tindakan mouse atau tombol keyboard + Hapus pencarian + Semua + Tidak ada tindakan yang cocok. Coba nama tombol atau tindakan lain. + Mouse + Tindakan aplikasi + Navigasi + Huruf + Angka + Tombol fungsi + Tombol pengubah + Keypad numerik + Simbol + Gerakan mouse + Langsung + Relatif + Pengenalan gestur + Umpan balik visual + Perilaku tombol mouse + %1$d md + Keypad numerik %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Panah atas + Panah bawah + Panah kiri + Panah kanan + Caps Lock + Num Lock + Scroll Lock + %1$s kiri + %1$s kanan + Shift + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 8aee58b53..8208d2fa3 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -30,7 +30,7 @@ Pembuatan frame Pilih mode pembuatan frame Nonaktif - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptif %1$d Memerlukan Lossless Scaling di Steam @@ -176,6 +176,8 @@ %1$s melalui FEX Program ini dibuat untuk PC %1$s dan berjalan melalui FEX, yang belum disiapkan. Buka Steam sekali saat masuk: DroidDeck akan mengunduh FEX dan Steam Linux Runtime yang diperlukan, lalu ini akan terbuka. Program ini + Tidak dapat mengunduh klien Steam. Jaringan atau VPN Anda mungkin memblokir server Steam; periksa koneksi lalu coba lagi. + Tidak dapat menyiapkan klien Steam. Periksa koneksi dan ruang penyimpanan kosong, lalu coba lagi. %1$s dibuat untuk PC x86 dan berjalan melalui FEX, yang belum terinstal. Mulai Steam sekali saat masuk dan biarkan FEX serta Steam Linux Runtime diunduh, lalu mulai lagi. ID aplikasi Flathub atau halaman Flathub-nya. Flatpak disiapkan dahulu jika belum. File @@ -500,7 +502,9 @@ Hapus yang asli ini? %1$s asli ini berasal dari build %2$s yang lebih lama. Asli build saat ini selalu dipertahankan. Di Nightlies · %1$d - Segarkan untuk melihat daftar paket di Nightlies. + Stabil + Nightly + Segarkan untuk melihat daftar paket di Nightlies dan DroidDeck. Tidak ada yang baru sejak pemeriksaan terakhir. Impor .wcp Pulihkan yang asli dari Proton ini @@ -536,7 +540,7 @@ Hanya Adreno. Lebih sedikit tersendat, lebih banyak penggunaan baterai dan panas. Perbaikan sesi Turnip: rendering sysmem - Lebih cepat untuk Steam dan sebagian besar gim. Diperlukan pada Adreno 710/720/722. + Lebih cepat untuk klien Steam; di gim, driver memilih rendering ubin atau memori sistem untuk setiap pass. Diperlukan pada Adreno 710/720/722. Lewati bantuan xalia milik Steam droiddeck-ntsync (eksperimental) Pengganti userspace untuk driver ntsync Linux, untuk pengujian. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya. @@ -578,6 +582,16 @@ Halaman sebelumnya Halaman berikutnya HUD performa + Laju bingkai + Suhu baterai + Grafik performa + Warna + Mint + Putih + Langit + Ambar + Ungu + Opasitas Mode penskalaan Ketajaman penskalaan Efek layar @@ -726,6 +740,9 @@ %1$d%% (Default) Tema Tampilan peluncur + Ikon aplikasi + Gelap + Terang Gunakan sebagai Layar Utama DroidDeck dapat menjadi aplikasi Utama DroidDeck tidak pernah ditawarkan sebagai aplikasi Utama @@ -1029,6 +1046,9 @@ Gim ditambahkan Gim khusus Penyimpanan gim + Menghapus filenya dan menghapusnya dari Steam. + Hapus %1$s? + Menghapus pintasannya. File game tetap ada. Pasangan terbaik untuk GPU ini, selalu diperbarui Pilih pasangan sendiri Memeriksa driver… @@ -1370,6 +1390,14 @@ Dasar hitam, tipe putih, biru untuk semua yang dipilih, difokuskan, atau ditekan. Navy elektrik Biru yang lebih cerah, pada permukaan yang condong ke navy, bukan abu-abu. + Gradasi + Warna primer dan sekunder pilihan Anda, dipadukan pada tombol dan sorotan. + Primer + Sekunder + Rona + Saturasi + Kecerahan + Warna hex Esc Ins Del @@ -1520,9 +1548,9 @@ Biru Steam Langit Putih - Violet + Ungu Hijau - Amber + Ambar Merah Merah muda Tombol A @@ -1741,12 +1769,13 @@ Yang asli dari build terinstal disimpan: itulah cara untuk kembali. Tidak tersimpan Menghapus yang asli %1$s dari %2$s. - Unduhan hanya berasal dari rilis Nightlies + Unduhan hanya berasal dari rilis Nightlies dan DroidDeck + Unduhan gagal: build nightly ini mungkin sudah diganti dengan yang lebih baru. Segarkan daftar lalu coba lagi. Daftar paket ini dibuat sebelum adanya checksum - segarkan dan coba lagi Checksum tidak cocok - Linear - Nearest + Linier + Terdekat Snapdragon GSR (kualitas) Hanya penajaman Otomatis (sesuaikan skala) @@ -1754,7 +1783,7 @@ Tidak ada yang diterapkan. Kejernihan Gim Penajaman dengan sedikit kontras - cocok untuk sehari-hari. - Vivid + Cerah Warna didorong lebih kuat. Deband aktif agar langit tidak bergaris. Sinematik Kontras lebih dalam, warna lebih dingin, tepi dihaluskan. @@ -1900,9 +1929,10 @@ Toko Setelan toko Setelan + Atur tab + Selesai (belum masuk) Toko - Terinstal Pustaka Semua Unduhan @@ -1914,10 +1944,8 @@ Tetap masuk Keluar Cari katalog %1$s… - Cari gim yang terinstal… Cari pustaka Anda… Cari - Belum ada yang terinstal Memuat Tidak ada gim Memuat toko… @@ -1936,7 +1964,19 @@ Lihat di %1$s Beli · %1$s Terinstal + Terinstal + Belum terinstal Dalam pustaka + Kunci API + Dapatkan kunci + Kunci ditolak + Tidak ada di pustaka itch.io Anda + Tidak ada unduhan Windows untuk game ini + Tidak ada game Windows di unduhan + Tidak dapat mengekstrak unduhan (kode %1$d) + itch.io tidak menjawab + Arsip %1$s tidak didukung + itch.io menjawab HTTP %1$d Gratis Lanjutkan Segarkan @@ -1950,6 +1990,7 @@ Tanggal rilis Tampilan daftar Tampilan kartu + Tampilan sampul Tampilkan konten dewasa Epic EOS @@ -1985,6 +2026,9 @@ Sinkronisasi terakhir %1$s Belum disinkronkan %1$d file + Sudah terbaru + Masuk ke toko untuk menyinkronkan simpanan + Simpanan tidak disinkronkan terinstal dalam pustaka Anda toko @@ -2116,7 +2160,7 @@ Ukuran Simpan Kembalikan ke bawaan - Digambar di atas latar gim, seperti yang dilakukan Steam. Posisi persisnya bisa berbeda beberapa piksel. + Posisi persis ini bisa berbeda beberapa piksel. Gim ini belum punya logo. Pilih salah satu di Logo untuk melihatnya di sini. Posisi logo tidak tersimpan. Coba lagi. Latar belakang @@ -2286,7 +2330,7 @@ Selesai Tata letak sentuh tidak dapat disimpan Tata letak sentuh disimpan ke Steam - App %1$d + App %1$s Label (opsional) Ikon Steam tidak ditemukan @@ -2375,6 +2419,15 @@ Bidang pandang SteamVR Lebih rendah membawa SteamVR lebih dekat dan memenuhi kacamata; lebih tinggi menampilkan lebih banyak di sekitar Anda. Bermain di %1$s + Pusatkan ulang + Menu SteamVR + Stik: tangan ⇄ game + Kontroler: %1$s + Keyboard + Menu sesi + Hentikan SteamVR + Kecerahan %1$d / %2$d + Baterai %1$d%% · %2$d °C Kalibrasi pelacakan Mengurangi drift dan menyelaraskan acuan horizontal. Kalibrasi tersimpan diterapkan ke kacamata ini. @@ -2429,6 +2482,7 @@ Menunggu koneksi Ethernet USB dari kacamata. Ponsel ini mungkin tidak menyediakannya untuk aplikasi. Koneksi XREAL terputus atau memberikan respons yang tidak didukung. Mencoba lagi… Dukungan 1S eksperimental. Gunakan mode Follow dan nonaktifkan stabilisasi di menu kacamata. Arah pelacakan dan mode tampilan masih perlu diuji di perangkat. + Pelacakan kepala Pro 2 eksperimental. Pengaturan kecerahan, volume, dan pergantian mode tampilan belum didukung. Cardboard VR Mode Cardboard Tampilan mata kiri dan kanan untuk kacamata VR ponsel. @@ -2641,7 +2695,7 @@ Video boot Video DroidDeck · %1$d fps - 60 fps terlihat lebih mulus tetapi memakai lebih banyak CPU saat Steam dimulai. Video startup yang dipilih di pengaturan Steam sendiri lebih diutamakan. + 60 fps terlihat lebih mulus tetapi memakai lebih banyak CPU saat Steam dimulai. Video yang dipilih di pengaturan Steam sendiri juga muncul di sini. Suara Video sendiri MP4, MOV, MKV, atau WebM. Format lain dan file di atas %1$d MB dikonversi ke WebM 720p di perangkat, hingga %2$d detik. @@ -2655,6 +2709,51 @@ Mengonversi %1$s untuk Steam. Biarkan DroidDeck tetap terbuka. Perangkat ini tidak dapat mendekode video itu. Tidak dapat mengimpor video itu + Hapus %1$s? + Ini menghapus video dari DroidDeck dan dari folder video Steam. + Terpasang + Impor + SteamDeckRepo + Suara + Video terpasang + Telusuri + DroidDeck + Diimpor + SteamDeckRepo + Steam + Toko Poin + Laporan film boot + Menyimpan apa yang diketahui DroidDeck dan Steam tentang film boot ke berkas teks yang dapat dilampirkan pada laporan bug. + Simpan + Disimpan di %1$s + Laporan tidak dapat disimpan. + Volume + Durasi + %1$d dtk + Berapa detik film yang diputar. Bagian yang lebih panjang akan dipotong di bagian akhir. + Cari di SteamDeckRepo + Semua perangkat + Steam Deck + Steam Machine + Tren + Paling banyak diunduh + Paling disukai + Terbaru + Tidak dapat terhubung ke SteamDeckRepo + Coba lagi + Video tidak ditemukan + Pasang + Gunakan + Sedang digunakan + %1$s terpasang + Tidak dapat mengunduh video itu + Sebelumnya + Berikutnya + Halaman %1$d + Hapus + Buka halaman + Pratinjau + Pratinjau tidak dapat diputar Kamera Luma Ultra — Eksperimental Pelacakan posisi Joy-Con Gunakan kamera kacamata pintar untuk memposisikan pengontrol Joy-Con yang terpasang. Rotasi dan input tombol berasal dari pengontrol Joy-Con. Posisi diperkirakan saat pergelangan tangan Anda tersembunyi. Menggantikan pengontrol yang dioperasikan dengan tangan kosong dan menghentikan pelacakan kamera ponsel. Arahkan pengontrol Joy-Con ke depan saat menghubungkan atau mengatur ulang pusat pelacakan. @@ -2828,7 +2927,6 @@ Menunggu pelacakan kepala yang stabil; reset pusat pelacakan jika diminta Pengamatan kamera terlalu tua; menunggu pasangan baru Cocok dengan %1$.2f piksel. Ukur jarak yang Anda pilih dari kamera kiri ke pusat target kalibrasi, lalu validasi. - OpenCV tidak tersedia: %1$s %1$d tampilan tersimpan dipulihkan. Hitung kalibrasi untuk mencoba ulang, atau mulai rangkaian baru untuk menangkap lebih banyak. Diambil %1$d / 24 penayangan. Jaga agar target kalibrasi tetap; pindahkan kacamata pintar untuk tampilan selanjutnya. Pengambilan gambar ditolak: %1$d / 12 sudut yang diperlukan terlihat di kedua kamera. Tampilkan lebih banyak target kalibrasi, diamkan, lalu coba lagi. diff --git a/app/src/main/res/values-it/gestures.xml b/app/src/main/res/values-it/gestures.xml new file mode 100644 index 000000000..b9a3147f4 --- /dev/null +++ b/app/src/main/res/values-it/gestures.xml @@ -0,0 +1,91 @@ + + + Gesti + Impostazioni dei gesti tattili + Personalizza i gesti di mouse e tastiera. Seleziona Gesti come modalità tattile per usarli. + Tocco + Trascinamento con un dito + Pressione prolungata + Doppio tocco (doppio clic sinistro) + Tocco con due dita + Trascinamento con due dita + Pressione con due dita + Tocco con tre dita + Trascinamento con tre dita + Pressione con tre dita + Pizzica per ingrandire + Tieni premuto il pulsante + Clic singolo + Ritardo della pressione + Intervallo del doppio tocco + Ripristina le impostazioni originali dei gesti + Annulla + Scegli azione + Clic sinistro + Clic destro + Clic centrale + Mostra tastiera + Clic sinistro e trascinamento + Clic destro e trascinamento + Spostamento con il pulsante centrale + Spostamento con il pulsante centrale (invertito) + Tasti freccia + Tasti freccia (invertiti) + WASD (invertito) + Rotellina del mouse + Soglia dei gesti: %1$d px + Mostra il gesto riconosciuto + Un dito + Due dita + Tre dita + Opzioni + Predefiniti + Questo gioco + Usa impostazioni predefinite + Salva modifiche + Chiudi + AZIONE + Cerca azioni del mouse o tasti della tastiera + Cancella ricerca + Tutte + Nessuna azione corrispondente. Prova un altro nome di tasto o azione. + Mouse + Azioni dell\'app + Navigazione + Lettere + Numeri + Tasti funzione + Tasti modificatori + Tastierino numerico + Simboli + Movimento del mouse + Diretto + Relativo + Riconoscimento dei gesti + Riscontro visivo + Comportamento del pulsante del mouse + %1$d ms + Tastierino numerico %1$s + Esc + Backspace + Invio + Tab + Spazio + Canc + Ins + Home + Fine + Pagina su + Pagina giù + Stamp + Freccia su + Freccia giù + Freccia sinistra + Freccia destra + Bloc Maiusc + Bloc Num + Bloc Scorr + %1$s sinistro + %1$s destro + Maiusc + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 84fc8340c..75e464513 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -30,7 +30,7 @@ Generazione di frame Seleziona la modalità di generazione di frame Disattivata - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adattivo %1$d Richiede Lossless Scaling su Steam @@ -176,6 +176,8 @@ %1$s tramite FEX Questo programma è compilato per PC %1$s e viene eseguito tramite FEX, che non è ancora configurato. Apri Steam una volta dopo aver eseguito l\'accesso: DroidDeck gli farà scaricare FEX e lo Steam Linux Runtime necessario, poi il programma si aprirà. Questo programma + Impossibile scaricare il client Steam. La rete o la VPN potrebbe bloccare i server di Steam; controlla la connessione e riprova. + Impossibile configurare il client Steam. Controlla la connessione e lo spazio libero, poi riprova. %1$s è compilato per PC x86 e viene eseguito tramite FEX, che non è ancora installato. Avvia Steam una volta dopo aver eseguito l\'accesso e lascia che scarichi FEX e lo Steam Linux Runtime, poi riavvia il programma. Un ID app di Flathub o la sua pagina su Flathub. Se manca, Flatpak viene prima configurato. File @@ -501,7 +503,9 @@ Eliminare questo originale? Questo originale di %1$s proviene da una build precedente di %2$s. L\'originale della build attuale viene sempre conservato. Nelle Nightlies · %1$d - Aggiorna per elencare i pacchetti nelle Nightlies. + Stabile + Nightly + Aggiorna per elencare i pacchetti nelle Nightlies e in DroidDeck. Niente di nuovo dall\'ultimo controllo. Importa .wcp Ripristina l\'originale di questo Proton @@ -537,7 +541,7 @@ Solo Adreno. Meno scatti, più consumo di batteria e più calore. Correzioni della sessione Turnip: rendering sysmem - Più veloce per Steam e la maggior parte dei giochi. Necessario su Adreno 710/720/722. + Più veloce per il client di Steam; nei giochi il driver sceglie per ogni passata tra rendering a tile e in memoria di sistema. Necessario su Adreno 710/720/722. Salta l\'assistente xalia di Steam droiddeck-ntsync (sperimentale) Un sostituto in userspace del driver ntsync di Linux, per i test. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione. @@ -579,6 +583,16 @@ Pagina precedente Pagina successiva HUD prestazioni + Frequenza fotogrammi + Temperatura batteria + Grafico prestazioni + Colore + Menta + Bianco + Cielo + Ambra + Viola + Opacità Modalità di ridimensionamento Nitidezza del ridimensionamento Effetti schermo @@ -721,6 +735,9 @@ %1$d%% (predefinita) Tema Aspetto del launcher + Icona dell\'app + Scura + Chiara Usa come schermata Home DroidDeck può essere l\'app Home DroidDeck non viene mai proposta come app Home @@ -1030,6 +1047,9 @@ Gioco aggiunto Gioco personalizzato Archiviazione dei giochi + Elimina i suoi file e lo rimuove da Steam. + Rimuovere %1$s? + Elimina il suo collegamento. I file del gioco restano. La coppia migliore per questa GPU, mantenuta aggiornata Scegli tu una coppia Ricerca dei driver… @@ -1387,6 +1407,14 @@ Fondo nero, testo bianco, il blu per tutto ciò che è selezionato, attivo o premuto. Blu elettrico Il blu più brillante, su superfici che tendono al blu navy invece che al grigio. + Gradiente + I tuoi colori primario e secondario, sfumati su pulsanti ed evidenziazioni. + Primario + Secondario + Tonalità + Saturazione + Luminosità + Colore esadecimale Esc Ins Canc @@ -1772,7 +1800,8 @@ L\'originale della build installata viene conservato: serve per tornare indietro. Non salvato Originale %1$s di %2$s eliminato. - I download provengono solo dalle release Nightly + I download provengono solo dalle release Nightly e DroidDeck + Download non riuscito: questa build nightly potrebbe essere stata sostituita da una più recente. Aggiorna l’elenco e riprova. Questo elenco di pacchetti è anteriore ai checksum - aggiornalo e riprova Checksum non corrispondente @@ -1931,9 +1960,10 @@ Negozi Impostazioni negozi Impostazioni + Disponi schede + Fine (accesso non eseguito) Negozio - Installati Libreria Tutti Download @@ -1945,10 +1975,8 @@ Resta connesso Esci Cerca nel catalogo di %1$s… - Cerca tra i giochi installati… Cerca nella tua libreria… Cerca - Niente di installato Caricamento Nessun gioco Caricamento del negozio… @@ -1967,7 +1995,19 @@ Visualizza su %1$s Acquista · %1$s Installato + Installato + Non installata In libreria + Chiave API + Ottieni una chiave + Chiave rifiutata + Non è nella tua libreria itch.io + Nessun download per Windows di questo gioco + Nessun gioco Windows trovato nel download + Impossibile estrarre il download (codice %1$d) + itch.io non ha risposto + Gli archivi %1$s non sono supportati + itch.io ha risposto HTTP %1$d Gratis Riprendi Aggiorna @@ -1981,6 +2021,7 @@ Data di uscita Visualizzazione elenco Visualizzazione schede + Vista copertine Mostra contenuti per adulti Epic EOS @@ -2016,6 +2057,9 @@ Ultima sincronizzazione %1$s Non ancora sincronizzato %1$d file + Già aggiornato + Accedi allo store per sincronizzare i salvataggi + I salvataggi non sono stati sincronizzati installato nella tua libreria negozio @@ -2144,7 +2188,7 @@ Dimensione Salva Ripristina predefinita - Viene disegnato sullo sfondo del gioco, come fa Steam. La posizione esatta può variare di qualche pixel. + Questa posizione esatta può variare di qualche pixel. Questo gioco non ha ancora un logo. Scegline uno in Logo per vederlo qui. Impossibile salvare la posizione del logo. Riprova. Sfondo @@ -2318,7 +2362,7 @@ Fine Impossibile salvare il layout touch Layout touch salvato su Steam - App %1$d + App %1$s Etichetta (opzionale) Le icone di Steam non sono state trovate @@ -2411,6 +2455,15 @@ Campo visivo di SteamVR Più basso avvicina SteamVR e riempie gli occhiali; più alto mostra di più intorno a te. Gioco in corso su %1$s + Ricentra + Menu di SteamVR + Levette: mani ⇄ gioco + Controller: %1$s + Tastiera + Menu della sessione + Arresta SteamVR + Luminosità %1$d / %2$d + Batteria %1$d%% · %2$d °C Calibrazione del tracciamento Riduce la deriva e allinea il riferimento orizzontale. La calibrazione salvata è applicata a questi occhiali. @@ -2465,6 +2518,7 @@ In attesa della connessione USB Ethernet degli occhiali. Questo telefono potrebbe non renderla disponibile alle app. La connessione XREAL si è interrotta o ha restituito una risposta non supportata. Nuovo tentativo… Supporto sperimentale per 1S. Usa la modalità Follow e disattiva la stabilizzazione nel menu degli occhiali. Direzioni del tracciamento e modalità di visualizzazione devono ancora essere testate sul dispositivo. + Tracciamento sperimentale della testa per Pro 2. La regolazione di luminosità e volume e il cambio della modalità di visualizzazione non sono ancora supportati. Cardboard VR Modalità Cardboard Immagini per l\'occhio sinistro e destro, per un visore VR per telefono. @@ -2613,32 +2667,39 @@ Gioca comunque %d installato + %d installati %d installati %d su scheda SD + %d su scheda SD %d su scheda SD %d strumento + %d strumenti %d strumenti Cartelle di prefisso vuote e cache shader di %d gioco + Cartelle di prefisso vuote e cache shader di %d giochi Cartelle di prefisso vuote e cache shader di %d giochi Il prefisso di %d gioco contiene ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima. + I prefissi di %d giochi contengono ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima. I prefissi di %d giochi contengono ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima. %d gioco + %d giochi %d giochi Proton predefinito di DroidDeck Opzione Proton 11 di DroidDeck Usato da %d gioco + Usato da %d giochi Usato da %d giochi Componenti @@ -2682,16 +2743,18 @@ Rimuove l\'app (%1$s) e i runtime che nessun altro usa. I suoi dati nella cartella home restano. %d pacchetto + %d pacchetti %d pacchetti %d app + %d app %d app Video di avvio Video DroidDeck · %1$d fps - 60 fps è più fluido ma usa più CPU durante l\'avvio di Steam. Un video di avvio scelto nelle impostazioni di Steam ha la precedenza. + 60 fps è più fluido ma usa più CPU durante l\'avvio di Steam. Anche un video scelto nelle impostazioni di Steam compare qui. Audio Il tuo video MP4, MOV, MKV o WebM. Altri formati e file oltre %1$d MB vengono convertiti in WebM 720p sul dispositivo, fino a %2$d secondi. @@ -2705,6 +2768,51 @@ Conversione di %1$s per Steam. Tieni aperto DroidDeck. Questo dispositivo non può decodificare il video. Impossibile importare il video + Rimuovere %1$s? + Il video verrà eliminato da DroidDeck e dalla cartella video di Steam. + Installati + Importa + SteamDeckRepo + Audio + Video installati + Sfoglia + DroidDeck + Importato + SteamDeckRepo + Steam + Negozio punti + Rapporto sui filmati di avvio + Salva in un file di testo ciò che DroidDeck e Steam sanno sui filmati di avvio, da allegare a una segnalazione di bug. + Salva + Salvato in %1$s + Impossibile salvare il rapporto. + Volume + Durata + %1$d s + Quanti secondi del filmato vengono riprodotti. Tutto ciò che è più lungo viene tagliato alla fine. + Cerca in SteamDeckRepo + Tutti i dispositivi + Steam Deck + Steam Machine + Di tendenza + Più scaricati + Più apprezzati + Più recenti + Impossibile raggiungere SteamDeckRepo + Riprova + Nessun video trovato + Installa + Usa + In uso + %1$s installato + Impossibile scaricare il video + Precedente + Successivo + Pagina %1$d + Cancella + Vai alla pagina + Anteprima + Impossibile riprodurre l\'anteprima Fotocamere Luma Ultra — Sperimentale Tracciamento della posizione dei Joy-Con Utilizza le fotocamere degli occhiali smart per posizionare i controller Joy-Con associati. La rotazione e gli input dei pulsanti provengono dai controller Joy-Con. La posizione viene stimata quando i polsi sono nascosti. Sostituisce i controller utilizzati a mani nude e mette in pausa il tracciamento tramite la fotocamera del telefono. Punta i controller Joy-Con in avanti quando li connetti o reimposti il ​​centro di tracciamento. @@ -2876,7 +2984,6 @@ In attesa di un tracciamento stabile della testa; ripristina il centro di tracciamento se richiesto. L’osservazione tramite telecamera è troppo vecchia; in attesa di un nuovo paio Adatta %1$.2f px. Misura la distanza selezionata dalla telecamera sinistra al centro del target di calibrazione, quindi convalida. - OpenCV non disponibile: %1$s %1$d visualizzazioni salvate ripristinate. Calcola la calibrazione per riprovare o avvia un nuovo set per acquisirne di più. Acquisito %1$d / 24 visualizzazioni. Mantenere fisso il target di calibrazione; sposta gli occhiali intelligenti per la visualizzazione successiva. Acquisizione rifiutata: %1$d / 12 angoli obbligatori visibili in entrambe le telecamere. Mostra una parte maggiore del target di calibrazione, resta fermo e riprova. diff --git a/app/src/main/res/values-ja/gestures.xml b/app/src/main/res/values-ja/gestures.xml new file mode 100644 index 000000000..961a4ec21 --- /dev/null +++ b/app/src/main/res/values-ja/gestures.xml @@ -0,0 +1,91 @@ + + + ジェスチャー + タッチジェスチャーの設定 + マウスとキーボードのジェスチャーを設定します。使用するにはタッチモードで「ジェスチャー」を選択してください。 + タップ + 1本指でドラッグ + 長押し + ダブルタップ(左ダブルクリック) + 2本指でタップ + 2本指でドラッグ + 2本指で長押し + 3本指でタップ + 3本指でドラッグ + 3本指で長押し + ピンチで拡大・縮小 + ボタンを押したままにする + 1回クリック + 長押しの待ち時間 + ダブルタップの間隔 + ジェスチャーの設定を初期状態に戻す + キャンセル + 操作を選択 + 左クリック + 右クリック + 中クリック + キーボードを表示 + 左クリックしてドラッグ + 右クリックしてドラッグ + マウスの中ボタンで視点移動 + マウスの中ボタンで視点移動(反転) + 矢印キー + 矢印キー(反転) + WASD(反転) + マウスホイール + ジェスチャーのしきい値:%1$d px + 認識したジェスチャーを表示 + 1本指 + 2本指 + 3本指 + オプション + デフォルト + このゲーム + 既定値を使用 + 変更を保存 + 閉じる + 操作 + マウス操作やキーボードのキーを検索 + 検索をクリア + すべて + 一致する操作がありません。別のキー名や操作名を試してください。 + マウス + アプリの操作 + ナビゲーション + 英字 + 数字 + ファンクションキー + 修飾キー + テンキー + 記号 + マウスの移動 + ダイレクト + 相対 + ジェスチャー認識 + 視覚的フィードバック + マウスボタンの動作 + %1$dミリ秒 + テンキー %1$s + Esc + Backspace + Enter + Tab + スペース + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + 上矢印 + 下矢印 + 左矢印 + 右矢印 + Caps Lock + Num Lock + Scroll Lock + 左%1$s + 右%1$s + Shift + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c26d44bf6..dc8517802 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -30,7 +30,7 @@ フレーム生成 使用するフレーム生成モードを選択してください オフ - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG アダプティブ %1$d Steam版のLossless Scalingが必要です @@ -176,6 +176,8 @@ %1$s(FEX経由) このプログラムは%1$s PC向けにビルドされており、FEX経由で実行されますが、FEXはまだセットアップされていません。ログインした状態で一度Steamを開いてください。DroidDeckがFEXと必要なSteam Linux Runtimeをダウンロードした後、このプログラムが開きます。 このプログラム + Steamクライアントをダウンロードできませんでした。ネットワークまたはVPNがSteamのサーバーをブロックしている可能性があります。接続を確認して、もう一度お試しください。 + Steamクライアントをセットアップできませんでした。接続と空き容量を確認して、もう一度お試しください。 %1$sはx86 PC向けにビルドされており、FEX経由で実行されますが、FEXはまだインストールされていません。ログインした状態で一度Steamを起動し、FEXとSteam Linux Runtimeをダウンロードしてから、もう一度起動してください。 FlathubアプリIDまたはそのFlathubページ。Flatpakがまだセットアップされていない場合は、先にセットアップします。 ファイル @@ -308,6 +310,8 @@ クライアント パフォーマンスオーバーレイ Deckモードでゲームがクラッシュしたり画面が真っ暗になる場合は、これをオフにしてください。次回のセッションで有効になります。 + ゲーム終了後にDroidDeckへ戻る + DroidDeckや他のランチャーから起動したゲームを閉じると、Steamが正しく終了し(クラウドセーブの同期を先に完了)、起動元に戻ります。オフ:Steamに留まります。 Steamクライアントを修復 次回Steamの起動時にSteamクライアントを再ダウンロードします。Steamが読み込み画面のまま進まない場合に使用してください。ゲーム、ログイン情報、設定は保持されます。 次回Steamの起動時にSteamクライアントを再ダウンロードします。 @@ -500,7 +504,9 @@ この元のバージョンを削除しますか? この%1$sの元のバージョンは、%2$sの以前のビルドのものです。現在のビルドの元のバージョンは常に保持されます。 Nightlies内 · %1$d - 更新してNightliesのパッケージを一覧表示します。 + 安定版 + ナイトリー + 更新してNightliesとDroidDeckのパッケージを一覧表示します。 前回の確認以降、新しいものはありません。 .wcpをインポート このProtonの元のファイルを復元 @@ -536,7 +542,7 @@ Adrenoのみ。スタッターは減りますが、消費電力と発熱が増えます。 セッション修正 Turnip:sysmemレンダリング - Steamとほとんどのゲームが高速になります。Adreno 710/720/722では必須です。 + Steamクライアントが高速になります。ゲームではドライバーがパスごとにタイルレンダリングとシステムメモリレンダリングを選びます。Adreno 710/720/722では必須です。 Steamのxalia補助をスキップ droiddeck-ntsync(実験的) Linuxのntsyncドライバーをユーザー空間で代替するテスト用機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。 @@ -578,6 +584,16 @@ 前のページ 次のページ パフォーマンスHUD + フレームレート + バッテリー温度 + パフォーマンスグラフ + 色 + ミント + ホワイト + スカイ + アンバー + バイオレット + 不透明度 スケーリングモード スケーリングのシャープネス 画面エフェクト @@ -726,6 +742,9 @@ %1$d%%(デフォルト) テーマ ランチャーの外観 + アプリアイコン + ダーク + ライト ホーム画面として使用 DroidDeckをホームアプリとして使用できます DroidDeckはホームアプリとして提供されません @@ -1029,6 +1048,9 @@ 追加されたゲーム カスタムゲーム ゲームストレージ + ファイルを削除し、Steamからも削除します。 + %1$sを削除しますか? + ショートカットを削除します。ゲームファイルは残ります。 このGPUに最適なドライバー構成を選択し、最新の状態に保ちます ドライバー構成を自分で選択する ドライバーを確認中… @@ -1370,6 +1392,14 @@ 黒の背景に白の文字、選択・フォーカス・押下時には青を使用します。 エレクトリックネイビー より明るい青を、グレーではなくネイビー寄りの画面に使用します。 + グラデーション + 自分で選んだプライマリとセカンダリの色を、ボタンやハイライトにグラデーションで使います。 + プライマリ + セカンダリ + 色相 + 彩度 + 明度 + 16進カラー Esc Ins Del @@ -1741,7 +1771,8 @@ インストール済みビルドの元のファイルは、元に戻すために保持されます。 保存されていません %2$sの%1$s版の元のファイルを削除しました。 - ダウンロードはNightliesのリリースからのみ行われます + ダウンロードはNightliesとDroidDeckのリリースからのみ行われます + ダウンロードに失敗しました:このナイトリービルドは新しいビルドに置き換えられた可能性があります。一覧を更新してもう一度お試しください。 このパッケージ一覧はチェックサム導入前のものです。更新してからもう一度お試しください チェックサムが一致しません @@ -1900,9 +1931,10 @@ ストア ストア設定 設定 + タブを整理 + 完了 (未ログイン) ストア - インストール済み ライブラリ すべて ダウンロード @@ -1914,10 +1946,8 @@ ログインしたままにする ログアウト %1$sのカタログを検索… - インストール済みのゲームを検索… ライブラリを検索… 検索 - 何もインストールされていません 読み込み中 ゲームがありません ストアを読み込み中… @@ -1936,7 +1966,19 @@ %1$sで見る 購入 · %1$s インストール済み + インストール済み + 未インストール ライブラリにあり + APIキー + キーを取得 + キーが拒否されました + itch.io ライブラリにありません + このゲームには Windows 版のダウンロードがありません + ダウンロードに Windows 版のゲームが見つかりません + ダウンロードを展開できませんでした (終了コード %1$d) + itch.io から応答がありません + %1$s 形式のアーカイブには対応していません + itch.io が HTTP %1$d を返しました 無料 再開 更新 @@ -1950,6 +1992,7 @@ リリース日 リスト表示 カード表示 + パッケージ表示 成人向けコンテンツを表示 Epic EOS @@ -1985,6 +2028,9 @@ 最終同期 %1$s 未同期 %1$d個のファイル + すでに最新です + セーブを同期するにはストアにサインインしてください + セーブは同期されませんでした インストール済み ライブラリ内 ストア @@ -2116,7 +2162,7 @@ サイズ 保存 既定に戻す - ゲームの背景に、Steam と同じ方法で描画されます。位置が数ピクセルずれることがあります。 + この位置は数ピクセルずれることがあります。 このゲームにはまだロゴがありません。ロゴでいずれかを選ぶとここに表示されます。 ロゴの位置を保存できませんでした。もう一度お試しください。 背景 @@ -2286,7 +2332,7 @@ 完了 タッチレイアウトを保存できませんでした タッチレイアウトをSteamに保存しました - アプリ%1$d + アプリ%1$s ラベル(任意) Steamのアイコンが見つかりませんでした @@ -2375,6 +2421,15 @@ SteamVR の視野角 小さくすると SteamVR が近くなりグラスいっぱいに広がり、大きくすると周囲が広く見えます。 %1$s でプレイ中 + 中心をリセット + SteamVR メニュー + スティック: 手 ⇄ ゲーム + コントローラー: %1$s + キーボード + セッションメニュー + SteamVR を停止 + 明るさ %1$d / %2$d + バッテリー %1$d%% · %2$d °C トラッキングのキャリブレーション ドリフトを減らし、水平の基準を合わせます。 保存したキャリブレーションをこのグラスに適用しています。 @@ -2429,6 +2484,7 @@ グラスの USB Ethernet 接続を待っています。このスマートフォンでは、アプリがこの接続を利用できない場合があります。 XREAL との接続が中断されたか、サポートされていない応答が返されました。再試行しています… 1S のサポートは試験運用中です。グラスのメニューで Follow モードを使い、安定化をオフにしてください。トラッキングの方向と表示モードは、まだ実機でのテストが必要です。 + Pro 2 のヘッドトラッキングは試験運用中です。明るさと音量の調整、および表示モードの切り替えにはまだ対応していません。 開発元 パブリッシャー 発売日 @@ -2641,7 +2697,7 @@ 起動ムービー ムービー DroidDeck · %1$d fps - 60 fps はなめらかですが、Steam の起動中に CPU をより多く使います。Steam 本体の設定で選んだ起動ムービーが優先されます。 + 60 fps はなめらかですが、Steam の起動中に CPU をより多く使います。Steam 本体の設定で選んだムービーもここに表示されます。 サウンド 自分のムービー MP4、MOV、MKV、WebM。その他の形式や %1$d MB を超えるファイルは、端末上で 720p の WebM に変換されます(%2$d 秒まで)。 @@ -2655,6 +2711,51 @@ %1$s を Steam 用に変換しています。DroidDeck を開いたままにしてください。 この端末ではその動画をデコードできません。 ムービーをインポートできませんでした + %1$s を削除しますか? + DroidDeck と Steam のムービーフォルダからムービーが削除されます。 + インストール済み + インポート + SteamDeckRepo + サウンド + インストール済みのムービー + 参照 + DroidDeck + インポート済み + SteamDeckRepo + Steam + ポイントショップ + 起動ムービーのレポート + DroidDeck と Steam が把握している起動ムービーの情報をテキストファイルに保存します。不具合報告に添付できます。 + 保存 + %1$s に保存しました + レポートを保存できませんでした。 + 音量 + 長さ + %1$d 秒 + ムービーを何秒間再生するかを設定します。それより長い部分は最後がカットされます。 + SteamDeckRepo を検索 + すべてのデバイス + Steam Deck + Steam Machine + トレンド + ダウンロード数順 + いいね順 + 新着順 + SteamDeckRepo に接続できませんでした + 再試行 + ムービーが見つかりません + インストール + 使用 + 使用中 + %1$s をインストールしました + ムービーをダウンロードできませんでした + 前へ + 次へ + %1$d ページ + クリア + ページへ移動 + プレビュー + プレビューを再生できませんでした スマートグラス スマートグラスを接続して、ディスプレイと追跡の設定を変更してください。接続を解除しても、保存された設定は保持されます。 VR @@ -2826,7 +2927,6 @@ 安定したヘッドトラッキングを待機中。必要に応じてトラッキングセンターをリセットします。 カメラによる観察は古すぎる。新しいペアを待っています %1$.2f ピクセルにフィットします。左側のカメラからキャリブレーション ターゲットの中心までの選択した距離を測定し、検証します。 - OpenCV は利用できません: %1$s %1$d 個の保存されたビューが復元されました。キャリブレーションを計算して再試行するか、新しいセットを開始してさらにキャプチャします。 %1$d / 24 ビューをキャプチャしました。キャリブレーションターゲットを固定してください。次のビューのためにスマート グラスを移動します。 キャプチャが拒否されました: %1$d / 12 個の必須コーナーが両方のカメラに表示されます。キャリブレーション ターゲットをさらに表示し、静止してからもう一度試してください。 diff --git a/app/src/main/res/values-ko/gestures.xml b/app/src/main/res/values-ko/gestures.xml new file mode 100644 index 000000000..9f32380c4 --- /dev/null +++ b/app/src/main/res/values-ko/gestures.xml @@ -0,0 +1,91 @@ + + + 제스처 + 터치 제스처 설정 + 마우스와 키보드 제스처를 맞춤 설정합니다. 사용하려면 터치 모드에서 제스처를 선택하세요. + 탭 + 한 손가락 드래그 + 길게 누르기 + 두 번 탭 (왼쪽 더블 클릭) + 두 손가락 탭 + 두 손가락 드래그 + 두 손가락 길게 누르기 + 세 손가락 탭 + 세 손가락 드래그 + 세 손가락 길게 누르기 + 손가락을 모으거나 벌려 확대/축소 + 버튼을 누른 상태로 유지 + 한 번 클릭 + 길게 누르기 대기 시간 + 두 번 탭 간격 + 제스처 설정을 초기 상태로 재설정 + 취소 + 동작 선택 + 왼쪽 클릭 + 오른쪽 클릭 + 가운데 클릭 + 키보드 표시 + 왼쪽 클릭 후 드래그 + 오른쪽 클릭 후 드래그 + 마우스 가운데 버튼으로 이동 + 마우스 가운데 버튼으로 이동 (반전) + 방향키 + 방향키 (반전) + WASD (반전) + 마우스 휠 + 제스처 임계값: %1$d px + 인식된 제스처 표시 + 한 손가락 + 두 손가락 + 세 손가락 + 옵션 + 기본값 + 이 게임 + 기본값 사용 + 변경 사항 저장 + 닫기 + 동작 + 마우스 동작 또는 키보드 키 검색 + 검색 지우기 + 전체 + 일치하는 동작이 없습니다. 다른 키나 동작 이름을 검색하세요. + 마우스 + 앱 동작 + 탐색 + 문자 + 숫자 + 기능키 + 보조키 + 숫자 키패드 + 기호 + 마우스 이동 + 직접 터치 + 상대 + 제스처 인식 + 시각적 피드백 + 마우스 버튼 동작 + %1$d ms + 숫자 키패드 %1$s + Esc + 백스페이스 + 엔터 + Tab + Space + 삭제 + 삽입 + Home + End + 페이지 위로 + 페이지 아래로 + 화면 인쇄 + 위쪽 화살표 + 아래쪽 화살표 + 왼쪽 화살표 + 오른쪽 화살표 + Caps Lock + Num Lock + Scroll Lock + 왼쪽 %1$s + 오른쪽 %1$s + Shift + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 96dfbf135..4cada9f9f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -30,7 +30,7 @@ 프레임 생성 사용할 프레임 생성 모드를 선택하세요 끔 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 적응형 %1$d Steam에서 Lossless Scaling이 필요합니다 @@ -176,6 +176,8 @@ FEX로 실행되는 %1$s 이 프로그램은 %1$s PC용으로 빌드되어 FEX를 통해 실행되지만, FEX가 아직 설정되지 않았습니다. 로그인한 상태로 Steam을 한 번 열면 DroidDeck이 FEX와 필요한 Steam Linux Runtime을 다운로드한 후 이 프로그램을 엽니다. 이 프로그램 + Steam 클라이언트를 다운로드할 수 없습니다. 네트워크나 VPN이 Steam 서버를 차단하고 있을 수 있습니다. 연결을 확인하고 다시 시도하세요. + Steam 클라이언트를 설정할 수 없습니다. 연결과 남은 저장 공간을 확인한 뒤 다시 시도하세요. %1$s은(는) x86 PC용으로 빌드되어 FEX를 통해 실행되지만, FEX가 아직 설치되지 않았습니다. 로그인한 상태로 Steam을 한 번 시작하여 FEX와 Steam Linux Runtime을 다운로드한 후 다시 시작하세요. Flathub 앱 ID 또는 해당 Flathub 페이지입니다. Flatpak이 아직 설정되지 않은 경우 먼저 설정합니다. 파일 @@ -308,6 +310,8 @@ 클라이언트 성능 오버레이 Deck 모드에서 게임이 충돌하거나 화면이 검게 변하면 이 옵션을 끄세요. 다음 세션에 적용됩니다. + 게임 종료 후 DroidDeck으로 돌아가기 + DroidDeck 또는 다른 런처에서 실행한 게임을 닫으면 Steam이 정상적으로 종료되고(클라우드 저장 동기화를 먼저 완료) 실행한 곳으로 돌아갑니다. 끄면 Steam에 머무릅니다. Steam 클라이언트 복구 다음에 Steam을 시작할 때 Steam 클라이언트를 다시 다운로드합니다. Steam이 로딩 화면에서 넘어가지 않을 때 사용하세요. 게임, 로그인 정보 및 설정은 유지됩니다. 다음에 Steam을 시작할 때 Steam 클라이언트를 다시 다운로드합니다. @@ -500,7 +504,9 @@ 이 원본 버전을 삭제하시겠습니까? 이 %1$s 원본 버전은 %2$s의 이전 빌드에서 가져온 것입니다. 현재 빌드의 원본 버전은 항상 유지됩니다. Nightlies 내 · %1$d - 새로 고쳐 Nightlies의 패키지를 나열합니다. + 안정판 + 나이틀리 + 새로 고쳐 Nightlies 및 DroidDeck의 패키지를 나열합니다. 마지막 확인 이후 새로운 내용이 없습니다. .wcp 가져오기 이 Proton의 원본 파일 복원 @@ -536,7 +542,7 @@ Adreno에만 해당됩니다. 끊김이 줄어들지만 전력 소모와 발열이 증가합니다. 세션 수정 Turnip: sysmem 렌더링 - Steam과 대부분의 게임이 빨라집니다. Adreno 710/720/722에 필수입니다. + Steam 클라이언트가 빨라집니다. 게임에서는 드라이버가 패스마다 타일 렌더링과 시스템 메모리 렌더링 중 하나를 고릅니다. Adreno 710/720/722에 필수입니다. Steam의 xalia 도우미 건너뛰기 droiddeck-ntsync(실험적) 테스트용으로 Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다. @@ -578,6 +584,16 @@ 이전 페이지 다음 페이지 성능 HUD + 프레임 속도 + 배터리 온도 + 성능 그래프 + 색상 + 민트 + 흰색 + 하늘색 + 호박색 + 보라색 + 불투명도 스케일링 모드 스케일링 선명도 화면 효과 @@ -726,6 +742,9 @@ %1$d%%(기본값) 테마 런처 외관 + 앱 아이콘 + 다크 + 라이트 홈 화면으로 사용 DroidDeck을 홈 앱으로 사용할 수 있습니다 DroidDeck은 홈 앱으로 제공되지 않습니다 @@ -1029,6 +1048,9 @@ 추가된 게임 사용자 정의 게임 게임 저장 공간 + 파일을 삭제하고 Steam에서 제거합니다. + %1$s을(를) 제거할까요? + 바로 가기를 삭제합니다. 게임 파일은 그대로 남습니다. 이 GPU에 가장 적합한 드라이버 조합을 선택하고 최신 상태로 유지합니다 드라이버 조합을 직접 선택 드라이버 확인 중… @@ -1370,6 +1392,14 @@ 검은색 배경에 흰색 글자, 선택·포커스·누름 상태에는 파란색을 사용합니다. 일렉트릭 네이비 회색 대신 네이비 톤의 화면에 더 밝은 파란색을 사용합니다. + 그라데이션 + 직접 고른 기본색과 보조색이 버튼과 강조 표시에 섞여 적용됩니다. + 기본색 + 보조색 + 색조 + 채도 + 명도 + 16진수 색상 Esc Ins Del @@ -1741,7 +1771,8 @@ 설치된 빌드의 원본은 되돌리기 위해 유지됩니다. 저장되지 않음 %2$s의 %1$s 원본을 삭제했습니다. - Nightlies 릴리스에서만 다운로드할 수 있습니다 + Nightlies 및 DroidDeck 릴리스에서만 다운로드할 수 있습니다 + 다운로드 실패: 이 나이틀리 빌드가 더 새로운 빌드로 교체되었을 수 있습니다. 목록을 새로 고친 후 다시 시도하세요. 이 패키지 목록은 체크섬 도입 이전의 것입니다. 새로 고친 후 다시 시도하세요 체크섬 불일치 @@ -1900,9 +1931,10 @@ 스토어 스토어 설정 설정 + 탭 정리 + 완료 (로그인 안 됨) 스토어 - 설치됨 라이브러리 전체 다운로드 @@ -1914,10 +1946,8 @@ 로그인 유지 로그아웃 %1$s 카탈로그 검색… - 설치된 게임 검색… 내 라이브러리 검색… 검색 - 설치된 항목 없음 불러오는 중 게임 없음 스토어 불러오는 중… @@ -1936,7 +1966,19 @@ %1$s에서 보기 구매 · %1$s 설치됨 + 설치됨 + 설치되지 않음 라이브러리에 있음 + API 키 + 키 받기 + 키가 거부되었습니다 + itch.io 라이브러리에 없습니다 + 이 게임의 Windows 다운로드가 없습니다 + 다운로드에서 Windows 게임을 찾을 수 없습니다 + 다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d) + itch.io가 응답하지 않았습니다 + %1$s 압축 파일은 지원하지 않습니다 + itch.io가 HTTP %1$d을(를) 반환했습니다 무료 재개 새로 고침 @@ -1950,6 +1992,7 @@ 출시일 목록 보기 카드 보기 + 표지 보기 성인용 콘텐츠 표시 Epic EOS @@ -1985,6 +2028,9 @@ 마지막 동기화 %1$s 아직 동기화 안 됨 파일 %1$d개 + 이미 최신 상태입니다 + 저장 파일을 동기화하려면 스토어에 로그인하세요 + 저장 파일이 동기화되지 않았습니다 설치됨 내 라이브러리 스토어 @@ -2116,7 +2162,7 @@ 크기 저장 기본값으로 - 게임 배경에 Steam과 같은 방식으로 그려집니다. 위치가 몇 픽셀 어긋날 수 있습니다. + 이 정확한 위치는 몇 픽셀 차이가 날 수 있습니다. 이 게임에는 아직 로고가 없습니다. 로고에서 하나를 고르면 여기에 표시됩니다. 로고 위치를 저장하지 못했습니다. 다시 시도하세요. 배경 @@ -2286,7 +2332,7 @@ 완료 터치 레이아웃을 저장할 수 없습니다 터치 레이아웃이 Steam에 저장되었습니다 - 앱 %1$d + 앱 %1$s 라벨(선택 사항) Steam 아이콘을 찾을 수 없습니다 @@ -2375,6 +2421,15 @@ SteamVR 시야각 낮추면 SteamVR이 가까워져 안경을 가득 채우고, 높이면 주변이 더 넓게 보입니다. %1$s에서 플레이 중 + 중앙 재설정 + SteamVR 메뉴 + 스틱: 손 ⇄ 게임 + 컨트롤러: %1$s + 키보드 + 세션 메뉴 + SteamVR 중지 + 밝기 %1$d / %2$d + 배터리 %1$d%% · %2$d °C 트래킹 보정 드리프트를 줄이고 수평 기준을 맞춥니다. 저장된 보정이 이 안경에 적용되어 있습니다. @@ -2429,6 +2484,7 @@ 안경의 USB Ethernet 연결을 기다리는 중입니다. 이 휴대폰은 앱에 이 연결을 제공하지 않을 수 있습니다. XREAL 연결이 중단되었거나 지원되지 않는 응답을 받았습니다. 다시 시도하는 중… 1S 지원은 실험 단계입니다. 안경 메뉴에서 Follow 모드를 사용하고 안정화를 끄세요. 트래킹 방향과 디스플레이 모드는 아직 기기에서 테스트가 필요합니다. + Pro 2 헤드 트래킹은 실험 단계입니다. 밝기와 음량 조절 및 디스플레이 모드 전환은 아직 지원되지 않습니다. 개발사 배급사 출시일 @@ -2641,7 +2697,7 @@ 부팅 동영상 동영상 DroidDeck · %1$d fps - 60 fps는 더 부드럽지만 Steam이 시작되는 동안 CPU를 더 사용합니다. Steam 자체 설정에서 고른 시작 동영상이 우선합니다. + 60 fps는 더 부드럽지만 Steam이 시작되는 동안 CPU를 더 사용합니다. Steam 자체 설정에서 고른 동영상도 여기에 표시됩니다. 소리 내 동영상 MP4, MOV, MKV 또는 WebM. 다른 형식과 %1$d MB를 넘는 파일은 기기에서 720p WebM으로 변환됩니다(최대 %2$d초). @@ -2655,6 +2711,51 @@ Steam용으로 %1$s 변환 중입니다. DroidDeck을 열어 두세요. 이 기기에서 해당 동영상을 디코딩할 수 없습니다. 동영상을 가져오지 못했습니다 + %1$s을(를) 삭제할까요? + DroidDeck과 Steam의 동영상 폴더에서 동영상이 삭제됩니다. + 설치됨 + 가져오기 + SteamDeckRepo + 소리 + 설치된 동영상 + 찾아보기 + DroidDeck + 가져옴 + SteamDeckRepo + Steam + 포인트 상점 + 부팅 영상 보고서 + DroidDeck과 Steam이 부팅 영상에 대해 알고 있는 내용을 텍스트 파일로 저장합니다. 버그 보고서에 첨부할 수 있습니다. + 저장 + %1$s에 저장됨 + 보고서를 저장하지 못했습니다. + 볼륨 + 길이 + %1$d초 + 동영상을 몇 초 동안 재생할지 설정합니다. 그보다 긴 부분은 끝에서 잘립니다. + SteamDeckRepo 검색 + 모든 기기 + Steam Deck + Steam Machine + 인기 + 다운로드 많은 순 + 좋아요 많은 순 + 최신순 + SteamDeckRepo에 연결할 수 없습니다 + 다시 시도 + 동영상이 없습니다 + 설치 + 사용 + 사용 중 + %1$s 설치됨 + 동영상을 다운로드하지 못했습니다 + 이전 + 다음 + %1$d페이지 + 지우기 + 페이지로 이동 + 미리보기 + 미리보기를 재생할 수 없습니다 Luma Ultra 카메라 — 실험적 기능 Joy-Con 위치 추적 스마트 안경 카메라를 사용하여 페어링된 Joy-Con 컨트롤러의 위치를 ​​조정합니다. 회전 및 버튼 입력은 Joy-Con 컨트롤러에서 이루어집니다. 손목이 가려진 경우 위치가 추정됩니다. 맨손으로 조작하는 컨트롤러를 대체하며, 휴대폰 카메라 추적이 일시 중지됩니다. 추적 센터를 연결하거나 재설정할 때는 Joy-Con 컨트롤러를 앞쪽으로 향하게 하십시오. @@ -2757,7 +2858,6 @@ 스테레오 또는 모션 검사에 의해 손목 위치가 거부됨 스테레오 포인트가 감지되었지만 손바닥 형상이 일치하지 않습니다. %1$.2f픽셀에 맞춥니다. 왼쪽 카메라에서 보정 대상 중심까지 선택한 거리를 측정한 다음 확인합니다. - OpenCV를 사용할 수 없습니다: %1$s %1$d 저장된 뷰가 복원되었습니다. 다시 시도하려면 보정을 계산하고, 더 많은 것을 캡처하려면 새 세트를 시작하세요. 캡쳐 %1$d / 조회수 24입니다. 교정 대상을 고정된 상태로 유지하십시오. 다음 보기를 위해 스마트 안경을 이동하세요. 캡처 거부됨: %1$d / 두 카메라 모두에 12개의 필수 모서리가 표시됩니다. 보정 대상을 더 많이 표시하고 가만히 있다가 다시 시도하세요. diff --git a/app/src/main/res/values-ms/gestures.xml b/app/src/main/res/values-ms/gestures.xml new file mode 100644 index 000000000..67f5d2091 --- /dev/null +++ b/app/src/main/res/values-ms/gestures.xml @@ -0,0 +1,91 @@ + + + Gerak isyarat + Tetapan gerak isyarat sentuhan + Sesuaikan gerak isyarat tetikus dan papan kekunci. Pilih Gerak isyarat sebagai mod sentuhan untuk menggunakannya. + Ketik + Seret dengan satu jari + Tekan lama + Ketik dua kali (klik kiri dua kali) + Ketik dengan dua jari + Seret dengan dua jari + Tahan dengan dua jari + Ketik dengan tiga jari + Seret dengan tiga jari + Tahan dengan tiga jari + Cubit untuk zum + Kekalkan butang ditekan + Klik sekali + Lengah sebelum menahan + Sela ketikan dua kali + Pulihkan tetapan gerak isyarat asal + Batal + Pilih tindakan + Klik kiri + Klik kanan + Klik tengah + Papar papan kekunci + Klik kiri dan seret + Klik kanan dan seret + Sorot dengan butang tengah tetikus + Sorot dengan butang tengah tetikus (terbalik) + Kekunci anak panah + Kekunci anak panah (terbalik) + WASD (terbalik) + Roda tetikus + Ambang gerak isyarat: %1$d px + Tunjukkan gerak isyarat yang dikenal pasti + Satu jari + Dua jari + Tiga jari + Pilihan + Lalai + Permainan ini + Gunakan lalai + Simpan perubahan + Tutup + TINDAKAN + Cari tindakan tetikus atau kekunci papan kekunci + Kosongkan carian + Semua + Tiada tindakan sepadan. Cuba nama kekunci atau tindakan lain. + Tetikus + Tindakan aplikasi + Navigasi + Huruf + Nombor + Kekunci fungsi + Kekunci pengubah suai + Pad nombor + Simbol + Pergerakan tetikus + Terus + Relatif + Pengecaman gerak isyarat + Maklum balas visual + Kelakuan butang tetikus + %1$d ms + Pad nombor %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Anak panah atas + Anak panah bawah + Anak panah kiri + Anak panah kanan + Caps Lock + Num Lock + Scroll Lock + %1$s kiri + %1$s kanan + Shift + diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 668a2419b..ef8c7d5f2 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -30,7 +30,7 @@ Penjanaan bingkai Pilih mod penjanaan bingkai Mati - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptif %1$d Memerlukan Lossless Scaling pada Steam @@ -53,7 +53,7 @@ Menambah pustaka Steam dan mengimbasnya untuk permainan Windows. Permainan yang dimiliki menggunakan entri Steam masing-masing; selebihnya menjadi pintasan bukan Steam. Persekitaran permainan Pembolehubah persekitaran untuk permainan Proton. - Edit + Sunting Semua permainan Proton Profil ID apl Steam @@ -153,7 +153,7 @@ Mengemas kini apl gagal Ikon itu tidak dapat dibaca %1$s disimpan - Edit + Sunting Semak kemas kini Menyemak kemas kini… Kemas kini kepada %1$s @@ -176,6 +176,8 @@ %1$s melalui FEX Program ini dibina untuk PC %1$s dan berjalan melalui FEX, yang belum disediakan lagi. Buka Steam sekali semasa anda log masuk: DroidDeck akan memuat turun FEX dan Steam Linux Runtime yang diperlukan, kemudian ini akan dibuka. Program ini + Tidak dapat memuat turun klien Steam. Rangkaian atau VPN anda mungkin menyekat pelayan Steam; semak sambungan dan cuba lagi. + Tidak dapat menyediakan klien Steam. Semak sambungan dan ruang storan kosong, kemudian cuba lagi. %1$s dibina untuk PC x86 dan berjalan melalui FEX, yang belum dipasang lagi. Mulakan Steam sekali semasa anda log masuk dan biarkan ia memuat turun FEX dan Steam Linux Runtime, kemudian mulakan ini semula. ID apl Flathub atau halaman Flathubnya. Flatpak akan disediakan dahulu jika belum lagi. Fail @@ -205,7 +207,7 @@ Daripada repositori Mencari ikon… Lalai - Edit %1$s + Sunting %1$s Simpan Menyemak folder… Foldernya dipautkan ke dalam sesi: %1$s @@ -315,7 +317,7 @@ Karya seni Kunci API SteamGridDB Kunci anda - Edit… + Sunting… Melengkapkan karya seni yang hilang daripada Steam, kemudian SteamGridDB. cover.jpg, hero.jpg, logo.png atau icon.png anda sendiri dalam folder permainan diutamakan. Alih keluar %1$s Alih keluar? @@ -500,7 +502,9 @@ Padam yang asal ini? %1$s asal ini daripada binaan terdahulu %2$s. Yang asal bagi binaan semasa sentiasa dikekalkan. Pada Nightlies · %1$d - Segarkan semula untuk menyenaraikan pakej pada Nightlies. + Stabil + Nightly + Segarkan semula untuk menyenaraikan pakej pada Nightlies dan DroidDeck. Tiada apa-apa yang baharu sejak semakan terakhir. Import .wcp Pulihkan yang asal bagi Proton ini @@ -536,7 +540,7 @@ Adreno sahaja. Kurang tersekat-sekat, lebih penggunaan bateri dan haba. Pembetulan sesi Turnip: rendering sysmem - Lebih pantas untuk Steam dan kebanyakan permainan. Diperlukan pada Adreno 710/720/722. + Lebih pantas untuk klien Steam; dalam permainan, pemacu memilih pemaparan jubin atau memori sistem bagi setiap laluan. Diperlukan pada Adreno 710/720/722. Langkau pembantu xalia Steam droiddeck-ntsync (eksperimen) Pengganti ruang pengguna untuk pemacu ntsync Linux, untuk pengujian. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya. @@ -578,6 +582,16 @@ Halaman sebelumnya Halaman seterusnya HUD prestasi + Kadar bingkai + Suhu bateri + Graf prestasi + Warna + Pudina + Putih + Langit + Ambar + Ungu + Kelegapan Mod penskalaan Ketajaman penskalaan Kesan skrin @@ -726,6 +740,9 @@ %1$d%% (Lalai) Tema Rupa pelancar + Ikon apl + Gelap + Cerah Gunakan sebagai skrin Utama DroidDeck boleh menjadi apl Utama DroidDeck tidak sekali-kali ditawarkan sebagai apl Utama @@ -1029,6 +1046,9 @@ Permainan ditambah Permainan tersuai Storan permainan + Memadam failnya dan mengalih keluarnya daripada Steam. + Alih keluar %1$s? + Memadam pintasannya. Fail permainan kekal. Pasangan terbaik untuk GPU ini, dikemas kini sentiasa Pilih pasangan sendiri Menyemak pemacu… @@ -1370,6 +1390,14 @@ Latar hitam, teks putih, biru untuk semua yang dipilih, difokus atau ditekan. Navy elektrik Biru lebih cerah, pada permukaan yang lebih cenderung navy daripada kelabu. + Kecerunan + Warna utama dan sekunder pilihan anda, diadun pada butang dan sorotan. + Utama + Sekunder + Rona + Ketepuan + Kecerahan + Warna hex Esc Ins Del @@ -1520,7 +1548,7 @@ Biru Steam Langit Putih - Violet + Ungu Hijau Ambar Merah @@ -1741,7 +1769,8 @@ Asal binaan yang dipasang dikekalkan: itulah jalan kembali. Tidak disimpan Asal %1$s bagi %2$s telah dipadam. - Muat turun hanya datang daripada keluaran Nightlies + Muat turun hanya datang daripada keluaran Nightlies dan DroidDeck + Muat turun gagal: binaan nightly ini mungkin telah diganti dengan yang lebih baharu. Segarkan semula senarai dan cuba lagi. Senarai pakej ini mendahului checksum - segarkan dan cuba lagi Checksum tidak sepadan @@ -1900,9 +1929,10 @@ Kedai Tetapan kedai Tetapan + Susun tab + Selesai (belum log masuk) Kedai - Dipasang Pustaka Semua Muat turun @@ -1914,10 +1944,8 @@ Kekal log masuk Log keluar Cari katalog %1$s… - Cari permainan dipasang… Cari pustaka anda… Cari - Tiada apa dipasang Memuatkan Tiada permainan Memuatkan kedai… @@ -1936,7 +1964,19 @@ Lihat pada %1$s Beli · %1$s Dipasang + Dipasang + Belum dipasang Dalam pustaka + Kunci API + Dapatkan kunci + Kunci ditolak + Tiada dalam pustaka itch.io anda + Tiada muat turun Windows untuk permainan ini + Tiada permainan Windows ditemui dalam muat turun + Tidak dapat mengekstrak muat turun (kod %1$d) + itch.io tidak menjawab + Arkib %1$s tidak disokong + itch.io menjawab HTTP %1$d Percuma Sambung Segar semula @@ -1950,6 +1990,7 @@ Tarikh keluaran Paparan senarai Paparan kad + Paparan kulit Papar kandungan matang Epic EOS @@ -1985,6 +2026,9 @@ Segerak terakhir %1$s Belum disegerakkan %1$d fail + Sudah terkini + Log masuk ke kedai untuk menyegerakkan simpanan + Simpanan tidak disegerakkan dipasang dalam pustaka anda kedai @@ -2116,7 +2160,7 @@ Saiz Simpan Kembali kepada lalai - Dilukis di atas latar belakang permainan, seperti yang dibuat oleh Steam. Kedudukan tepatnya mungkin berbeza beberapa piksel. + Kedudukan tepat ini mungkin berbeza beberapa piksel. Permainan ini belum ada logo. Pilih satu di Logo untuk melihatnya di sini. Kedudukan logo tidak dapat disimpan. Cuba lagi. Latar belakang @@ -2286,7 +2330,7 @@ Selesai Susun atur sentuh tidak dapat disimpan Susun atur sentuh disimpan ke Steam - Apl %1$d + Apl %1$s Label (pilihan) Ikon Steam tidak dijumpai @@ -2375,6 +2419,15 @@ Medan pandangan SteamVR Lebih rendah mendekatkan SteamVR dan memenuhi cermin mata; lebih tinggi menunjukkan lebih banyak di sekeliling anda. Bermain pada %1$s + Pusatkan semula + Menu SteamVR + Kayu bedik: tangan ⇄ permainan + Pengawal: %1$s + Papan kekunci + Menu sesi + Hentikan SteamVR + Kecerahan %1$d / %2$d + Bateri %1$d%% · %2$d °C Penentukuran penjejakan Mengurangkan hanyutan dan menjajarkan rujukan mendatar. Penentukuran yang disimpan digunakan pada cermin mata ini. @@ -2429,6 +2482,7 @@ Menunggu sambungan USB Ethernet cermin mata. Telefon ini mungkin tidak mendedahkannya kepada apl. Sambungan XREAL terganggu atau memberikan respons yang tidak disokong. Mencuba semula… Sokongan eksperimen untuk 1S. Gunakan mod Follow dan matikan penstabilan dalam menu cermin mata. Arah penjejakan dan mod paparan masih perlu diuji pada peranti. + Penjejakan kepala Pro 2 secara eksperimen. Pelarasan kecerahan, kelantangan dan pertukaran mod paparan belum disokong. Cardboard VR Mod Cardboard Paparan mata kiri dan kanan untuk kaca mata VR telefon. @@ -2641,7 +2695,7 @@ Video but Video DroidDeck · %1$d fps - 60 fps kelihatan lebih lancar tetapi menggunakan lebih banyak CPU semasa Steam bermula. Video permulaan yang dipilih dalam tetapan Steam sendiri diutamakan. + 60 fps kelihatan lebih lancar tetapi menggunakan lebih banyak CPU semasa Steam bermula. Video yang dipilih dalam tetapan Steam sendiri turut dipaparkan di sini. Bunyi Video anda sendiri MP4, MOV, MKV atau WebM. Format lain dan fail melebihi %1$d MB ditukar kepada WebM 720p pada peranti, sehingga %2$d saat. @@ -2655,6 +2709,51 @@ Menukar %1$s untuk Steam. Biarkan DroidDeck terbuka. Peranti ini tidak dapat menyahkod video itu. Tidak dapat mengimport video itu + Alih keluar %1$s? + Ini memadamkan video daripada DroidDeck dan daripada folder video Steam. + Dipasang + Import + SteamDeckRepo + Bunyi + Video dipasang + Layari + DroidDeck + Diimport + SteamDeckRepo + Steam + Kedai Mata + Laporan filem but + Menyimpan apa yang DroidDeck dan Steam ketahui tentang filem but ke dalam fail teks yang boleh dilampirkan pada laporan pepijat. + Simpan + Disimpan di %1$s + Laporan tidak dapat disimpan. + Kelantangan + Tempoh + %1$d s + Berapa saat filem dimainkan. Bahagian yang lebih panjang akan dipotong pada penghujungnya. + Cari SteamDeckRepo + Semua peranti + Steam Deck + Steam Machine + Sohor kini + Paling banyak dimuat turun + Paling disukai + Terbaharu + Tidak dapat menghubungi SteamDeckRepo + Cuba semula + Tiada video ditemui + Pasang + Guna + Sedang digunakan + %1$s dipasang + Tidak dapat memuat turun video itu + Sebelumnya + Seterusnya + Halaman %1$d + Kosongkan + Pergi ke halaman + Pratonton + Pratonton tidak dapat dimainkan Kamera Luma Ultra — Eksperimen Penjejakan kedudukan Joy-Con Gunakan kamera cermin mata pintar untuk meletakkan pengawal Joy-Con yang dipasangkan. Input putaran dan butang datang daripada pengawal Joy-Con. Kedudukan dianggarkan apabila pergelangan tangan anda tersembunyi. Menggantikan pengawal yang dikendalikan dengan tangan kosong dan menjeda penjejakan kamera telefon. Halakan pengawal Joy-Con ke hadapan semasa menyambung atau menetapkan semula pusat penjejakan. @@ -2826,7 +2925,6 @@ Menunggu penjejakan kepala yang stabil; tetapkan semula pusat penjejakan jika diminta Pemerhatian kamera terlalu lama; menunggu pasangan baru Muat %1$.2f px. Ukur jarak pilihan anda dari kamera kiri ke pusat sasaran penentukuran, kemudian sahkan. - OpenCV tidak tersedia: %1$s %1$d paparan yang disimpan dipulihkan. Kira penentukuran untuk mencuba semula, atau mulakan set baharu untuk menangkap lebih banyak lagi. Ditangkap %1$d / 24 paparan. Pastikan sasaran penentukuran tetap; alihkan cermin mata pintar untuk paparan seterusnya. Tangkapan ditolak: %1$d / 12 sudut yang diperlukan kelihatan dalam kedua-dua kamera. Tunjukkan lebih banyak sasaran penentukuran, tahan diam, kemudian cuba lagi. diff --git a/app/src/main/res/values-nb/gestures.xml b/app/src/main/res/values-nb/gestures.xml new file mode 100644 index 000000000..4f4143850 --- /dev/null +++ b/app/src/main/res/values-nb/gestures.xml @@ -0,0 +1,91 @@ + + + Bevegelser + Innstillinger for berøringsbevegelser + Tilpass muse- og tastaturbevegelser. Velg Bevegelser som berøringsmodus for å bruke dem. + Trykk + Dra med én finger + Langt trykk + Dobbelttrykk (dobbelt venstreklikk) + Trykk med to fingre + Dra med to fingre + Hold med to fingre + Trykk med tre fingre + Dra med tre fingre + Hold med tre fingre + Knip for å zoome + Hold knappen nede + Klikk én gang + Forsinkelse før hold + Intervall for dobbelttrykk + Gjenopprett de opprinnelige bevegelsesinnstillingene + Avbryt + Velg handling + Venstreklikk + Høyreklikk + Midtklikk + Vis tastatur + Venstreklikk og dra + Høyreklikk og dra + Panorer med midtre museknapp + Panorer med midtre museknapp (omvendt) + Piltaster + Piltaster (omvendt) + WASD (omvendt) + Musehjul + Bevegelsesterskel: %1$d px + Vis gjenkjent bevegelse + Én finger + To fingre + Tre fingre + Alternativer + Standardverdier + Dette spillet + Bruk standardinnstillinger + Lagre endringer + Lukk + HANDLING + Søk etter musehandlinger eller tastaturtaster + Tøm søket + Alle + Ingen handlinger samsvarer. Prøv et annet navn på en tast eller handling. + Mus + Apphandlinger + Navigasjon + Bokstaver + Tall + Funksjonstaster + Modifikatortaster + Numerisk tastatur + Symboler + Musebevegelse + Direkte + Relativ + Gjenkjenning av bevegelser + Visuell tilbakemelding + Museknappens funksjon + %1$d ms + Numerisk tastatur %1$s + Escape + Tilbake + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pil opp + Pil ned + Pil til venstre + Pil til høyre + Caps Lock + Num Lock + Scroll Lock + Venstre %1$s + Høyre %1$s + Shift + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 5c1ebd5db..5dc36a00c 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -30,7 +30,7 @@ Bildegenerering Velg modus for bildegenerering Av - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Krever Lossless Scaling på Steam @@ -176,6 +176,8 @@ %1$s via FEX Dette programmet er bygget for %1$s-PC-er og kjører via FEX, som ikke er satt opp ennå. Åpne Steam én gang mens du er innlogget: DroidDeck lar den laste ned FEX og Steam Linux Runtime den trenger, og så åpnes dette. Dette programmet + Kunne ikke laste ned Steam-klienten. Nettverket eller VPN-en din blokkerer kanskje Steams servere; sjekk tilkoblingen og prøv igjen. + Kunne ikke sette opp Steam-klienten. Sjekk tilkoblingen og ledig lagringsplass, og prøv igjen. %1$s er bygget for x86-PC-er og kjører via FEX, som ikke er installert ennå. Start Steam én gang mens du er innlogget, og la den laste ned FEX og Steam Linux Runtime, og start dette på nytt. En Flathub-app-ID eller dens Flathub-side. Flatpak settes opp først hvis det ikke allerede er gjort. Fil @@ -502,7 +504,9 @@ Slette denne originalen? Denne %1$s-originalen er fra en tidligere build av %2$s. Den gjeldende buildens original beholdes alltid. På Nightlies · %1$d - Oppdater for å liste pakker på Nightlies. + Stabil + Nightly + Oppdater for å liste pakker på Nightlies og DroidDeck. Ingenting nytt siden siste sjekk. Importer .wcp Gjenopprett denne Protonens original @@ -527,7 +531,7 @@ Klientgrensesnitt Trådet GL Kan gjøre Steam-menyer mer responsive. - Zink: lazy descriptors + Zink: late deskriptorer Anbefales for drivere uten descriptor buffers. Hopp over GL-feilkontroller Slår av GL-validering per kall. @@ -538,7 +542,7 @@ Bare Adreno. Færre hikk, mer batteribruk og varme. Sesjonsfikser Turnip: sysmem-rendering - Raskere for Steam og de fleste spill. Påkrevd på Adreno 710/720/722. + Raskere for Steam-klienten; i spill velger driveren flis- eller systemminnerendering for hvert pass. Påkrevd på Adreno 710/720/722. Hopp over Steams xalia-hjelper droiddeck-ntsync (eksperimentell) En erstatning i brukerrom for Linux-driveren ntsync, for testing. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon. @@ -580,6 +584,16 @@ Forrige side Neste side Ytelses-HUD + Bildefrekvens + Batteritemperatur + Ytelsesgraf + Farge + Mint + Hvit + Himmel + Amber + Fiolett + Opasitet Skaleringsmodus Skaleringsskarphet Skjermeffekter @@ -729,6 +743,9 @@ %1$d%% (Standard) Tema Launcherens utseende + Appikon + Mørk + Lys Bruk som Hjem-skjerm DroidDeck kan være Hjem-appen DroidDeck tilbys aldri som en Hjem-app @@ -1035,6 +1052,9 @@ Lagt til spill Egendefinert spill Spillagring + Sletter filene og fjerner det fra Steam. + Fjern %1$s? + Sletter snarveien. Spillets filer blir liggende. Det beste paret for denne GPU-en, holdt oppdatert Velg et par selv Ser etter drivere… @@ -1384,6 +1404,14 @@ Svart bakgrunn, hvit skrift, det blå for alt som er valgt, fokusert eller trykket. Elektrisk marineblå Det blå lysere, på flater som lener mer mot marineblått enn grått. + Gradient + Dine egne primær- og sekundærfarger, blandet på knapper og uthevinger. + Primær + Sekundær + Fargetone + Metning + Lysstyrke + Hex-farge Esc Ins Del @@ -1762,7 +1790,8 @@ Originalen til den installerte builden beholdes: det er veien tilbake. Ikke lagret Slettet %1$s-originalen av %2$s. - Nedlastinger kommer bare fra Nightlies-utgivelsene + Nedlastinger kommer bare fra Nightlies- og DroidDeck-utgivelsene + Nedlastingen mislyktes: denne nightly-versjonen kan være erstattet av en nyere. Oppdater listen og prøv igjen. Denne pakkelisten er eldre enn sjekksummene – oppdater den og prøv igjen Sjekksummen stemmer ikke @@ -1921,9 +1950,10 @@ Butikker Butikkinnstillinger Innstillinger + Ordne faner + Ferdig (ikke innlogget) Butikk - Installert Bibliotek Alle Nedlastinger @@ -1935,10 +1965,8 @@ Fortsett innlogget Logg ut Søk i %1$s-katalogen… - Søk i installerte spill… Søk i biblioteket ditt… Søk - Ingenting installert Laster Ingen spill Laster butikken… @@ -1957,7 +1985,19 @@ Vis på %1$s Kjøp · %1$s Installert + Installert + Ikke installert I biblioteket + API-nøkkel + Hent en nøkkel + Nøkkelen ble avvist + Ikke i itch.io-biblioteket ditt + Ingen Windows-nedlasting for dette spillet + Fant ikke noe Windows-spill i nedlastingen + Kunne ikke pakke ut nedlastingen (kode %1$d) + itch.io svarte ikke + %1$s-arkiver støttes ikke + itch.io svarte HTTP %1$d Gratis Gjenoppta Oppdater @@ -1971,6 +2011,7 @@ Utgivelsesdato Listevisning Kortvisning + Omslagsvisning Vis innhold for voksne Epic EOS @@ -2006,6 +2047,9 @@ Siste synkronisering %1$s Ikke synkronisert ennå %1$d filer + Allerede oppdatert + Logg inn i butikken for å synkronisere lagrede spill + Lagrede spill ble ikke synkronisert installert i biblioteket ditt butikk @@ -2137,7 +2181,7 @@ Størrelse Lagre Tilbake til standard - Tegnes oppå spillets bakgrunn, slik Steam gjør. Den nøyaktige posisjonen kan avvike noen piksler. + Denne nøyaktige plasseringen kan avvike med noen piksler. Dette spillet har ingen logo ennå. Velg en under Logo for å se den her. Logoposisjonen kunne ikke lagres. Prøv igjen. Bakgrunn @@ -2309,7 +2353,7 @@ Fullført Kunne ikke lagre berøringsoppsettet Berøringsoppsett lagret til Steam - App %1$d + App %1$s Etikett (valgfritt) Steams ikoner ble ikke funnet @@ -2400,6 +2444,15 @@ SteamVR-synsfelt Lavere bringer SteamVR nærmere og fyller brillene; høyere viser mer rundt deg. Spiller på %1$s + Sentrer på nytt + SteamVR-meny + Spaker: hender ⇄ spill + Kontrollere: %1$s + Tastatur + Øktmeny + Stopp SteamVR + Lysstyrke %1$d / %2$d + Batteri %1$d%% · %2$d °C Sporingskalibrering Reduserer avdrift og retter inn horisontreferansen. Lagret kalibrering brukes for disse brillene. @@ -2454,6 +2507,7 @@ Venter på USB Ethernet-tilkoblingen til brillene. Denne telefonen gjør den kanskje ikke tilgjengelig for apper. XREAL-tilkoblingen ble avbrutt eller ga et svar som ikke støttes. Prøver igjen… Eksperimentell støtte for 1S. Bruk Follow-modus og slå av stabilisering i brillemenyen. Sporingsretninger og visningsmoduser må fortsatt testes på enheten. + Eksperimentell hodesporing for Pro 2. Justering av lysstyrke og volum samt bytte av visningsmodus støttes ikke ennå. Cardboard VR Cardboard-modus Bilder for venstre og høyre øye til en VR-brille for mobil. @@ -2666,7 +2720,7 @@ Oppstartsvideo Video DroidDeck · %1$d fps - 60 fps ser jevnere ut, men bruker mer CPU mens Steam starter. En oppstartsvideo valgt i Steams egne innstillinger har forrang. + 60 fps ser jevnere ut, men bruker mer CPU mens Steam starter. En video valgt i Steams egne innstillinger vises også her. Lyd Din egen video MP4, MOV, MKV eller WebM. Andre formater og filer over %1$d MB konverteres til 720p-WebM på enheten, opptil %2$d sekunder. @@ -2680,6 +2734,51 @@ Konverterer %1$s for Steam. Hold DroidDeck åpen. Enheten kan ikke dekode videoen. Kunne ikke importere videoen + Fjerne %1$s? + Dette sletter videoen fra DroidDeck og fra Steams videomappe. + Installert + Importer + SteamDeckRepo + Lyd + Installerte videoer + Bla gjennom + DroidDeck + Importert + SteamDeckRepo + Steam + Poengbutikk + Rapport om oppstartsfilmer + Lagrer det DroidDeck og Steam vet om oppstartsfilmer i en tekstfil du kan legge ved en feilrapport. + Lagre + Lagret i %1$s + Rapporten kunne ikke lagres. + Volum + Lengde + %1$d s + Hvor mange sekunder av filmen som spilles av. Alt som er lengre, klippes bort på slutten. + Søk i SteamDeckRepo + Alle enheter + Steam Deck + Steam Machine + Trender + Mest nedlastet + Mest likt + Nyeste + Fikk ikke kontakt med SteamDeckRepo + Prøv igjen + Fant ingen videoer + Installer + Bruk + I bruk + %1$s installert + Kunne ikke laste ned videoen + Forrige + Neste + Side %1$d + Tøm + Gå til side + Forhåndsvis + Forhåndsvisningen kunne ikke spilles av Luma Ultra-kameraer – Eksperimentelt Joy-Con-posisjonssporing Bruk smartbrillenes kameraer til å plassere de parede Joy-Con-kontrollerne. Rotasjon og knappinndata kommer fra Joy-Con-kontrollerne. Posisjonen estimeres når håndleddene dine er skjult. Erstatter kontrollere som betjenes med bare hender og pauser sporing av telefonkamera. Rett Joy-Con-kontrollerne fremover når du kobler til eller tilbakestiller sporingssenteret. @@ -2853,7 +2952,6 @@ Venter på stabil hodesporing; tilbakestill sporingssenteret hvis forespurt. Kameraobservasjon er for gammel; venter på et nytt par Fit %1$.2f px. Mål den valgte avstanden fra venstre kamera til kalibreringsmålsenteret, og valider deretter. - OpenCV utilgjengelig: %1$s %1$d lagrede visninger gjenopprettet. Beregn kalibrering for å prøve på nytt, eller start et nytt sett for å fange mer. Fanget %1$d / 24 visninger. Hold kalibreringsmålet fast; flytt smartbrillene for neste visning. Opptaket avvist: %1$d / 12 nødvendige hjørner synlige i begge kameraene. Vis mer av kalibreringsmålet, hold stille, og prøv på nytt. diff --git a/app/src/main/res/values-nl/gestures.xml b/app/src/main/res/values-nl/gestures.xml new file mode 100644 index 000000000..a970becd5 --- /dev/null +++ b/app/src/main/res/values-nl/gestures.xml @@ -0,0 +1,91 @@ + + + Gebaren + Instellingen voor aanraakgebaren + Pas muis- en toetsenbordgebaren aan. Kies Gebaren als aanraakmodus om ze te gebruiken. + Tikken + Slepen met één vinger + Lang indrukken + Dubbeltikken (dubbele linkermuisklik) + Tikken met twee vingers + Slepen met twee vingers + Vasthouden met twee vingers + Tikken met drie vingers + Slepen met drie vingers + Vasthouden met drie vingers + Knijpen om te zoomen + Knop ingedrukt houden + Eenmaal klikken + Wachttijd voor vasthouden + Interval voor dubbeltikken + Oorspronkelijke gebareninstellingen herstellen + Annuleren + Actie kiezen + Linkermuisklik + Rechtermuisklik + Middelste muisklik + Toetsenbord tonen + Linkermuisknop en slepen + Rechtermuisknop en slepen + Pannen met de middelste muisknop + Pannen met de middelste muisknop (omgekeerd) + Pijltoetsen + Pijltoetsen (omgekeerd) + WASD (omgekeerd) + Muiswiel + Gebaardrempel: %1$d px + Herkend gebaar tonen + Eén vinger + Twee vingers + Drie vingers + Opties + Standaardinstellingen + Dit spel + Standaardinstellingen gebruiken + Wijzigingen opslaan + Sluiten + ACTIE + Zoek muisacties of toetsen + Zoekopdracht wissen + Alle + Geen overeenkomende acties. Probeer een andere toets- of actienaam. + Muis + App-acties + Navigatie + Letters + Cijfers + Functietoetsen + Modificatietoetsen + Numeriek toetsenblok + Symbolen + Muisbeweging + Direct + Relatief + Gebarenherkenning + Visuele feedback + Gedrag van de muisknop + %1$d ms + Numeriek toetsenblok %1$s + Escape + Backspace + Enter + Tab + Spatie + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pijl omhoog + Pijl omlaag + Pijl links + Pijl rechts + Caps Lock + Num Lock + Scroll Lock + Linker %1$s + Rechter %1$s + Shift + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 46389acca..02b1493d5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -30,7 +30,7 @@ Framegeneratie Kies de modus voor framegeneratie Uit - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptief %1$d Vereist Lossless Scaling op Steam @@ -176,6 +176,8 @@ %1$s via FEX Dit programma is gemaakt voor %1$s-pc’s en draait via FEX, dat nog niet is ingesteld. Open Steam één keer terwijl je bent ingelogd: DroidDeck laat het dan FEX en de benodigde Steam Linux Runtime downloaden. Daarna opent dit. Dit programma + De Steam-client kon niet worden gedownload. Je netwerk of VPN blokkeert mogelijk de servers van Steam; controleer de verbinding en probeer het opnieuw. + De Steam-client kon niet worden ingesteld. Controleer de verbinding en de vrije opslag en probeer het opnieuw. %1$s is gemaakt voor x86-pc’s en draait via FEX, dat nog niet is geïnstalleerd. Start Steam één keer terwijl je bent ingelogd en laat het FEX en de Steam Linux Runtime downloaden. Start dit daarna opnieuw. Een Flathub-app-ID of de Flathub-pagina ervan. Flatpak wordt eerst ingesteld als dat nog niet is gebeurd. Bestand @@ -502,7 +504,9 @@ Dit origineel verwijderen? Dit origineel van %1$s komt uit een eerdere build van %2$s. Het origineel van de huidige build wordt altijd bewaard. In de Nightlies · %1$d - Vernieuw om de pakketten in de Nightlies te tonen. + Stabiel + Nightly + Vernieuw om de pakketten in de Nightlies en bij DroidDeck te tonen. Niets nieuws sinds de laatste controle. .wcp importeren Origineel van deze Proton herstellen @@ -538,7 +542,7 @@ Alleen Adreno. Minder haperingen, meer batterijverbruik en warmte. Sessiefixes Turnip: sysmem-rendering - Sneller voor Steam en de meeste games. Vereist op Adreno 710/720/722. + Sneller voor de Steam-client; in games kiest het stuurprogramma per pass tussen tegel- en systeemgeheugenrendering. Vereist op Adreno 710/720/722. Sla de xalia-helper van Steam over droiddeck-ntsync (experimenteel) Een userspace-vervanger voor de ntsync-driver van Linux, voor tests. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie. @@ -580,6 +584,16 @@ Vorige pagina Volgende pagina Prestatie-HUD + Beeldsnelheid + Accutemperatuur + Prestatiegrafiek + Kleur + Mint + Wit + Hemelsblauw + Amber + Violet + Transparantie Schaalmodus Schaalscherpte Schermeffecten @@ -729,6 +743,9 @@ %1$d%% (Standaard) Thema Uiterlijk van de launcher + App-pictogram + Donker + Licht Als startscherm gebruiken DroidDeck kan de startschermapp zijn DroidDeck wordt nooit aangeboden als startschermapp @@ -1035,6 +1052,9 @@ Toegevoegde game Aangepaste game Opslag voor games + Verwijdert de bestanden en haalt het uit Steam. + %1$s verwijderen? + Verwijdert de snelkoppeling. De gamebestanden blijven staan. Het beste paar voor deze GPU, blijft up-to-date Kies zelf een paar Controleren op drivers… @@ -1384,6 +1404,14 @@ Zwarte achtergrond, witte tekst, het blauw voor alles wat geselecteerd, gefocust of ingedrukt is. Elektrisch marineblauw Het blauw feller, op vlakken die naar marineblauw neigen in plaats van grijs. + Kleurverloop + Je eigen primaire en secundaire kleur, doorlopend op knoppen en accenten. + Primair + Secundair + Tint + Verzadiging + Helderheid + Hex-kleur Esc Ins Del @@ -1762,7 +1790,8 @@ Het origineel van de geïnstalleerde build wordt bewaard: dat is de weg terug. Niet opgeslagen Het %1$s-origineel van %2$s is verwijderd. - Downloads komen alleen van de Nightlies-releases + Downloads komen alleen van de Nightlies- en DroidDeck-releases + Download mislukt: deze nightly is mogelijk vervangen door een nieuwere build. Vernieuw de lijst en probeer het opnieuw. Deze pakketlijst is ouder dan de checksums - vernieuw de lijst en probeer het opnieuw Checksum komt niet overeen @@ -1921,9 +1950,10 @@ Winkels Instellingen voor winkels Instellingen + Tabbladen rangschikken + Klaar (niet aangemeld) Winkel - Geïnstalleerd Bibliotheek Alle Downloads @@ -1935,10 +1965,8 @@ Aangemeld blijven Afmelden Zoeken in de catalogus van %1$s… - Geïnstalleerde games zoeken… Zoeken in je bibliotheek… Zoeken - Niets geïnstalleerd Laden Geen games Winkel laden… @@ -1957,7 +1985,19 @@ Bekijken op %1$s Kopen · %1$s Geïnstalleerd + Geïnstalleerd + Niet geïnstalleerd In bibliotheek + API-sleutel + Sleutel ophalen + Sleutel geweigerd + Niet in je itch.io-bibliotheek + Geen Windows-download voor dit spel + Geen Windows-game gevonden in de download + Download kan niet worden uitgepakt (code %1$d) + itch.io gaf geen antwoord + %1$s-archieven worden niet ondersteund + itch.io antwoordde HTTP %1$d Gratis Hervatten Vernieuwen @@ -1971,6 +2011,7 @@ Releasedatum Lijstweergave Kaartweergave + Hoesweergave Inhoud voor volwassenen tonen Epic EOS @@ -2006,6 +2047,9 @@ Laatste synchronisatie %1$s Nog niet gesynchroniseerd %1$d bestanden + Al up-to-date + Log in bij de winkel om opslagbestanden te synchroniseren + Opslagbestanden zijn niet gesynchroniseerd geïnstalleerd in je bibliotheek winkel @@ -2137,7 +2181,7 @@ Grootte Opslaan Terug naar standaard - Wordt over de achtergrond van de game getekend, zoals Steam doet. De exacte positie kan een paar pixels afwijken. + Deze exacte plek kan een paar pixels verschillen. Deze game heeft nog geen logo. Kies er een bij Logo om het hier te zien. De logopositie kon niet worden opgeslagen. Probeer het opnieuw. Achtergrond @@ -2309,7 +2353,7 @@ Klaar De aanraakindeling kon niet worden opgeslagen Aanraakindeling opgeslagen in Steam - App %1$d + App %1$s Label (optioneel) Steams pictogrammen zijn niet gevonden @@ -2400,6 +2444,15 @@ SteamVR-gezichtsveld Lager brengt SteamVR dichterbij en vult de bril; hoger toont meer om je heen. Je speelt op %1$s + Opnieuw centreren + SteamVR-menu + Sticks: handen ⇄ spel + Controllers: %1$s + Toetsenbord + Sessiemenu + SteamVR stoppen + Helderheid %1$d / %2$d + Batterij %1$d%% · %2$d °C Trackingkalibratie Vermindert drift en lijnt de horizontale referentie uit. De opgeslagen kalibratie wordt op deze bril toegepast. @@ -2454,6 +2507,7 @@ Wachten op de USB Ethernet-verbinding van de bril. Deze telefoon maakt die mogelijk niet beschikbaar voor apps. De XREAL-verbinding is onderbroken of gaf een niet-ondersteund antwoord. Opnieuw proberen… Experimentele ondersteuning voor 1S. Gebruik de Follow-modus en schakel stabilisatie uit in het menu van de bril. Trackingrichtingen en weergavemodi moeten nog op een apparaat worden getest. + Experimentele hoofdtracking voor Pro 2. Het aanpassen van helderheid en volume en het wisselen van weergavemodus worden nog niet ondersteund. Cardboard VR Cardboard-modus Beelden voor linker- en rechteroog voor een VR-bril voor je telefoon. @@ -2666,7 +2720,7 @@ Opstartvideo Video DroidDeck · %1$d fps - 60 fps oogt vloeiender maar gebruikt meer CPU terwijl Steam opstart. Een opstartvideo die in Steam zelf is gekozen heeft voorrang. + 60 fps oogt vloeiender maar gebruikt meer CPU terwijl Steam opstart. Een video die in Steam zelf is gekozen verschijnt hier ook. Geluid Eigen video MP4, MOV, MKV of WebM. Andere formaten en bestanden boven %1$d MB worden op het apparaat omgezet naar 720p-WebM, tot %2$d seconden. @@ -2680,6 +2734,51 @@ %1$s wordt omgezet voor Steam. Houd DroidDeck open. Dit apparaat kan deze video niet decoderen. Kan deze video niet importeren + %1$s verwijderen? + Hiermee wordt de video verwijderd uit DroidDeck en uit de videomap van Steam. + Geïnstalleerd + Importeren + SteamDeckRepo + Geluid + Geïnstalleerde video\'s + Bladeren + DroidDeck + Geïmporteerd + SteamDeckRepo + Steam + Puntenwinkel + Rapport over opstartfilms + Slaat op in een tekstbestand wat DroidDeck en Steam weten over opstartfilms, om bij een bugrapport te voegen. + Opslaan + Opgeslagen in %1$s + Het rapport kon niet worden opgeslagen. + Volume + Duur + %1$d s + Hoeveel seconden van de film worden afgespeeld. Alles wat langer is, wordt aan het einde afgekapt. + Zoeken in SteamDeckRepo + Alle apparaten + Steam Deck + Steam Machine + Trending + Meest gedownload + Meest geliked + Nieuwste + Kan SteamDeckRepo niet bereiken + Opnieuw + Geen video\'s gevonden + Installeren + Gebruiken + In gebruik + %1$s geïnstalleerd + Kan die video niet downloaden + Vorige + Volgende + Pagina %1$d + Wissen + Ga naar pagina + Voorbeeld + Het voorbeeld kon niet worden afgespeeld Luma Ultra-camera\'s — Experimenteel Positietracking van Joy-Con-controllers Gebruik de camera\'s van de smartbril om je gekoppelde Joy-Con-controllers te positioneren. Rotatie en knopbediening komen van de Joy-Con-controllers. De positie wordt geschat wanneer je polsen verborgen zijn. Vervangt controllers die met blote handen worden bediend en pauzeert de tracking door de telefooncamera. Richt de Joy-Con-controllers naar voren bij het verbinden of resetten van het trackingcentrum. @@ -2851,7 +2950,6 @@ Wachten op stabiele hoofdtracking; reset het trackingcentrum indien aangevraagd. Cameraobservatie is te oud; wachtend op een nieuw paar Pas %1$.2f px aan. Meet de door u geselecteerde afstand vanaf de linkercamera tot het midden van het kalibratiedoel en valideer vervolgens. - OpenCV niet beschikbaar: %1$s %1$d opgeslagen weergaven hersteld. Bereken de kalibratie om het opnieuw te proberen, of start een nieuwe set om meer vast te leggen. Vastgelegd %1$d / 24 weergaven. Houd het kalibratiedoel vast; verplaats de slimme bril voor de volgende weergave. Opname afgewezen: %1$d / 12 vereiste hoeken zichtbaar in beide camera\'s. Laat meer van het kalibratiedoel zien, blijf stil staan ​​en probeer het opnieuw. diff --git a/app/src/main/res/values-pl/gestures.xml b/app/src/main/res/values-pl/gestures.xml new file mode 100644 index 000000000..8325469e7 --- /dev/null +++ b/app/src/main/res/values-pl/gestures.xml @@ -0,0 +1,91 @@ + + + Gesty + Ustawienia gestów dotykowych + Dostosuj gesty myszy i klawiatury. Aby ich używać, wybierz Gesty jako tryb dotyku. + Stuknięcie + Przeciąganie jednym palcem + Długie naciśnięcie + Podwójne stuknięcie (podwójny lewy klik) + Stuknięcie dwoma palcami + Przeciąganie dwoma palcami + Przytrzymanie dwoma palcami + Stuknięcie trzema palcami + Przeciąganie trzema palcami + Przytrzymanie trzema palcami + Uszczypnij, aby przybliżyć + Trzymaj przycisk wciśnięty + Kliknij raz + Czas przytrzymania + Odstęp podwójnego stuknięcia + Przywróć pierwotne ustawienia gestów + Anuluj + Wybierz akcję + Lewy klik + Prawy klik + Środkowy klik + Pokaż klawiaturę + Lewy klik i przeciąganie + Prawy klik i przeciąganie + Przesuwanie środkowym przyciskiem myszy + Przesuwanie środkowym przyciskiem myszy (odwrócone) + Klawisze strzałek + Klawisze strzałek (odwrócone) + WASD (odwrócone) + Kółko myszy + Próg gestów: %1$d px + Pokaż rozpoznany gest + Jeden palec + Dwa palce + Trzy palce + Opcje + Domyślne + Ta gra + Użyj domyślnych + Zapisz zmiany + Zamknij + AKCJA + Szukaj akcji myszy lub klawiszy klawiatury + Wyczyść wyszukiwanie + Wszystkie + Brak pasujących akcji. Spróbuj innej nazwy klawisza lub akcji. + Mysz + Akcje aplikacji + Nawigacja + Litery + Cyfry + Klawisze funkcyjne + Klawisze modyfikujące + Klawiatura numeryczna + Symbole + Ruch myszy + Bezpośredni + Względny + Rozpoznawanie gestów + Wizualne potwierdzenia + Działanie przycisku myszy + %1$d ms + Klawiatura numeryczna %1$s + Escape + Backspace + Enter + Tab + Spacja + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Strzałka w górę + Strzałka w dół + Strzałka w lewo + Strzałka w prawo + Caps Lock + Num Lock + Scroll Lock + Lewy %1$s + Prawy %1$s + Shift + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 32634d99b..a135e012d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -30,7 +30,7 @@ Generowanie klatek Wybierz tryb generowania klatek Wył. - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptacyjne %1$d Wymaga Lossless Scaling ze Steam @@ -176,6 +176,8 @@ %1$s przez FEX Ten program jest zbudowany dla komputerów %1$s i działa przez FEX, który nie jest jeszcze skonfigurowany. Otwórz raz Steam po zalogowaniu: DroidDeck pobierze wtedy FEX i potrzebne Steam Linux Runtime, a potem program się otworzy. Ten program + Nie udało się pobrać klienta Steam. Twoja sieć lub VPN może blokować serwery Steam; sprawdź połączenie i spróbuj ponownie. + Nie udało się skonfigurować klienta Steam. Sprawdź połączenie i wolne miejsce, a potem spróbuj ponownie. %1$s jest zbudowany dla komputerów x86 i działa przez FEX, który nie jest jeszcze zainstalowany. Uruchom raz Steam po zalogowaniu i pozwól mu pobrać FEX oraz Steam Linux Runtime, a potem uruchom program ponownie. ID aplikacji z Flathub lub jej strona na Flathub. Jeśli Flatpak nie jest jeszcze skonfigurowany, zostanie skonfigurowany najpierw. Plik @@ -506,7 +508,9 @@ Usunąć ten oryginał? Ten oryginał %1$s pochodzi z wcześniejszej wersji %2$s. Oryginał bieżącej wersji jest zawsze zachowywany. W wersjach nightly · %1$d - Odśwież, aby wyświetlić pakiety w wersjach nightly. + Stabilna + Nightly + Odśwież, aby wyświetlić pakiety w Nightlies i DroidDeck. Nic nowego od ostatniego sprawdzenia. Importuj .wcp Przywróć oryginał tego Proton @@ -542,7 +546,7 @@ Tylko Adreno. Mniej przycięć, większe zużycie baterii i więcej ciepła. Poprawki sesji Turnip: renderowanie sysmem - Szybsze dla Steam i większości gier. Wymagane na Adreno 710/720/722. + Szybsze dla klienta Steam; w grach sterownik dla każdego przebiegu wybiera renderowanie kafelkowe lub w pamięci systemowej. Wymagane na Adreno 710/720/722. Pomijaj pomocnika xalia w Steam droiddeck-ntsync (eksperymentalne) Zastępnik sterownika Linux ntsync w przestrzeni użytkownika, do testów. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji. @@ -584,6 +588,16 @@ Poprzednia strona Następna strona HUD wydajności + Liczba klatek + Temperatura baterii + Wykres wydajności + Kolor + Mięta + Biały + Błękitny + Bursztynowy + Fioletowy + Przezroczystość Tryb skalowania Ostrość skalowania Efekty ekranowe @@ -735,6 +749,9 @@ %1$d%% (domyślnie) Motyw Wygląd aplikacji + Ikona aplikacji + Ciemna + Jasna Użyj jako ekranu głównego DroidDeck może być aplikacją ekranu głównego DroidDeck nigdy nie jest proponowany jako aplikacja ekranu głównego @@ -1047,6 +1064,9 @@ Dodana gra Własna gra Miejsce na gry + Usuwa jej pliki i usuwa ją ze Steam. + Usunąć %1$s? + Usuwa skrót. Pliki gry pozostają. Najlepsza para dla tego GPU, aktualizowana automatycznie Wybierz parę samodzielnie Sprawdzanie sterowników… @@ -1412,6 +1432,14 @@ Czarne tło, biały tekst i niebieski dla wszystkiego, co zaznaczone, aktywne lub naciśnięte. Elektryczny granat Jaśniejszy niebieski na powierzchniach wpadających w granat zamiast w szarość. + Gradient + Własny kolor podstawowy i dodatkowy, płynnie przechodzące na przyciskach i wyróżnieniach. + Podstawowy + Dodatkowy + Odcień + Nasycenie + Jasność + Kolor hex Esc Ins Del @@ -1804,7 +1832,8 @@ Oryginał zainstalowanej wersji zostaje zachowany: to droga powrotu. Nie przechowywano Usunięto oryginał %2$s z %1$s. - Pobieranie odbywa się tylko z wydań Nightlies + Pobieranie odbywa się tylko z wydań Nightlies i DroidDeck + Pobieranie nie powiodło się: ta wersja nightly mogła zostać zastąpiona nowszą. Odśwież listę i spróbuj ponownie. Ta lista pakietów jest starsza niż sumy kontrolne – odśwież ją i spróbuj ponownie Niezgodna suma kontrolna @@ -1963,9 +1992,10 @@ Sklepy Ustawienia sklepów Ustawienia + Ułóż karty + Gotowe (niezalogowano) Sklep - Zainstalowane Biblioteka Wszystkie Pobierania @@ -1977,10 +2007,8 @@ Zostań zalogowany Wyloguj się Szukaj w katalogu %1$s… - Szukaj zainstalowanych gier… Szukaj w Twojej bibliotece… Szukaj - Nic nie zainstalowano Wczytywanie Brak gier Wczytywanie sklepu… @@ -1999,7 +2027,19 @@ Zobacz w %1$s Kup · %1$s Zainstalowano + Zainstalowano + Nie zainstalowano W bibliotece + Klucz API + Pobierz klucz + Klucz odrzucony + Nie ma go w Twojej bibliotece itch.io + Brak wersji na Windows dla tej gry + W pobranych plikach nie znaleziono gry na Windows + Nie można rozpakować pobranego pliku (kod %1$d) + itch.io nie odpowiedziało + Archiwa %1$s nie są obsługiwane + itch.io odpowiedziało HTTP %1$d Bezpłatna Wznów Odśwież @@ -2013,6 +2053,7 @@ Data wydania Widok listy Widok kart + Widok okładek Pokaż treści dla dorosłych Epic EOS @@ -2048,6 +2089,9 @@ Ostatnia synchronizacja %1$s Jeszcze nie zsynchronizowano %1$d plików + Już aktualne + Zaloguj się do sklepu, aby synchronizować zapisy + Zapisy nie zostały zsynchronizowane zainstalowana w Twojej bibliotece sklep @@ -2126,6 +2170,9 @@ Menedżer pobierania Pobierania naraz Instaluj w + Język gry + GOG i Epic instalują w tym języku. System to język aplikacji. + Systemowy Zainstaluj %1$s: Wewnętrzna %1$s wolnego miejsca @@ -2179,7 +2226,7 @@ Rozmiar Zapisz Przywróć domyślne - Logo jest rysowane na tle gry, tak jak robi to Steam. Dokładna pozycja może się różnić o kilka pikseli. + To dokładne miejsce może różnić się o kilka pikseli. Ta gra nie ma jeszcze logo. Wybierz je w Logo, aby zobaczyć je tutaj. Nie udało się zapisać pozycji logo. Spróbuj ponownie. Tło @@ -2355,7 +2402,7 @@ Gotowe Nie udało się zapisać układu dotykowego Układ dotykowy zapisany w Steam - Aplikacja %1$d + Aplikacja %1$s Etykieta (opcjonalnie) Nie znaleziono ikon Steam @@ -2450,6 +2497,15 @@ Pole widzenia SteamVR Mniejsze przybliża SteamVR i wypełnia okulary; większe pokazuje więcej dookoła. Grasz na: %1$s + Wyśrodkuj ponownie + Menu SteamVR + Gałki: dłonie ⇄ gra + Kontrolery: %1$s + Klawiatura + Menu sesji + Zatrzymaj SteamVR + Jasność %1$d / %2$d + Bateria %1$d%% · %2$d °C Kalibracja śledzenia Ogranicza dryf i wyrównuje poziom odniesienia. Dla tych okularów używana jest zapisana kalibracja. @@ -2504,6 +2560,7 @@ Oczekiwanie na połączenie USB Ethernet okularów. Ten telefon może nie udostępniać go aplikacjom. Połączenie z XREAL zostało przerwane lub zwróciło nieobsługiwaną odpowiedź. Ponawianie… Eksperymentalna obsługa 1S. Użyj trybu Follow i wyłącz stabilizację w menu okularów. Kierunki śledzenia i tryby wyświetlania wymagają jeszcze testów na urządzeniu. + Eksperymentalne śledzenie ruchów głowy dla Pro 2. Regulacja jasności i głośności oraz przełączanie trybu wyświetlania nie są jeszcze obsługiwane. Cardboard VR Tryb Cardboard Obraz dla lewego i prawego oka do gogli VR na telefon. @@ -2734,7 +2791,7 @@ Film startowy Film DroidDeck · %1$d kl./s - 60 kl./s wygląda płynniej, ale bardziej obciąża procesor podczas uruchamiania Steam. Film startowy wybrany w ustawieniach Steam ma pierwszeństwo. + 60 kl./s wygląda płynniej, ale bardziej obciąża procesor podczas uruchamiania Steam. Film wybrany w ustawieniach Steam też jest tu widoczny. Dźwięk Własny film MP4, MOV, MKV lub WebM. Inne formaty i pliki powyżej %1$d MB są konwertowane na urządzeniu do WebM 720p, do %2$d sekund. @@ -2748,6 +2805,51 @@ Konwertowanie %1$s dla Steam. Nie zamykaj DroidDeck. To urządzenie nie może zdekodować tego filmu. Nie udało się zaimportować filmu + Usunąć %1$s? + Film zostanie usunięty z DroidDeck i z folderu filmów Steam. + Zainstalowane + Importuj + SteamDeckRepo + Dźwięk + Zainstalowane filmy + Przeglądaj + DroidDeck + Zaimportowane + SteamDeckRepo + Steam + Sklep za punkty + Raport o filmach startowych + Zapisuje w pliku tekstowym to, co DroidDeck i Steam wiedzą o filmach startowych, do dołączenia do zgłoszenia błędu. + Zapisz + Zapisano w %1$s + Nie udało się zapisać raportu. + Głośność + Długość + %1$d s + Ile sekund filmu jest odtwarzane. Wszystko, co trwa dłużej, jest ucinane na końcu. + Szukaj w SteamDeckRepo + Wszystkie urządzenia + Steam Deck + Steam Machine + Na czasie + Najczęściej pobierane + Najbardziej lubiane + Najnowsze + Nie można połączyć z SteamDeckRepo + Ponów + Nie znaleziono filmów + Zainstaluj + Użyj + W użyciu + Zainstalowano %1$s + Nie udało się pobrać tego filmu + Poprzednia + Następna + Strona %1$d + Wyczyść + Przejdź do strony + Podgląd + Nie udało się odtworzyć podglądu Kamery Luma Ultra — Eksperymentalne Śledzenie pozycji Joy-Conów Użyj kamer w inteligentnych okularach, aby ustawić sparowane kontrolery Joy-Con. Obrót i sterowanie przyciskami pochodzą z kontrolerów Joy-Con. Pozycja jest szacowana, gdy nadgarstki są ukryte. Zastępuje kontrolery obsługiwane gołymi rękami i wstrzymuje śledzenie kamery telefonu. Skieruj kontrolery Joy-Con do przodu podczas podłączania lub resetowania centrum śledzenia. @@ -2918,7 +3020,6 @@ Oczekiwanie na stabilne śledzenie głowy; zresetuj centrum śledzenia, jeśli zostaniesz o to poproszony. Obserwacja kamerą jest zbyt stara; czekam na świeżą parę Dopasuj %1$.2f pikseli. Zmierz wybraną odległość od lewej kamery do środka celu kalibracji, a następnie zatwierdź. - OpenCV niedostępne: %1$s Przywrócono %1$d zapisane widoki. Oblicz kalibrację, aby spróbować ponownie, lub rozpocznij nową serię, aby uchwycić więcej. Zarejestrowane widoki: %1$d / 24. Utrzymuj cel kalibracji nieruchomo; przesuń inteligentne okulary, aby uzyskać następny widok. Przechwytywanie odrzucone: %1$d / 12 wymaganych rogów widocznych w obu kamerach. Pokaż więcej celu kalibracji, przytrzymaj nieruchomo i spróbuj ponownie. diff --git a/app/src/main/res/values-pt-rPT/gestures.xml b/app/src/main/res/values-pt-rPT/gestures.xml new file mode 100644 index 000000000..2d8f3cf8e --- /dev/null +++ b/app/src/main/res/values-pt-rPT/gestures.xml @@ -0,0 +1,91 @@ + + + Gestos + Configurações de gestos de toque + Personalize os gestos de rato e teclado. Selecione Gestos como modo de toque para usá-los. + Toque + Arrastar com um dedo + Toque longo + Toque duplo (clique duplo esquerdo) + Toque com dois dedos + Arrastar com dois dedos + Manter premido com dois dedos + Toque com três dedos + Arrastar com três dedos + Manter premido com três dedos + Pinçar para ampliar + Manter o botão premido + Clicar uma vez + Tempo para manter premido + Intervalo de toque duplo + Restaurar as configurações originais de gestos + Cancelar + Escolher ação + Clique esquerdo + Clique direito + Clique do meio + Mostrar teclado + Clique esquerdo e arrastar + Clique direito e arrastar + Mover com o botão do meio + Mover com o botão do meio (invertido) + Teclas de direção + Teclas de direção (invertidas) + WASD (invertido) + Roda do rato + Limiar dos gestos: %1$d px + Mostrar gesto reconhecido + Um dedo + Dois dedos + Três dedos + Opções + Valores padrão + Este jogo + Usar padrões + Salvar alterações + Fechar + AÇÃO + Pesquisar ações de rato ou teclas do teclado + Limpar pesquisa + Todas + Nenhuma ação correspondente. Tente outro nome de tecla ou ação. + Rato + Ações da aplicação + Navegação + Letras + Números + Teclas de função + Teclas modificadoras + Teclado numérico + Símbolos + Movimento do rato + Direto + Relativo + Reconhecimento de gestos + Resposta visual + Comportamento do botão do rato + %1$d ms + Teclado numérico %1$s + Esc + Backspace + Enter + Tab + Espaço + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Seta para cima + Seta para baixo + Seta para a esquerda + Seta para a direita + Caps Lock + Num Lock + Scroll Lock + %1$s esquerdo + %1$s direito + Shift + diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b33f52523..b29e753e2 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -30,7 +30,7 @@ Geração de fotogramas Selecione o modo de geração de fotogramas Desligado - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptativo %1$d Requer o Lossless Scaling no Steam @@ -176,6 +176,8 @@ %1$s através do FEX Este programa foi compilado para PCs %1$s e é executado através do FEX, que ainda não está configurado. Abra o Steam uma vez com a sessão iniciada: o DroidDeck transfere o FEX e o Steam Linux Runtime necessários e, depois, este programa abre. Este programa + Não foi possível transferir o cliente Steam. A sua rede ou VPN pode estar a bloquear os servidores da Steam; verifique a ligação e tente novamente. + Não foi possível configurar o cliente Steam. Verifique a ligação e o espaço livre e tente novamente. %1$s foi compilado para PCs x86 e é executado através do FEX, que ainda não está instalado. Abra o Steam uma vez com a sessão iniciada e deixe-o transferir o FEX e o Steam Linux Runtime; depois, volte a iniciá-lo. Um ID de app do Flathub ou a respetiva página no Flathub. O Flatpak é configurado primeiro, se ainda não estiver. Ficheiro @@ -501,7 +503,9 @@ Eliminar este original? Este original de %1$s é de uma versão anterior de %2$s. O original da versão atual é sempre mantido. Nas Nightlies · %1$d - Atualize para listar os pacotes nas Nightlies. + Estável + Nightly + Atualize para listar os pacotes nas Nightlies e no DroidDeck. Nada de novo desde a última verificação. Importar .wcp Restaurar o original deste Proton @@ -537,7 +541,7 @@ Apenas Adreno. Menos soluços, mais consumo de bateria e mais calor. Correções da sessão Turnip: renderização em sysmem - Mais rápido para o Steam e a maioria dos jogos. Obrigatório no Adreno 710/720/722. + Mais rápido para o cliente Steam; nos jogos, o controlador escolhe em cada passagem entre renderização em mosaico ou na memória do sistema. Obrigatório no Adreno 710/720/722. Ignorar o assistente xalia do Steam droiddeck-ntsync (experimental) Um substituto em espaço de utilizador para o controlador ntsync do Linux, para testes. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão. @@ -579,6 +583,16 @@ Página anterior Página seguinte HUD de desempenho + Taxa de fotogramas + Temperatura da bateria + Gráfico de desempenho + Cor + Menta + Branco + Céu + Âmbar + Violeta + Opacidade Modo de escala Nitidez da escala Efeitos de ecrã @@ -721,6 +735,9 @@ %1$d%% (padrão) Tema Aspeto do iniciador + Ícone da app + Escuro + Claro Utilizar como página inicial O DroidDeck pode ser a app de página inicial O DroidDeck nunca é proposto como app de página inicial @@ -1030,6 +1047,9 @@ Jogo adicionado Jogo personalizado Armazenamento de jogos + Apaga os ficheiros e remove-o da Steam. + Remover %1$s? + Apaga o atalho. Os ficheiros do jogo mantêm-se. O melhor par para esta GPU, sempre atualizado Escolha um par manualmente A procurar controladores… @@ -1092,10 +1112,10 @@ A sessão Linux ainda não está pronta para um terminal. Steam Jogos - Desktop + Ambiente de trabalho Loja Componentes - Apps + Aplicações Configuração Atualizações %1$d%% @@ -1387,6 +1407,14 @@ Fundo preto, texto branco e o azul para tudo o que está selecionado, focado ou premido. Azul-marinho elétrico O azul mais vivo, em superfícies que tendem para o azul-marinho em vez do cinzento. + Gradiente + As suas próprias cores primária e secundária, combinadas nos botões e destaques. + Primária + Secundária + Matiz + Saturação + Brilho + Cor hexadecimal Esc Ins Del @@ -1772,7 +1800,8 @@ O original da versão instalada é mantido: é o caminho de volta. Não guardado Original de %1$s de %2$s eliminado. - As transferências vêm apenas das versões Nightlies + As transferências vêm apenas das versões Nightlies e DroidDeck + A transferência falhou: esta versão nightly pode ter sido substituída por uma mais recente. Atualize a lista e tente novamente. Esta lista de pacotes é anterior às somas de verificação - atualize-a e tente novamente A soma de verificação não corresponde @@ -1931,9 +1960,10 @@ Lojas Definições das lojas Definições + Organizar separadores + Concluído (sessão não iniciada) Loja - Instalados Biblioteca Todos Transferências @@ -1945,10 +1975,8 @@ Manter sessão iniciada Terminar sessão Pesquisar no catálogo %1$s… - Pesquisar jogos instalados… Pesquisar na sua biblioteca… Pesquisar - Nada instalado A carregar Nenhum jogo A carregar a loja… @@ -1967,7 +1995,19 @@ Ver em %1$s Comprar · %1$s Instalado + Instalado + Não instalado Na biblioteca + Chave de API + Obter uma chave + Chave rejeitada + Não está na sua biblioteca do itch.io + Sem transferência para Windows deste jogo + Não foi encontrado nenhum jogo para Windows na transferência + Não foi possível extrair a transferência (código %1$d) + O itch.io não respondeu + Ficheiros %1$s não são suportados + O itch.io respondeu HTTP %1$d Grátis Retomar Atualizar @@ -1981,6 +2021,7 @@ Data de lançamento Vista de lista Vista de cartões + Vista de capas Mostrar conteúdo para adultos Epic EOS @@ -2016,6 +2057,9 @@ Última sincronização: %1$s Ainda não sincronizado %1$d ficheiros + Já está atualizado + Inicie sessão na loja para sincronizar os jogos guardados + Os jogos guardados não foram sincronizados instalado na sua biblioteca loja @@ -2144,7 +2188,7 @@ Tamanho Guardar Repor predefinição - É desenhado sobre o fundo do jogo, como o Steam faz. A posição exata pode variar alguns píxeis. + Esta posição exata pode variar alguns píxeis. Este jogo ainda não tem logótipo. Escolha um em Logótipo para o ver aqui. Não foi possível guardar a posição do logótipo. Tente novamente. Fundo @@ -2242,7 +2286,7 @@ Diagnóstico Pronto para jogar Ainda não está pronto para jogar - runtime %1$s + ambiente de execução %1$s limite de processos desativado sessão iniciada como %1$s Quando algo precisa de si @@ -2318,7 +2362,7 @@ Concluído Não foi possível guardar o esquema tátil Esquema tátil guardado na Steam - Aplicação %1$d + Aplicação %1$s Etiqueta (opcional) Os ícones da Steam não foram encontrados @@ -2411,6 +2455,15 @@ Campo de visão do SteamVR Mais baixo aproxima o SteamVR e preenche os óculos; mais alto mostra mais à sua volta. A jogar em %1$s + Recentrar + Menu do SteamVR + Manípulos: mãos ⇄ jogo + Comandos: %1$s + Teclado + Menu da sessão + Parar o SteamVR + Brilho %1$d / %2$d + Bateria %1$d%% · %2$d °C Calibração do seguimento Reduz a deriva e alinha a referência de nível. A calibração guardada é aplicada a estes óculos. @@ -2465,6 +2518,7 @@ A aguardar a ligação Ethernet por USB dos óculos. Este telemóvel pode não a disponibilizar às apps. A ligação aos óculos XREAL foi interrompida ou devolveu uma resposta não suportada. A tentar novamente… Suporte experimental para os 1S. Utilize o modo Follow e desative a estabilização no menu dos óculos. As direções de seguimento e os modos de ecrã ainda têm de ser testados no dispositivo. + Seguimento experimental dos movimentos da cabeça para Pro 2. O ajuste do brilho e do volume e a mudança do modo de ecrã ainda não são suportados. Cardboard VR Modo Cardboard Imagens para o olho esquerdo e o direito, para uns óculos de RV para telemóvel. @@ -2613,32 +2667,39 @@ Jogar mesmo assim %d instalado + %d instalados %d instalados %d no cartão SD + %d no cartão SD %d no cartão SD %d ferramenta + %d ferramentas %d ferramentas Pastas de prefixo vazias e cache de shaders de %d jogo + Pastas de prefixo vazias e cache de shaders de %d jogos Pastas de prefixo vazias e cache de shaders de %d jogos O prefixo de %d jogo ainda tem ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes. + Os prefixos de %d jogos ainda têm ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes. Os prefixos de %d jogos ainda têm ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes. %d jogo + %d jogos %d jogos Proton predefinido do DroidDeck Opção Proton 11 do DroidDeck Usado por %d jogo + Usado por %d jogos Usado por %d jogos Componentes @@ -2682,16 +2743,18 @@ Remove a aplicação (%1$s) e os ambientes de execução que mais ninguém usa. Os dados dela na pasta pessoal mantêm-se. %d pacote + %d pacotes %d pacotes %d aplicação + %d aplicações %d aplicações Vídeo de arranque Vídeo DroidDeck · %1$d fps - 60 fps é mais fluido, mas usa mais CPU enquanto o Steam arranca. Um vídeo de arranque escolhido nas definições do Steam tem prioridade. + 60 fps é mais fluido, mas usa mais CPU enquanto o Steam arranca. Um vídeo escolhido nas definições do Steam também aparece aqui. Som O seu próprio vídeo MP4, MOV, MKV ou WebM. Outros formatos e ficheiros acima de %1$d MB são convertidos para WebM 720p no dispositivo, até %2$d segundos. @@ -2705,6 +2768,51 @@ A converter %1$s para o Steam. Mantenha o DroidDeck aberto. Este dispositivo não consegue descodificar esse vídeo. Não foi possível importar esse vídeo + Remover %1$s? + Isto elimina o vídeo do DroidDeck e da pasta de vídeos do Steam. + Instalados + Importar + SteamDeckRepo + Som + Vídeos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Loja de Pontos + Relatório dos filmes de arranque + Guarda num ficheiro de texto o que o DroidDeck e o Steam sabem sobre os filmes de arranque, para anexar a um relatório de erro. + Guardar + Guardado em %1$s + Não foi possível guardar o relatório. + Volume + Duração + %1$d s + Quantos segundos do filme são reproduzidos. Tudo o que for mais longo é cortado no fim. + Pesquisar no SteamDeckRepo + Todos os dispositivos + Steam Deck + Steam Machine + Em destaque + Mais transferidos + Mais gostados + Mais recentes + Não foi possível aceder ao SteamDeckRepo + Tentar novamente + Nenhum vídeo encontrado + Instalar + Usar + Em utilização + %1$s instalado + Não foi possível transferir esse vídeo + Anterior + Seguinte + Página %1$d + Limpar + Ir para a página + Pré-visualizar + Não foi possível reproduzir a pré-visualização Câmaras Luma Ultra — Experimental Rastreio de posição do Joy-Con Utilize as câmaras dos óculos inteligentes para posicionar os seus comandos Joy-Con emparelhados. A rotação e os comandos dos botões vêm dos comandos Joy-Con. A posição é estimada quando os seus pulsos estão escondidos. Substitui os controlos operados com as mãos nuas e pausa o rastreio da câmara do telefone. Aponte os controlos Joy-Con para a frente ao ligar ou repor o centro de seguimento. @@ -2877,7 +2985,6 @@ Aguarda rastreio de cabeça estável; reponha o centro de rastreio se solicitado. A observação da câmara é muito antiga; à espera de um novo par Ajustar %1$.2f px. Meça a distância selecionada da câmara esquerda até ao centro do alvo de calibração e valide. - OpenCV indisponível: %1$s %1$d visualizações guardadas restauradas. Calcule a calibração para tentar novamente ou inicie um novo conjunto para captar mais. Capturado %1$d/24 visualizações. Mantenha o alvo de calibração fixo; mova os óculos inteligentes para a visualização seguinte. Captura rejeitada: %1$d/12 cantos necessários visíveis em ambas as câmaras. Mostre mais do alvo de calibração, mantenha-se imóvel e tente novamente. diff --git a/app/src/main/res/values-pt/gestures.xml b/app/src/main/res/values-pt/gestures.xml new file mode 100644 index 000000000..c345617df --- /dev/null +++ b/app/src/main/res/values-pt/gestures.xml @@ -0,0 +1,91 @@ + + + Gestos + Configurações de gestos de toque + Personalize os gestos de mouse e teclado. Selecione Gestos como modo de toque para usá-los. + Toque + Arrastar com um dedo + Toque longo + Toque duplo (clique duplo esquerdo) + Toque com dois dedos + Arrastar com dois dedos + Manter pressionado com dois dedos + Toque com três dedos + Arrastar com três dedos + Manter pressionado com três dedos + Pinçar para ampliar + Manter o botão pressionado + Clicar uma vez + Tempo para manter pressionado + Intervalo de toque duplo + Restaurar as configurações originais de gestos + Cancelar + Escolher ação + Clique esquerdo + Clique direito + Clique do meio + Mostrar teclado + Clique esquerdo e arrastar + Clique direito e arrastar + Mover com o botão do meio + Mover com o botão do meio (invertido) + Teclas de direção + Teclas de direção (invertidas) + WASD (invertido) + Roda do mouse + Limiar dos gestos: %1$d px + Mostrar gesto reconhecido + Um dedo + Dois dedos + Três dedos + Opções + Padrões + Este jogo + Usar padrões + Salvar alterações + Fechar + AÇÃO + Pesquisar ações de mouse ou teclas do teclado + Limpar pesquisa + Todos + Nenhuma ação correspondente. Tente outro nome de tecla ou ação. + Mouse + Ações do aplicativo + Navegação + Letras + Números + Teclas de função + Teclas modificadoras + Teclado numérico + Símbolos + Movimento do mouse + Direto + Relativo + Reconhecimento de gestos + Resposta visual + Comportamento do botão do mouse + %1$d ms + Teclado numérico %1$s + Esc + Backspace + Enter + Tab + Espaço + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Seta para cima + Seta para baixo + Seta para a esquerda + Seta para a direita + Caps Lock + Num Lock + Scroll Lock + %1$s esquerdo + %1$s direito + Shift + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0dc9d0e57..79239e0c5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -30,7 +30,7 @@ Geração de quadros Selecione o modo de geração de quadros Desativada - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptativo %1$d Requer o Lossless Scaling na Steam @@ -176,6 +176,8 @@ %1$s pelo FEX Este programa é feito para PCs %1$s e roda pelo FEX, que ainda não está configurado. Abra a Steam uma vez com a sessão iniciada: o DroidDeck faz com que ela baixe o FEX e o Steam Linux Runtime necessários, e então isto abre. Este programa + Não foi possível baixar o cliente Steam. Sua rede ou VPN pode estar bloqueando os servidores da Steam; verifique a conexão e tente novamente. + Não foi possível configurar o cliente Steam. Verifique a conexão e o espaço livre e tente novamente. %1$s é feito para PCs x86 e roda pelo FEX, que ainda não está instalado. Abra a Steam uma vez com a sessão iniciada e deixe-a baixar o FEX e o Steam Linux Runtime; depois, abra isto de novo. Um ID de app do Flathub ou a página dele no Flathub. O Flatpak é configurado antes, se ainda não estiver. Arquivo @@ -501,7 +503,9 @@ Excluir este original? Este %1$s original é de uma versão anterior de %2$s. O original da versão atual é sempre mantido. Nas Nightlies · %1$d - Atualize para listar os pacotes nas Nightlies. + Estável + Nightly + Atualize para listar os pacotes nas Nightlies e no DroidDeck. Nada novo desde a última verificação. Importar .wcp Restaurar o original deste Proton @@ -537,7 +541,7 @@ Só em Adreno. Menos engasgos, mais gasto de bateria e mais calor. Correções da sessão Turnip: renderização sysmem - Mais rápido para a Steam e a maioria dos jogos. Obrigatório nas Adreno 710/720/722. + Mais rápido para o cliente da Steam; nos jogos, o driver escolhe a cada passagem entre renderização em blocos ou na memória do sistema. Obrigatório nas Adreno 710/720/722. Pular o assistente xalia da Steam droiddeck-ntsync (experimental) Um substituto em espaço de usuário para o driver ntsync do Linux, para testes. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão. @@ -579,6 +583,16 @@ Página anterior Próxima página HUD de desempenho + Taxa de quadros + Temperatura da bateria + Gráfico de desempenho + Cor + Menta + Branco + Céu + Âmbar + Violeta + Opacidade Modo de escala Nitidez da escala Efeitos de tela @@ -721,6 +735,9 @@ %1$d%% (padrão) Tema Aparência do inicializador + Ícone do app + Escuro + Claro Usar como tela inicial O DroidDeck pode ser o app de início O DroidDeck nunca é oferecido como app de início @@ -1030,6 +1047,9 @@ Jogo adicionado Jogo personalizado Armazenamento de jogos + Apaga os arquivos e o remove da Steam. + Remover %1$s? + Apaga o atalho. Os arquivos do jogo ficam. O melhor par para esta GPU, sempre atualizado Escolha um par você mesmo Procurando drivers… @@ -1092,7 +1112,7 @@ A sessão Linux ainda não está pronta para um terminal. Steam Jogos - Desktop + Área de trabalho Loja Componentes Apps @@ -1387,6 +1407,14 @@ Fundo preto, texto branco e o azul em tudo que estiver selecionado, em foco ou pressionado. Marinho elétrico O azul mais vivo, sobre superfícies puxadas para o marinho em vez do cinza. + Gradiente + Suas próprias cores primária e secundária, combinadas em botões e destaques. + Primária + Secundária + Matiz + Saturação + Brilho + Cor hexadecimal Esc Ins Del @@ -1772,7 +1800,8 @@ O original da versão instalada é mantido: é o caminho de volta. Não armazenado Original do %1$s de %2$s excluído. - Os downloads vêm só das versões Nightlies + Os downloads vêm só das versões Nightlies e DroidDeck + Falha no download: esta versão nightly pode ter sido substituída por uma mais nova. Atualize a lista e tente de novo. Esta lista de pacotes é anterior às somas de verificação - atualize-a e tente de novo A soma de verificação não confere @@ -1931,9 +1960,10 @@ Lojas Configurações das lojas Configurações + Organizar abas + Concluído (sem login) Loja - Instalados Biblioteca Todos Downloads @@ -1945,10 +1975,8 @@ Continuar conectado Sair Pesquisar no catálogo de %1$s… - Pesquisar jogos instalados… Pesquisar na sua biblioteca… Pesquisar - Nada instalado Carregando Nenhum jogo Carregando a loja… @@ -1967,7 +1995,19 @@ Ver em %1$s Comprar · %1$s Instalado + Instalado + Não instalado Na biblioteca + Chave de API + Obter uma chave + Chave rejeitada + Não está na sua biblioteca do itch.io + Sem download para Windows deste jogo + Nenhum jogo para Windows encontrado no download + Não foi possível extrair o download (código %1$d) + O itch.io não respondeu + Arquivos %1$s não são suportados + O itch.io respondeu HTTP %1$d Grátis Retomar Atualizar @@ -1981,6 +2021,7 @@ Data de lançamento Vista de lista Vista de cartões + Visualização de capas Mostrar conteúdo adulto Epic EOS @@ -2016,6 +2057,9 @@ Última sincronização: %1$s Ainda não sincronizado %1$d arquivos + Já está atualizado + Entre na loja para sincronizar os saves + Os saves não foram sincronizados instalado na sua biblioteca loja @@ -2144,7 +2188,7 @@ Tamanho Salvar Voltar ao padrão - É desenhado sobre o plano de fundo do jogo, como a Steam faz. A posição exata pode variar alguns pixels. + Esta posição exata pode variar alguns pixels. Este jogo ainda não tem logo. Escolha um em Logo para vê-lo aqui. Não foi possível salvar a posição do logo. Tente de novo. Fundo @@ -2318,7 +2362,7 @@ Concluído Não foi possível salvar o layout de toque Layout de toque salvo na Steam - App %1$d + App %1$s Rótulo (opcional) Os ícones da Steam não foram encontrados @@ -2411,6 +2455,15 @@ Campo de visão do SteamVR Menor aproxima o SteamVR e preenche os óculos; maior mostra mais ao seu redor. Jogando em %1$s + Recentralizar + Menu do SteamVR + Analógicos: mãos ⇄ jogo + Controles: %1$s + Teclado + Menu da sessão + Parar o SteamVR + Brilho %1$d / %2$d + Bateria %1$d%% · %2$d °C Calibração do rastreamento Reduz a deriva e alinha a referência de nível. A calibração salva é aplicada a estes óculos. @@ -2465,6 +2518,7 @@ Aguardando a conexão Ethernet por USB dos óculos. Este celular pode não disponibilizá-la para os apps. A conexão com os óculos XREAL foi interrompida ou retornou uma resposta incompatível. Tentando novamente… Suporte experimental ao 1S. Use o modo Follow e desative a estabilização no menu dos óculos. As direções do rastreamento e os modos de exibição ainda precisam de testes no dispositivo. + Rastreamento experimental da cabeça para Pro 2. Os ajustes de brilho e volume e a troca do modo de exibição ainda não são compatíveis. Cardboard VR Modo Cardboard Imagens para o olho esquerdo e o direito, para um óculos de RV de celular. @@ -2613,32 +2667,39 @@ Jogar assim mesmo %d instalado + %d instalados %d instalados %d no cartão SD + %d no cartão SD %d no cartão SD %d ferramenta + %d ferramentas %d ferramentas Pastas de prefixo vazias e cache de shaders de %d jogo + Pastas de prefixo vazias e cache de shaders de %d jogos Pastas de prefixo vazias e cache de shaders de %d jogos O prefixo de %d jogo ainda tem arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes. + Os prefixos de %d jogos ainda têm arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes. Os prefixos de %d jogos ainda têm arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes. %d jogo + %d jogos %d jogos Proton padrão do DroidDeck Opção Proton 11 do DroidDeck Usado por %d jogo + Usado por %d jogos Usado por %d jogos Componentes @@ -2682,16 +2743,18 @@ Remove o app (%1$s) e os runtimes que mais ninguém usa. Os dados dele na pasta pessoal ficam. %d pacote + %d pacotes %d pacotes %d app + %d apps %d apps Vídeo de inicialização Vídeo DroidDeck · %1$d fps - 60 fps fica mais fluido, mas usa mais CPU enquanto o Steam inicia. Um vídeo de inicialização escolhido nas configurações do Steam tem prioridade. + 60 fps fica mais fluido, mas usa mais CPU enquanto o Steam inicia. Um vídeo escolhido nas configurações do Steam também aparece aqui. Som Seu próprio vídeo MP4, MOV, MKV ou WebM. Outros formatos e arquivos acima de %1$d MB são convertidos para WebM 720p no aparelho, até %2$d segundos. @@ -2705,6 +2768,51 @@ Convertendo %1$s para o Steam. Mantenha o DroidDeck aberto. Este aparelho não consegue decodificar esse vídeo. Não foi possível importar esse vídeo + Remover %1$s? + Isso exclui o vídeo do DroidDeck e da pasta de vídeos do Steam. + Instalados + Importar + SteamDeckRepo + Som + Vídeos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Loja de Pontos + Relatório dos filmes de inicialização + Salva em um arquivo de texto o que o DroidDeck e o Steam sabem sobre os filmes de inicialização, para anexar a um relatório de bug. + Salvar + Salvo em %1$s + Não foi possível salvar o relatório. + Volume + Duração + %1$d s + Quantos segundos do vídeo são reproduzidos. Tudo o que for mais longo é cortado no final. + Pesquisar no SteamDeckRepo + Todos os dispositivos + Steam Deck + Steam Machine + Em alta + Mais baixados + Mais curtidos + Mais recentes + Não foi possível acessar o SteamDeckRepo + Tentar novamente + Nenhum vídeo encontrado + Instalar + Usar + Em uso + %1$s instalado + Não foi possível baixar esse vídeo + Anterior + Próxima + Página %1$d + Limpar + Ir para a página + Pré-visualizar + Não foi possível reproduzir a pré-visualização Câmeras Luma Ultra — Experimental Rastreamento de posição do Joy-Con Use as câmeras dos óculos inteligentes para posicionar seus controles Joy-Con emparelhados. A rotação e os comandos dos botões vêm dos controles Joy-Con. A posição é estimada quando seus pulsos estão escondidos. Substitui os controles operados com as mãos nuas e pausa o rastreamento da câmera do telefone. Aponte os controles Joy-Con para frente ao conectar ou redefinir o centro de rastreamento. @@ -2877,7 +2985,6 @@ Aguardando rastreamento de cabeça estável; redefina o centro de rastreamento se solicitado. A observação da câmera é muito antiga; esperando por um novo par Ajustar %1$.2f px. Meça a distância selecionada da câmera esquerda até o centro do alvo de calibração e valide. - OpenCV indisponível: %1$s %1$d visualizações salvas restauradas. Calcule a calibração para tentar novamente ou inicie um novo conjunto para capturar mais. Capturado %1$d/24 visualizações. Mantenha o alvo de calibração fixo; mova os óculos inteligentes para a próxima visualização. Captura rejeitada: %1$d/12 cantos necessários visíveis em ambas as câmeras. Mostre mais do alvo de calibração, fique imóvel e tente novamente. diff --git a/app/src/main/res/values-ro/gestures.xml b/app/src/main/res/values-ro/gestures.xml new file mode 100644 index 000000000..6fb7b66fd --- /dev/null +++ b/app/src/main/res/values-ro/gestures.xml @@ -0,0 +1,91 @@ + + + Gesturi + Setări pentru gesturile tactile + Personalizează gesturile de mouse și tastatură. Selectează Gesturi ca mod tactil pentru a le folosi. + Atingere + Tragere cu un deget + Apăsare lungă + Atingere dublă (dublu clic stânga) + Atingere cu două degete + Tragere cu două degete + Menținere cu două degete + Atingere cu trei degete + Tragere cu trei degete + Menținere cu trei degete + Apropie sau depărtează degetele pentru zoom + Ține butonul apăsat + Un singur clic + Durata până la menținere + Intervalul atingerii duble + Restabilește setările originale ale gesturilor + Anulează + Alege acțiunea + Clic stânga + Clic dreapta + Clic mijloc + Arată tastatura + Clic stânga și tragere + Clic dreapta și tragere + Deplasare cu butonul din mijloc + Deplasare cu butonul din mijloc (inversată) + Taste săgeată + Taste săgeată (inversate) + WASD (inversat) + Rotița mouse-ului + Pragul gesturilor: %1$d px + Arată gestul recunoscut + Un deget + Două degete + Trei degete + Opțiuni + Valori implicite + Acest joc + Folosește valorile implicite + Salvează modificările + Închide + ACȚIUNE + Caută acțiuni de mouse sau taste + Șterge căutarea + Toate + Nicio acțiune potrivită. Încearcă alt nume de tastă sau acțiune. + Mouse + Acțiuni ale aplicației + Navigare + Litere + Cifre + Taste funcționale + Taste modificatoare + Tastatură numerică + Simboluri + Mișcarea mouse-ului + Direct + Relativă + Recunoașterea gesturilor + Feedback vizual + Comportamentul butonului de mouse + %1$d ms + Tastatură numerică %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Săgeată sus + Săgeată jos + Săgeată stânga + Săgeată dreapta + Caps Lock + Num Lock + Scroll Lock + %1$s stânga + %1$s dreapta + Shift + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index d1ea59179..c0736cfe0 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -30,7 +30,7 @@ Generare de cadre Selectează modul de generare a cadrelor Dezactivat - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Necesită Lossless Scaling pe Steam @@ -176,6 +176,8 @@ %1$s prin FEX Acest program este compilat pentru PC-uri %1$s și rulează prin FEX, care nu este încă configurat. Deschide Steam o dată, fiind autentificat: DroidDeck îl pune să descarce FEX și Steam Linux Runtime de care are nevoie, apoi acesta se deschide. Acest program + Clientul Steam nu a putut fi descărcat. Rețeaua sau VPN-ul tău poate bloca serverele Steam; verifică conexiunea și încearcă din nou. + Clientul Steam nu a putut fi configurat. Verifică conexiunea și spațiul liber, apoi încearcă din nou. %1$s este compilat pentru PC-uri x86 și rulează prin FEX, care nu este încă instalat. Pornește Steam o dată, fiind autentificat, și lasă-l să descarce FEX și Steam Linux Runtime, apoi pornește din nou acesta. Un ID de aplicație Flathub sau pagina sa Flathub. Flatpak este configurat mai întâi, dacă nu este deja. Fișier @@ -504,7 +506,9 @@ Ștergi acest original? Acest original %1$s este dintr-o versiune anterioară a %2$s. Originalul versiunii curente este păstrat mereu. Pe versiunile nightly · %1$d - Reîmprospătează pentru a lista pachetele de pe versiunile nightly. + Stabilă + Nightly + Reîmprospătează pentru a lista pachetele din Nightlies și DroidDeck. Nimic nou de la ultima verificare. Importă .wcp Restaurează originalul acestui Proton @@ -540,7 +544,7 @@ Doar Adreno. Mai puține întreruperi, consum mai mare de baterie și căldură. Remedieri de sesiune Turnip: randare sysmem - Mai rapidă pentru Steam și majoritatea jocurilor. Necesară pe Adreno 710/720/722. + Mai rapidă pentru clientul Steam; în jocuri, driverul alege la fiecare trecere între randarea pe dale și cea în memoria sistemului. Necesară pe Adreno 710/720/722. Omite asistentul xalia al Steam droiddeck-ntsync (experimental) Un substitut la nivel de utilizator pentru driverul Linux ntsync, pentru testare. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare. @@ -582,6 +586,16 @@ Pagina anterioară Pagina următoare HUD de performanță + Rată de cadre + Temperatură baterie + Grafic de performanță + Culoare + Mentă + Alb + Cer + Chihlimbariu + Violet + Opacitate Mod de scalare Claritatea scalării Efecte de ecran @@ -732,6 +746,9 @@ %1$d%% (Implicit) Temă Aspectul launcherului + Pictograma aplicației + Întunecată + Luminoasă Folosește ca ecran Acasă DroidDeck poate fi aplicația Acasă DroidDeck nu este niciodată oferit ca aplicație Acasă @@ -1041,6 +1058,9 @@ Joc adăugat Joc personalizat Stocarea jocurilor + Șterge fișierele sale și îl elimină din Steam. + Elimini %1$s? + Șterge comanda rapidă. Fișierele jocului rămân. Cea mai bună pereche pentru acest GPU, păstrată actualizată Alege o pereche tu însuți Se verifică driverele… @@ -1398,6 +1418,14 @@ Fundal negru, text alb, albastrul pentru tot ce este selectat, focalizat sau apăsat. Bleumarin electric Albastrul mai strălucitor, pe suprafețe care tind spre bleumarin în loc de gri. + Gradient + Culorile tale primară și secundară, combinate pe butoane și evidențieri. + Primară + Secundară + Nuanță + Saturație + Luminozitate + Culoare hex Esc Ins Del @@ -1442,8 +1470,8 @@ Fixează folderul curent Niciun favorit încă - fixează un folder din meniul său ⋮ pentru a reveni rapid aici. Elimină din favorite - 0 bytes - bytes + 0 octeți + octeți Locație Tip Dimensiune @@ -1562,7 +1590,7 @@ Alb Violet Verde - Amber + Chihlimbariu Roșu Roz Butonul A @@ -1783,7 +1811,8 @@ Originalul versiunii instalate este păstrat: este calea de revenire. Nesalvat S-a șters originalul %1$s al %2$s. - Descărcările vin doar din versiunile Nightlies + Descărcările vin doar din versiunile Nightlies și DroidDeck + Descărcarea a eșuat: această versiune nightly poate fi fost înlocuită cu una mai nouă. Reîmprospătează lista și încearcă din nou. Această listă de pachete e anterioară sumelor de control - reîmprospătează-o și încearcă din nou Sumă de control incorectă @@ -1798,7 +1827,7 @@ Claritate cu puțin contrast - cea de zi cu zi. Viu Culoare intensificată puternic. Eliminarea benzilor activată, ca cerurile să nu aibă dungi. - Cinematic + Cinematografic Contrast mai profund, culoare mai rece, margini netezite. Competitiv Umbre ridicate și claritate puternică - observă persoanele mai întâi. @@ -1942,9 +1971,10 @@ Magazine Setările magazinelor Setări + Aranjează filele + Gata (neautentificat) Magazin - Instalate Bibliotecă Toate Descărcări @@ -1956,10 +1986,8 @@ Rămâi autentificat Deconectare Caută în catalogul %1$s… - Caută jocurile instalate… Caută în biblioteca ta… Căutare - Nimic instalat Se încarcă Niciun joc Se încarcă magazinul… @@ -1978,7 +2006,19 @@ Vezi pe %1$s Cumpără · %1$s Instalat + Instalat + Neinstalat În bibliotecă + Cheie API + Obține o cheie + Cheie respinsă + Nu este în biblioteca ta itch.io + Nu există descărcare pentru Windows la acest joc + Nu s-a găsit niciun joc Windows în descărcare + Descărcarea nu a putut fi extrasă (cod %1$d) + itch.io nu a răspuns + Arhivele %1$s nu sunt acceptate + itch.io a răspuns HTTP %1$d Gratuit Reluare Reîmprospătează @@ -1992,6 +2032,7 @@ Data lansării Vizualizare listă Vizualizare carduri + Vizualizare coperți Arată conținutul pentru adulți Epic EOS @@ -2027,6 +2068,9 @@ Ultima sincronizare %1$s Nesincronizat încă %1$d fișiere + Deja actualizat + Conectează-te la magazin pentru a sincroniza salvările + Salvările nu au fost sincronizate instalat în biblioteca ta magazin @@ -2158,7 +2202,7 @@ Dimensiune Salvează Revino la implicit - Se desenează peste fundalul jocului, ca în Steam. Poziția exactă poate diferi cu câțiva pixeli. + Poziția exactă poate diferi cu câțiva pixeli. Acest joc nu are încă un logo. Alege unul la Logo ca să îl vezi aici. Poziția logo-ului nu a putut fi salvată. Încearcă din nou. Fundal @@ -2332,7 +2376,7 @@ Terminat Aranjamentul tactil nu a putut fi salvat Aranjamentul tactil a fost salvat în Steam - Aplicația %1$d + Aplicația %1$s Etichetă (opțional) Pictogramele Steam nu au fost găsite @@ -2425,6 +2469,15 @@ Câmp vizual SteamVR Mai mic aduce SteamVR mai aproape și umple ochelarii; mai mare arată mai mult din jurul tău. Se joacă pe %1$s + Recentrează + Meniu SteamVR + Stickuri: mâini ⇄ joc + Controlere: %1$s + Tastatură + Meniul sesiunii + Oprește SteamVR + Luminozitate %1$d / %2$d + Baterie %1$d%% · %2$d °C Calibrarea urmăririi Reduce deriva și aliniază referința de nivel. Calibrarea salvată se aplică acestor ochelari. @@ -2479,6 +2532,7 @@ Se așteaptă conexiunea USB Ethernet a ochelarilor. Este posibil ca acest telefon să nu o expună aplicațiilor. Conexiunea XREAL a fost întreruptă sau a returnat un răspuns neacceptat. Se reîncearcă… Suport experimental pentru 1S. Folosește modul Follow și dezactivează stabilizarea din meniul ochelarilor. Direcțiile de urmărire și modurile de afișare trebuie încă testate pe dispozitiv. + Urmărire experimentală a mișcărilor capului pentru Pro 2. Reglarea luminozității și a volumului și schimbarea modului de afișare nu sunt încă acceptate. Cardboard VR Mod Cardboard Imagini pentru ochiul stâng și cel drept, pentru un vizor VR pentru telefon. @@ -2700,7 +2754,7 @@ Videoclip de pornire Videoclip DroidDeck · %1$d fps - 60 fps arată mai fluid, dar folosește mai mult procesorul cât pornește Steam. Un videoclip de pornire ales în setările Steam are prioritate. + 60 fps arată mai fluid, dar folosește mai mult procesorul cât pornește Steam. Un videoclip ales în setările Steam apare și aici. Sunet Videoclipul tău MP4, MOV, MKV sau WebM. Alte formate și fișierele de peste %1$d MB sunt convertite pe dispozitiv în WebM 720p, până la %2$d secunde. @@ -2714,6 +2768,51 @@ Se convertește %1$s pentru Steam. Lasă DroidDeck deschis. Acest dispozitiv nu poate decoda videoclipul. Videoclipul nu a putut fi importat + Elimini %1$s? + Videoclipul va fi șters din DroidDeck și din folderul de videoclipuri Steam. + Instalate + Importă + SteamDeckRepo + Sunet + Videoclipuri instalate + Răsfoiește + DroidDeck + Importat + SteamDeckRepo + Steam + Magazin de puncte + Raport despre filmele de pornire + Salvează într-un fișier text ce știu DroidDeck și Steam despre filmele de pornire, pentru a-l atașa la un raport de eroare. + Salvează + Salvat în %1$s + Raportul nu a putut fi salvat. + Volum + Durată + %1$d s + Câte secunde din film se redau. Tot ce este mai lung este tăiat la final. + Caută în SteamDeckRepo + Toate dispozitivele + Steam Deck + Steam Machine + Populare + Cele mai descărcate + Cele mai apreciate + Cele mai noi + Nu s-a putut contacta SteamDeckRepo + Reîncearcă + Nu s-au găsit videoclipuri + Instalează + Folosește + În uz + %1$s instalat + Videoclipul nu a putut fi descărcat + Înapoi + Înainte + Pagina %1$d + Șterge + Mergi la pagina + Previzualizare + Previzualizarea nu a putut fi redată Camere Luma Ultra — Experimentale Urmărirea poziției Joy-Con Folosește camerele ochelarilor inteligenți pentru a poziționa controlerele Joy-Con asociate. Rotirea și comenzile de butoane provin de la controlerele Joy-Con. Poziția este estimată atunci când încheieturile mâinilor sunt ascunse. Înlocuiește controlerele acționate cu mâinile goale și întrerupe urmărirea camerei telefonului. Îndreaptă controlerele Joy-Con înainte atunci când conectezi sau resetezi centrul de urmărire. @@ -2887,7 +2986,6 @@ Se așteaptă o urmărire stabilă a capului; resetează centrul de urmărire dacă este solicitat Observarea camerei este prea veche; așteptând o pereche proaspătă Eroare de calibrare: %1$.2f px. Măsoară distanța selectată de la camera din stânga la centrul țintei de calibrare, apoi validează. - OpenCV indisponibil: %1$s Au fost restaurate %1$d vederi salvate. Calculează calibrarea pentru a reîncerca sau începe un set nou pentru a captura mai multe. Captură %1$d / 24 de vizualizări. Păstrează ținta de calibrare fixă; mută ochelarii inteligenți pentru următoarea vizualizare. Captură respinsă: %1$d / 12 colțuri necesare sunt vizibile în ambele camere. Arată o porțiune mai mare din ținta de calibrare, stai nemișcat, apoi încearcă din nou. diff --git a/app/src/main/res/values-ru/gestures.xml b/app/src/main/res/values-ru/gestures.xml new file mode 100644 index 000000000..bb758cb1b --- /dev/null +++ b/app/src/main/res/values-ru/gestures.xml @@ -0,0 +1,91 @@ + + + Жесты + Настройки сенсорных жестов + Настройте жесты мыши и клавиатуры. Чтобы использовать их, выберите режим касаний «Жесты». + Касание + Перетаскивание одним пальцем + Долгое нажатие + Двойное касание (двойной щелчок левой кнопкой) + Касание двумя пальцами + Перетаскивание двумя пальцами + Удержание двумя пальцами + Касание тремя пальцами + Перетаскивание тремя пальцами + Удержание тремя пальцами + Масштабирование щипком + Удерживать кнопку нажатой + Один щелчок + Задержка удержания + Интервал двойного касания + Восстановить исходные настройки жестов + Отмена + Выберите действие + Левая кнопка мыши + Правая кнопка мыши + Средняя кнопка мыши + Показать клавиатуру + Перетаскивание левой кнопкой + Перетаскивание правой кнопкой + Панорамирование средней кнопкой + Панорамирование средней кнопкой (обратное) + Клавиши со стрелками + Клавиши со стрелками (обратные) + WASD (обратное) + Колесо мыши + Порог жестов: %1$d пикс. + Показывать распознанный жест + Один палец + Два пальца + Три пальца + Параметры + По умолчанию + Эта игра + Использовать настройки по умолчанию + Сохранить изменения + Закрыть + ДЕЙСТВИЕ + Поиск действий мыши или клавиш клавиатуры + Очистить поиск + Все + Подходящих действий нет. Попробуйте другое название клавиши или действия. + Мышь + Действия приложения + Навигация + Буквы + Цифры + Функциональные клавиши + Клавиши-модификаторы + Цифровой блок + Символы + Движение мыши + Прямой + Относительное + Распознавание жестов + Визуальный отклик + Поведение кнопки мыши + %1$d мс + Цифровой блок: %1$s + Esc + Backspace + Enter + Tab + Пробел + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Стрелка вверх + Стрелка вниз + Стрелка влево + Стрелка вправо + Caps Lock + Num Lock + Scroll Lock + Левый %1$s + Правый %1$s + Shift + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 706a74139..2018816c1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -30,9 +30,9 @@ Генерация кадров Выберите режим генерации кадров Выкл. - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× - LSFG Adaptive %1$d + LSFG Адаптивный %1$d Требуется Lossless Scaling из Steam Установите Lossless Scaling в Steam или импортируйте Lossless.dll Импорт Lossless.dll… @@ -176,6 +176,8 @@ %1$s через FEX Эта программа собрана для ПК на %1$s и работает через FEX, который еще не настроен. Откройте Steam один раз, войдя в аккаунт: DroidDeck скачает через него FEX и нужную Steam Linux Runtime, после чего программа откроется. Эта программа + Не удалось скачать клиент Steam. Возможно, ваша сеть или VPN блокирует серверы Steam; проверьте подключение и повторите попытку. + Не удалось настроить клиент Steam. Проверьте подключение и свободное место, затем повторите попытку. %1$s — сборка для x86-ПК, которая работает через FEX, а он еще не установлен. Запустите Steam один раз, войдя в аккаунт, и дайте ему скачать FEX и Steam Linux Runtime, затем запустите программу снова. ID приложения Flathub или ссылка на его страницу. Если Flatpak еще не настроен, сначала будет выполнена его настройка. Файл @@ -305,6 +307,8 @@ Клиент Оверлей производительности Отключите, если игры вылетают или показывают черный экран в режиме Deck. Вступит в силу в следующей сессии. + Возвращаться в DroidDeck после игры + Когда игра, запущенная из DroidDeck или другого лаунчера, закрывается, Steam корректно завершает работу (сначала синхронизирует облачные сохранения), и вы возвращаетесь туда. Выкл.: остаётесь в Steam. Восстановить клиент Steam При следующем запуске Steam клиент будет скачан заново. Используйте, если Steam зависает на экране загрузки. Игры, вход и настройки сохраняются. Клиент Steam будет скачан заново при следующем запуске Steam. @@ -503,7 +507,9 @@ Удалить оригинальную версию? Эта оригинальная версия %1$s из более ранней сборки %2$s. Оригинальная версия текущей сборки всегда сохраняется. В Nightlies · %1$d - Обновите, чтобы увидеть пакеты в Nightlies. + Стабильная + Ночная + Обновите, чтобы увидеть пакеты в Nightlies и DroidDeck. С момента последней проверки нового нет. Импорт .wcp Восстановить оригинальные файлы этого Proton @@ -539,7 +545,7 @@ Только для Adreno. Меньше подтормаживаний, но больше энергопотребления и нагрева. Исправления сессии Turnip: рендеринг sysmem - Steam и большинство игр будут быстрее. Требуется для Adreno 710/720/722. + Клиент Steam будет быстрее; в играх драйвер для каждого прохода выбирает тайловый рендеринг или рендеринг в системной памяти. Требуется для Adreno 710/720/722. Пропустить помощник xalia в Steam droiddeck-ntsync (экспериментально) Замена драйвера ntsync Linux в пространстве пользователя, для тестирования. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии. @@ -581,6 +587,16 @@ Предыдущая страница Следующая страница HUD производительности + Частота кадров + Температура батареи + График производительности + Цвет + Мята + Белый + Небесный + Янтарный + Фиолетовый + Непрозрачность Режим масштабирования Резкость масштабирования Экранные эффекты @@ -732,6 +748,9 @@ %1$d%% (по умолчанию) Тема Внешний вид лаунчера + Значок приложения + Тёмный + Светлый Использовать как главный экран DroidDeck может использоваться как приложение главного экрана DroidDeck не будет доступен как приложение главного экрана @@ -1044,6 +1063,9 @@ Добавленная игра Своя игра Хранилище игр + Удаляет файлы игры и убирает ее из Steam. + Убрать %1$s? + Удаляет ярлык. Файлы игры остаются. Лучшая пара для этого GPU, обновляется автоматически Выбрать комбинацию драйверов вручную Проверка драйверов… @@ -1409,6 +1431,14 @@ Черный фон, белый текст, синий — для всего выбранного, активного или нажатого. Электрик Более яркий синий на поверхностях темно-синего, а не серого оттенка. + Градиент + Ваши основной и дополнительный цвета, плавно переходящие на кнопках и выделениях. + Основной + Дополнительный + Оттенок + Насыщенность + Яркость + Цвет hex Esc Ins Del @@ -1592,7 +1622,7 @@ Правый триггер Кнопка View Кнопка «Меню» - View + Вид Меню Крестовина вверх Крестовина вниз @@ -1801,7 +1831,8 @@ Оригинал установленной сборки сохраняется: через него можно вернуться. Не сохранено Удален оригинал %2$s из сборки %1$s. - Загрузки доступны только из выпусков Nightlies + Загрузки доступны только из выпусков Nightlies и DroidDeck + Не удалось загрузить: возможно, эта ночная сборка заменена более новой. Обновите список и повторите попытку. Этот список пакетов создан до появления контрольных сумм — обновите его и повторите попытку Контрольная сумма не совпадает @@ -1960,9 +1991,10 @@ Магазины Настройки магазинов Настройки + Упорядочить вкладки + Готово (вход не выполнен) Магазин - Установлено Библиотека Все Загрузки @@ -1974,10 +2006,8 @@ Не выходить Выйти Поиск в каталоге %1$s… - Поиск установленных игр… Поиск в библиотеке… Поиск - Ничего не установлено Загрузка Нет игр Загрузка магазина… @@ -1996,7 +2026,19 @@ Открыть в %1$s Купить · %1$s Установлено + Установлено + Не установлено В библиотеке + Ключ API + Получить ключ + Ключ отклонён + Нет в вашей библиотеке itch.io + Для этой игры нет загрузки для Windows + В загрузке не найдена игра для Windows + Не удалось распаковать загрузку (код %1$d) + itch.io не ответил + Архивы %1$s не поддерживаются + itch.io ответил HTTP %1$d Бесплатно Продолжить Обновить @@ -2010,6 +2052,7 @@ Дата выхода Вид списком Вид карточками + Вид обложек Показывать контент для взрослых Epic EOS @@ -2045,6 +2088,9 @@ Последняя синхронизация: %1$s Еще не синхронизировано Файлов: %1$d + Уже актуально + Войдите в магазин, чтобы синхронизировать сохранения + Сохранения не синхронизированы установлено в вашей библиотеке магазин @@ -2173,7 +2219,7 @@ Размер Сохранить Вернуть по умолчанию - Рисуется поверх фона игры, как в Steam. Точное положение может отличаться на несколько пикселей. + Точное положение может отличаться на несколько пикселей. У этой игры пока нет логотипа. Выберите его в разделе «Логотип», чтобы увидеть здесь. Не удалось сохранить положение логотипа. Попробуйте ещё раз. Фон @@ -2349,7 +2395,7 @@ Готово Не удалось сохранить сенсорную раскладку Сенсорная раскладка сохранена в Steam - Приложение %1$d + Приложение %1$s Подпись (необязательно) Значки Steam не найдены @@ -2444,6 +2490,15 @@ Поле зрения SteamVR Меньше — SteamVR ближе и заполняет очки; больше — шире обзор вокруг. Игра на %1$s + Перецентрировать + Меню SteamVR + Стики: руки ⇄ игра + Контроллеры: %1$s + Клавиатура + Меню сеанса + Остановить SteamVR + Яркость %1$d / %2$d + Батарея %1$d%% · %2$d °C Калибровка отслеживания Уменьшает дрейф и выравнивает опорный горизонт. Для этих очков применяется сохранённая калибровка. @@ -2498,6 +2553,7 @@ Ожидание подключения очков по USB Ethernet. Этот телефон может не предоставлять его приложениям. Подключение к XREAL прервано или получен неподдерживаемый ответ. Повторная попытка… Экспериментальная поддержка 1S. Включите режим Follow и отключите стабилизацию в меню очков. Направления отслеживания и режимы отображения ещё нужно проверить на устройстве. + Экспериментальное отслеживание движений головы для Pro 2. Настройка яркости и громкости и переключение режимов отображения пока не поддерживаются. Разработчик Издатель Дата выхода @@ -2734,7 +2790,7 @@ Загрузочное видео Видео DroidDeck · %1$d кадр/с - 60 кадр/с выглядит плавнее, но сильнее нагружает процессор при запуске Steam. Видео при запуске, выбранное в настройках самого Steam, имеет приоритет. + 60 кадр/с выглядит плавнее, но сильнее нагружает процессор при запуске Steam. Видео, выбранное в настройках самого Steam, тоже появляется здесь. Звук Своё видео MP4, MOV, MKV или WebM. Другие форматы и файлы больше %1$d МБ преобразуются на устройстве в WebM 720p, до %2$d секунд. @@ -2748,6 +2804,51 @@ %1$s преобразуется для Steam. Не закрывайте DroidDeck. Устройство не может декодировать это видео. Не удалось импортировать видео + Удалить %1$s? + Видео будет удалено из DroidDeck и из папки видео Steam. + Установленные + Импорт + SteamDeckRepo + Звук + Установленные видео + Обзор + DroidDeck + Импортировано + SteamDeckRepo + Steam + Магазин баллов + Отчет о загрузочных роликах + Сохраняет в текстовый файл то, что DroidDeck и Steam знают о загрузочных роликах, чтобы приложить к сообщению об ошибке. + Сохранить + Сохранено: %1$s + Не удалось сохранить отчет. + Громкость + Длительность + %1$d с + Сколько секунд фильма воспроизводится. Всё, что длиннее, обрезается в конце. + Поиск в SteamDeckRepo + Все устройства + Steam Deck + Steam Machine + В тренде + Больше всего загрузок + Больше всего лайков + Новые + Не удалось подключиться к SteamDeckRepo + Повторить + Видео не найдены + Установить + Использовать + Используется + %1$s установлено + Не удалось скачать это видео + Назад + Далее + Страница %1$d + Очистить + Перейти к странице + Просмотр + Не удалось воспроизвести предпросмотр Камеры Luma Ultra — Экспериментальная Отслеживание положения Joy-Con Используйте камеры умных очков для позиционирования сопряженных контроллеров Joy-Con. Вращение и ввод с кнопок осуществляются с помощью контроллеров Joy-Con. Положение оценивается, когда ваши запястья скрыты. Заменяет контроллеры, управляемые голыми руками, и приостанавливает отслеживание камерой телефона. Направьте контроллеры Joy-Con вперед при подключении или сбросе центра отслеживания. @@ -2918,7 +3019,6 @@ Ожидание стабильного отслеживания головы; сбросьте центр отслеживания, если потребуется. Камера наблюдения устарела; жду новую пару По размеру %1$.2f пикселей. Измерьте выбранное расстояние от левой камеры до центра калибровочной цели, а затем подтвердите. - OpenCV недоступен: %1$s %1$d сохраненные просмотры восстановлены. Рассчитайте калибровку, чтобы повторить попытку, или начните новую настройку, чтобы захватить больше. Снято %1$d / 24 просмотра. Держите цель калибровки фиксированной; переместите умные очки для следующего просмотра. Захват отклонен: %1$d / 12 требуемых углов видны в обеих камерах. Покажите больше калибровочной цели, задержитесь и повторите попытку. diff --git a/app/src/main/res/values-sv/gestures.xml b/app/src/main/res/values-sv/gestures.xml new file mode 100644 index 000000000..a860712b4 --- /dev/null +++ b/app/src/main/res/values-sv/gestures.xml @@ -0,0 +1,91 @@ + + + Gester + Inställningar för pekgester + Anpassa mus- och tangentbordsgester. Välj Gester som pekläge för att använda dem. + Tryck + Dra med ett finger + Långt tryck + Dubbeltryck (dubbelt vänsterklick) + Tryck med två fingrar + Dra med två fingrar + Håll med två fingrar + Tryck med tre fingrar + Dra med tre fingrar + Håll med tre fingrar + Nyp för att zooma + Håll knappen nedtryckt + Klicka en gång + Fördröjning före hållning + Intervall för dubbeltryck + Återställ de ursprungliga gestinställningarna + Avbryt + Välj åtgärd + Vänsterklick + Högerklick + Mittenklick + Visa tangentbord + Vänsterklicka och dra + Högerklicka och dra + Panorera med musens mittknapp + Panorera med musens mittknapp (omvänt) + Piltangenter + Piltangenter (omvänt) + WASD (omvänt) + Mushjul + Gesttröskel: %1$d px + Visa identifierad gest + Ett finger + Två fingrar + Tre fingrar + Alternativ + Standardvärden + Det här spelet + Använd standardinställningar + Spara ändringar + Stäng + ÅTGÄRD + Sök efter musåtgärder eller tangenter + Rensa sökning + Alla + Inga matchande åtgärder. Prova ett annat tangent- eller åtgärdsnamn. + Mus + Appåtgärder + Navigering + Bokstäver + Siffror + Funktionstangenter + Modifierartangenter + Numeriskt tangentbord + Symboler + Musrörelse + Direkt + Relativ + Gestidentifiering + Visuell återkoppling + Musknappens beteende + %1$d ms + Numeriskt tangentbord %1$s + Escape + Backsteg + Retur + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pil upp + Pil ned + Pil vänster + Pil höger + Caps Lock + Num Lock + Scroll Lock + Vänster %1$s + Höger %1$s + Skift + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4ddc7eacc..1da71dc1c 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -30,7 +30,7 @@ Bildrutegenerering Välj läge för bildrutegenerering Av - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptiv %1$d Kräver Lossless Scaling på Steam @@ -176,6 +176,8 @@ %1$s via FEX Programmet är byggt för %1$s-datorer och körs via FEX, som inte har konfigurerats än. Öppna Steam en gång medan du är inloggad: DroidDeck låter Steam ladda ned FEX och den Steam Linux Runtime som behövs, sedan öppnas detta. Det här programmet + Det gick inte att ladda ned Steam-klienten. Ditt nätverk eller din VPN kanske blockerar Steams servrar; kontrollera anslutningen och försök igen. + Det gick inte att konfigurera Steam-klienten. Kontrollera anslutningen och det lediga lagringsutrymmet och försök igen. %1$s är byggt för x86-datorer och körs via FEX, som inte är installerat än. Starta Steam en gång medan du är inloggad och låt det ladda ned FEX och Steam Linux Runtime. Starta sedan detta igen. Ett app-ID från Flathub eller appens Flathub-sida. Flatpak konfigureras först om det inte redan är gjort. Fil @@ -502,7 +504,9 @@ Ta bort det här originalet? Det här originalet av %1$s är från ett tidigare bygge av %2$s. Originalet från det aktuella bygget behålls alltid. I nattbyggena · %1$d - Uppdatera för att lista paket i nattbyggena. + Stabil + Nattbygge + Uppdatera för att lista paket i Nightlies och DroidDeck. Inget nytt sedan senaste kontrollen. Importera .wcp Återställ den här Protonens original @@ -538,7 +542,7 @@ Bara Adreno. Färre hack, högre batteriförbrukning och mer värme. Sessionsfixar Turnip: sysmem-rendering - Snabbare för Steam och de flesta spel. Krävs på Adreno 710/720/722. + Snabbare för Steam-klienten; i spel väljer drivrutinen kakel- eller systemminnesrendering för varje pass. Krävs på Adreno 710/720/722. Hoppa över Steams xalia-hjälp droiddeck-ntsync (experimentell) En ersättning i användarutrymmet för Linux ntsync-drivrutinen, för testning. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session. @@ -580,6 +584,16 @@ Föregående sida Nästa sida Prestanda-HUD + Bildfrekvens + Batteritemperatur + Prestandagraf + Färg + Mint + Vit + Himmelsblå + Amber + Lila + Opacitet Skalningsläge Skalningsskärpa Skärmeffekter @@ -729,6 +743,9 @@ %1$d %% (Standard) Tema Startprogrammets utseende + Appikon + Mörk + Ljus Använd som startskärm DroidDeck kan vara startskärmsappen DroidDeck erbjuds aldrig som startskärmsapp @@ -1035,6 +1052,9 @@ Tillagt spel Anpassat spel Spellagring + Tar bort dess filer och tar bort det från Steam. + Ta bort %1$s? + Tar bort genvägen. Spelets filer ligger kvar. Det bästa paret för den här GPU:n, hålls uppdaterat Välj ett par själv Söker efter drivrutiner… @@ -1384,6 +1404,14 @@ Svart bakgrund, vit text, den blå färgen för allt som är markerat, i fokus eller nedtryckt. Elektrisk marinblå Den blå färgen klarare, på ytor som lutar åt marinblått i stället för grått. + Gradient + Dina egna primära och sekundära färger, blandade på knappar och markeringar. + Primär + Sekundär + Nyans + Mättnad + Ljusstyrka + Hex-färg Esc Ins Del @@ -1762,7 +1790,8 @@ Den installerade versionens original behålls: det är vägen tillbaka. Inte sparad Tog bort %1$s-originalet av %2$s. - Nedladdningar kommer bara från Nightlies-utgåvorna + Nedladdningar kommer bara från Nightlies- och DroidDeck-utgåvorna + Nedladdningen misslyckades: det här nattbygget kan ha ersatts av ett nyare. Uppdatera listan och försök igen. Den här paketlistan är från innan kontrollsummor fanns - uppdatera den och försök igen Kontrollsumman stämmer inte @@ -1921,9 +1950,10 @@ Butiker Butiksinställningar Inställningar + Ordna flikar + Klart (inte inloggad) Butik - Installerade Bibliotek Alla Nedladdningar @@ -1935,10 +1965,8 @@ Fortsätt vara inloggad Logga ut Sök i %1$s-katalogen… - Sök installerade spel… Sök i ditt bibliotek… Sök - Inget installerat Läser in Inga spel Läser in butiken… @@ -1957,7 +1985,19 @@ Visa på %1$s Köp · %1$s Installerat + Installerat + Inte installerad I biblioteket + API-nyckel + Hämta en nyckel + Nyckeln avvisades + Inte i ditt itch.io-bibliotek + Ingen Windows-nedladdning för det här spelet + Inget Windows-spel hittades i nedladdningen + Det gick inte att packa upp nedladdningen (kod %1$d) + itch.io svarade inte + %1$s-arkiv stöds inte + itch.io svarade HTTP %1$d Gratis Återuppta Uppdatera @@ -1971,6 +2011,7 @@ Utgivningsdatum Listvy Kortvy + Omslagsvy Visa innehåll för vuxna Epic EOS @@ -2006,6 +2047,9 @@ Senast synkad %1$s Inte synkad än %1$d filer + Redan uppdaterat + Logga in i butiken för att synkronisera sparfiler + Sparfilerna synkroniserades inte installerat i ditt bibliotek butik @@ -2137,7 +2181,7 @@ Storlek Spara Tillbaka till standard - Ritas ovanpå spelets bakgrund, som Steam gör. Exakt position kan skilja några pixlar. + Den exakta positionen kan avvika med några pixlar. Det här spelet har ingen logotyp än. Välj en under Logotyp för att se den här. Logotypens position kunde inte sparas. Försök igen. Bakgrund @@ -2309,7 +2353,7 @@ Klar Det gick inte att spara pekskärmslayouten Pekskärmslayout sparad till Steam - App %1$d + App %1$s Etikett (valfritt) Steams ikoner hittades inte @@ -2400,6 +2444,15 @@ SteamVR-synfält Lägre för SteamVR närmare och fyller glasögonen; högre visar mer runt dig. Spelar på %1$s + Centrera om + SteamVR-meny + Spakar: händer ⇄ spel + Handkontroller: %1$s + Tangentbord + Sessionsmeny + Stoppa SteamVR + Ljusstyrka %1$d / %2$d + Batteri %1$d%% · %2$d °C Spårningskalibrering Minskar drift och justerar horisontreferensen. Sparad kalibrering används för de här glasögonen. @@ -2454,6 +2507,7 @@ Väntar på glasögonens USB Ethernet-anslutning. Telefonen kanske inte gör den tillgänglig för appar. XREAL-anslutningen avbröts eller gav ett svar som inte stöds. Försöker igen… Experimentellt stöd för 1S. Använd Follow-läget och stäng av stabiliseringen i glasögonens meny. Spårningsriktningar och visningslägen behöver fortfarande testas på enheten. + Experimentell huvudspårning för Pro 2. Justering av ljusstyrka och volym samt byte av visningsläge stöds inte ännu. Cardboard VR Cardboard-läge Bilder för vänster och höger öga till ett VR-headset för mobilen. @@ -2666,7 +2720,7 @@ Startvideo Video DroidDeck · %1$d fps - 60 fps ser jämnare ut men använder mer CPU medan Steam startar. En startvideo vald i Steams egna inställningar har företräde. + 60 fps ser jämnare ut men använder mer CPU medan Steam startar. En video vald i Steams egna inställningar visas också här. Ljud Egen video MP4, MOV, MKV eller WebM. Andra format och filer över %1$d MB konverteras till 720p-WebM på enheten, upp till %2$d sekunder. @@ -2680,6 +2734,51 @@ Konverterar %1$s för Steam. Håll DroidDeck öppet. Enheten kan inte avkoda videon. Kunde inte importera videon + Ta bort %1$s? + Videon tas bort från DroidDeck och från Steams videomapp. + Installerade + Importera + SteamDeckRepo + Ljud + Installerade videor + Bläddra + DroidDeck + Importerad + SteamDeckRepo + Steam + Poängbutiken + Rapport om startfilmer + Sparar det DroidDeck och Steam vet om startfilmer i en textfil som du kan bifoga en felrapport. + Spara + Sparad i %1$s + Rapporten kunde inte sparas. + Volym + Längd + %1$d s + Hur många sekunder av filmen som spelas upp. Allt som är längre klipps bort i slutet. + Sök i SteamDeckRepo + Alla enheter + Steam Deck + Steam Machine + Trendar + Mest nedladdade + Mest gillade + Nyast + Kunde inte nå SteamDeckRepo + Försök igen + Inga videor hittades + Installera + Använd + Används + %1$s installerad + Kunde inte ladda ner videon + Föregående + Nästa + Sida %1$d + Rensa + Gå till sida + Förhandsvisa + Förhandsvisningen kunde inte spelas upp Luma Ultra-kameror — Experimentell Joy-Con-positionsspårning Använd smartglasögonens kameror för att placera dina parade Joy-Con-kontroller. Rotation och knappinmatningar kommer från Joy-Con-kontrollerna. Positionen uppskattas när dina handleder är dolda. Ersätter kontroller som manövreras med bara händer och pausar telefonkameran. Rikta Joy-Con-kontrollerna framåt när du ansluter eller återställer spårningscentret. @@ -2852,7 +2951,6 @@ Väntar på stabil huvudspårning; återställ spårningscenter om det begärs. Kameraobservation är för gammal; väntar på ett nytt par Fit %1$.2f px. Mät det valda avståndet från den vänstra kameran till kalibreringsmålets centrum och validera sedan. - OpenCV inte tillgängligt: %1$s %1$d sparade vyer återställda. Beräkna kalibreringen för att försöka igen, eller starta en ny uppsättning för att fånga mer. Fångad %1$d / 24 visningar. Håll kalibreringsmålet fixerat; flytta de smarta glasögonen för nästa vy. Bilden avvisades: %1$d / 12 nödvändiga hörn synliga i båda kamerorna. Visa mer av kalibreringsmålet, håll stilla och försök sedan igen. diff --git a/app/src/main/res/values-th/gestures.xml b/app/src/main/res/values-th/gestures.xml new file mode 100644 index 000000000..0fedbbfa1 --- /dev/null +++ b/app/src/main/res/values-th/gestures.xml @@ -0,0 +1,91 @@ + + + ท่าทาง + การตั้งค่าท่าทางสัมผัส + ปรับแต่งท่าทางเมาส์และแป้นพิมพ์ เลือกท่าทางเป็นโหมดสัมผัสเพื่อใช้งาน + แตะ + ลากด้วยหนึ่งนิ้ว + กดค้าง + แตะสองครั้ง (ดับเบิลคลิกซ้าย) + แตะด้วยสองนิ้ว + ลากด้วยสองนิ้ว + กดค้างด้วยสองนิ้ว + แตะด้วยสามนิ้ว + ลากด้วยสามนิ้ว + กดค้างด้วยสามนิ้ว + จีบนิ้วเพื่อซูม + กดปุ่มค้างไว้ + คลิกหนึ่งครั้ง + ระยะเวลาก่อนกดค้าง + ช่วงเวลาแตะสองครั้ง + คืนค่าการตั้งค่าท่าทางเดิม + ยกเลิก + เลือกการกระทำ + คลิกซ้าย + คลิกขวา + คลิกกลาง + แสดงแป้นพิมพ์ + คลิกซ้ายแล้วลาก + คลิกขวาแล้วลาก + เลื่อนมุมมองด้วยปุ่มเมาส์กลาง + เลื่อนมุมมองด้วยปุ่มเมาส์กลาง (กลับด้าน) + ปุ่มลูกศร + ปุ่มลูกศร (กลับด้าน) + WASD (กลับด้าน) + ล้อเมาส์ + เกณฑ์ท่าทาง: %1$d พิกเซล + แสดงท่าทางที่ตรวจพบ + หนึ่งนิ้ว + สองนิ้ว + สามนิ้ว + ตัวเลือก + ค่าเริ่มต้น + เกมนี้ + ใช้ค่าเริ่มต้น + บันทึกการเปลี่ยนแปลง + ปิด + การกระทำ + ค้นหาการกระทำของเมาส์หรือปุ่มแป้นพิมพ์ + ล้างการค้นหา + ทั้งหมด + ไม่พบการกระทำที่ตรงกัน ลองชื่อปุ่มหรือการกระทำอื่น + เมาส์ + การกระทำของแอป + การนำทาง + ตัวอักษร + ตัวเลข + ปุ่มฟังก์ชัน + ปุ่มปรับเปลี่ยน + แป้นตัวเลข + สัญลักษณ์ + การเคลื่อนที่ของเมาส์ + โดยตรง + สัมพัทธ์ + การตรวจจับท่าทาง + การตอบสนองทางภาพ + การทำงานของปุ่มเมาส์ + %1$d มิลลิวินาที + แป้นตัวเลข %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + ลูกศรขึ้น + ลูกศรลง + ลูกศรซ้าย + ลูกศรขวา + Caps Lock + Num Lock + Scroll Lock + %1$s ซ้าย + %1$s ขวา + Shift + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c9cac80c4..75dc4f572 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -30,7 +30,7 @@ การสร้างเฟรม เลือกโหมดการสร้างเฟรม ปิด - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG แบบปรับอัตโนมัติ %1$d ต้องมี Lossless Scaling บน Steam @@ -176,6 +176,8 @@ %1$s ผ่าน FEX โปรแกรมนี้สร้างมาสำหรับพีซี %1$s และรันผ่าน FEX ซึ่งยังไม่ได้ตั้งค่า เปิด Steam สักครั้งขณะลงชื่อเข้าใช้: DroidDeck จะดาวน์โหลด FEX และ Steam Linux Runtime ที่จำเป็น จากนั้นโปรแกรมนี้จะเปิดขึ้น โปรแกรมนี้ + ดาวน์โหลดไคลเอนต์ Steam ไม่ได้ เครือข่ายหรือ VPN ของคุณอาจบล็อกเซิร์ฟเวอร์ของ Steam ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง + ตั้งค่าไคลเอนต์ Steam ไม่ได้ ตรวจสอบการเชื่อมต่อและพื้นที่ว่าง แล้วลองอีกครั้ง %1$s สร้างมาสำหรับพีซี x86 และรันผ่าน FEX ซึ่งยังไม่ได้ติดตั้ง เริ่ม Steam สักครั้งขณะลงชื่อเข้าใช้และให้ดาวน์โหลด FEX และ Steam Linux Runtime จากนั้นเริ่มโปรแกรมนี้อีกครั้ง รหัสแอป Flathub หรือหน้า Flathub ของมัน จะตั้งค่า Flatpak ก่อนหากยังไม่ได้ตั้งค่า ไฟล์ @@ -500,7 +502,9 @@ ลบต้นฉบับนี้? ต้นฉบับ %1$s นี้มาจากบิลด์ก่อนหน้าของ %2$s ต้นฉบับของบิลด์ปัจจุบันจะถูกเก็บไว้เสมอ ใน Nightlies · %1$d - รีเฟรชเพื่อแสดงรายการแพ็กเกจใน Nightlies + เสถียร + Nightly + รีเฟรชเพื่อแสดงรายการแพ็กเกจใน Nightlies และ DroidDeck ไม่มีอะไรใหม่ตั้งแต่การตรวจสอบครั้งล่าสุด นำเข้า .wcp คืนค่าต้นฉบับของ Proton นี้ @@ -536,7 +540,7 @@ สำหรับ Adreno เท่านั้น สะดุดน้อยลง แต่ใช้แบตเตอรี่และเกิดความร้อนมากขึ้น การแก้ไขเซสชัน Turnip: การเรนเดอร์ sysmem - เร็วขึ้นสำหรับ Steam และเกมส่วนใหญ่ จำเป็นสำหรับ Adreno 710/720/722 + เร็วขึ้นสำหรับไคลเอนต์ Steam ในเกมไดรเวอร์จะเลือกการเรนเดอร์แบบไทล์หรือในหน่วยความจำระบบในแต่ละพาส จำเป็นสำหรับ Adreno 710/720/722 ข้ามตัวช่วย xalia ของ Steam droiddeck-ntsync (ทดลอง) ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux สำหรับการทดสอบ เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป @@ -578,6 +582,16 @@ หน้าก่อนหน้า หน้าถัดไป HUD ประสิทธิภาพ + อัตราเฟรม + อุณหภูมิแบตเตอรี่ + กราฟประสิทธิภาพ + สี + มิ้นต์ + ขาว + ฟ้าสดใส + อำพัน + ม่วง + ความโปร่งใส โหมดการปรับขนาด ความคมชัดของการปรับขนาด เอฟเฟกต์หน้าจอ @@ -726,6 +740,9 @@ %1$d%% (ค่าเริ่มต้น) ธีม รูปลักษณ์ของตัวเปิดแอป + ไอคอนแอป + มืด + สว่าง ใช้เป็นหน้าจอหลัก DroidDeck สามารถเป็นแอปหน้าจอหลักได้ DroidDeck จะไม่ถูกเสนอให้เป็นแอปหน้าจอหลัก @@ -1029,6 +1046,9 @@ เกมที่เพิ่ม เกมกำหนดเอง พื้นที่จัดเก็บเกม + ลบไฟล์และนำออกจาก Steam + ลบ %1$s? + ลบช็อตคัต ไฟล์เกมยังอยู่ คู่ที่ดีที่สุดสำหรับ GPU นี้ อัปเดตให้ล่าสุดเสมอ เลือกคู่ด้วยตัวเอง กำลังตรวจสอบไดรเวอร์… @@ -1362,7 +1382,7 @@ ไม่สามารถตรวจสอบการอัปเดตจาก Flathub ได้ ติดตั้งแล้ว โฟลเดอร์ - Graphite + แกรไฟต์ พื้นผิวสีเทาแกรไฟต์ พร้อมสีฟ้าของโลโก้สำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด กระดาษบนพื้นดำ สีขาวเป็นหลัก สีฟ้าของไอคอนเป็นสัญญาณสำหรับเส้น วงแหวน และจุด @@ -1370,6 +1390,14 @@ พื้นดำ ตัวอักษรขาว สีฟ้าสำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด กรมท่าไฟฟ้า สีฟ้าสว่างขึ้น บนพื้นผิวที่เอียงไปทางกรมท่ามากกว่าเทา + ไล่เฉดสี + สีหลักและสีรองที่คุณเลือกเอง ผสมกันบนปุ่มและส่วนที่เน้น + สีหลัก + สีรอง + เฉดสี + ความอิ่มตัว + ความสว่าง + สีเลขฐานสิบหก Esc Ins Del @@ -1741,12 +1769,13 @@ ต้นฉบับของบิลด์ที่ติดตั้งไว้ถูกเก็บไว้: นี่คือทางกลับ ไม่ได้บันทึกไว้ ลบต้นฉบับ %1$s ของ %2$s แล้ว - การดาวน์โหลดมาจากรุ่นที่เผยแพร่ใน Nightlies เท่านั้น + การดาวน์โหลดมาจากรุ่นที่เผยแพร่ใน Nightlies และ DroidDeck เท่านั้น + ดาวน์โหลดไม่สำเร็จ: บิลด์ nightly นี้อาจถูกแทนที่ด้วยบิลด์ที่ใหม่กว่า รีเฟรชรายการแล้วลองอีกครั้ง รายการแพ็กเกจนี้เก่ากว่าระบบค่าตรวจสอบ - รีเฟรชแล้วลองใหม่ ค่าตรวจสอบไม่ตรงกัน - Linear - Nearest + เชิงเส้น + ใกล้ที่สุด Snapdragon GSR (คุณภาพ) เพิ่มความคมชัดเท่านั้น อัตโนมัติ (ตามอัตราขยาย) @@ -1900,9 +1929,10 @@ สโตร์ การตั้งค่าสโตร์ การตั้งค่า + จัดเรียงแท็บ + เสร็จสิ้น (ยังไม่ลงชื่อเข้าใช้) สโตร์ - ติดตั้งแล้ว ไลบรารี ทั้งหมด การดาวน์โหลด @@ -1914,10 +1944,8 @@ ลงชื่อเข้าใช้ต่อไป ลงชื่อออก ค้นหาแคตตาล็อกของ %1$s… - ค้นหาเกมที่ติดตั้งไว้… ค้นหาไลบรารีของคุณ… ค้นหา - ไม่มีสิ่งที่ติดตั้งไว้ กำลังโหลด ไม่มีเกม กำลังโหลดสโตร์… @@ -1936,7 +1964,19 @@ ดูบน %1$s ซื้อ · %1$s ติดตั้งแล้ว + ติดตั้งแล้ว + ยังไม่ติดตั้ง อยู่ในไลบรารี + คีย์ API + รับคีย์ + คีย์ถูกปฏิเสธ + ไม่มีในคลัง itch.io ของคุณ + เกมนี้ไม่มีการดาวน์โหลดสำหรับ Windows + ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด + แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d) + itch.io ไม่ตอบสนอง + ไม่รองรับไฟล์ %1$s + itch.io ตอบกลับ HTTP %1$d ฟรี ดำเนินการต่อ รีเฟรช @@ -1950,6 +1990,7 @@ วันวางจำหน่าย มุมมองรายการ มุมมองการ์ด + มุมมองปก แสดงเนื้อหาสำหรับผู้ใหญ่ Epic EOS @@ -1985,6 +2026,9 @@ ซิงค์ล่าสุด %1$s ยังไม่ได้ซิงค์ %1$d ไฟล์ + อัปเดตแล้ว + ลงชื่อเข้าใช้ร้านค้าเพื่อซิงค์เซฟ + เซฟไม่ได้ซิงค์ ติดตั้งแล้ว อยู่ในไลบรารีของคุณ สโตร์ @@ -2116,7 +2160,7 @@ ขนาด บันทึก กลับเป็นค่าเริ่มต้น - วาดทับพื้นหลังของเกมเหมือนที่ Steam ทำ ตำแหน่งจริงอาจต่างไปสองสามพิกเซล + ตำแหน่งที่แน่นอนอาจต่างกันไปไม่กี่พิกเซล เกมนี้ยังไม่มีโลโก้ เลือกในโลโก้เพื่อดูที่นี่ บันทึกตำแหน่งโลโก้ไม่ได้ ลองอีกครั้ง พื้นหลัง @@ -2286,7 +2330,7 @@ เสร็จสิ้น ไม่สามารถบันทึกเค้าโครงสัมผัสได้ บันทึกเค้าโครงสัมผัสลง Steam แล้ว - แอป %1$d + แอป %1$s ป้ายชื่อ (ไม่บังคับ) ไม่พบไอคอนของ Steam @@ -2375,6 +2419,15 @@ ขอบเขตการมองเห็นของ SteamVR ค่าต่ำจะดึง SteamVR เข้ามาใกล้และเต็มแว่นตา ค่าสูงจะแสดงสิ่งรอบตัวคุณได้มากขึ้น กำลังเล่นบน %1$s + จัดกึ่งกลางใหม่ + เมนู SteamVR + สติ๊ก: มือ ⇄ เกม + คอนโทรลเลอร์: %1$s + แป้นพิมพ์ + เมนูเซสชัน + หยุด SteamVR + ความสว่าง %1$d / %2$d + แบตเตอรี่ %1$d%% · %2$d °C การปรับเทียบการติดตาม ลดการดริฟต์และปรับแนวระดับอ้างอิงให้ตรง ใช้การปรับเทียบที่บันทึกไว้กับแว่นตานี้แล้ว @@ -2429,6 +2482,7 @@ กำลังรอการเชื่อมต่อ USB Ethernet ของแว่นตา โทรศัพท์นี้อาจไม่เปิดให้แอปเข้าถึงการเชื่อมต่อนี้ การเชื่อมต่อ XREAL ขาดหายหรือได้รับการตอบกลับที่ไม่รองรับ กำลังลองใหม่… รองรับ 1S แบบทดลอง ใช้โหมด Follow และปิดการกันสั่นในเมนูของแว่นตา ทิศทางการติดตามและโหมดการแสดงผลยังต้องทดสอบกับอุปกรณ์จริง + การติดตามศีรษะแบบทดลองสำหรับ Pro 2 ยังไม่รองรับการปรับความสว่าง ระดับเสียง และการสลับโหมดการแสดงผล Cardboard VR โหมด Cardboard ภาพสำหรับตาซ้ายและตาขวาสำหรับแว่น VR ของโทรศัพท์ @@ -2641,7 +2695,7 @@ วิดีโอบูต วิดีโอ DroidDeck · %1$d fps - 60 fps ลื่นกว่าแต่ใช้ CPU มากขึ้นระหว่างที่ Steam กำลังเริ่ม วิดีโอเริ่มต้นที่เลือกในการตั้งค่าของ Steam เองจะมีลำดับความสำคัญกว่า + 60 fps ลื่นกว่าแต่ใช้ CPU มากขึ้นระหว่างที่ Steam กำลังเริ่ม วิดีโอที่เลือกในการตั้งค่าของ Steam เองจะแสดงที่นี่ด้วย เสียง วิดีโอของคุณเอง MP4, MOV, MKV หรือ WebM รูปแบบอื่นและไฟล์ที่ใหญ่กว่า %1$d MB จะถูกแปลงเป็น WebM 720p บนอุปกรณ์ ความยาวไม่เกิน %2$d วินาที @@ -2655,6 +2709,51 @@ กำลังแปลง %1$s สำหรับ Steam โปรดเปิด DroidDeck ค้างไว้ อุปกรณ์นี้ถอดรหัสวิดีโอนั้นไม่ได้ นำเข้าวิดีโอนี้ไม่ได้ + ลบ %1$s หรือไม่? + วิดีโอจะถูกลบออกจาก DroidDeck และจากโฟลเดอร์วิดีโอของ Steam + ติดตั้งแล้ว + นำเข้า + SteamDeckRepo + เสียง + วิดีโอที่ติดตั้งแล้ว + เรียกดู + DroidDeck + นำเข้าแล้ว + SteamDeckRepo + Steam + ร้านค้าแต้ม + รายงานวิดีโอเปิดเครื่อง + บันทึกสิ่งที่ DroidDeck และ Steam รู้เกี่ยวกับวิดีโอเปิดเครื่องลงในไฟล์ข้อความ เพื่อแนบกับรายงานข้อบกพร่อง + บันทึก + บันทึกไปที่ %1$s แล้ว + ไม่สามารถบันทึกรายงานได้ + ระดับเสียง + ความยาว + %1$d วิ + จำนวนวินาทีของภาพยนตร์ที่จะเล่น ส่วนที่ยาวเกินกว่านั้นจะถูกตัดออกที่ท้าย + ค้นหาใน SteamDeckRepo + ทุกอุปกรณ์ + Steam Deck + Steam Machine + มาแรง + ดาวน์โหลดมากที่สุด + ถูกใจมากที่สุด + ใหม่ล่าสุด + เชื่อมต่อ SteamDeckRepo ไม่ได้ + ลองอีกครั้ง + ไม่พบวิดีโอ + ติดตั้ง + ใช้ + กำลังใช้งาน + ติดตั้ง %1$s แล้ว + ดาวน์โหลดวิดีโอนั้นไม่ได้ + ก่อนหน้า + ถัดไป + หน้า %1$d + ล้าง + ไปที่หน้า + ดูตัวอย่าง + เล่นตัวอย่างไม่ได้ กล้อง Luma Ultra — ทดลอง การติดตามตำแหน่ง Joy-Con ใช้กล้องของแว่นตาอัจฉริยะเพื่อกำหนดตำแหน่งคอนโทรลเลอร์ Joy-Con ที่จับคู่ไว้ การหมุนและการกดปุ่มจะมาจากคอนโทรลเลอร์ Joy-Con ตำแหน่งจะถูกประมาณเมื่อข้อมือของคุณถูกซ่อนอยู่ แทนที่คอนโทรลเลอร์ที่ใช้งานด้วยมือเปล่าและหยุดการติดตามของกล้องโทรศัพท์ชั่วคราว ชี้คอนโทรลเลอร์ Joy-Con ไปข้างหน้าเมื่อเชื่อมต่อหรือรีเซ็ตศูนย์กลางการติดตาม @@ -2828,7 +2927,6 @@ กำลังรอการติดตามศีรษะที่เสถียร รีเซ็ตจุดศูนย์กลางการติดตามหากมีการร้องขอ การสังเกตด้วยกล้องเก่าเกินไป รอคู่ใหม่ครับ พอดี %1$.2f พิกเซล วัดระยะทางที่คุณเลือกจากกล้องด้านซ้ายไปยังศูนย์กลางเป้าหมายการสอบเทียบ จากนั้นตรวจสอบ - OpenCV ไม่พร้อมใช้งาน: %1$s ถ่ายแล้ว %1$d / 24 ครั้ง รักษาเป้าหมายการสอบเทียบให้คงที่ เลื่อนแว่นตาอัจฉริยะเพื่อดูมุมมองถัดไป การจับภาพถูกปฏิเสธ: %1$d / 12 มุมที่ต้องมองเห็นได้ในกล้องทั้งสองตัว แสดงเป้าหมายการสอบเทียบเพิ่มเติม ให้ค้างไว้แล้วลองอีกครั้ง การตรวจสอบระยะทางไม่เสร็จสิ้น: %1$s ลองอีกครั้ง diff --git a/app/src/main/res/values-tr/gestures.xml b/app/src/main/res/values-tr/gestures.xml new file mode 100644 index 000000000..92ca99e01 --- /dev/null +++ b/app/src/main/res/values-tr/gestures.xml @@ -0,0 +1,91 @@ + + + Hareketler + Dokunmatik hareket ayarları + Fare ve klavye hareketlerini özelleştirin. Kullanmak için dokunma modu olarak Hareketler\'i seçin. + Dokunma + Tek parmakla sürükleme + Uzun basma + Çift dokunma (çift sol tıklama) + İki parmakla dokunma + İki parmakla sürükleme + İki parmakla basılı tutma + Üç parmakla dokunma + Üç parmakla sürükleme + Üç parmakla basılı tutma + İki parmakla yakınlaştırma + Düğmeyi basılı tut + Bir kez tıkla + Basılı tutma gecikmesi + Çift dokunma aralığı + İlk hareket ayarlarına sıfırla + İptal + Eylem seç + Sol tıklama + Sağ tıklama + Orta tıklama + Klavyeyi göster + Sol tıklayıp sürükleme + Sağ tıklayıp sürükleme + Orta fare düğmesiyle kaydırma + Orta fare düğmesiyle kaydırma (ters) + Ok tuşları + Ok tuşları (ters) + WASD (ters) + Fare tekerleği + Hareket eşiği: %1$d piksel + Algılanan hareketi göster + Tek parmak + İki parmak + Üç parmak + Seçenekler + Varsayılanlar + Bu oyun + Varsayılanları kullan + Değişiklikleri kaydet + Kapat + EYLEM + Fare eylemlerini veya klavye tuşlarını ara + Aramayı temizle + Tümü + Eşleşen eylem yok. Başka bir tuş veya eylem adı deneyin. + Fare + Uygulama eylemleri + Gezinme + Harfler + Sayılar + İşlev tuşları + Değiştirici tuşlar + Sayısal tuş takımı + Semboller + Fare hareketi + Doğrudan + Göreli + Hareket algılama + Görsel geri bildirim + Fare düğmesi davranışı + %1$d ms + Sayısal tuş takımı %1$s + Escape + Geri silme + Enter + Tab + Boşluk + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Yukarı ok + Aşağı ok + Sol ok + Sağ ok + Caps Lock + Num Lock + Scroll Lock + Sol %1$s + Sağ %1$s + Üst Karakter + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 3322f1c92..da288eb3e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -30,7 +30,7 @@ Kare oluşturma Kare oluşturma modunu seçin Kapalı - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Uyarlanabilir %1$d Steam\'de Lossless Scaling gerekli @@ -176,6 +176,8 @@ FEX ile %1$s Bu program %1$s PC\'ler için derlenmiş ve FEX ile çalışır, ancak FEX henüz kurulmadı. Oturum açmışken Steam\'i bir kez açın: DroidDeck, FEX\'i ve gereken Steam Linux Runtime\'ı indirtir, ardından bu program açılır. Bu program + Steam istemcisi indirilemedi. Ağınız veya VPN\'iniz Steam sunucularını engelliyor olabilir; bağlantıyı kontrol edip yeniden deneyin. + Steam istemcisi kurulamadı. Bağlantınızı ve boş depolama alanını kontrol edip yeniden deneyin. %1$s x86 PC\'ler için derlenmiş ve FEX ile çalışır, ancak FEX henüz yüklü değil. Oturum açmışken Steam\'i bir kez başlatıp FEX\'i ve Steam Linux Runtime\'ı indirmesini bekleyin, ardından bunu yeniden başlatın. Bir Flathub uygulama kimliği veya Flathub sayfası. Flatpak henüz kurulu değilse önce o kurulur. Dosya @@ -499,7 +501,9 @@ Bu orijinal silinsin mi? Bu %1$s orijinali, %2$s sürümünün önceki bir derlemesine ait. Geçerli derlemenin orijinali her zaman saklanır. Nightlies içinde · %1$d - Nightlies paketlerini listelemek için yenileyin. + Kararlı + Nightly + Nightlies ve DroidDeck paketlerini listelemek için yenileyin. Son kontrolden beri yeni bir şey yok. .wcp içe aktar Bu Proton\'un orijinalini geri yükle @@ -535,7 +539,7 @@ Yalnızca Adreno. Daha az takılma, daha fazla pil tüketimi ve ısınma. Oturum düzeltmeleri Turnip: sysmem işleme - Steam ve çoğu oyun için daha hızlı. Adreno 710/720/722\'de gereklidir. + Steam istemcisi için daha hızlı; oyunlarda sürücü her geçişte döşeme tabanlı veya sistem belleğinde işlemeyi seçer. Adreno 710/720/722\'de gereklidir. Steam\'in xalia yardımcısını atla droiddeck-ntsync (deneysel) Linux ntsync sürücüsünün test amaçlı, kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır. @@ -577,6 +581,16 @@ Önceki sayfa Sonraki sayfa Performans HUD\'u + Kare hızı + Pil sıcaklığı + Performans grafiği + Renk + Nane + Beyaz + Gök mavisi + Kehribar + Mor + Opaklık Ölçekleme modu Ölçekleme keskinliği Ekran efektleri @@ -591,7 +605,7 @@ Sahte HDR VR SteamVR - SteamVR\'ı başlat + SteamVR\'ye gir SteamVR denetleniyor… Bant giderme Bant giderme gücü @@ -612,9 +626,9 @@ Klavye Gecikme Düşük gecikmeli kumanda - Kumanda 250 Hz yerine 1000 Hz bildirir. Biraz daha fazla CPU. + Kumanda 250 Hz yerine 1000 Hz bildirir. CPU kullanımı biraz artar. Düşük gecikmeli dokunma - Dokunuşlar kare birleştirmeyi atlar; Steam dokunmatik kontrolleri 1000 Hz çalışır. Biraz daha fazla CPU. + Dokunmalar kare gruplamasını atlar; Steam dokunmatik kontrolleri 1000 Hz çalışır. CPU kullanımı biraz artar. PC klavyesi Android İkinci ekran @@ -718,6 +732,9 @@ %%%1$d (Varsayılan) Tema Başlatıcı görünümü + Uygulama simgesi + Koyu + Açık Ana ekran olarak kullan DroidDeck ana ekran uygulaması olabilir DroidDeck hiçbir zaman ana ekran uygulaması olarak sunulmaz @@ -1024,6 +1041,9 @@ Eklenen oyun Özel oyun Oyun depolama alanı + Dosyalarını siler ve Steam\'den kaldırır. + Kaldır: %1$s? + Kısayolunu siler. Oyun dosyaları kalır. Bu GPU için en iyi çift, güncel tutulur Çifti kendiniz seçin Sürücüler kontrol ediliyor… @@ -1139,6 +1159,8 @@ Otomatik olarak PiP moduna geç Açılmış ekrandaki kontroller Cihaz açıkken oyun, Steam Deck dokunmatik yüzeylerinin ve arka tutamaklarının üstünde durur. Katlıyken oyun tam ekran olur. + Kumanda bağlıyken de göster + Telefonu tutan kumandalar için, kumanda bağlıyken de açılmış ekrandaki kontrolleri gösterir. Askıya alındı Pencere içinde pencere, oturum hazır olduğunda kullanılabilir. Android ayarlarında DroidDeck için pencere içinde pencereye izin verin. @@ -1265,15 +1287,15 @@ %1$s %2$s sürümüne güncellendi. %1$s %2$s sürümüne güncellendi. Ekran sürücüsü, DroidDeck yeniden başlatıldıktan sonra uygulanır. Sürücü güncellemeleri oturum bitene kadar bekler. - %1$s %2$s, mobil veriyi ve pil tasarrufunu kapattığınızda indirilecek. - %1$s %2$s burada oturum başlatmadı, bu yüzden Otomatik çalışan sürücülerde kalıyor. - %1$s iki kez oturum başlatmadı, bu yüzden Otomatik çalışan sürücülere geri döndü. + %1$s %2$s, mobil veriden ve pil tasarrufundan çıkınca indirilecek. + %1$s %2$s burada oturum başlatamadı, bu yüzden Otomatik, oturum başlatabilen sürücülerde kalıyor. + %1$s iki kez oturum başlatamadı, bu yüzden Otomatik, sorunsuz çalışan önceki sürücülere döndü. %1$s %2$s sürümüne geçildi. %1$s %2$s sürümüne geçildi. Ekran sürücüsü, DroidDeck yeniden başlatıldıktan sonra uygulanır. Kullanılan: %1$s %2$s. Kullanılan: %1$s %2$s. Ekran sürücüsü, DroidDeck yeniden başlatıldıktan sonra uygulanır. - Pakette gelen Quest görüntü sürücüsü ve varsayılan çalışma zamanı kullanılıyor. - Pakette gelen Quest görüntü sürücüsü ve varsayılan çalışma zamanı kullanılıyor. Görüntü sürücüsü DroidDeck yeniden başladıktan sonra geçerli olur. + Pakete dahil Quest ekran sürücüsü ve çalışma zamanı varsayılanı kullanılıyor. + Pakete dahil Quest ekran sürücüsü ve çalışma zamanı varsayılanı kullanılıyor. Ekran sürücüsü DroidDeck yeniden başladıktan sonra uygulanır. Sürücü zip dosyası değil: ne AdrenoTools sürücüsü, ne -Linux Turnip ne de Android + Linux paketi İçe aktarma başarısız oldu: %1$s İçe aktarılan sürücü @@ -1373,6 +1395,14 @@ Siyah zemin, beyaz yazı; seçilen, odaklanan veya basılan her şey için mavi. Elektrik laciverti Daha parlak mavi; gri yerine laciverte çalan yüzeylerde. + Geçiş + Kendi birincil ve ikincil renklerin, düğmelerde ve vurgularda harmanlanır. + Birincil + İkincil + Ton + Doygunluk + Parlaklık + Hex rengi Esc Ins Del @@ -1695,7 +1725,7 @@ DroidDeck\'e geri dönmek için dokunun. Kablosuz hata ayıklamayı şimdi kapatabilirsiniz. Kablosuz hata ayıklama kurulumu durdu - İndirme başarısız oldu + İndirme başarısız İndirme boyutu yayınlanan APK ile eşleşmedi ve atıldı - yeniden deneyin İndirme, sağlama toplamıyla eşleşmedi ve atıldı - yeniden deneyin Güncelleme, %2$s değil %1$s Android paketi içindir @@ -1751,7 +1781,8 @@ Yüklü derlemenin orijinali saklanır: geri dönüş yolu budur. Saklanmıyor %2$s\'nin %1$s orijinali silindi. - İndirmeler yalnızca Nightlies yayınlarından gelir + İndirmeler yalnızca Nightlies ve DroidDeck yayınlarından gelir + İndirme başarısız: bu nightly derleme daha yenisiyle değiştirilmiş olabilir. Listeyi yenileyip tekrar deneyin. Bu paket listesi sağlama toplamlarından önceki bir tarihe ait - yenileyip yeniden deneyin Sağlama toplamı uyuşmuyor @@ -1793,7 +1824,7 @@ GitHub\'ın hız sınırına ulaşıldı - daha sonra yeniden deneyin GitHub HTTP %1$d yanıtı verdi - İndirme başarısız oldu + İndirme başarısız Bu sürücü listesi sağlama toplamlarından önceki bir tarihe ait - yenileyip yeniden deneyin Sağlama toplamı uyuşmuyor - indirme atıldı Bir AdrenoTools sürücüsü değil: %1$s @@ -1910,9 +1941,10 @@ Mağazalar Mağaza ayarları Ayarlar + Sekmeleri düzenle + Bitti (oturum açılmadı) Mağaza - Yüklü Kütüphane Tümü İndirmeler @@ -1924,10 +1956,8 @@ Oturumu açık tut Oturumu kapat %1$s kataloğunda ara… - Yüklü oyunlarda ara… Kütüphanenizde ara… Ara - Yüklü bir şey yok Yükleniyor Oyun yok Mağaza yükleniyor… @@ -1946,7 +1976,19 @@ %1$s\'te görüntüle Satın al · %1$s Yüklü + Yüklü + Yüklü değil Kütüphanede + API anahtarı + Anahtar al + Anahtar reddedildi + itch.io kütüphanenizde yok + Bu oyunun Windows indirmesi yok + İndirmede Windows oyunu bulunamadı + İndirme ayıklanamadı (kod %1$d) + itch.io yanıt vermedi + %1$s arşivleri desteklenmiyor + itch.io HTTP %1$d yanıtı verdi Ücretsiz Sürdür Yenile @@ -1960,6 +2002,7 @@ Çıkış tarihi Liste görünümü Kart görünümü + Kapak görünümü Yetişkin içeriği göster Epic EOS @@ -1995,6 +2038,9 @@ Son eşitleme %1$s Henüz eşitlenmedi %1$d dosya + Zaten güncel + Kayıtları eşitlemek için mağazada oturum açın + Kayıtlar eşitlenmedi yüklü kütüphanenizde mağaza @@ -2123,7 +2169,7 @@ Boyut Kaydet Varsayılana döndür - Steam\'in yaptığı gibi oyunun arka planının üzerine çizilir. Tam konum birkaç piksel değişebilir. + Tam konum birkaç piksel farklı olabilir. Bu oyunun henüz logosu yok. Burada görmek için Logo bölümünden bir tane seçin. Logo konumu kaydedilemedi. Yeniden deneyin. Arka plan @@ -2141,14 +2187,14 @@ SteamGridDB\'de ara… SteamGridDB: %1$s Oyun ara - Diğer sonuçlar - Sonuç yok. - SteamGridDB bir API anahtarı gerektiriyor. Kendinizinkini Kurulum › Başlatıcı bölümüne ekleyin. - SteamGridDB API anahtarını kabul etmedi. Kurulum › Başlatıcı bölümünden denetleyin. - SteamGridDB şu anda istekleri sınırlıyor. Bir dakika sonra yeniden deneyin. - SteamGridDB\'ye ulaşılamadı. Bağlantınızı denetleyin. - SteamGridDB\'de bir sorun oluştu. Daha sonra yeniden deneyin. - SteamGridDB\'de bu oyun için sonuç yok. Aramayı deneyin. + Diğer eşleşmeler + Hiçbir şey bulunamadı. + SteamGridDB bir API anahtarı ister. Kurulum › Başlatıcı bölümüne kendi anahtarınızı ekleyin. + SteamGridDB API anahtarını kabul etmedi. Kurulum › Başlatıcı bölümünden kontrol edin. + SteamGridDB şu anda istekleri sınırlıyor. Bir dakika sonra tekrar deneyin. + SteamGridDB\'ye ulaşılamadı. Bağlantıyı kontrol edin. + SteamGridDB\'de bir sorun oluştu. Daha sonra tekrar deneyin. + SteamGridDB\'de bu oyun için eşleşme yok. Bir arama deneyin. %1$d oyun eklendi %1$d oyun eklendi @@ -2177,44 +2223,44 @@ Çalışma ortamı ve ekran sürücülerini ayrı ayrı %1$s kullanılsın mı? Sürücüler nasıl seçilir - Bu GPU için en iyi eşleşme, her zaman güncel + Bu GPU için en iyi çift, güncel tutulur Eşleşen çift Bir çift seçin; iki sürücüyü birden ayarlar - Serbest eşleştir - Görüntü ve çalışma zamanını ayrı ayrı seçin + Karıştır ve eşleştir + Ekran ve çalışma zamanını ayrı ayrı seçin Çift - Karışık - OTO + Karma + OTOMATİK ÇİFT PAKET - SEÇİLİ + SEÇİLEN GPU\'YA GÖRE VARSAYILAN - Şimdi çiziyor - Yeniden başlatma bekliyor · o zamana kadar %1$s çiziyor - Çalışıyor - Sonraki oturum · bu oturumda %1$s kalır - Ekranı çizer. Sonraki oturumdan itibaren geçerli. + Şu an çiziyor + Yeniden başlatma bekliyor · o zamana kadar %1$s çizer + Şu an çalışıyor + Sonraki oturum · bu oturum %1$s kullanmaya devam eder + Ekranı çizer. Sonraki oturumdan itibaren uygulanır. önceki sürücü - Görüntü sürücüsünü değiştirmek için yeniden başlatın. - Görüntü sürücüsünü değiştirmek için oturumu durdurup DroidDeck\'i yeniden başlatın. + Ekran sürücüsünü değiştirmek için yeniden başlatın. + Çalışan oturumu durdurun, sonra ekran sürücüsünü değiştirmek için DroidDeck\'i yeniden başlatın. Şimdi yeniden başlat Sonra - Otomatik, %1$s için bu çifti seçti - Otomatik, %1$s için yerleşik sürücüleri kullanıyor + Otomatik, %1$s cihazınız için bu çifti seçti + Otomatik, %1$s cihazınız için yerleşik sürücüleri kullanıyor %1$s ailesi için seçildi. Yeni sürümler arka planda kurulur. Bir çift seçin - Bir çift, görüntü sürücüsünü ve çalışma zamanı sürücüsünü birlikte ayarlar. + Bir çift, ekran ve çalışma zamanı sürücülerini birlikte ayarlar. Her sürücüyü seçin - Bir paket iki yuvayı da doldurur. Görüntü değişiklikleri yeniden başlatmadan sonra, çalışma zamanı değişiklikleri sonraki oturumdan itibaren geçerli olur. - Görüntü sürücüsünü değiştir + Bir paket iki yuvayı da doldurur. Ekran değişiklikleri yeniden başlatmadan sonra, çalışma zamanı değişiklikleri sonraki oturumda uygulanır. + Ekran sürücüsünü değiştir Çalışma zamanı sürücüsünü değiştir - OTOMATİK SEÇİMİ - Kurulu + OTOMATİK\'İN SEÇİMİ + Yüklü %1$s + %2$s %1$s · uygulamak için yeniden başlatın %1$s · yeniden başlatma bekliyor - Bu aygıt + Bu cihaz %1$s kaldırılsın mı? Tanılama Oynamaya hazır @@ -2226,11 +2272,11 @@ Görünüm Bu cihaz Kütüphane görselleri - İndirme + İndirmeler Kaynak Otomatik Doğrudan - Ayna + Yansı Özel önek Site Önek @@ -2295,7 +2341,7 @@ Bitti Dokunmatik düzen kaydedilemedi Dokunmatik düzen Steam\'e kaydedildi - Uygulama %1$d + Uygulama %1$s Etiket (isteğe bağlı) Steam\'in simgeleri bulunamadı @@ -2386,6 +2432,15 @@ SteamVR görüş alanı Düşük değer SteamVR\'ı yaklaştırır ve gözlüğü doldurur; yüksek değer çevrenizi daha çok gösterir. %1$s ile oynanıyor + Yeniden ortala + SteamVR menüsü + Çubuklar: eller ⇄ oyun + Kumandalar: %1$s + Klavye + Oturum menüsü + SteamVR’ı durdur + Parlaklık %1$d / %2$d + Pil %1$d%% · %2$d °C Takip kalibrasyonu Kaymayı azaltır ve yatay düzlem referansını hizalar. Kayıtlı kalibrasyon bu gözlüğe uygulanır. @@ -2440,6 +2495,7 @@ Gözlüğün USB Ethernet bağlantısı bekleniyor. Bu telefon bu bağlantıyı uygulamalara açmıyor olabilir. XREAL bağlantısı kesildi veya desteklenmeyen bir yanıt döndü. Yeniden deneniyor… Deneysel 1S desteği. Gözlük menüsünde Follow modunu kullanın ve görüntü sabitlemeyi kapatın. Takip yönlerinin ve ekran modlarının hâlâ cihazda test edilmesi gerekiyor. + Pro 2 için deneysel baş takibi. Parlaklık ve ses düzeyi ayarı ile ekran modu değiştirme henüz desteklenmiyor. Cardboard VR Cardboard modu Telefon VR gözlüğü için sol ve sağ göz görüntüleri. @@ -2489,7 +2545,7 @@ Yeterli boş alan yok: %1$s gerekli, yalnızca %2$s boş Yetersiz alan (%1$s boş, %2$s gerekli) Boş alan bilinmiyor - Resim konumu + Görüntü konumu Üst Alt Orta (varsayılan) @@ -2497,71 +2553,71 @@ Depolama ölçülüyor… İlk seferde birkaç saniye sürer. Dahili depolama - %2$s alanın %1$s kadarı kullanılıyor + %2$s içinde %1$s kullanıldı %1$s boş - DroidDeck %1$s yer kaplıyor. Ölçüm: %2$s. + DroidDeck %1$s kullanıyor. %2$s saatinde ölçüldü. yeniden ölçülüyor… Diğer Android uygulamaları - Şimdi boşaltılabilir - Kullanılmayan çalışma zamanları, kalıntılar, eski günlükler + Şimdi güvenle boşaltılabilir + Kullanılmayan çalışma zamanları, artıklar, eski günlükler Temizle… - Temizlemek veya kaldırmak için oturumu durdurun. Boyutlar görünmeye devam eder. + Bir şeyi temizlemek veya kaldırmak için oturumu durdurun. Boyutlar yine de gösterilir. Nereye gidiyor Steam oyunları Oyun verileri Önekler %1$s · atölye %2$s · gölgelendiriciler %3$s - Proton ve çalışma zamanları + Proton & çalışma zamanları %1$s kullanılmıyor - GOG, Epic ve diğer mağazalar - Mağazalar bölümünden kaldırın + GOG, Epic & diğer mağazalar + Bunları Mağazalar\'dan kaldırın Masaüstü uygulamaları Flatpak uygulamaları ve çalışma zamanları - Linux sistemi ve Steam istemcisi - Herhangi bir şeyin çalışması için gerekli + Linux sistemi & Steam istemcisi + Herhangi bir şeyi çalıştırmak için gerekli DroidDeck verileri - Eski oturum günlükleri %1$s · önbellek · sürücüler + Eski oturum günlükleri %1$s · önbellekler · sürücüler Oyunlar Oyun verileri - Proton ve çalışma zamanları + Proton & çalışma zamanları Diğer mağazalar Masaüstü uygulamaları Linux sistemi DroidDeck - Temizlik - Burada hiçbir şey kurulu oyunlara veya süren indirmelere dokunmaz. + Temizle + Burada hiçbir şey yüklü oyunlarınıza veya süren indirmelerinize dokunmaz. Şu anda temizlenecek bir şey yok. - Seçili: %1$s · %2$s → %3$s boş + Seçilen: %1$s · %2$s → %3$s boş Temizleniyor… %1$s boşalt %1$s boşaltıldı. - Kullanılmayan Steam çalışma zamanları ve Proton - Kaldırılmış oyunların kalıntıları - Kayıt içerebilecek kalıntılar + Kullanılmayan Steam çalışma zamanları ve Proton\'lar + Kaldırılan oyunlardan kalan artıklar + Kayıt tutabilecek artıklar Eski oturum günlükleri - Geçerli oturumun günlükleri tutulur - Hiçbir oyun kullanmıyor. Gerekirse Bileşenler bölümünden yeniden yükleyin. - Kurulu oyunların gölgelendirici önbelleği - Oynarken yeniden oluşur. Her oyunun ilk dakikaları yine takılır. + Geçerli oturumun günlükleri korunur + Hiçbir oyun buna ayarlı değil. Gerekirse Bileşenler\'den yeniden yükleyin. + Yüklü oyunların gölgelendirici önbellekleri + Oynadıkça yeniden oluşur. Her oyunun ilk dakikalarında yine takılma olur. Güvenli Kullanılmıyor Takılma - Kayıtları denetleyin + Kayıtları kontrol et Varsayılan Gerekli Kullanılmıyor Depolama - Verileriyle birlikte %1$s + %1$s, verileriyle birlikte En büyük En az oynanan SD kartta Burada oyun yok. - %1$s oynandı + Oynanma: %1$s Hiç oynanmadı Oyun %1$s · veri %2$s - Kaldır - Kaldırma, Steam\'in kaydetmesi için Steam penceresini açar. Bir oyunu SD karta taşımak için Steam › Ayarlar › Depolama yolunu kullanın. + Yüklemeyi kaldır + Yüklemeyi kaldırma, Steam\'in kendi iletişim kutusunu açar; böylece Steam kaydı tutar. Bir oyunu SD karta taşımak için Steam › Ayarlar › Depolama\'yı kullanın. %1$s kaldırılsın mı? - Steam açılır ve onay ister. Bu, oyunun %1$s alanını boşaltır. Kalıntıları temizleyene kadar önek kalır ve Steam Cloud kayıtları hesabınızda kalır. + Steam açılır ve onay ister. Bu, oyunun %1$s alanını boşaltır. Öneki, artıkları temizleyene kadar kalır ve Steam Cloud kayıtları hesabınızda durur. Steam\'i aç %1$s · %2$s kullanılmıyor Kullanılmayanları kaldır · %1$s @@ -2569,30 +2625,30 @@ Kullanılmıyor Kaldır %1$s kaldırılsın mı? - %1$s boşaltır. Bir oyun gerektirirse Steam yeniden indirir. + %1$s boşaltır. Bir oyun isterse Steam yeniden indirir. Linux x86 oyunları için gerekli (FEX) ARM64 Proton\'lar için gerekli - Oyunların istediği VC++ ve DirectX kurulumları + Oyunların istediği VC++ ve DirectX yükleyicileri Linux x86 oyunlarını çalıştırır Steam aracı DroidDeck oyunları bu çalışma zamanı olmadan çalıştırır - DroidDeck\'in çalıştıramadığı Proton x86 - Tüm oyunlar için varsayılan - Hiçbir oyun kullanmıyor - Steam ayarları okunamıyor, bu yüzden tutuluyor + x86 Proton, DroidDeck bunu çalıştıramaz + Her oyun için varsayılan + Hiçbir oyun buna ayarlı değil + Steam\'in ayarları okunamadı, bu yüzden korunuyor Depolama temizliğinin bitmesini bekleyin Yalnızca %1$s boş - Steam\'in güncellemeler ve gölgelendirici önbelleği için alana ihtiyacı var. 10 GB altında oyun güncellemeleri yarıda kalabilir. - %1$s risksiz boşaltılabilir. + Steam\'in güncellemeler ve gölgelendirici önbellekleri için yere ihtiyacı var. 10 GB\'ın altında oyun güncellemeleri yarıda başarısız olabilir. + %1$s güvenle boşaltılabilir. Yer aç Yine de oyna - %d kurulu - %d kurulu + %d yüklü + %d yüklü - %d SD kartta - %d SD kartta + SD kartta %d + SD kartta %d %d araç @@ -2603,15 +2659,15 @@ %d oyunun boş önek klasörleri ve gölgelendirici önbelleği - %d oyunun önekinde hâlâ dosyalar var; yerel kayıtlar da olabilir. Gerekirse önce bir kopya alın. - %d oyunun önekinde hâlâ dosyalar var; yerel kayıtlar da olabilir. Gerekirse önce bir kopya alın. + %d oyunun öneki hâlâ dosya içeriyor, bunlar yerel kayıtları da içerebilir. İsterseniz önce yedekleyin. + %d oyunun önekleri hâlâ dosya içeriyor, bunlar yerel kayıtları da içerebilir. İsterseniz önce yedekleyin. %d oyun %d oyun - DroidDeck varsayılan Proton - DroidDeck Proton 11 seçeneği + DroidDeck\'in varsayılan Proton\'u + DroidDeck\'in Proton 11 seçeneği %d oyun kullanıyor %d oyun kullanıyor @@ -2620,41 +2676,41 @@ Bileşenler Kullanılmayan bileşenler Orijinal %1$s · %2$s - %1$s boşaltır. Yalnızca artık kurulu olmayan bir Proton sürümünü geri yükler. - Denetlenmedi: bir Steam uygulamasının manifest dosyası okunamıyor, bu yüzden kaldırılmış oyunların önekleri okunana kadar kalır. + %1$s boşaltır. Yalnızca artık yüklü olmayan bir Proton sürümünü geri yükler. + Denetlenmedi: bir Steam uygulama manifesti okunamadı, bu yüzden kaldırılan oyunların önekleri manifest okunabilene kadar korunuyor. Tamamlanamadı: %1$s - Paylaşılan %1$s - Kurulu oyunlar + paylaşılan %1$s + Yüklü oyunlar Önek %1$s · gölgelendiriciler %2$s · atölye %3$s - Gölgelendiricileri sil - Kaldırılmış oyunlar + Gölgelendiricileri temizle + Kaldırılan oyunlar Steam uygulaması %1$d - Önekinde hâlâ dosyalar var; yerel kayıtlar da olabilir - Önekini (Wine) ve gölgelendirici önbelleğini siler (%1$s). Gerekli kayıtların önce bir kopyasını alın; Steam Cloud kayıtları hesabınızda kalır. - Tutulanlar - Steam dışı oyunlar ve ortak Steam öneki - Mağazaların veya elle eklenen oyunların önekleri + Öneki hâlâ dosya içeriyor, bunlar yerel kayıtları da içerebilir + Wine önekini ve gölgelendirici önbelleğini (%1$s) siler. Önce istediğiniz kayıtları yedekleyin; Steam Cloud kayıtları hesabınızda durur. + Korunan + Steam dışı oyunlar ve Steam\'in ortak öneki + Mağazalardan veya elle eklenen oyunların önekleri İndirilen paketler - Tutulan orijinaller + Kaydedilen orijinaller İndirilmiş bileşen paketi yok. Bir Proton veya oyun için seçili Hiçbir yerde seçili değil - Kurulu Proton\'ı geri yükler - Artık kurulu olmayan Proton sürümü + Yüklü Proton\'u geri yükler + Artık yüklü olmayan Proton sürümü Kullanımda - Kullanımdaki bileşenler kilitli kalır. Onları Bileşenler bölümünden seçin; burada kaldırdığınız paketler oradan yeniden indirilebilir. + Kullanımdaki bileşenler kilitli kalır. Bileşenler\'den seçin; burada kaldırılan her şey oradan yeniden indirilebilir. %1$s boşaltır. Kendi içe aktardığınız paketler yeniden indirilemez. - Flatpak uygulamaları için %1$s ve bunların paylaştıkları + Flatpak uygulamaları ve paylaştıkları şeyler için %1$s Uygulamalar Kurulu Flatpak uygulaması yok. - Uygulamaların paylaştıkları + Uygulamaların ortak kullandıkları Çalışma zamanları - Uygulamaların üzerinde çalıştığı kitaplıklar; kullanılmayanlar Kullanılmayanları kaldır ile gider + Uygulamaların üzerinde çalıştığı kütüphaneler; kullanılmayanlar Kullanılmayanları kaldır ile gider İndirme deposu - Flatpak object store ve önbelleğe alınmış indirmeler + Flatpak\'in nesne deposu ve önbelleğe alınmış indirmeler Kullanılmayanları kaldır - Kurulu hiçbir uygulamanın istemediği çalışma zamanlarını kaldırır ve Flatpak object store\'u temizler. Kurulu uygulamalar çalışmaya devam eder. - Uygulamayı (%1$s) ve başka kimsenin kullanmadığı çalışma zamanlarını kaldırır. Ev klasöründeki verileri kalır. + Kurulu hiçbir uygulamanın ihtiyaç duymadığı çalışma zamanlarını kaldırır ve Flatpak\'in nesne deposunu temizler. Kurulu uygulamalar çalışmaya devam eder. + Uygulamayı (%1$s) ve başka hiçbir şeyin kullanmadığı çalışma zamanlarını kaldırır. Ev klasörünüzdeki verileri kalır. %d paket %d paket @@ -2666,7 +2722,7 @@ Açılış videosu Video DroidDeck · %1$d fps - 60 fps daha akıcı görünür ama Steam açılırken işlemciyi daha çok kullanır. Steam\'in kendi ayarlarında seçilen açılış videosu önceliklidir. + 60 fps daha akıcı görünür ama Steam açılırken işlemciyi daha çok kullanır. Steam\'in kendi ayarlarında seçilen bir video da burada görünür. Ses Kendi videon MP4, MOV, MKV veya WebM. Diğer biçimler ve %1$d MB üzeri dosyalar cihazda 720p WebM\'e dönüştürülür, en fazla %2$d saniye. @@ -2680,6 +2736,51 @@ %1$s Steam için dönüştürülüyor. DroidDeck\'i açık tut. Bu cihaz o videonun kodunu çözemiyor. Video içe aktarılamadı + %1$s kaldırılsın mı? + Video, DroidDeck\'ten ve Steam\'in videolar klasöründen silinir. + Yüklü + İçe aktar + SteamDeckRepo + Ses + Yüklü videolar + Göz at + DroidDeck + İçe aktarılan + SteamDeckRepo + Steam + Puan Mağazası + Açılış videosu raporu + DroidDeck ve Steam’in açılış videoları hakkında bildiklerini, hata raporuna ekleyebileceğiniz bir metin dosyasına kaydeder. + Kaydet + %1$s konumuna kaydedildi + Rapor kaydedilemedi. + Ses düzeyi + Süre + %1$d sn + Videonun kaç saniyesinin oynatılacağı. Daha uzun olan kısım sonunda kesilir. + SteamDeckRepo\'da ara + Tüm cihazlar + Steam Deck + Steam Machine + Trend + En çok indirilen + En çok beğenilen + En yeni + SteamDeckRepo\'ya ulaşılamadı + Tekrar dene + Video bulunamadı + Yükle + Kullan + Kullanımda + %1$s yüklendi + Video indirilemedi + Önceki + Sonraki + Sayfa %1$d + Temizle + Sayfaya git + Önizle + Önizleme oynatılamadı Luma Ultra kameralar — Deneysel Joy-Con konum takibi Eşleştirilmiş Joy-Con kumandalarınızı konumlandırmak için akıllı gözlük kameralarını kullanın. Döndürme ve düğme girişleri Joy-Con kumandalarından gelir. Bilekleriniz gizlendiğinde konum tahmin edilir. Çıplak elle çalıştırılan kumandaların yerini alır ve telefon kamerası izlemesini duraklatır. İzleme merkezini bağlarken veya sıfırlarken Joy-Con kumandalarını ileriye doğru çevirin. @@ -2852,7 +2953,6 @@ Sabit kafa izlemesi bekleniyor; istenirse izleme merkezini sıfırlayın Kamera gözlemi çok eski; yeni bir çift bekliyorum %1$.2f piksele sığdır. Sol kameradan kalibrasyon hedef merkezine kadar seçtiğiniz mesafeyi ölçün ve ardından doğrulayın. - OpenCV kullanılamıyor: %1$s %1$d kayıtlı görünüm geri yüklendi. Yeniden denemek için kalibrasyonu hesaplayın veya daha fazlasını yakalamak için yeni bir set başlatın. Yakalandı %1$d / 24 görüntüleme. Kalibrasyon hedefini sabit tutun; Bir sonraki görünüm için akıllı gözlükleri hareket ettirin. Yakalama reddedildi: %1$d / 12 gerekli köşe her iki kamerada da görünüyor. Kalibrasyon hedefinin daha fazlasını gösterin, sabit durun ve tekrar deneyin. diff --git a/app/src/main/res/values-uk/gestures.xml b/app/src/main/res/values-uk/gestures.xml new file mode 100644 index 000000000..7ed3663c6 --- /dev/null +++ b/app/src/main/res/values-uk/gestures.xml @@ -0,0 +1,91 @@ + + + Жести + Налаштування сенсорних жестів + Налаштуйте жести миші та клавіатури. Щоб використовувати їх, виберіть режим дотику «Жести». + Дотик + Перетягування одним пальцем + Тривале натискання + Подвійний дотик (подвійне клацання лівою кнопкою) + Дотик двома пальцями + Перетягування двома пальцями + Утримання двома пальцями + Дотик трьома пальцями + Перетягування трьома пальцями + Утримання трьома пальцями + Масштабування щипком + Утримувати кнопку натиснутою + Одне клацання + Затримка утримання + Інтервал подвійного дотику + Відновити початкові налаштування жестів + Скасувати + Виберіть дію + Ліва кнопка миші + Права кнопка миші + Середня кнопка миші + Показати клавіатуру + Перетягування лівою кнопкою + Перетягування правою кнопкою + Переміщення середньою кнопкою миші + Переміщення середньою кнопкою миші (зворотне) + Клавіші зі стрілками + Клавіші зі стрілками (зворотні) + WASD (зворотне) + Коліщатко миші + Поріг жестів: %1$d пікс. + Показувати розпізнаний жест + Один палець + Два пальці + Три пальці + Параметри + Стандартні + Ця гра + Використовувати типові + Зберегти зміни + Закрити + ДІЯ + Пошук дій миші або клавіш клавіатури + Очистити пошук + Усі + Відповідних дій немає. Спробуйте іншу назву клавіші або дії. + Миша + Дії застосунку + Навігація + Літери + Цифри + Функціональні клавіші + Клавіші-модифікатори + Цифровий блок + Символи + Рух миші + Пряме + Відносний + Розпізнавання жестів + Візуальний відгук + Поведінка кнопки миші + %1$d мс + Цифровий блок: %1$s + Esc + Backspace + Enter + Tab + Пробіл + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Стрілка вгору + Стрілка вниз + Стрілка ліворуч + Стрілка праворуч + Caps Lock + Num Lock + Scroll Lock + Лівий %1$s + Правий %1$s + Shift + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fb30a8e4e..002514a82 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -30,7 +30,7 @@ Генерація кадрів Виберіть режим генерації кадрів Вимк. - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG адаптивний %1$d Потрібен Lossless Scaling зі Steam @@ -176,6 +176,8 @@ %1$s через FEX Цю програму зібрано для ПК з архітектурою %1$s, і вона працює через FEX, який ще не налаштовано. Один раз відкрийте Steam, увійшовши в обліковий запис: DroidDeck завантажить FEX і потрібний йому Steam Linux Runtime, після чого програма відкриється. Ця програма + Не вдалося завантажити клієнт Steam. Можливо, ваша мережа або VPN блокує сервери Steam; перевірте з’єднання та спробуйте ще раз. + Не вдалося налаштувати клієнт Steam. Перевірте з’єднання та вільне місце, потім спробуйте ще раз. %1$s зібрано для ПК x86; для запуску потрібен FEX, який ще не встановлено. Один раз запустіть Steam, увійшовши в обліковий запис, дайте йому завантажити FEX і Steam Linux Runtime, а потім запустіть це знову. ID додатка Flathub або його сторінка на Flathub. Якщо Flatpak ще не налаштовано, його буде налаштовано спочатку. Файл @@ -506,7 +508,9 @@ Видалити цей оригінал? Цей оригінал %1$s походить із попередньої збірки %2$s. Оригінал поточної збірки зберігається завжди. У нічних збірках · %1$d - Оновіть, щоб побачити пакети з нічних збірок. + Стабільна + Нічна + Оновіть, щоб побачити пакети з Nightlies і DroidDeck. Нічого нового з часу останньої перевірки. Імпортувати .wcp Відновити оригінал цього Proton @@ -542,7 +546,7 @@ Лише Adreno. Менше запинок, вища витрата заряду та нагрів. Виправлення сеансу Turnip: рендеринг через sysmem - Швидше для Steam і більшості ігор. Потрібно для Adreno 710/720/722. + Швидше для клієнта Steam; в іграх драйвер для кожного проходу вибирає тайловий рендеринг або рендеринг у системній пам’яті. Потрібно для Adreno 710/720/722. Пропускати помічник Steam xalia droiddeck-ntsync (експериментально) Програмна заміна драйвера ntsync ядра Linux у просторі користувача, для тестування. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі. @@ -584,6 +588,16 @@ Попередня сторінка Наступна сторінка HUD продуктивності + Частота кадрів + Температура батареї + Графік продуктивності + Колір + М\'ята + Білий + Небесний + Бурштиновий + Фіолетовий + Непрозорість Режим масштабування Чіткість масштабування Ефекти екрана @@ -735,6 +749,9 @@ %1$d%% (стандартно) Тема Вигляд лаунчера + Значок застосунку + Темний + Світлий Використовувати як головний екран DroidDeck може бути додатком головного екрана DroidDeck ніколи не пропонується як додаток головного екрана @@ -1047,6 +1064,9 @@ Додана гра Власна гра Сховище ігор + Видаляє її файли та прибирає її зі Steam. + Видалити %1$s? + Видаляє ярлик. Файли гри залишаються. Найкраща пара для цього GPU, завжди в актуальному стані Виберіть пару самостійно Перевірка драйверів… @@ -1412,6 +1432,14 @@ Чорне тло, білий текст і синій для всього вибраного, у фокусі чи натиснутого. Електричний темно-синій Яскравіший синій на поверхнях із темно-синім відтінком замість сірого. + Градієнт + Ваші основний і додатковий кольори, що плавно переходять на кнопках і виділеннях. + Основний + Додатковий + Відтінок + Насиченість + Яскравість + Колір hex Esc Ins Del @@ -1804,7 +1832,8 @@ Оригінал встановленої збірки збережено: це шлях назад. Не збережено Видалено оригінал %2$s зі збірки %1$s. - Завантаження надходять лише з випусків Nightlies + Завантаження надходять лише з випусків Nightlies і DroidDeck + Не вдалося завантажити: можливо, цю нічну збірку замінено новішою. Оновіть список і спробуйте ще раз. Цей список пакетів створено до появи контрольних сум - оновіть його і спробуйте ще раз Контрольна сума не збіглася @@ -1963,9 +1992,10 @@ Магазини Налаштування магазинів Налаштування + Упорядкувати вкладки + Готово (вхід не виконано) Магазин - Встановлені Бібліотека Усі Завантаження @@ -1977,10 +2007,8 @@ Не виходити Вийти Пошук у каталозі %1$s… - Пошук встановлених ігор… Пошук у вашій бібліотеці… Пошук - Нічого не встановлено Завантаження Немає ігор Завантаження магазину… @@ -1999,7 +2027,19 @@ Переглянути на %1$s Купити · %1$s Встановлено + Встановлено + Не встановлено У бібліотеці + Ключ API + Отримати ключ + Ключ відхилено + Немає у вашій бібліотеці itch.io + Для цієї гри немає завантаження для Windows + У завантаженні не знайдено гри для Windows + Не вдалося розпакувати завантаження (код %1$d) + itch.io не відповів + Архіви %1$s не підтримуються + itch.io відповів HTTP %1$d Безкоштовно Продовжити Оновити @@ -2013,6 +2053,7 @@ Дата виходу Вигляд списком Вигляд картками + Вигляд обкладинок Показувати контент для дорослих Epic EOS @@ -2048,6 +2089,9 @@ Остання синхронізація %1$s Ще не синхронізовано %1$d файлів + Уже актуально + Увійдіть у магазин, щоб синхронізувати збереження + Збереження не синхронізовано встановлено у вашій бібліотеці магазин @@ -2179,7 +2223,7 @@ Розмір Зберегти Повернути типове - Малюється поверх тла гри, як це робить Steam. Точна позиція може відрізнятися на кілька пікселів. + Точне розташування може відрізнятися на кілька пікселів. У цієї гри ще немає логотипу. Виберіть його в розділі Логотип, щоб побачити тут. Не вдалося зберегти позицію логотипу. Спробуйте ще раз. Фон @@ -2355,7 +2399,7 @@ Готово Не вдалося зберегти сенсорну розкладку Сенсорну розкладку збережено в Steam - Застосунок %1$d + Застосунок %1$s Підпис (необов\'язково) Значки Steam не знайдено @@ -2450,6 +2494,15 @@ Поле зору SteamVR Нижче значення наближає SteamVR і заповнює окуляри; вище — показує більше навколо вас. Гра на %1$s + Перецентрувати + Меню SteamVR + Стіки: руки ⇄ гра + Контролери: %1$s + Клавіатура + Меню сеансу + Зупинити SteamVR + Яскравість %1$d / %2$d + Акумулятор %1$d%% · %2$d °C Калібрування відстеження Зменшує дрейф і вирівнює опорний горизонт. До цих окулярів застосовано збережене калібрування. @@ -2504,6 +2557,7 @@ Очікування з’єднання USB Ethernet від окулярів. Цей телефон може не надавати його додаткам. З’єднання з XREAL перервалося або повернуло непідтримувану відповідь. Повторна спроба… Експериментальна підтримка 1S. Увімкніть режим Follow і вимкніть стабілізацію в меню окулярів. Напрямки відстеження й режими відображення ще потрібно перевірити на пристрої. + Експериментальне відстеження рухів голови для Pro 2. Налаштування яскравості й гучності та перемикання режимів відображення ще не підтримуються. Cardboard VR Режим Cardboard Зображення для лівого й правого ока для VR-окулярів під телефон. @@ -2734,7 +2788,7 @@ Завантажувальне відео Відео DroidDeck · %1$d кадр/с - 60 кадр/с виглядає плавніше, але більше навантажує процесор під час запуску Steam. Відео запуску, вибране в налаштуваннях самого Steam, має пріоритет. + 60 кадр/с виглядає плавніше, але більше навантажує процесор під час запуску Steam. Відео, вибране в налаштуваннях самого Steam, також з\'являється тут. Звук Власне відео MP4, MOV, MKV або WebM. Інші формати й файли понад %1$d МБ перетворюються на пристрої на WebM 720p, до %2$d секунд. @@ -2748,6 +2802,51 @@ %1$s перетворюється для Steam. Не закривайте DroidDeck. Пристрій не може декодувати це відео. Не вдалося імпортувати відео + Видалити %1$s? + Відео буде видалено з DroidDeck і з папки відео Steam. + Встановлені + Імпорт + SteamDeckRepo + Звук + Встановлені відео + Огляд + DroidDeck + Імпортовано + SteamDeckRepo + Steam + Магазин балів + Звіт про завантажувальні ролики + Зберігає в текстовий файл те, що DroidDeck і Steam знають про завантажувальні ролики, щоб додати до повідомлення про помилку. + Зберегти + Збережено: %1$s + Не вдалося зберегти звіт. + Гучність + Тривалість + %1$d с + Скільки секунд фільму відтворюється. Усе, що довше, обрізається в кінці. + Пошук у SteamDeckRepo + Усі пристрої + Steam Deck + Steam Machine + У тренді + Найбільше завантажень + Найбільше вподобань + Нові + Не вдалося підключитися до SteamDeckRepo + Повторити + Відео не знайдено + Встановити + Використати + Використовується + %1$s встановлено + Не вдалося завантажити це відео + Назад + Далі + Сторінка %1$d + Очистити + Перейти на сторінку + Перегляд + Не вдалося відтворити перегляд Камери Luma Ultra — Експериментально Відстеження положення Joy-Con Використовуйте камери розумних окулярів, щоб розташувати парні контролери Joy-Con. Обертання та введення кнопок надходять від контролерів Joy-Con. Положення оцінюється, коли ваші зап\'ястя приховані. Замінює контролери, якими керують голими руками, та призупиняє відстеження камерою телефону. Направте контролери Joy-Con вперед під час підключення або скидання центру відстеження. @@ -2920,7 +3019,6 @@ Очікування стабільного відстеження голови; скиньте центр відстеження, якщо запитується. Камера спостереження занадто стара; в очікуванні свіжої пари Розмістити %1$.2f пікс. Виміряйте вибрану відстань від лівої камери до центру калібрувальної мішені, а потім перевірте. - OpenCV недоступний: %1$s %1$d збережені перегляди відновлено. Обчисліть калібрування, щоб повторити спробу, або почніть новий набір, щоб отримати більше. Знято %1$d / 24 перегляди. Тримайте цілі калібрування фіксованими; перемістіть розумні окуляри для наступного перегляду. Зйомку відхилено: %1$d / 12 необхідних кутів видно в обох камерах. Покажіть більше калібрувальної мішені, затримайтеся та повторіть спробу. diff --git a/app/src/main/res/values-vi/gestures.xml b/app/src/main/res/values-vi/gestures.xml new file mode 100644 index 000000000..90196d5ae --- /dev/null +++ b/app/src/main/res/values-vi/gestures.xml @@ -0,0 +1,91 @@ + + + Cử chỉ + Cài đặt cử chỉ cảm ứng + Tùy chỉnh cử chỉ chuột và bàn phím. Chọn Cử chỉ làm chế độ cảm ứng để sử dụng. + Chạm + Kéo bằng một ngón tay + Nhấn giữ + Chạm hai lần (nhấp đúp chuột trái) + Chạm bằng hai ngón tay + Kéo bằng hai ngón tay + Giữ bằng hai ngón tay + Chạm bằng ba ngón tay + Kéo bằng ba ngón tay + Giữ bằng ba ngón tay + Chụm hoặc mở hai ngón tay để thu phóng + Giữ nút được nhấn + Nhấp một lần + Độ trễ nhấn giữ + Khoảng thời gian chạm hai lần + Khôi phục cài đặt cử chỉ ban đầu + Hủy + Chọn thao tác + Nhấp chuột trái + Nhấp chuột phải + Nhấp chuột giữa + Hiện bàn phím + Nhấp chuột trái và kéo + Nhấp chuột phải và kéo + Di chuyển bằng nút chuột giữa + Di chuyển bằng nút chuột giữa (đảo ngược) + Phím mũi tên + Phím mũi tên (đảo ngược) + WASD (đảo ngược) + Con lăn chuột + Ngưỡng cử chỉ: %1$d px + Hiện cử chỉ đã nhận dạng + Một ngón tay + Hai ngón tay + Ba ngón tay + Tùy chọn + Mặc định + Trò chơi này + Dùng mặc định + Lưu thay đổi + Đóng + THAO TÁC + Tìm thao tác chuột hoặc phím bàn phím + Xóa tìm kiếm + Tất cả + Không có thao tác phù hợp. Thử tên phím hoặc thao tác khác. + Chuột + Thao tác ứng dụng + Điều hướng + Chữ cái + Chữ số + Phím chức năng + Phím bổ trợ + Bàn phím số + Ký hiệu + Chuyển động chuột + Trực tiếp + Tương đối + Nhận dạng cử chỉ + Phản hồi hình ảnh + Cách hoạt động của nút chuột + %1$d ms + Bàn phím số %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Mũi tên lên + Mũi tên xuống + Mũi tên trái + Mũi tên phải + Caps Lock + Num Lock + Scroll Lock + %1$s trái + %1$s phải + Shift + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 05ff91a85..14e1ffd7e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -30,7 +30,7 @@ Tạo khung hình Chọn chế độ tạo khung hình Tắt - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG thích ứng %1$d Cần Lossless Scaling trên Steam @@ -176,6 +176,8 @@ %1$s qua FEX Chương trình này được build cho PC %1$s và chạy qua FEX, hiện chưa được thiết lập. Mở Steam một lần khi đã đăng nhập: DroidDeck sẽ tải FEX và Steam Linux Runtime cần thiết, sau đó ứng dụng này sẽ mở. Chương trình này + Không thể tải xuống client Steam. Mạng hoặc VPN của bạn có thể đang chặn máy chủ Steam; hãy kiểm tra kết nối rồi thử lại. + Không thể thiết lập client Steam. Hãy kiểm tra kết nối và dung lượng trống rồi thử lại. %1$s được build cho PC x86 và chạy qua FEX, hiện chưa được cài. Khởi động Steam một lần khi đã đăng nhập và để nó tải FEX cùng Steam Linux Runtime, sau đó khởi động lại ứng dụng này. Một app ID Flathub hoặc trang Flathub của nó. Flatpak sẽ được thiết lập trước nếu chưa có. Tệp @@ -372,7 +374,7 @@ Âm thanh DirectAudio cho game Bỏ qua PulseAudio để giảm độ trễ. - Microphone + Micro Dùng cho chat bằng giọng nói. Mạng Dò tìm Wi-Fi @@ -500,7 +502,9 @@ Xóa bản gốc này? Bản gốc %1$s này là từ một build trước của %2$s. Bản gốc của build hiện tại luôn được giữ lại. Trên Nightlies · %1$d - Làm mới để liệt kê các gói trên Nightlies. + Ổn định + Nightly + Làm mới để liệt kê các gói trên Nightlies và DroidDeck. Không có gì mới từ lần kiểm tra trước. Nhập tệp .wcp Khôi phục bản gốc của Proton này @@ -536,7 +540,7 @@ Chỉ dành cho Adreno. Ít giật hơn, dùng nhiều pin và nhiệt hơn. Các bản sửa phiên Turnip: render sysmem - Nhanh hơn cho Steam và hầu hết game. Bắt buộc trên Adreno 710/720/722. + Nhanh hơn cho ứng dụng Steam; trong game, trình điều khiển chọn kết xuất theo ô hoặc trong bộ nhớ hệ thống cho từng lượt. Bắt buộc trên Adreno 710/720/722. Bỏ qua trình trợ giúp xalia của Steam droiddeck-ntsync (thử nghiệm) Một bản thay thế ở không gian người dùng cho driver ntsync của Linux, dùng để thử nghiệm. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau. @@ -578,6 +582,16 @@ Trang trước Trang sau HUD hiệu năng + Tốc độ khung hình + Nhiệt độ pin + Biểu đồ hiệu năng + Màu + Bạc hà + Trắng + Xanh da trời + Hổ phách + Tím + Độ mờ Chế độ chia tỷ lệ Độ sắc nét khi chia tỷ lệ Hiệu ứng màn hình @@ -726,6 +740,9 @@ %1$d%% (Mặc định) Giao diện Giao diện launcher + Biểu tượng ứng dụng + Tối + Sáng Dùng làm màn hình chính DroidDeck có thể là ứng dụng Màn hình chính DroidDeck không bao giờ được đề xuất làm ứng dụng Màn hình chính @@ -756,7 +773,7 @@ Tìm kiếm Đã cài %1$s · %2$d - Store + Cửa hàng Flathub · ARM64 ● Flatpak đã sẵn sàng Cài runtime Linux từ Thiết lập trước. @@ -1029,6 +1046,9 @@ Game đã thêm Game tùy chỉnh Lưu trữ game + Xóa các tệp của nó và gỡ nó khỏi Steam. + Xóa %1$s? + Xóa shortcut của nó. Các tệp game vẫn còn. Cặp tốt nhất cho GPU này, luôn được cập nhật Tự chọn một cặp Đang kiểm tra driver… @@ -1069,7 +1089,7 @@ Pad trái Pad phải Cả hai - Gamepad + Tay cầm Cần Trackpad Chuyển giữa cần và trackpad @@ -1092,8 +1112,8 @@ Steam Games Desktop - Store - Components + Cửa hàng + Thành phần Ứng dụng Thiết lập Cập nhật @@ -1370,6 +1390,14 @@ Nền đen, chữ trắng, xanh cho mọi thứ được chọn, lấy nét hoặc nhấn. Xanh điện navy Màu xanh sáng hơn, trên các bề mặt nghiêng về navy thay vì xám. + Chuyển màu + Màu chính và màu phụ do bạn chọn, hòa trộn trên các nút và phần nổi bật. + Chính + Phụ + Sắc độ + Độ bão hòa + Độ sáng + Màu hex Esc Ins Del @@ -1506,11 +1534,11 @@ LƯU TRỮ NHANH YÊU THÍCH - Downloads - Downlds + TẢI XUỐNG + TẢI ROMs Log phiên - Pictures + HÌNH ẢNH Đã thêm \"%1$s\" vào Yêu thích Đã xóa \"%1$s\" khỏi Yêu thích Thư mục trống @@ -1741,7 +1769,8 @@ Bản gốc của build đã cài được giữ lại: đó là đường quay về. Chưa được lưu Đã xóa bản gốc %1$s của %2$s. - Lượt tải chỉ đến từ các bản phát hành Nightlies + Lượt tải chỉ đến từ các bản phát hành Nightlies và DroidDeck + Tải xuống thất bại: bản nightly này có thể đã được thay bằng bản mới hơn. Hãy làm mới danh sách và thử lại. Danh sách gói này có trước checksum - hãy làm mới và thử lại Checksum không khớp @@ -1752,27 +1781,27 @@ Tự động (khớp tỷ lệ) Không áp dụng gì. - Game Clarity + Rõ nét trò chơi Làm nét với một chút tương phản - lựa chọn hàng ngày. - Vivid + Sống động Màu sắc được đẩy mạnh. Khử dải màu được bật để bầu trời không bị sọc. - Cinematic + Điện ảnh Tương phản sâu hơn, màu lạnh hơn, cạnh được làm mượt. - Competitive + Thi đấu Vùng tối được nâng lên và làm nét mạnh - phát hiện người trước tiên. - Adaptive Sharpen + Làm nét thích ứng Chỉ làm nét, không đổi màu sắc. Filmic Đường cong giống phim, hơi trầm. Arcade Mạnh mẽ và rực rỡ. - Retro CRT + CRT cổ điển Ống CRT và tín hiệu analog cùng áp dụng. - Upscale Sharp + Nâng cấp sắc nét Dùng khi chạy dưới độ phân giải màn hình. - Pixel Clean + Điểm ảnh sạch Pixel sắc nét cho game 2D và các game cũ. - Anime Edge + Viền anime Viền hoạt hình cộng làm nét. Không phải GPU Adreno @@ -1885,10 +1914,10 @@ Buộc SSBS Giữ một thiết lập tốc độ CPU mà Wine liên tục tắt. Có thể giúp game Windows nhanh hơn trên Snapdragon 8 Elite; thay đổi ít thấy trên chip cũ hơn. Áp dụng vào lần game khởi động kế tiếp. - Stores + Cửa hàng game Steam Tùy chỉnh - Stores + Cửa hàng Mở một store trên Mức tốc độ tải Engine stores @@ -1897,15 +1926,16 @@ Cân bằng Nhanh Tối đa - Stores + Cửa hàng Thiết lập Stores Thiết lập + Sắp xếp thẻ + Xong (chưa đăng nhập) - Store - Đã cài + Cửa hàng Thư viện Tất cả - Downloads + Tải xuống %1$s (%2$d) Đăng nhập %1$s · đăng xuất @@ -1914,10 +1944,8 @@ Vẫn đăng nhập Đăng xuất Tìm trong danh mục %1$s… - Tìm game đã cài… Tìm trong thư viện của bạn… Tìm kiếm - Chưa cài gì Đang tải Không có game Đang tải store… @@ -1936,7 +1964,19 @@ Xem trên %1$s Mua · %1$s Đã cài + Đã cài + Chưa cài Trong thư viện + Khóa API + Lấy khóa + Khóa bị từ chối + Không có trong thư viện itch.io của bạn + Không có bản tải Windows cho game này + Không tìm thấy trò chơi Windows trong bản tải xuống + Không thể giải nén bản tải xuống (mã %1$d) + itch.io không phản hồi + Không hỗ trợ tệp %1$s + itch.io trả về HTTP %1$d Miễn phí Tiếp tục Làm mới @@ -1950,6 +1990,7 @@ Ngày phát hành Chế độ danh sách Chế độ thẻ + Chế độ xem bìa Hiện nội dung người lớn Epic EOS @@ -1985,9 +2026,12 @@ Đồng bộ lần cuối %1$s Chưa đồng bộ %1$d tệp + Đã cập nhật + Đăng nhập cửa hàng để đồng bộ bản lưu + Bản lưu chưa được đồng bộ đã cài trong thư viện của bạn - store + cửa hàng Thành tích Đã mở khóa %1$d/%2$d %1$d / %2$d @@ -2014,7 +2058,7 @@ Lựa chọn của Steam cho mỗi game Âm thanh DirectAudio - Components + Thành phần FEX, DXVK, VKD3D Engine %1$s · gốc @@ -2116,7 +2160,7 @@ Kích thước Lưu Về mặc định - Được vẽ đè lên nền game như Steam làm. Vị trí chính xác có thể lệch vài pixel. + Vị trí chính xác có thể lệch vài pixel. Game này chưa có logo. Chọn một logo để xem tại đây. Không lưu được vị trí logo. Thử lại. Hình nền @@ -2286,7 +2330,7 @@ Xong Không thể lưu bố cục chạm Đã lưu bố cục chạm vào Steam - Ứng dụng %1$d + Ứng dụng %1$s Nhãn (không bắt buộc) Không tìm thấy biểu tượng của Steam @@ -2375,6 +2419,15 @@ Trường nhìn của SteamVR Thấp hơn sẽ đưa SteamVR lại gần và lấp đầy kính; cao hơn cho thấy nhiều hơn xung quanh bạn. Đang chơi trên %1$s + Căn giữa lại + Menu SteamVR + Cần: tay ⇄ trò chơi + Tay cầm: %1$s + Bàn phím + Menu phiên + Dừng SteamVR + Độ sáng %1$d / %2$d + Pin %1$d%% · %2$d °C Hiệu chỉnh theo dõi Giảm hiện tượng trôi và căn lại mốc thăng bằng. Hiệu chỉnh đã lưu đang được áp dụng cho kính này. @@ -2429,6 +2482,7 @@ Đang chờ kết nối USB Ethernet của kính. Điện thoại này có thể không cho ứng dụng truy cập kết nối đó. Kết nối XREAL bị gián đoạn hoặc trả về phản hồi không được hỗ trợ. Đang thử lại… Hỗ trợ 1S thử nghiệm. Dùng chế độ Follow và tắt ổn định hình ảnh trong menu của kính. Hướng theo dõi và các chế độ hiển thị vẫn cần thử nghiệm trên thiết bị. + Theo dõi chuyển động đầu thử nghiệm cho Pro 2. Chưa hỗ trợ điều chỉnh độ sáng, âm lượng và chuyển chế độ hiển thị. Cardboard VR Chế độ Cardboard Hình cho mắt trái và mắt phải dành cho kính VR điện thoại. @@ -2641,7 +2695,7 @@ Video khởi động Video DroidDeck · %1$d fps - 60 fps mượt hơn nhưng tốn CPU hơn khi Steam đang khởi động. Video khởi động chọn trong cài đặt của chính Steam sẽ được ưu tiên. + 60 fps mượt hơn nhưng tốn CPU hơn khi Steam đang khởi động. Video chọn trong cài đặt của chính Steam cũng hiện ở đây. Âm thanh Video của bạn MP4, MOV, MKV hoặc WebM. Định dạng khác và tệp trên %1$d MB được chuyển sang WebM 720p ngay trên thiết bị, tối đa %2$d giây. @@ -2655,6 +2709,51 @@ Đang chuyển đổi %1$s cho Steam. Hãy để DroidDeck mở. Thiết bị này không giải mã được video đó. Không thể nhập video này + Xóa %1$s? + Thao tác này xóa video khỏi DroidDeck và khỏi thư mục video của Steam. + Đã cài + Nhập + SteamDeckRepo + Âm thanh + Video đã cài + Duyệt + DroidDeck + Đã nhập + SteamDeckRepo + Steam + Cửa hàng điểm + Báo cáo phim khởi động + Lưu những gì DroidDeck và Steam biết về phim khởi động vào một tệp văn bản để đính kèm báo cáo lỗi. + Lưu + Đã lưu vào %1$s + Không thể lưu báo cáo. + Âm lượng + Độ dài + %1$d giây + Số giây của phim sẽ được phát. Phần dài hơn sẽ bị cắt ở cuối. + Tìm trong SteamDeckRepo + Tất cả thiết bị + Steam Deck + Steam Machine + Thịnh hành + Tải nhiều nhất + Thích nhiều nhất + Mới nhất + Không kết nối được SteamDeckRepo + Thử lại + Không tìm thấy video + Cài đặt + Dùng + Đang dùng + Đã cài %1$s + Không thể tải video đó + Trước + Tiếp + Trang %1$d + Xóa + Đến trang + Xem trước + Không thể phát bản xem trước Camera Luma Ultra — Thử nghiệm Theo dõi vị trí Joy-Con Sử dụng camera của kính thông minh để định vị bộ điều khiển Joy-Con đã ghép nối. Xoay và nhập nút đến từ bộ điều khiển Joy-Con. Vị trí được ước tính khi cổ tay của bạn bị che khuất. Thay thế bộ điều khiển được điều khiển bằng tay không và tạm dừng theo dõi bằng camera điện thoại. Hướng bộ điều khiển Joy-Con về phía trước khi kết nối hoặc đặt lại trung tâm theo dõi. @@ -2827,7 +2926,6 @@ Đang chờ theo dõi đầu ổn định; đặt lại trung tâm theo dõi nếu được yêu cầu Camera quan sát quá cũ; đang chờ một cặp mới Vừa vặn %1$.2f px. Đo khoảng cách đã chọn của bạn từ camera bên trái đến trung tâm mục tiêu hiệu chỉnh, sau đó xác thực. - OpenCV không có sẵn: %1$s %1$d lượt xem đã lưu được khôi phục. Tính toán hiệu chuẩn để thử lại hoặc bắt đầu một bộ mới để thu được nhiều dữ liệu hơn. Đã chụp được %1$d / 24 lượt xem. Giữ mục tiêu hiệu chuẩn cố định; di chuyển kính thông minh để xem tiếp theo. Ảnh chụp bị từ chối: %1$d / 12 góc bắt buộc hiển thị ở cả hai máy ảnh. Hiển thị thêm mục tiêu hiệu chỉnh, giữ yên rồi thử lại. diff --git a/app/src/main/res/values-zh-rCN/gestures.xml b/app/src/main/res/values-zh-rCN/gestures.xml new file mode 100644 index 000000000..18f6c00ce --- /dev/null +++ b/app/src/main/res/values-zh-rCN/gestures.xml @@ -0,0 +1,91 @@ + + + 手势 + 触屏手势设置 + 自定义鼠标和键盘手势。请选择“手势”触控模式以使用这些功能。 + 点按 + 单指拖动 + 长按 + 双击(鼠标左键双击) + 双指点按 + 双指拖动 + 双指按住 + 三指点按 + 三指拖动 + 三指按住 + 双指缩放 + 持续按住按钮 + 单击一次 + 按住延迟 + 双击间隔 + 恢复初始手势设置 + 取消 + 选择操作 + 左键单击 + 右键单击 + 中键单击 + 显示键盘 + 按住左键拖动 + 按住右键拖动 + 鼠标中键平移 + 鼠标中键平移(反向) + 方向键 + 方向键(反向) + WASD(反向) + 鼠标滚轮 + 手势阈值:%1$d 像素 + 显示识别到的手势 + 单指 + 双指 + 三指 + 选项 + 默认值 + 此游戏 + 使用默认设置 + 保存更改 + 关闭 + 操作 + 搜索鼠标操作或键盘按键 + 清除搜索 + 全部 + 没有匹配的操作。请尝试其他按键或操作名称。 + 鼠标 + 应用操作 + 导航 + 字母 + 数字 + 功能键 + 修饰键 + 数字小键盘 + 符号 + 鼠标移动 + 直接 + 相对 + 手势识别 + 视觉反馈 + 鼠标按钮行为 + %1$d 毫秒 + 数字小键盘 %1$s + Esc + 退格 + 回车 + Tab + 空格 + 删除 + 插入 + Home + End + 向上翻页 + 向下翻页 + 打印屏幕 + 向上箭头 + 向下箭头 + 向左箭头 + 向右箭头 + 大写锁定 + 数字锁定 + 滚动锁定 + 左 %1$s + 右 %1$s + Shift + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 64a60c133..4c37e48e4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -30,7 +30,7 @@ 帧生成 选择帧生成模式 关闭 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 自适应 %1$d 需要 Steam 上的 Lossless Scaling @@ -176,6 +176,8 @@ %1$s 经由 FEX 此程序面向 %1$s PC 构建,需经由 FEX 运行,而 FEX 尚未配置。请在登录状态下打开一次 Steam:DroidDeck 会让其下载 FEX 及所需的 Steam Linux Runtime,之后即可打开。 此程序 + 无法下载 Steam 客户端。你的网络或 VPN 可能屏蔽了 Steam 的服务器;请检查网络连接后重试。 + 无法设置 Steam 客户端。请检查网络连接和可用存储空间,然后重试。 %1$s 面向 x86 PC 构建,需经由 FEX 运行,而 FEX 尚未安装。请在登录状态下启动一次 Steam,让其下载 FEX 与 Steam Linux Runtime,然后重新启动。 一个 Flathub 应用 ID 或其 Flathub 页面。若尚未配置 Flatpak 会先进行配置。 文件 @@ -308,6 +310,8 @@ 客户端 性能覆盖层 若游戏在 Deck 模式下崩溃或黑屏,请关闭性能覆盖层。将在下次会话生效。 + 游戏结束后返回 DroidDeck + 从 DroidDeck 或其他启动器启动的游戏关闭后,Steam 会正常退出(先完成云存档同步),并返回启动位置。关闭:留在 Steam 中。 修复 Steam 客户端 下次启动 Steam 时重新下载 Steam 客户端。如果 Steam 一直停在加载画面,请使用此项。游戏、登录信息和设置都会保留。 下次启动 Steam 时将重新下载 Steam 客户端。 @@ -500,7 +504,9 @@ 删除此原件? 此 %1$s 原件来自 %2$s 的较早构建。当前构建的原件始终保留。 在 Nightlies 上 · %1$d - 刷新以列出 Nightlies 上的软件包。 + 稳定版 + 每夜版 + 刷新以列出 Nightlies 和 DroidDeck 上的软件包。 自上次检查以来没有新内容。 导入 .wcp 还原此 Proton 的原件 @@ -536,7 +542,7 @@ 仅限 Adreno。卡顿更少,但更耗电、更发热。 会话修复 Turnip:sysmem 渲染 - 对 Steam 和大多数游戏更快。Adreno 710/720/722 必需。 + Steam 客户端更快;游戏中驱动会为每个渲染通道选择分块渲染或系统内存渲染。Adreno 710/720/722 必需。 跳过 Steam 的 xalia 助手 droiddeck-ntsync(实验性) Linux ntsync 驱动的用户态替代品,供测试使用。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。 @@ -578,6 +584,16 @@ 上一页 下一页 性能 HUD + 帧率 + 电池温度 + 性能图表 + 颜色 + 薄荷绿 + 白色 + 天蓝 + 琥珀 + 紫罗兰 + 不透明度 缩放模式 缩放锐度 屏幕效果 @@ -726,6 +742,9 @@ %1$d%%(默认) 主题 启动器外观 + 应用图标 + 深色 + 浅色 用作主屏幕 DroidDeck 可以设为主屏幕应用 系统不会把 DroidDeck 作为主屏幕应用提供 @@ -761,7 +780,7 @@ 大小 保存 重置为默认 - 显示在游戏背景上,与 Steam 的绘制方式一致。具体位置可能相差几个像素。 + 确切位置可能相差几个像素。 该游戏还没有标志。在标志中选择一个即可在此查看。 无法保存标志位置。请重试。 搜索 SteamGridDB… @@ -1111,6 +1130,9 @@ 已添加游戏 自定义游戏 游戏存储 + 删除游戏文件并从 Steam 中移除。 + 移除 %1$s? + 删除快捷方式,游戏文件保留。 为此 GPU 挑选的最佳组合,并保持更新 自行挑选驱动组合 正在检查驱动… @@ -1446,6 +1468,14 @@ 黑色背景、白色文字,选中、聚焦或按下的元素使用蓝色。 电光藏青 更明亮的蓝色,配以偏藏青而非灰色的界面。 + 渐变 + 你自己选择的主色和辅色,在按钮和高亮处渐变呈现。 + 主色 + 辅色 + 色相 + 饱和度 + 亮度 + 十六进制颜色 Esc Ins Del @@ -1817,7 +1847,8 @@ 已安装构建的原件会保留:它是还原的途径。 未存储 已删除 %2$s 的 %1$s 原件。 - 只能从 Nightlies 发布版本下载 + 只能从 Nightlies 和 DroidDeck 发布版本下载 + 下载失败:此每夜版本可能已被更新的版本替换。请刷新列表后重试。 此软件包列表早于校验和功能 — 请刷新后重试 校验和不匹配 @@ -1854,6 +1885,7 @@ 不是 Adreno GPU 受支持 实验性:其驱动为测试构建 + Adreno 850 实验支持:需要配套的实验版显示驱动和游戏驱动;尚未在此设备上验证 低于已测试的硬件(Adreno 725 及更新):可能无法运行 不受支持:没有适用于此 GPU 的兼容图形后端 @@ -1976,9 +2008,10 @@ 游戏商店 游戏商店设置 设置 + 整理标签 + 完成 (未登录) 商店 - 已安装 库 全部 下载 @@ -1990,10 +2023,8 @@ 保持登录 退出登录 搜索 %1$s 目录… - 搜索已安装的游戏… 搜索你的库… 搜索 - 尚未安装任何内容 加载中 没有游戏 正在加载商店… @@ -2012,7 +2043,19 @@ 在 %1$s 上查看 购买 · %1$s 已安装 + 已安装 + 未安装 已在库中 + API 密钥 + 获取密钥 + 密钥被拒绝 + 不在你的 itch.io 库中 + 这个游戏没有 Windows 下载 + 下载内容中未找到 Windows 游戏 + 无法解压下载内容(代码 %1$d) + itch.io 没有响应 + 不支持 %1$s 压缩包 + itch.io 返回 HTTP %1$d 免费 继续 刷新 @@ -2026,6 +2069,7 @@ 发行日期 列表视图 卡片视图 + 封面视图 显示成人内容 Epic EOS @@ -2061,6 +2105,9 @@ 上次同步:%1$s 尚未同步 %1$d 个文件 + 已是最新 + 登录商店以同步存档 + 存档未同步 已安装 在你的库中 商店 @@ -2286,7 +2333,7 @@ 完成 无法保存触控布局 触控布局已保存到 Steam - 应用 %1$d + 应用 %1$s 标签(可选) 未找到 Steam 的图标 @@ -2375,6 +2422,15 @@ SteamVR 视场角 调低让 SteamVR 更近并填满眼镜;调高可看到更多周围内容。 正在 %1$s 上游玩 + 重新居中 + SteamVR 菜单 + 摇杆:双手 ⇄ 游戏 + 控制器:%1$s + 键盘 + 会话菜单 + 停止 SteamVR + 亮度 %1$d / %2$d + 电池 %1$d%% · %2$d °C 追踪校准 减少漂移并对齐水平基准。 这副眼镜已应用保存的校准。 @@ -2429,6 +2485,7 @@ 正在等待眼镜的 USB Ethernet 连接。这部手机可能不向应用开放此连接。 XREAL 连接中断或返回了不受支持的响应。正在重试… 实验性 1S 支持。请在眼镜菜单中使用跟随模式并关闭防抖。追踪方向和显示模式仍需在设备上测试。 + Pro 2 实验性头部追踪。暂不支持亮度、音量调节和显示模式切换。 开发商 发行商 发行日期 @@ -2641,7 +2698,7 @@ 开机动画 视频 DroidDeck · %1$d fps - 60 fps 更流畅,但 Steam 启动时会占用更多 CPU。在 Steam 自身设置中选择的启动动画优先。 + 60 fps 更流畅,但 Steam 启动时会占用更多 CPU。在 Steam 自身设置中选择的动画也会显示在这里。 声音 自定义视频 MP4、MOV、MKV 或 WebM。其他格式及超过 %1$d MB 的文件会在设备上转换为 720p WebM,最长 %2$d 秒。 @@ -2655,6 +2712,51 @@ 正在为 Steam 转换 %1$s。请保持 DroidDeck 打开。 此设备无法解码该视频。 无法导入该视频 + 移除 %1$s? + 这会从 DroidDeck 和 Steam 的动画文件夹中删除该动画。 + 已安装 + 导入 + SteamDeckRepo + 声音 + 已安装的动画 + 浏览 + DroidDeck + 已导入 + SteamDeckRepo + Steam + 点数商店 + 启动影片报告 + 将 DroidDeck 和 Steam 掌握的启动影片信息保存为文本文件,可附在错误报告中。 + 保存 + 已保存到 %1$s + 无法保存报告。 + 音量 + 时长 + %1$d 秒 + 播放影片的秒数。超出该时长的部分会在结尾被截断。 + 搜索 SteamDeckRepo + 所有设备 + Steam Deck + Steam Machine + 热门 + 下载最多 + 最受欢迎 + 最新 + 无法连接 SteamDeckRepo + 重试 + 未找到动画 + 安装 + 使用 + 使用中 + 已安装 %1$s + 无法下载该动画 + 上一页 + 下一页 + 第 %1$d 页 + 清除 + 转到页面 + 预览 + 无法播放此预览 Luma Ultra 摄像头 — 实验性功能 Joy-Con 位置追踪 使用智能眼镜摄像头定位已配对的 Joy-Con 控制器。旋转和按键输入来自 Joy-Con 控制器。当您的手腕被遮挡时,系统会估算位置。此功能会取代徒手操作的控制器,并暂停手机摄像头追踪。连接或重置追踪中心时,请将 Joy-Con 控制器指向前方。 @@ -2825,7 +2927,6 @@ 正在等待稳定的头部追踪;如果需要,请重置追踪中心。 相机观察太旧;等待一双新的 适合 %1$.2f 像素。测量您选择的从左摄像头到校准目标中心的距离,然后进行验证。 - OpenCV 不可用:%1$s %1$d 已恢复保存的视图。计算校准以重试,或开始新的一组以捕获更多。 已捕获 %1$d / 24 次观看。保持校准目标固定;移动智能眼镜以查看下一个视图。 捕获被拒绝:%1$d / 12 个必需的角在两个摄像头中均可见。显示更多校准目标,保持不动,然后重试。 diff --git a/app/src/main/res/values-zh-rHK/gestures.xml b/app/src/main/res/values-zh-rHK/gestures.xml new file mode 100644 index 000000000..45bbcc062 --- /dev/null +++ b/app/src/main/res/values-zh-rHK/gestures.xml @@ -0,0 +1,91 @@ + + + 手勢 + 觸控手勢設定 + 自訂滑鼠與鍵盤手勢。請選擇「手勢」觸控模式以使用這些功能。 + 點按 + 單指拖曳 + 長按 + 連點兩下(滑鼠左鍵連按兩下) + 雙指點按 + 雙指拖曳 + 雙指按住 + 三指點按 + 三指拖曳 + 三指按住 + 雙指縮放 + 持續按住按鈕 + 按一下 + 按住延遲 + 連點兩下間隔 + 還原初始手勢設定 + 取消 + 選擇動作 + 按一下左鍵 + 按一下右鍵 + 按一下中鍵 + 顯示鍵盤 + 按住左鍵拖曳 + 按住右鍵拖曳 + 滑鼠中鍵平移 + 滑鼠中鍵平移(反向) + 方向鍵 + 方向鍵(反向) + WASD(反向) + 滑鼠滾輪 + 手勢閾值:%1$d 像素 + 顯示辨識到的手勢 + 單指 + 雙指 + 三指 + 選項 + 預設值 + 此遊戲 + 使用預設值 + 儲存變更 + 關閉 + 動作 + 搜尋滑鼠動作或鍵盤按鍵 + 清除搜尋 + 全部 + 沒有符合的動作。請嘗試其他按鍵或動作名稱。 + 滑鼠 + 應用程式動作 + 導覽 + 字母 + 數字 + 功能鍵 + 修飾鍵 + 數字鍵盤 + 符號 + 滑鼠移動 + 直接 + 相對 + 手勢辨識 + 視覺回饋 + 滑鼠按鈕行為 + %1$d 毫秒 + 數字鍵盤 %1$s + Esc + 退格 + Enter + Tab + 空格 + 刪除 + 插入 + Home + End + 上一頁 + 下一頁 + 列印螢幕 + 向上箭頭 + 向下箭頭 + 向左箭頭 + 向右箭頭 + 大寫鎖定 + 數字鎖定 + 捲動鎖定 + 左 %1$s + 右 %1$s + Shift + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 0deb49bae..942fbbcb8 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -30,7 +30,7 @@ 幀生成 選擇幀生成模式 關閉 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 自適應 %1$d 需要 Steam 上的 Lossless Scaling @@ -176,6 +176,8 @@ %1$s,透過 FEX 此程式是為 %1$s 電腦而設,需透過 FEX 執行,但 FEX 尚未設定。請在登入狀態下開啟一次 Steam:DroidDeck 會讓它下載 FEX 及所需的 Steam Linux Runtime,之後便會開啟此程式。 此程式 + 無法下載 Steam 用戶端。你的網絡或 VPN 可能封鎖了 Steam 的伺服器;請檢查連線後再試。 + 無法設定 Steam 用戶端。請檢查連線和可用儲存空間,然後再試。 %1$s 是為 x86 電腦而設,需透過 FEX 執行,但 FEX 尚未安裝。請在登入狀態下啟動一次 Steam,讓它下載 FEX 和 Steam Linux Runtime,然後再啟動此程式。 Flathub 應用程式 ID 或其 Flathub 頁面。如尚未設定 Flatpak,會先進行設定。 檔案 @@ -308,6 +310,8 @@ 用戶端 效能疊加層 如果在 Deck 模式下遊戲當機或黑畫面,請關閉此項。下次會話生效。 + 遊戲結束後返回 DroidDeck + 從 DroidDeck 或其他啟動器啟動的遊戲關閉後,Steam 會正常退出(先完成雲端存檔同步),並返回啟動位置。關閉:留在 Steam 中。 修復 Steam 用戶端 下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停留在載入畫面,請使用此功能。遊戲、登入資料和設定都會保留。 下次啟動 Steam 時會重新下載 Steam 用戶端。 @@ -500,7 +504,9 @@ 刪除此原始版本? 此 %1$s 原始版本來自 %2$s 的較早版本。目前版本的原始版本一定會保留。 Nightlies · %1$d - 重新整理以列出 Nightlies 中的軟件包。 + 穩定版 + 每夜版 + 重新整理以列出 Nightlies 及 DroidDeck 中的軟件包。 自上次檢查以來沒有新內容。 匯入 .wcp 還原此 Proton 的原始版本 @@ -536,7 +542,7 @@ 只限 Adreno。減少卡頓,但更耗電、更發熱。 會話修正 Turnip:sysmem 渲染 - Steam 和大部分遊戲會更快。Adreno 710/720/722 必須開啟。 + Steam 用戶端會更快;遊戲中驅動程式會為每個渲染通道選擇分塊渲染或系統記憶體渲染。Adreno 710/720/722 必須開啟。 略過 Steam 的 xalia 輔助程式 droiddeck-ntsync(實驗性) 在用戶空間代替 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。 @@ -578,6 +584,16 @@ 上一頁 下一頁 效能 HUD + 幀率 + 電池溫度 + 效能圖表 + 顏色 + 薄荷綠 + 白色 + 天藍 + 琥珀色 + 紫羅蘭 + 不透明度 縮放模式 縮放銳利度 畫面效果 @@ -726,6 +742,9 @@ %1$d%%(預設) 主題 啟動器外觀 + 應用程式圖示 + 深色 + 淺色 用作主畫面 DroidDeck 可用作主畫面應用程式 DroidDeck 不會作為主畫面應用程式的選項 @@ -1029,6 +1048,9 @@ 已新增的遊戲 自訂遊戲 遊戲儲存位置 + 刪除遊戲檔案並從 Steam 中移除。 + 移除 %1$s? + 刪除捷徑,遊戲檔案保留。 此 GPU 的最佳組合,並會保持最新 自行選擇組合 正在檢查驅動程式… @@ -1370,6 +1392,14 @@ 黑色背景、白色文字,所有已選取、焦點所在或按下的項目都使用藍色。 電光海軍藍 更明亮的藍色,搭配偏海軍藍而非灰色的介面。 + 漸層 + 你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。 + 主色 + 輔色 + 色相 + 飽和度 + 亮度 + 十六進位色彩 Esc Ins Del @@ -1741,7 +1771,8 @@ 已安裝版本的原始版本會保留,以便還原。 未儲存 已刪除 %2$s 的 %1$s 原始版本。 - 只會從 Nightlies 發佈版本下載 + 只會從 Nightlies 及 DroidDeck 發佈版本下載 + 下載失敗:此每夜版本可能已被較新的版本取代。請重新整理清單後再試。 此軟件包清單早於校驗和推出 - 請重新整理後再試一次 校驗和不符 @@ -1778,6 +1809,7 @@ 不是 Adreno GPU 受支援 實驗性:其驅動程式為測試版本 + Adreno 850 實驗性支援:需要相容的實驗版顯示驅動程式及遊戲驅動程式;尚未在此裝置上驗證 低於已測試的硬件(Adreno 725 或更新):可能無法執行 不受支援:沒有適用於此 GPU 的兼容圖形後端 @@ -1900,9 +1932,10 @@ 商店 商店設定 設定 + 整理標籤 + 完成 (未登入) 商店 - 已安裝 收藏庫 全部 下載 @@ -1914,10 +1947,8 @@ 保持登入 登出 搜尋 %1$s 目錄… - 搜尋已安裝的遊戲… 搜尋你的收藏庫… 搜尋 - 未安裝任何遊戲 正在載入 沒有遊戲 正在載入商店… @@ -1936,7 +1967,19 @@ 在 %1$s 查看 購買 · %1$s 已安裝 + 已安裝 + 未安裝 已在收藏庫中 + API 金鑰 + 取得金鑰 + 金鑰被拒絕 + 不在你的 itch.io 收藏庫中 + 這個遊戲沒有 Windows 下載 + 下載內容中找不到 Windows 遊戲 + 無法解壓下載內容(代碼 %1$d) + itch.io 沒有回應 + 不支援 %1$s 壓縮檔 + itch.io 回應 HTTP %1$d 免費 繼續 重新整理 @@ -1950,6 +1993,7 @@ 發行日期 列表檢視 卡片檢視 + 封面檢視 顯示成人內容 Epic EOS @@ -1985,6 +2029,9 @@ 上次同步:%1$s 尚未同步 %1$d 個檔案 + 已是最新 + 登入商店以同步存檔 + 存檔未同步 已安裝 在你的收藏庫中 商店 @@ -2116,7 +2163,7 @@ 大小 儲存 還原預設 - 顯示在遊戲背景上,與 Steam 的繪製方式一致。實際位置可能相差幾個像素。 + 確切位置可能相差幾個像素。 此遊戲尚無標誌。請在標誌中選擇一個,即可在此檢視。 無法儲存標誌位置。請再試一次。 背景 @@ -2286,7 +2333,7 @@ 完成 無法儲存觸控佈局 觸控佈局已儲存至 Steam - 應用程式 %1$d + 應用程式 %1$s 標籤(選用) 找不到 Steam 的圖示 @@ -2375,6 +2422,15 @@ SteamVR 視場角 調低令 SteamVR 更近並填滿眼鏡;調高可看到更多周圍內容。 正在 %1$s 上遊玩 + 重新置中 + SteamVR 選單 + 搖桿:雙手 ⇄ 遊戲 + 控制器:%1$s + 鍵盤 + 工作階段選單 + 停止 SteamVR + 亮度 %1$d / %2$d + 電池 %1$d%% · %2$d °C 追蹤校準 減少漂移並對齊水平基準。 這副眼鏡已套用儲存的校準。 @@ -2429,6 +2485,7 @@ 正在等候眼鏡的 USB Ethernet 連線。這部手機可能不會向應用程式開放此連線。 XREAL 連線中斷或傳回不支援的回應。正在重試… 實驗性 1S 支援。請在眼鏡選單中使用跟隨模式並關閉畫面穩定。追蹤方向和顯示模式仍需在裝置上測試。 + Pro 2 實驗性頭部追蹤。暫不支援亮度、音量調整及顯示模式切換。 開發商 發行商 發行日期 @@ -2641,7 +2698,7 @@ 開機動畫 影片 DroidDeck · %1$d fps - 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的啟動動畫優先。 + 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的動畫也會顯示在這裡。 聲音 自訂影片 MP4、MOV、MKV 或 WebM。其他格式及超過 %1$d MB 的檔案會在裝置上轉換為 720p WebM,最長 %2$d 秒。 @@ -2655,6 +2712,51 @@ 正在為 Steam 轉換 %1$s。請保持 DroidDeck 開啟。 此裝置無法解碼該影片。 無法匯入此影片 + 移除 %1$s? + 這會從 DroidDeck 和 Steam 的動畫資料夾中刪除該動畫。 + 已安裝 + 匯入 + SteamDeckRepo + 聲音 + 已安裝的動畫 + 瀏覽 + DroidDeck + 已匯入 + SteamDeckRepo + Steam + 點數商店 + 啟動影片報告 + 將 DroidDeck 和 Steam 掌握的啟動影片資訊儲存為文字檔,可附在錯誤報告中。 + 儲存 + 已儲存到 %1$s + 無法儲存報告。 + 音量 + 長度 + %1$d 秒 + 播放影片的秒數。超出該時長的部分會在結尾被截斷。 + 搜尋 SteamDeckRepo + 所有裝置 + Steam Deck + Steam Machine + 熱門 + 下載最多 + 最受歡迎 + 最新 + 無法連接 SteamDeckRepo + 重試 + 找不到動畫 + 安裝 + 使用 + 使用中 + 已安裝 %1$s + 無法下載該動畫 + 上一頁 + 下一頁 + 第 %1$d 頁 + 清除 + 前往頁面 + 預覽 + 無法播放此預覽 Luma Ultra 相機 — 實驗性功能 Joy-Con 位置追踪 使用智慧眼鏡攝影機定位已配對的 Joy-Con 控制器。旋轉和按鍵輸入來自 Joy-Con 控制器。當您的手腕被遮住時,系統會估算位置。此功能會取代徒手操作的控制器,並暫停手機相機追蹤。連接或重設追蹤中心時,請將 Joy-Con 控制器指向前方。 @@ -2825,7 +2927,6 @@ 正在等待穩定的頭部追蹤;如果需要,請重設追蹤中心。 相機觀察太舊;等待一雙新的 適合 %1$.2f 像素。測量您選擇的從左側攝影機到校準目標中心的距離,然後進行驗證。 - OpenCV 不可用:%1$s %1$d 已恢復已儲存的視圖。計算校準以重試,或開始新的一組以捕獲更多。 已捕獲 %1$d / 24 次觀看。保持校準目標固定;移動智慧眼鏡以查看下一個視圖。 捕捉被拒絕:%1$d / 12 個必需的角落在兩個攝影機中均可見。顯示更多校準目標,保持不動,然後重試。 diff --git a/app/src/main/res/values-zh-rTW/gestures.xml b/app/src/main/res/values-zh-rTW/gestures.xml new file mode 100644 index 000000000..464f94d88 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/gestures.xml @@ -0,0 +1,91 @@ + + + 手勢 + 觸控手勢設定 + 自訂滑鼠與鍵盤手勢。請選擇「手勢」觸控模式以使用這些功能。 + 點按 + 單指拖曳 + 長按 + 連點兩下(滑鼠左鍵連按兩下) + 雙指點按 + 雙指拖曳 + 雙指按住 + 三指點按 + 三指拖曳 + 三指按住 + 雙指縮放 + 持續按住按鈕 + 按一下 + 按住延遲 + 連點兩下間隔 + 還原初始手勢設定 + 取消 + 選擇動作 + 按一下左鍵 + 按一下右鍵 + 按一下中鍵 + 顯示鍵盤 + 按住左鍵拖曳 + 按住右鍵拖曳 + 滑鼠中鍵平移 + 滑鼠中鍵平移(反向) + 方向鍵 + 方向鍵(反向) + WASD(反向) + 滑鼠滾輪 + 手勢閾值:%1$d 像素 + 顯示辨識到的手勢 + 單指 + 雙指 + 三指 + 選項 + 預設值 + 此遊戲 + 使用預設值 + 儲存變更 + 關閉 + 動作 + 搜尋滑鼠動作或鍵盤按鍵 + 清除搜尋 + 全部 + 沒有符合的動作。請嘗試其他按鍵或動作名稱。 + 滑鼠 + 應用程式動作 + 導覽 + 字母 + 數字 + 功能鍵 + 修飾鍵 + 數字鍵盤 + 符號 + 滑鼠移動 + 直接 + 相對 + 手勢辨識 + 視覺回饋 + 滑鼠按鈕行為 + %1$d 毫秒 + 數字鍵盤 %1$s + Esc + 退格 + Enter + Tab + Space + 刪除 + 插入 + Home + End + 上一頁 + 下一頁 + 列印螢幕 + 向上箭頭 + 向下箭頭 + 向左箭頭 + 向右箭頭 + 大寫鎖定 + 數字鎖定 + 捲動鎖定 + 左 %1$s + 右 %1$s + Shift + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0a4e9a0c3..99ea1e187 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -30,7 +30,7 @@ 影格生成 請選擇你要使用的影格生成模式 關閉 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 自適應 %1$d 需要 Steam 上的 Lossless Scaling @@ -176,6 +176,8 @@ 透過 FEX 執行 %1$s 此程式是為 %1$s 電腦組建的,需透過 FEX 執行,但 FEX 尚未設定。請在登入狀態下開啟一次 Steam:DroidDeck 會讓它下載 FEX 和所需的 Steam Linux Runtime,之後就能開啟此程式。 此程式 + 無法下載 Steam 用戶端。你的網路或 VPN 可能封鎖了 Steam 的伺服器;請檢查連線後再試一次。 + 無法設定 Steam 用戶端。請檢查連線和可用儲存空間,然後再試一次。 %1$s 是為 x86 電腦組建的,需透過 FEX 執行,但 FEX 尚未安裝。請在登入狀態下啟動一次 Steam,讓它下載 FEX 和 Steam Linux Runtime,然後再重新啟動此程式。 Flathub 應用程式 ID 或其 Flathub 頁面。如果尚未設定 Flatpak,會先進行設定。 檔案 @@ -308,6 +310,8 @@ 用戶端 效能重疊層 如果 Deck 模式下遊戲崩潰或黑畫面,請關閉此項。下次工作階段生效。 + 遊戲結束後返回 DroidDeck + 從 DroidDeck 或其他啟動器啟動的遊戲關閉後,Steam 會正常退出(先完成雲端存檔同步),並返回啟動位置。關閉:留在 Steam 中。 修復 Steam 用戶端 下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停在載入畫面,請使用此功能。遊戲、登入狀態和設定都會保留。 下次啟動 Steam 時會重新下載 Steam 用戶端。 @@ -500,7 +504,9 @@ 刪除這個原始版本? 這個 %1$s 原始版本來自 %2$s 的較早組建。目前組建的原始版本一律會保留。 Nightlies 中 · %1$d - 重新整理以列出 Nightlies 中的套件。 + 穩定版 + 每夜版 + 重新整理以列出 Nightlies 和 DroidDeck 中的套件。 自上次檢查以來沒有新內容。 匯入 .wcp 還原此 Proton 的原始檔案 @@ -536,7 +542,7 @@ 僅適用於 Adreno。卡頓更少,但耗電和發熱更多。 工作階段修復 Turnip:sysmem 渲染 - Steam 和大多數遊戲會更快。Adreno 710/720/722 必需。 + Steam 用戶端會更快;遊戲中驅動程式會為每個算繪通道選擇分塊算繪或系統記憶體算繪。Adreno 710/720/722 必需。 跳過 Steam 的 xalia 輔助程式 droiddeck-ntsync(實驗性) 以使用者空間替代 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。 @@ -578,6 +584,16 @@ 上一頁 下一頁 效能 HUD + 幀率 + 電池溫度 + 效能圖表 + 顏色 + 薄荷綠 + 白色 + 天藍 + 琥珀 + 紫羅蘭 + 不透明度 縮放模式 縮放銳利度 畫面效果 @@ -726,6 +742,9 @@ %1$d%%(預設) 佈景主題 啟動器外觀 + 應用程式圖示 + 深色 + 淺色 用作主螢幕 DroidDeck 可以作為主螢幕應用程式 DroidDeck 不會作為主螢幕應用程式提供 @@ -1029,6 +1048,9 @@ 已新增的遊戲 自訂遊戲 遊戲儲存空間 + 刪除遊戲檔案並從 Steam 中移除。 + 要移除 %1$s 嗎? + 刪除捷徑,遊戲檔案保留。 為此 GPU 選擇最佳驅動程式組合,並保持最新 自行選擇驅動程式組合 正在檢查驅動程式… @@ -1370,6 +1392,14 @@ 黑色背景、白色文字,選取、聚焦或按下的項目使用藍色。 電光藏青 更亮的藍色,搭配偏藏青而非灰色的介面。 + 漸層 + 你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。 + 主色 + 輔色 + 色相 + 飽和度 + 亮度 + 十六進位色彩 Esc Ins Del @@ -1741,7 +1771,8 @@ 已安裝組建的原始版本會保留,以便還原。 未儲存 已刪除 %2$s 來自 %1$s 的原始版本。 - 只會從 Nightlies 發行版本下載 + 只會從 Nightlies 和 DroidDeck 發行版本下載 + 下載失敗:此每夜版本可能已被較新的版本取代。請重新整理清單後再試一次。 此套件清單早於校驗碼機制 - 請重新整理後再試一次 校驗碼不符 @@ -1778,6 +1809,7 @@ 不是 Adreno GPU 受支援 實驗性:其驅動程式為測試版本 + Adreno 850 實驗性支援:需要相容的實驗版顯示驅動程式及遊戲驅動程式;尚未在此裝置上驗證 低於已測試的硬體(Adreno 725 及更新機型):可能無法執行 不受支援:沒有適用於此 GPU 的相容圖形後端 @@ -1900,9 +1932,10 @@ 遊戲商店 遊戲商店設定 設定 + 整理分頁 + 完成 (未登入) 商店 - 已安裝 收藏庫 全部 下載 @@ -1914,10 +1947,8 @@ 保持登入 登出 搜尋 %1$s 目錄… - 搜尋已安裝的遊戲… 搜尋你的收藏庫… 搜尋 - 尚未安裝任何內容 正在載入 沒有遊戲 正在載入商店… @@ -1936,7 +1967,19 @@ 在 %1$s 檢視 購買 · %1$s 已安裝 + 已安裝 + 未安裝 已在收藏庫中 + API 金鑰 + 取得金鑰 + 金鑰被拒絕 + 不在你的 itch.io 收藏庫中 + 這個遊戲沒有 Windows 下載 + 下載內容中找不到 Windows 遊戲 + 無法解壓縮下載內容(代碼 %1$d) + itch.io 沒有回應 + 不支援 %1$s 壓縮檔 + itch.io 回應 HTTP %1$d 免費 繼續 重新整理 @@ -1950,6 +1993,7 @@ 發行日期 列表檢視 卡片檢視 + 封面檢視 顯示成人內容 Epic EOS @@ -1985,6 +2029,9 @@ 上次同步:%1$s 尚未同步 %1$d 個檔案 + 已是最新 + 登入商店以同步存檔 + 存檔未同步 已安裝 在你的收藏庫中 商店 @@ -2116,7 +2163,7 @@ 大小 儲存 還原預設 - 顯示在遊戲背景上,與 Steam 的繪製方式一致。實際位置可能相差幾個像素。 + 確切位置可能相差幾個像素。 此遊戲尚無標誌。請在標誌中選擇一個,即可在此檢視。 無法儲存標誌位置。請再試一次。 背景 @@ -2286,7 +2333,7 @@ 完成 無法儲存觸控版面配置 觸控版面配置已儲存至 Steam - 應用程式 %1$d + 應用程式 %1$s 標籤(選用) 找不到 Steam 的圖示 @@ -2375,6 +2422,15 @@ SteamVR 視野角度 調低讓 SteamVR 更近並填滿眼鏡;調高可看到更多周圍內容。 正在 %1$s 上遊玩 + 重新置中 + SteamVR 選單 + 搖桿:雙手 ⇄ 遊戲 + 控制器:%1$s + 鍵盤 + 工作階段選單 + 停止 SteamVR + 亮度 %1$d / %2$d + 電池 %1$d%% · %2$d °C 追蹤校正 減少漂移並對齊水平基準。 這副眼鏡已套用儲存的校正資料。 @@ -2429,6 +2485,7 @@ 正在等待眼鏡的 USB Ethernet 連線。這支手機可能不會向應用程式開放此連線。 XREAL 連線中斷或傳回不支援的回應。正在重試… 實驗性 1S 支援。請在眼鏡選單中使用跟隨模式並關閉畫面穩定。追蹤方向和顯示模式仍需在實機上測試。 + Pro 2 實驗性頭部追蹤。尚不支援亮度、音量調整及顯示模式切換。 開發商 發行商 發行日期 @@ -2641,7 +2698,7 @@ 開機動畫 影片 DroidDeck · %1$d fps - 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的啟動動畫優先。 + 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的動畫也會顯示在這裡。 聲音 自訂影片 MP4、MOV、MKV 或 WebM。其他格式及超過 %1$d MB 的檔案會在裝置上轉換為 720p WebM,最長 %2$d 秒。 @@ -2655,6 +2712,51 @@ 正在為 Steam 轉換 %1$s。請保持 DroidDeck 開啟。 此裝置無法解碼該影片。 無法匯入此影片 + 移除 %1$s? + 這會從 DroidDeck 和 Steam 的動畫資料夾中刪除該動畫。 + 已安裝 + 匯入 + SteamDeckRepo + 聲音 + 已安裝的動畫 + 瀏覽 + DroidDeck + 已匯入 + SteamDeckRepo + Steam + 點數商店 + 啟動影片報告 + 將 DroidDeck 和 Steam 掌握的啟動影片資訊儲存為文字檔,可附在錯誤報告中。 + 儲存 + 已儲存到 %1$s + 無法儲存報告。 + 音量 + 長度 + %1$d 秒 + 播放影片的秒數。超出該時長的部分會在結尾被截斷。 + 搜尋 SteamDeckRepo + 所有裝置 + Steam Deck + Steam Machine + 熱門 + 下載最多 + 最受歡迎 + 最新 + 無法連線至 SteamDeckRepo + 重試 + 找不到動畫 + 安裝 + 使用 + 使用中 + 已安裝 %1$s + 無法下載該動畫 + 上一頁 + 下一頁 + 第 %1$d 頁 + 清除 + 前往頁面 + 預覽 + 無法播放此預覽 Luma Ultra 相機 — 實驗性功能 Joy-Con 位置追踪 使用智慧眼鏡攝影機定位已配對的 Joy-Con 控制器。旋轉和按鍵輸入來自 Joy-Con 控制器。當您的手腕被遮住時,系統會估算位置。此功能會取代徒手操作的控制器,並暫停手機相機追蹤。連接或重設追蹤中心時,請將 Joy-Con 控制器指向前方。 @@ -2825,7 +2927,6 @@ 正在等待穩定的頭部追蹤;如果需要,請重設追蹤中心。 相機觀察太舊;等待一雙新的 適合 %1$.2f 像素。測量您選擇的從左側攝影機到校準目標中心的距離,然後進行驗證。 - OpenCV 不可用:%1$s %1$d 已恢復已儲存的視圖。計算校準以重試,或開始新的一組以捕獲更多。 已捕獲 %1$d / 24 次觀看。保持校準目標固定;移動智慧眼鏡以查看下一個視圖。 捕捉被拒絕:%1$d / 12 個必需的角落在兩個攝影機中均可見。顯示更多校準目標,保持不動,然後重試。 diff --git a/app/src/main/res/values/gestures.xml b/app/src/main/res/values/gestures.xml new file mode 100644 index 000000000..7c9132422 --- /dev/null +++ b/app/src/main/res/values/gestures.xml @@ -0,0 +1,91 @@ + + + Gestures + Touch gesture settings + Customize mouse and keyboard gestures. Select Gestures as the touch mode to use them. + Tap + One-finger drag + Long press + Double tap (double left click) + Two-finger tap + Two-finger drag + Two-finger hold + Three-finger tap + Three-finger drag + Three-finger hold + Pinch to zoom + Keep button held + Click once + Hold delay + Double-tap interval + Reset to original gesture settings + Cancel + Choose action + Left click + Right click + Middle click + Show keyboard + Left click and drag + Right click and drag + Middle mouse pan + Middle mouse pan (inverted) + Arrow keys + Arrow keys (inverted) + WASD (inverted) + Mouse wheel + Gesture threshold: %1$d px + Show recognized gesture + One finger + Two fingers + Three fingers + Options + Defaults + This game + Use defaults + Save changes + Close + ACTION + Search mouse actions or keyboard keys + Clear search + All + No matching actions. Try another key or action name. + Mouse + App actions + Navigation + Letters + Numbers + Function keys + Modifiers + Numpad + Symbols + Mouse movement + Direct + Relative + Gesture recognition + Visual feedback + Mouse button behavior + %1$d ms + Numpad %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Up arrow + Down arrow + Left arrow + Right arrow + Caps Lock + Num Lock + Scroll Lock + Left %1$s + Right %1$s + Shift + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1aec6d7d4..f1923aeb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,7 +34,7 @@ Frame generation Select the frame generation mode Off - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptive %1$d Needs Lossless Scaling on Steam @@ -90,6 +90,7 @@ Set to 1 to keep this game off droiddeck-fsync; it uses droiddeck-esync where a pack exists, otherwise droiddeck-ntsync. Set to 1 to run this game on droiddeck-fsync, or 0 to put droiddeck-esync first where a pack fits its Proton. Set to 0 to disable Proton’s gamepad-navigation helper for this game. + On by default. Starts an Easy Anti-Cheat game without its anti-cheat when the game supports offline play. Turn off to launch through Easy Anti-Cheat as usual. Online modes that require Easy Anti-Cheat remain unavailable. DXVK overlay for D3D9–11. fps displays frame rate; unset hides it. DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play. D3D12 frame-rate limit. 0 disables the limit. @@ -181,6 +182,8 @@ This program is built for %1$s PCs and runs through FEX, which is not set up yet. Open Steam once while signed in: DroidDeck has it download FEX and the Steam Linux Runtime it needs, then this opens. This program The session could not draw to the screen: %1$s. This screen shows why instead of a black one; the session logs have the details. + Couldn\'t download the Steam client. Your network or VPN may be blocking Steam\'s servers; check the connection and try again. + Couldn\'t set up the Steam client. Check your connection and free storage, then try again. %1$s is built for x86 PCs and runs through FEX, which is not installed yet. Start Steam once while signed in and let it download FEX and the Steam Linux Runtime, then start this again. A Flathub app ID or its Flathub page. Flatpak is set up first if it is not yet. File @@ -306,6 +309,12 @@ Up to date · %1$s Loader Install latest + Update Channel + Which Decky Loader releases to install. Decky\'s own updater uses the same channel. + Stable + Prerelease + Testing + Could not save the Decky update channel Starts with Steam. While on, other apps on this device may be able to control Steam. Off. Steam\'s remote debugging port stays closed. Uninstall Decky Loader? @@ -313,6 +322,8 @@ Client Performance overlay Turn off if games crash or go black in Deck mode. Applies next session. + Return to DroidDeck after a game + When a game you started from DroidDeck or another launcher closes, Steam exits cleanly (cloud saves finish first) and you go back there. Off: stay in Steam. Repair Steam client Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept. The Steam client will be downloaded again the next time Steam starts. @@ -507,7 +518,9 @@ Delete this original? This %1$s original is from an earlier build of %2$s. The current build\'s original is always kept. On the Nightlies · %1$d - Refresh to list packages on the Nightlies. + Stable + Nightly + Refresh to list packages on the Nightlies and DroidDeck releases. Nothing new since the last check. Import .wcp Restore this Proton\'s original @@ -543,7 +556,7 @@ Adreno only. Fewer hitches, more battery use and heat. Session fixes Turnip: sysmem rendering - Faster for Steam and most games. Required on Adreno 710/720/722. + Faster for the Steam client; in games the driver picks tile or system-memory rendering per pass. Required on Adreno 710/720/722. Skip Steam\'s xalia helper droiddeck-ntsync (experimental) A userspace stand-in for the Linux ntsync driver, for testing. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session. @@ -585,6 +598,16 @@ Previous page Next page Performance HUD + Frame rate + Battery temperature + Performance graph + Color + Mint + White + Sky + Amber + Violet + Opacity Scaling mode Scaling sharpness Screen effects @@ -769,6 +792,9 @@ %1$d%% (Default) Theme Launcher appearance + App icon + Dark + Light Use as Home screen DroidDeck can be the Home app DroidDeck is never offered as a Home app @@ -1075,6 +1101,9 @@ Added game Custom game Games storage + Deletes its files and removes it from Steam. + Remove %1$s? + Deletes its shortcut. The game files stay. The best pair for this GPU, kept up to date Pick a pair yourself Checking for drivers… @@ -1190,6 +1219,8 @@ Automatically enter PiP Controls on the unfolded screen Unfolded, the game sits above the Steam Deck trackpads and back grips. Folded, the game is full screen. + Keep them with a controller + Also show the unfolded controls while a controller is connected, for controllers that hold the phone. Suspended Picture in picture is available once the session is ready. Allow picture in picture for DroidDeck in Android settings. @@ -1253,6 +1284,9 @@ Install anyway Remove Linux runtime This deletes the runtime, the Steam client inside it, and every game installed there. + Linux runtime + Repair reinstalls the runtime\'s system files and keeps Steam, your games and settings. Extras such as the desktop install again when you next use them. Remove deletes everything. + Repair runtime Applied: %1$s Could not open %1$s on the primary screen Could not open %1$s on %2$s @@ -1382,6 +1416,11 @@ Could not reach the runtime catalog. Check the connection and press Play again. The Linux runtime did not install. Check the runtime in Setup and press Play again. The desktop did not install (%1$s). Check the connection and press Desktop again. + An install that was interrupted could not be finished (%1$s). Check the connection and space, then try again. + + The Linux runtime is damaged: %1$d library link points to a missing file (%2$s), usually after an install was interrupted. Repair keeps your games and settings. + The Linux runtime is damaged: %1$d library links point to missing files (%2$s and others), usually after an install was interrupted. Repair keeps your games and settings. + %1$s stopped unexpectedly. Share the logs with a bug report, or try again. The program stopped unexpectedly. Share the logs with a bug report, or try again. The desktop stopped unexpectedly. Share the logs with a bug report, or try again. @@ -1396,6 +1435,13 @@ Install a plugin ZIP for the next Steam session. Downloading… Install from ZIP + Third-Party Plugin Installation + The Decky Loader team has not reviewed this plugin. It may contain malware, such as software to steal your Steam account or harm your device. By installing this plugin, you agree you have assumed all risks to your device. + Continue + + Please wait %1$d second + Please wait %1$d seconds + Games Audio & Video Graphics @@ -1424,6 +1470,14 @@ Black ground, white type, the blue for everything selected, focused or pressed. Electric navy The blue brighter, on surfaces that lean navy instead of grey. + Gradient + Your own primary and secondary colors, blended on buttons and highlights. + Primary + Secondary + Hue + Saturation + Brightness + Hex color Esc Ins Del @@ -1813,7 +1867,8 @@ The installed build\'s original is kept: it is the way back. Not stored Deleted the %1$s original of %2$s. - Downloads come only from the Nightlies releases + Downloads come only from the Nightlies and DroidDeck releases + Download failed: this nightly may have been replaced by a newer build. Refresh the list and try again. This package list predates checksums - refresh it and try again Checksum mismatch @@ -1866,6 +1921,7 @@ DXVK is provided by the system PowerVR runtime. Other DXVK packages can be stored but are not applied with this driver. Supported Experimental: its drivers are test builds + Experimental Adreno 850 support: requires compatible experimental display and game drivers; not verified on this device Below tested hardware (Adreno 725 and newer): it may not run Not supported: no compatible graphics backend is available for this GPU @@ -1989,9 +2045,10 @@ Stores Stores settings Settings + Arrange tabs + Done (not signed in) Store - Installed Library All Downloads @@ -2003,10 +2060,8 @@ Stay signed in Sign out Search the %1$s catalog… - Search installed games… Search your library… Search - Nothing installed Loading No games Loading the store… @@ -2025,7 +2080,19 @@ View on %1$s Buy · %1$s Installed + Installed + Not installed In library + API key + Get a key + Key rejected + Not in your itch.io library + No Windows download for this game + No Windows game found in the download + Could not extract the download (exit %1$d) + itch.io did not answer + %1$s archives are not supported + itch.io answered HTTP %1$d Free Resume Refresh @@ -2039,6 +2106,7 @@ Release date List view Card view + Capsule view Show mature content Epic EOS @@ -2075,6 +2143,9 @@ Last sync %1$s Not synced yet %1$d files + Already up to date + Sign in to the store to sync saves + Saves were not synced installed in your library store @@ -2163,6 +2234,9 @@ Download manager Downloads at a time Install to + Game language + GOG and Epic install this language. System follows the app language. + System Install %1$s: Internal %1$s free @@ -2216,7 +2290,7 @@ Size Save Reset to default - Shown on the game\'s background, the way Steam draws it. The exact spot can differ by a few pixels. + This exact spot can differ by a few pixels. This game has no logo yet. Choose one under Logo to see it here. Couldn\'t save the logo position. Try again. Background @@ -2398,7 +2472,7 @@ Done Could not save the touch layout Touch layout saved to Steam - App %1$d + App %1$s Label (optional) Steam\'s icons were not found @@ -2495,6 +2569,22 @@ Automatic keeps this 2D game fixed in view. Choose Virtual screen in Display to use head tracking. Head tracking applies to a virtual screen or a running SteamVR game. Device + Controllers + SteamVR controllers + Game pad in VR + The two VR hands your controller plays as. Changes apply right away, also while SteamVR runs. Joy-Cons and tracked hands still take over their side. + Regular game pad + Valve Index controllers + Meta Quest 3 controllers + HTC Vive wands + Sticks start by + Press both sticks in together during VR to switch. + Moving you in the game + Moving the hands + Buttons + Triggers are triggers, bumpers grip, A/B and X/Y are each hand\'s face buttons, stick clicks click the thumbsticks, Guide opens the SteamVR menu. D-pad up and down move the hands closer or farther. + The sticks are trackpads; A/X or a stick click presses them. Triggers are triggers, bumpers grip, Start and Select are menu, Guide opens the SteamVR menu. + No VR hands. Games that support a game pad, and the SteamVR menu, get your controller as usual. Brightness Volume Sunblock @@ -2512,6 +2602,15 @@ SteamVR field of view Lower brings SteamVR closer and fills the glasses; higher shows more around you. Playing on %1$s + Recenter + SteamVR menu + Sticks: hands ⇄ game + Controllers: %1$s + Keyboard + Session menu + Stop SteamVR + Brightness %1$d / %2$d + Battery %1$d%% · %2$d °C Tracking calibration Reduce drift and align the level reference. Saved calibration is applied to these glasses. @@ -2550,6 +2649,12 @@ Camera placement Check that the camera can see you before you play. Check + Hand tracking download + Hand tracking needs a one-time %1$s download. + Download + Retry + Downloading hand tracking… %1$d%% + Hand tracking couldn\'t be downloaded. Check your connection and try again. Camera placement Stand the device up 1.5 to 2 metres away at about chest height, with the back camera facing you. Then step into view with your hands out. Stand the device up 1.5 to 2 metres away at about chest height, with the screen facing you. Then step into view with your hands out. @@ -2568,6 +2673,7 @@ Waiting for the glasses’ USB Ethernet connection. This phone may not expose it to apps. The XREAL connection was interrupted or returned an unsupported response. Retrying… Experimental 1S support. Use Follow mode and disable stabilization in the glasses menu. Tracking directions and display modes still need device testing. + Experimental Pro 2 head tracking. Brightness, volume, and display switching are not supported yet. Picture position Top Bottom @@ -2774,12 +2880,14 @@ Boot movie Movie DroidDeck · %1$d fps - 60 fps looks smoother but uses more CPU while Steam starts. A startup movie picked in Steam\'s own settings takes priority. + 60 fps looks smoother but uses more CPU while Steam starts. A movie picked in Steam\'s own settings shows up here too. Sound Your own movie MP4, MOV, MKV or WebM. Other formats and files over %1$d MB are converted to 720p WebM on the device, up to %2$d seconds. Import Remove + Remove %1$s? + This deletes the movie from DroidDeck and from Steam\'s movies folder. Choose a boot movie %1$s is now the boot movie %1$s is now the boot movie, without sound @@ -2788,6 +2896,49 @@ Converting %1$s for Steam. Keep DroidDeck open. This device cannot decode that video. Could not import that movie + Installed + Import + SteamDeckRepo + Sound + Installed movies + Browse + DroidDeck + Imported + SteamDeckRepo + Steam + Points Shop + Boot movie report + Saves what DroidDeck and Steam know about boot movies to a text file you can attach to a bug report. + Save + Saved to %1$s + Could not save the report. + Volume + Length + %1$d s + How many seconds of the movie play. Anything longer is cut off at the end. + Search SteamDeckRepo + All devices + Steam Deck + Steam Machine + Trending + Most downloaded + Most liked + Newest + Could not reach SteamDeckRepo + Retry + No movies found + Install + Use + In use + %1$s installed + Could not download that movie + Previous + Next + Page %1$d + Clear + Go to page + Preview + This preview could not be played Steady hand aim Aim from your shoulder through your palm. Turn off for wrist-controlled rotation. Spatial setup @@ -2900,7 +3051,6 @@ Waiting for stable head tracking; recenter if requested Camera observation is too old; waiting for a fresh pair Fit %1$.2f px. Measure your selected distance from the left camera to the board center, then validate. - OpenCV unavailable: %1$s %1$d saved views restored. Calculate calibration to retry, or start a new set to capture more. Captured %1$d / 24 views. Keep the board fixed; move the glasses for the next view. Capture rejected: %1$d / 12 required corners visible in both cameras. Show more of the board, hold still, then try again. diff --git a/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt b/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt new file mode 100644 index 000000000..9172c5a5c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.core + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AddonPackTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val root get() = File(context.filesDir, "addons/sample") + + @Before fun clean() { + root.deleteRecursively() + } + + private fun installed(version: String, complete: Boolean = true, modified: Long = 1_000L): File = + File(root, version).apply { + mkdirs() + File(this, "tool").writeText("x") + if (complete) File(this, AddonPack.COMPLETE).writeText(version) + setLastModified(modified) + } + + @Test fun unpinnedLocalBuildReusesTheNewestCompletePack() { + installed("a".repeat(64), modified = 1_000L) + installed("b".repeat(64), modified = 2_000L) + installed("c".repeat(64), complete = false, modified = 3_000L) + val pack = AddonPack("sample", "", 0L, listOf("tool"), reuseDeviceCopy = true) + + assertTrue(pack.installed(context)) + assertEquals("b".repeat(64), pack.dir(context).name) + } + + @Test fun unpinnedLocalBuildWithNothingInstalledHasNoPack() { + assertFalse(AddonPack("sample", "", 0L, listOf("tool"), reuseDeviceCopy = true).installed(context)) + } + + @Test fun pinnedBuildIgnoresOtherInstalledVersions() { + installed("b".repeat(64)) + val pack = AddonPack("sample", "a".repeat(64), 1L, listOf("tool")) + + assertFalse(pack.installed(context)) + assertEquals("a".repeat(64), pack.dir(context).name) + } + + @Test fun ciBuildWithoutAPinNeverReuses() { + installed("b".repeat(64)) + assertFalse(AddonPack("sample", "", 0L, listOf("tool"), reuseDeviceCopy = false).installed(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt index a3faef070..73fd0161c 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt @@ -167,4 +167,10 @@ class DownloaderResumeTest { assertEquals(listOf(catalog), DownloadSource.candidates(catalog)) } + + @Test fun withoutADigestOnlyTheOriginalUrlIsTried() { + val url = "https://github.com/owner/repo/releases/download/v1/app.AppImage" + assertEquals(listOf(url), Downloader.sources(url, null)) + assertEquals(DownloadSource.candidates(url), Downloader.sources(url, "a".repeat(64))) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt index 4fe40920d..281a78224 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt @@ -7,6 +7,7 @@ class GameEnvironmentTest { @Test fun defaultsIncludeRequestedGraphicsLevelsAndKeepCaching() { val values = GameEnvironment.defaults("") assertEquals("false", values["MESA_SHADER_CACHE_DISABLE"]) + assertEquals("1", values["DROIDDECK_ANTICHEAT_OFFLINE"]) assertEquals("12_2", values["VKD3D_FEATURE_LEVEL"]) assertEquals("6_9", values["VKD3D_SHADER_MODEL"]) } diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorStreamTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorStreamTest.kt new file mode 100644 index 000000000..38aeaca6a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorStreamTest.kt @@ -0,0 +1,86 @@ +package com.droiddeck.launcher.core + +import java.io.StringWriter +import java.io.Writer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LogRedactorStreamTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun utf16AndNulDelimitedSecretsAreWithheld() { + val token = "synthetic_private_value" + for (encoding in listOf(Charsets.UTF_16LE, Charsets.UTF_16BE, Charsets.UTF_16)) { + val src = tmp.newFile() + src.writeBytes("sessionid=$token".toByteArray(encoding)) + val out = StringWriter() + LogRedactor.scrubForShare(src, out) + assertFalse(out.toString().replace("\u0000", "").contains(token)) + assertTrue(out.toString().contains("withheld")) + } + } + + @Test fun aNulAfterTheInitialBinaryProbeWithholdsTheWholeLine() { + val src = tmp.newFile() + src.writeText(".".repeat(9000) + "\u0000sessionid=synthetic_private_value") + val out = StringWriter() + LogRedactor.scrubForShare(src, out) + assertFalse(out.toString().contains("synthetic_private_value")) + assertTrue(out.toString().contains("withheld")) + } + + @Test fun aVeryLongLineIsWithheldAndTheFollowingLineIsScrubbed() { + val src = tmp.newFile() + src.bufferedWriter().use { writer -> + writer.write("sessionid=synthetic_private_value ") + repeat(128) { writer.write(".".repeat(64 * 1024)) } + writer.write("\nready=1 token=another_private_value\n") + } + val out = StringWriter() + LogRedactor.scrubForShare(src, out) + assertTrue("oversized line was retained", out.toString().length < 1024) + assertTrue(out.toString().contains("withheld")) + assertTrue(out.toString().contains("ready=1 token=")) + assertFalse(out.toString().contains("private_value")) + } + + @Test fun malformedUtf8WithholdsWithoutReturningTheUnredactedLine() { + val src = tmp.newFile() + src.writeBytes("sessionid=synthetic_private_value ".toByteArray() + byteArrayOf(0xc3.toByte(), 0x28)) + val out = StringWriter() + LogRedactor.scrubForShare(src, out) + assertFalse(out.toString().contains("synthetic_private_value")) + assertTrue(out.toString().contains("withheld")) + } + + @Test fun normalUnicodeAndLineEndingsKeepTheirDiagnosticContent() { + val src = tmp.newFile() + src.writeText("hazır=✓ token=synthetic_private_value\r\n\rstatus=ok\nson") + val out = StringWriter() + LogRedactor.scrubForShare(src, out) + assertEquals("hazır=✓ token=\n\nstatus=ok\nson\n", out.toString()) + } + + @Test fun aLogAboveTheInMemoryLimitIsStillStreamed() { + val src = tmp.newFile() + val block = ("status=ok " + ".".repeat(4090) + "\n").repeat(16) + src.bufferedWriter().use { writer -> + while (src.length() <= LogRedactor.IN_MEMORY_SCRUB_MAX) { + writer.write(block) + writer.flush() + } + } + var chars = 0L + val out = object : Writer() { + override fun write(buffer: CharArray, offset: Int, length: Int) { chars += length } + override fun flush() {} + override fun close() {} + } + LogRedactor.scrubForShare(src, out) + assertEquals(src.length(), chars) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt index ddeb3ac1f..a67093e93 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -37,6 +37,83 @@ class LogRedactorTest { assertEquals("login ok", LogRedactor.redact("login someone@example.com ok")) } + @Test fun onlyLoaderDiagnosticsKeepHidlSharedLibraryNames() { + val library = "android.hardware.graphics.mapper@4.0.so" + val line = "dlopen failed: library \"$library\" not found" + val pathLine = "dlopen failed: library \"/vendor/lib64/hw/$library\" not found" + + assertEquals(line, LogRedactor.redact(line)) + assertEquals(line, LogRedactor.redactForShare(line)) + assertEquals(pathLine, LogRedactor.redact(pathLine)) + assertEquals(pathLine, LogRedactor.redactForShare(pathLine)) + } + + @Test fun privacyRulesOverrideTheHidlSharedLibraryException() { + val library = "android.hardware.graphics.mapper@4.0.so" + assertEquals("token=", LogRedactor.redact("token=$library")) + assertEquals("username=", LogRedactor.redact("username=$library")) + assertEquals("{\"password\":\"\"}", LogRedactor.redact("{\"password\":\"$library\"}")) + assertEquals("{\"password\":\"\"}", LogRedactor.redactForShare("{\"password\":\"$library\"}")) + assertEquals("{\"password\":\"\"}", LogRedactor.redact("{\"password\":\"don't-use-$library\"}")) + assertEquals("{\"password\":\"\"}", LogRedactor.redactForShare("{\"password\":\"don't-use-$library\"}")) + + val mixed = "dlopen failed: library \"$library\" not found; token=abcdef123456; contact=alice!$library; {\"password\":\"library \\\"$library\\\" not found\"}" + val expected = "dlopen failed: library \"$library\" not found; token=; contact=alice!; {\"password\":\"\"}" + assertEquals(expected, LogRedactor.redact(mixed)) + assertEquals(expected, LogRedactor.redactForShare(mixed)) + + val root = java.nio.file.Files.createTempDirectory("hidl-redactor").toFile() + val users = java.io.File(root, "loginusers.vdf") + users.writeText("\"AccountName\" \"$library\"") + try { + LogRedactor.learnAccounts(users) + assertEquals("", LogRedactor.redact(library)) + } finally { + LogRedactor.learnAccounts() + root.deleteRecursively() + } + } + + @Test fun hidlSharedLibraryNamesAreRedactedOutsideLoaderDiagnostics() { + val library = "android.hardware.graphics.mapper@4.0.so" + + assertEquals("", LogRedactor.redact(library)) + assertEquals("", LogRedactor.redactForShare(library)) + assertEquals("/vendor/lib64/hw/", LogRedactor.redact("/vendor/lib64/hw/$library")) + for (punctuation in listOf("!", "#", "~", "/", "=")) { + val expected = "contact=alice$punctuation" + assertEquals(expected, LogRedactor.redact("contact=alice$punctuation$library")) + assertEquals(expected, LogRedactor.redactForShare("contact=alice$punctuation$library")) + } + assertEquals("", LogRedactor.redact("$library.evil")) + assertEquals("", LogRedactor.redact("someone@company.so")) + assertEquals("", LogRedactor.redact("not$library")) + assertEquals("_debug", LogRedactor.redact("${library}_debug")) + assertEquals(".1", LogRedactor.redact("${library}.1")) + assertEquals("@attacker", LogRedactor.redact("${library}@attacker")) + assertEquals("", LogRedactor.redact("android.hardware.graphics.mapper@4.0.software")) + assertEquals("", LogRedactor.redact("android.hardware.graphics.mapper@4.0.so.evil")) + } + + @Test fun loaderContextRequiresExactWordsAndABoundedAndroidPath() { + val library = "android.hardware.graphics.mapper@4.0.so" + val valid = "library \"/system/lib64/$library\" not found: needed by app" + assertEquals(valid, LogRedactor.redact(valid)) + assertEquals(valid, LogRedactor.redactForShare(valid)) + for (line in listOf( + "not_library \"$library\" not found", + "library \"$library\" not found_suffix", + "library \"/home/user/$library\" not found", + "library \"relative/$library\" not found", + "library \"/vendor/invalid!/$library\" not found", + "library \"/vendor/" + "a/".repeat(2050) + "$library\" not found", + )) { + val expected = line.replace(library, "") + assertEquals(expected, LogRedactor.redact(line)) + assertEquals(expected, LogRedactor.redactForShare(line)) + } + } + @Test fun clockTimeIsNotAnAddress() { val line = "[2026-09-27 10:03:05] Startup" assertEquals(line, LogRedactor.redact(line)) diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt new file mode 100644 index 000000000..ac13ac781 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt @@ -0,0 +1,172 @@ +package com.droiddeck.launcher.files + +import java.io.File +import java.util.concurrent.CancellationException +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import org.junit.Assume.assumeTrue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FileOpsCopyTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun cancellingAnOverwritePreservesTheDestinationAndSource() { + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFile("dest").apply { writeText("original user data") } + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(src, dst, { _, _ -> throw CancellationException("cancel") }) + } + assertTrue("original destination was removed", dst.exists()) + assertEquals("original user data", dst.readText()) + assertEquals("replacement", src.readText()) + assertEquals(setOf("source", "dest"), tmp.root.list()!!.toSet()) + } + + @Test fun cancellingANewCopyLeavesNoPartialDestination() { + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = File(tmp.root, "dest") + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(src, dst, { _, _ -> throw CancellationException("cancel") }) + } + assertFalse(dst.exists()) + assertEquals("replacement", src.readText()) + assertEquals(setOf("source"), tmp.root.list()!!.toSet()) + } + + @Test fun cancellingAMoveFallbackPreservesBothFiles() { + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFile("dest").apply { writeText("original user data") } + assertThrows(CancellationException::class.java) { + FileOps.moveWithProgress(src, dst, { _, _ -> throw CancellationException("cancel") }) + } + assertTrue("original destination was removed", dst.exists()) + assertEquals("original user data", dst.readText()) + assertEquals("replacement", src.readText()) + } + + @Test fun cancellingStopsATreeOfEmptyFiles() { + val src = tmp.newFolder("source") + for (name in listOf("a", "b", "c")) File(src, name).createNewFile() + val dst = File(tmp.root, "dest") + var checks = 0 + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(src, dst, null) { checks++ > 1 } + } + assertEquals(1, dst.list()!!.size) + assertEquals(3, src.list()!!.size) + } + + @Test fun cancellingStopsATreeOfEmptyFolders() { + val src = tmp.newFolder("source") + for (name in listOf("a", "b", "c")) File(src, name).mkdir() + val dst = File(tmp.root, "dest") + var checks = 0 + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(src, dst, null) { checks++ > 1 } + } + assertEquals(1, dst.list()!!.size) + } + + @Test fun aCompletedCopyReplacesTheDestination() { + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFile("dest").apply { writeText("original user data") } + assertTrue(FileOps.copyWithProgress(src, dst, null)) + assertEquals("replacement", dst.readText()) + assertEquals("replacement", src.readText()) + assertEquals(setOf("source", "dest"), tmp.root.list()!!.toSet()) + } + + @Test fun aLongDestinationNameDoesNotOverflowTheStagingName() { + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = File(tmp.root, "d".repeat(240)) + assertTrue(FileOps.copyWithProgress(src, dst, null)) + assertEquals("replacement", dst.readText()) + } + + @Test fun anOverwritePreservesTheTargetsExecutablePermissions() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFile("dest").apply { writeText("original") } + val permissions = PosixFilePermissions.fromString("rwxr-x---") + Files.setPosixFilePermissions(dst.toPath(), permissions) + assertTrue(FileOps.copyWithProgress(src, dst, null)) + assertEquals("replacement", dst.readText()) + assertEquals(permissions, Files.getPosixFilePermissions(dst.toPath())) + } + + @Test fun aMoveFallbackPreservesTheTargetsExecutablePermissions() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFile("dest").apply { writeText("original") } + val permissions = PosixFilePermissions.fromString("rwxr-x---") + Files.setPosixFilePermissions(dst.toPath(), permissions) + assertTrue(FileOps.moveWithProgress(src, dst, null)) + assertFalse(src.exists()) + assertEquals("replacement", dst.readText()) + assertEquals(permissions, Files.getPosixFilePermissions(dst.toPath())) + } + + @Test fun anOverwriteStillRefusesAReadOnlyDestination() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFile("dest").apply { writeText("original") } + Files.setPosixFilePermissions(dst.toPath(), PosixFilePermissions.fromString("r--r--r--")) + try { + assertFalse(FileOps.copyWithProgress(src, dst, null)) + assertEquals("original", dst.readText()) + assertEquals("replacement", src.readText()) + } finally { + dst.setWritable(true) + } + } + + @Test fun anEmptyFileCanReplaceAnExistingFile() { + val src = tmp.newFile("source") + val dst = tmp.newFile("dest").apply { writeText("original user data") } + var callbacks = 0 + assertTrue(FileOps.copyWithProgress(src, dst, { _, _ -> callbacks++ })) + assertEquals(0, callbacks) + assertEquals(0L, dst.length()) + assertTrue(src.exists()) + } + + @Test fun aFailedPublicationPreservesTheDestinationAndCleansStaging() { + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFolder("dest") + File(dst, "keep").writeText("original user data") + assertFalse(FileOps.copyWithProgress(src, dst, null)) + assertEquals("original user data", File(dst, "keep").readText()) + assertEquals("replacement", src.readText()) + assertEquals(setOf("source", "dest"), tmp.root.list()!!.toSet()) + } + + @Test fun aSuccessfulMoveFallbackDeletesTheSourceOnlyAfterCopying() { + val src = tmp.newFile("source").apply { writeText("replacement") } + val dst = tmp.newFile("dest").apply { writeText("original user data") } + assertTrue(FileOps.moveWithProgress(src, dst, null)) + assertFalse(src.exists()) + assertEquals("replacement", dst.readText()) + assertEquals(setOf("dest"), tmp.root.list()!!.toSet()) + } + + @Test fun overwritingThroughASymlinkKeepsTheLinkAndUpdatesItsTarget() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val src = tmp.newFile("source").apply { writeText("replacement") } + val realTarget = tmp.newFile("real-target").apply { writeText("original user data") } + val link = File(tmp.root, "dest-link") + Files.createSymbolicLink(link.toPath(), realTarget.toPath()) + + assertTrue(FileOps.copyWithProgress(src, link, null)) + assertTrue("destination symlink was replaced instead of written through", Files.isSymbolicLink(link.toPath())) + assertEquals("replacement", realTarget.readText()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsLinksTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsLinksTest.kt new file mode 100644 index 000000000..03ad97a19 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsLinksTest.kt @@ -0,0 +1,85 @@ +package com.droiddeck.launcher.files + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FileOpsLinksTest { + @get:Rule val tmp = TemporaryFolder() + + private fun link(at: File, target: String): Boolean = + runCatching { Files.createSymbolicLink(at.toPath(), File(target).toPath()) }.isSuccess + + @Test fun aMovedFolderKeepsItsLinks() { + val src = tmp.newFolder("game") + File(src, "data.bin").writeText("x") + assumeTrue("symlinks unavailable here", link(File(src, "lib.so"), "data.bin")) + val dst = File(tmp.root, "other/game") + File(dst, "keep").mkdirs() + assertTrue(FileOps.moveWithProgress(src, dst, null)) + assertFalse(src.exists()) + assertTrue(Files.isSymbolicLink(File(dst, "lib.so").toPath())) + assertEquals("data.bin", Files.readSymbolicLink(File(dst, "lib.so").toPath()).toString()) + } + + @Test fun aCopiedLinkPointsWhereTheOriginalDoes() { + val src = tmp.newFolder("a") + assumeTrue("symlinks unavailable here", link(File(src, "l"), "/nowhere/target")) + val dst = File(tmp.root, "b") + assertTrue(FileOps.copyWithProgress(src, dst, null)) + assertTrue(Files.isSymbolicLink(File(src, "l").toPath())) + assertTrue(Files.isSymbolicLink(File(dst, "l").toPath())) + } + + @Test fun copyingALinkPreservesAnUnrelatedStagingName() { + val src = File(tmp.root, "source") + assumeTrue("symlinks unavailable here", link(src, "missing-target")) + val dst = File(tmp.root, "dest") + dst.writeText("old destination") + val sibling = File(tmp.root, ".dest.link-tmp") + sibling.writeText("unrelated user data") + + assertTrue(FileOps.copyWithProgress(src, dst, null)) + assertEquals("unrelated user data", sibling.readText()) + assertEquals("missing-target", Files.readSymbolicLink(dst.toPath()).toString()) + assertEquals(setOf("source", "dest", ".dest.link-tmp"), tmp.root.list()!!.toSet()) + } + + @Test fun anUnrelatedStagingDirectoryDoesNotPreventCopyingALink() { + val src = File(tmp.root, "source") + assumeTrue("symlinks unavailable here", link(src, "missing-target")) + val sibling = tmp.newFolder(".dest.link-tmp") + File(sibling, "keep").writeText("unrelated user data") + + assertTrue(FileOps.copyWithProgress(src, File(tmp.root, "dest"), null)) + assertEquals("unrelated user data", File(sibling, "keep").readText()) + } + + @Test fun aLongDestinationNameDoesNotOverflowTheStagingName() { + val src = File(tmp.root, "source") + assumeTrue("symlinks unavailable here", link(src, "missing-target")) + val dst = File(tmp.root, "d".repeat(240)) + assertTrue(FileOps.copyWithProgress(src, dst, null)) + assertEquals("missing-target", Files.readSymbolicLink(dst.toPath()).toString()) + } + + @Test fun aLinkThatCannotReplaceTheDestinationLeavesItAlone() { + val src = tmp.newFolder("c") + assumeTrue("symlinks unavailable here", link(File(src, "l"), "x")) + val dst = File(tmp.root, "d") + File(dst, "l").mkdirs() + File(dst, "l/keep.txt").writeText("kept") + assertFalse(FileOps.copyWithProgress(File(src, "l"), File(dst, "l"), null)) + assertEquals("kept", File(dst, "l/keep.txt").readText()) + assertFalse(File(dst, ".l.link-tmp").exists()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt index 2d0a29de4..00b9988bd 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -4,6 +4,7 @@ import android.os.Environment import com.droiddeck.launcher.session.SessionPrefs import java.io.File import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -84,12 +85,141 @@ class AddedExesTest { assertEquals(listOf(File(games, "Example").path), AddedExes.list(app).map { it.folder }) } - @Test fun anExeTheSessionCannotSeeIsNotAdded() { + @Test fun aGameInsideAFolderAddedAsOneGameIsAddedOnItsOwn() { + noGamesFolders() + val held = onStorage("Download/ADM/Death Stranding/ds.exe") + val pick = onStorage("Download/ADM/Pro Evolution Soccer 2017/PES2017.exe") + val container = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(container.path, held.path, picked = false)) + val added = AddedGames.addExe(app, pick) + assertTrue(added is AddedGames.AddResult.Added) + assertEquals(pick.parentFile!!.path, (added as AddedGames.AddResult.Added).game.folder.path) + assertEquals(setOf(held.path, pick.path), AddedGames.scan(app).map { it.exe.path }.toSet()) + } + + @Test fun aRemovedFolderGameDoesNotComeBackForAnotherGame() { + noGamesFolders() + val held = onStorage("Download/ADM/Death Stranding/ds.exe") + val pick = onStorage("Download/ADM/Blur Game/Blur.exe") + val container = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(container.path, held.path, picked = false)) + SessionPrefs.setAddedGameName(app, container.path, "Death Stranding Directors Cut") + AddedGames.remove(app, container) + val added = AddedGames.addExe(app, pick) + assertTrue(added is AddedGames.AddResult.Added) + assertEquals("Blur Game", (added as AddedGames.AddResult.Added).game.name) + assertTrue(container.path in SessionPrefs.removedAddedGames(app)) + assertEquals(listOf(pick.path), AddedGames.scan(app).map { it.exe.path }) + } + + @Test fun anotherBuildOfTheSameExeInASiblingFolderSwitchesTheGame() { + noGamesFolders() + val held = onStorage("Games2/Oddworld/x64/Oddworld.exe") + val other = onStorage("Games2/Oddworld/x86/Oddworld.exe") + val folder = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(folder.path, held.path, picked = false)) + val switched = AddedGames.addExe(app, other) + assertTrue(switched is AddedGames.AddResult.Switched) + assertEquals(folder.path, (switched as AddedGames.AddResult.Switched).game.folder.path) + assertEquals(listOf(other.path), AddedGames.scan(app).map { it.exe.path }) + } + + @Test fun aDifferentGameWithANumberedNameIsNotTakenAsAnotherBuild() { + noGamesFolders() + val held = onStorage("Games2/id/Doom/Doom.exe") + val pick = onStorage("Games2/id/Doom64/Doom64.exe") + AddedExes.put(app, AddedExes.Entry(held.parentFile!!.parentFile!!.path, held.path, picked = false)) + assertTrue(AddedGames.addExe(app, pick) is AddedGames.AddResult.Added) + assertEquals(2, AddedGames.scan(app).size) + } + + @Test fun anExeTheSessionCannotSeeBindsItsFolderAndIsAdded() { + noGamesFolders() + val outside = exe(File(tmp.newFolder("card"), "Hades/Hades.exe")) + assertNull(AddedGames.guestPath(app, outside)) + val game = (AddedGames.addExe(app, outside) as AddedGames.AddResult.Added).game + assertEquals(listOf(outside.parent to "${AddedGames.GUEST_DIR}/Hades"), SessionPrefs.gameFolderBinds(app)) + assertEquals("${AddedGames.GUEST_DIR}/Hades/Hades.exe", game.guestExe) + assertTrue(AddedGames.addExe(app, outside) is AddedGames.AddResult.Existing) + assertEquals(1, SessionPrefs.gameFolderBinds(app).size) + } + + @Test fun anotherExeOfAGameTheSessionCannotSeeBindsTheGameFolder() { + noGamesFolders() + val held = exe(File(tmp.newFolder("card"), "Hades/Hades.exe")) + val pick = exe(File(held.parentFile, "x64/Hades.exe")) + AddedExes.put(app, AddedExes.Entry(held.parent!!, held.path, picked = false)) + val switched = AddedGames.addExe(app, pick) as AddedGames.AddResult.Switched + assertEquals(listOf(held.parent to "${AddedGames.GUEST_DIR}/Hades"), SessionPrefs.gameFolderBinds(app)) + assertEquals("${AddedGames.GUEST_DIR}/Hades/x64/Hades.exe", switched.game.guestExe) + } + + @Test fun aGameFolderTheSessionAlreadySeesIsNotBoundAgain() { + noGamesFolders() + val held = onStorage("Games2/Hades/Hades.exe") + onStorage("Games2/Hades/x64/Hades.exe") + AddedExes.put(app, AddedExes.Entry(held.parent!!, held.path, picked = false)) + val alias = File(tmp.root, "alias").toPath() + java.nio.file.Files.createSymbolicLink(alias, held.parentFile!!.parentFile!!.toPath()) + assertEquals(AddedGames.AddResult.Unreachable, AddedGames.addExe(app, File(alias.toFile(), "Hades/x64/Hades.exe"))) + assertEquals(0, SessionPrefs.gameFolderBinds(app).size) + } + + @Test fun anExeInAFolderTheAppCannotReadIsNotAdded() { noGamesFolders() val outside = exe(File(tmp.newFolder("elsewhere"), "Game/Game.exe")) - assertEquals(AddedGames.AddResult.Unreachable, AddedGames.addExe(app, outside)) + outside.parentFile!!.setReadable(false) + try { + assertEquals(AddedGames.AddResult.Unreachable, AddedGames.addExe(app, outside)) + } finally { + outside.parentFile!!.setReadable(true) + } assertEquals(0, AddedExes.list(app).size) - assertEquals(0, AddedGames.scan(app).size) + assertEquals(0, SessionPrefs.gameFolderBinds(app).size) + } + + @Test fun addingAFolderTheSessionCannotSeeBindsItAndAddsItsGames() { + noGamesFolders() + val games = File(tmp.newFolder("card"), "Winlator/Games") + exe(File(games, "Alpha/Alpha.exe")) + exe(File(games, "Beta/Beta.exe")) + File(games, "steamapps").mkdirs() + assertNull(AddedGames.guestPath(app, games)) + + val result = AddedGames.addFolder(app, games) + assertEquals(listOf("Alpha", "Beta"), result.added.map { it.name }) + assertEquals(listOf(games.path to "${AddedGames.GUEST_DIR}/Games"), SessionPrefs.gameFolderBinds(app)) + assertEquals("${AddedGames.GUEST_DIR}/Games/Alpha/Alpha.exe", result.added.first().guestExe) + + val again = AddedGames.addFolder(app, games) + assertEquals(0, again.added.size) + assertEquals(2, again.already) + assertEquals(1, SessionPrefs.gameFolderBinds(app).size) + } + + @Test fun aSecondFolderWithTheSameNameGetsItsOwnGuestPath() { + noGamesFolders() + val first = File(tmp.newFolder("a"), "Games").apply { exe(File(this, "One/One.exe")) } + val second = File(tmp.newFolder("b"), "Games").apply { exe(File(this, "Two/Two.exe")) } + AddedGames.addFolder(app, first) + AddedGames.addFolder(app, second) + val guests = SessionPrefs.gameFolderBinds(app).map { it.second } + assertEquals(2, guests.toSet().size) + assertEquals("${AddedGames.GUEST_DIR}/Games", guests.first()) + } + + @Test fun aGuestPathAlreadyTakenIsNeverReused() { + noGamesFolders() + val a = File(tmp.newFolder("a"), "Games").apply { mkdirs() } + val b = File(tmp.newFolder("b"), "Games").apply { mkdirs() } + val c = File(tmp.newFolder("c"), "Games").apply { exe(File(this, "Three/Three.exe")) } + val clash = GamesFolderMigration.legacyRoots(listOf(a.path, b.path, c.path)).last().guest + SessionPrefs.setGameFolderBinds(app, listOf(a.path to "${AddedGames.GUEST_DIR}/Games", b.path to clash)) + AddedGames.addFolder(app, c) + val guests = SessionPrefs.gameFolderBinds(app).map { it.second } + assertEquals(3, guests.size) + assertEquals(3, guests.toSet().size) + assertEquals("$clash-2", guests.last()) } @Test fun aRemovedSingleExeGameStaysOffUntilPickedAgain() { diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesSkipExeTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesSkipExeTest.kt new file mode 100644 index 000000000..720b0a6b5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesSkipExeTest.kt @@ -0,0 +1,19 @@ +package com.droiddeck.launcher.frontend + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AddedGamesSkipExeTest { + @Test fun gameTitlesContainingShortSkipWordsAreKept() { + listOf("BeachBuggyRacing.exe", "Reach.exe", "Breach.exe", "Crashday.exe", "CrashBandicoot.exe", + "Observer.exe", "Observer-Win64-Shipping.exe", "Game.exe").forEach { assertFalse(it, AddedGames.skipExe(it)) } + } + + @Test fun installersToolsAndServersAreSkipped() { + listOf("unins000.exe", "setup.exe", "vc_redist.x64.exe", "DXSETUP.exe", "CrashReportClient.exe", + "UnityCrashHandler64.exe", "gamelaunchhelper.exe", "EACLauncher.exe", "DedicatedServer.exe", + "UE4Editor.exe", "valheim_server.exe", "GameConfig.exe", "GameConfigure.exe", "Settings.exe", "Updater.exe", + "EasyAntiCheat_Setup.exe").forEach { assertTrue(it, AddedGames.skipExe(it)) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameFilesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameFilesTest.kt index d37689e29..bcae5c8fe 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/GameFilesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameFilesTest.kt @@ -132,6 +132,23 @@ class GameFilesTest { } } + @Test fun longTitlesKeepTheirEndingAndStayApart() { + val folder = tmp.newFolder() + val first = game("32370", "STAR WARS™ Knights of the Old Republic™") + val second = game("208580", "STAR WARS™ Knights of the Old Republic™ II - The Sith Lords™") + assertEquals("STAR WARS™ Knights of the Old Republic™ II - The Sith Lords™.steam", GameFiles.filename(second)) + GameFiles.sync(app, folder, listOf(first, second)) + GameFiles.sync(app, folder, listOf(first, second)) + assertEquals("32370", File(folder, "STAR WARS™ Knights of the Old Republic™.steam").readText()) + assertEquals("208580", File(folder, "STAR WARS™ Knights of the Old Republic™ II - The Sith Lords™.steam").readText()) + assertEquals(2, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION }) + assertEquals("a".repeat(200) + ".steam", GameFiles.filename(game(name = "a".repeat(300)))) + assertEquals("a".repeat(199) + ".steam", GameFiles.filename(game(name = "a".repeat(199) + " b"))) + assertEquals("Game.steam", GameFiles.filename(game(name = " . "))) + assertEquals("Game.steam", GameFiles.filename(game(name = "Game . ."))) + assertEquals("a".repeat(197) + ".steam", GameFiles.filename(game(name = "a".repeat(197) + " . b"))) + } + @Test fun refusesSymlinksLeavingTheExportFolder() { val folder = tmp.newFolder() val outside = tmp.newFile() diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameIdentifierTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameIdentifierTest.kt index f7ddc0212..d67170d1e 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/GameIdentifierTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameIdentifierTest.kt @@ -179,7 +179,7 @@ class GameIdentifierTest { @Test fun isJunkPeName_flagsLaunchersNotGames() { - for (junk in listOf("Rockstar Games Launcher Redirector", "GSE", "Steam", "Launcher", "Epic Games Launcher")) { + for (junk in listOf("Rockstar Games Launcher Redirector", "GSE", "Steam", "Launcher", "Epic Games Launcher", "BootstrapPackagedGame")) { assertTrue("expected junk: $junk", GameIdentifier.isJunkPeName(junk)) } for (real in listOf("God of War", "Grand Theft Auto V", "DARK SOULS: REMASTERED", "Hades")) { diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt index e23faaf69..9493a630b 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend import android.content.Intent import android.net.Uri import android.app.Activity +import com.droiddeck.launcher.LauncherIcon import com.droiddeck.launcher.MainActivity import com.droiddeck.launcher.frontend.Library import org.junit.Assert.assertEquals @@ -68,12 +69,24 @@ class GameShortcutIntentTest { val launch = requireNotNull(shortcut.intent) assertEquals("game:8400", shortcut.id) assertEquals("Geometry Wars: Retro Evolved", shortcut.shortLabel.toString()) - assertEquals(MainActivity::class.java.name, shortcut.activity?.className) + assertEquals("com.droiddeck.launcher.LauncherDark", shortcut.activity?.className) + assertEquals(MainActivity::class.java.name, launch.component?.className) assertEquals(Intent.ACTION_VIEW, launch.action) assertEquals(Uri.parse("droiddeck://game/8400"), launch.data) assertEquals(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, launch.flags) } + @Test fun pinnedShortcutFollowsTheLightLauncherIcon() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + LauncherIcon.set(activity, LauncherIcon.LIGHT) + + GameShortcuts.pin(activity, Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)) + + val shortcut = activity.getSystemService(android.content.pm.ShortcutManager::class.java).pinnedShortcuts.single() + assertEquals(LauncherIcon.LIGHT, LauncherIcon.current(activity)) + assertEquals("com.droiddeck.launcher.LauncherLight", shortcut.activity?.className) + } + @Test fun actionCreateShortcutUsesAndroidsShortcutResultIntentApi() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L) diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt index d3a230a81..8ecd411b0 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt @@ -22,4 +22,17 @@ class LibraryArchTest { assertEquals(listOf("Team Fortress 2"), Library.launchableGames(app, added = emptyList(), arch = "x86_64").map { it.name }) assertEquals(emptyList(), Library.launchableGames(app, added = emptyList(), arch = "arm64").map { it.name }) } + + @Test fun anAddedGameSteamAlreadyListsFromItsFolderIsShownOnce() { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val folder = File(app.filesDir, "games/Example").apply { mkdirs() } + val exe = File(folder, "Example.exe").apply { writeText("game") } + val apps = File(SessionPrefs.steamRoot(app, "arm64"), "steamapps").apply { mkdirs() } + java.nio.file.Files.createSymbolicLink(File(apps, "common").apply { mkdirs() }.toPath().resolve("DroidDeck-42"), folder.toPath()) + File(apps, "appmanifest_42.acf").writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"42\"\n\t\"name\"\t\t\"Example\"\n\t\"StateFlags\"\t\t\"4\"\n\t\"installdir\"\t\t\"DroidDeck-42\"\n}\n") + val added = AddedGames.Game(folder, "Example", exe, "/g/Example.exe", "/g", 0x87654321L, (0x87654321L shl 32) or 0x02000000L, emptyList()) + val other = File(app.filesDir, "games/Other").apply { mkdirs() } + val second = AddedGames.Game(other, "Other", File(other, "Other.exe"), "/g/Other.exe", "/g", 0x81234567L, (0x81234567L shl 32) or 0x02000000L, emptyList()) + assertEquals(listOf("Example" to "internal", "Other" to Library.ADDED), Library.launchableGames(app, added = listOf(added, second), arch = "arm64").map { it.name to it.library }) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryConcealedAppsTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryConcealedAppsTest.kt new file mode 100644 index 000000000..8d00784cc --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryConcealedAppsTest.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.frontend + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class LibraryConcealedAppsTest { + @get:Rule val tmp = TemporaryFolder() + + private fun steamRoot(): File = tmp.newFolder("Steam").also { root -> + File(root, "config").mkdirs() + File(root, "config/loginusers.vdf").writeText( + "\"users\"\n{\n\t\"76561198352563669\"\n\t{\n\t\t\"AccountName\"\t\t\"a\"\n\t\t\"MostRecent\"\t\t\"1\"\n\t}\n}\n") + } + + private fun config(root: File) = File(root, "userdata/392297941/config").apply { mkdirs() } + + @Test fun hiddenCollectionAndPrivateAppsAreBothConcealed() { + val root = steamRoot() + val config = config(root) + File(config, "cloudstorage").mkdirs() + File(config, "cloudstorage/cloud-storage-namespace-1.json").writeText( + """[["user-collections.favorite",{"key":"user-collections.favorite","value":"{\"id\":\"favorite\",\"added\":[620],\"removed\":[]}"}],""" + + """["user-collections.hidden",{"key":"user-collections.hidden","value":"{\"id\":\"hidden\",\"name\":\"Hidden\",\"added\":[223470,2931908220,400],\"removed\":[400]}"}]]""") + File(config, "localconfig.vdf").writeText( + "\"UserLocalConfigStore\"\n{\n\t\"WebStorage\"\n\t{\n\t\t\"PrivateApps_392297941\"\t\t\"[339800,567090]\"\n\t}\n}\n") + assertEquals(setOf(223470L, 2931908220L, 339800L, 567090L), Library.concealedSteamApps(root)) + } + + @Test fun deletedHiddenCollectionAndMissingFilesConcealNothing() { + val root = steamRoot() + assertEquals(emptySet(), Library.concealedSteamApps(root)) + val config = config(root) + File(config, "cloudstorage").mkdirs() + File(config, "cloudstorage/cloud-storage-namespace-1.json").writeText( + """[["user-collections.hidden",{"key":"user-collections.hidden","is_deleted":true}]]""") + assertEquals(emptySet(), Library.concealedSteamApps(root)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt index a0e84775d..448cca6a8 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt @@ -36,7 +36,7 @@ class StoreGameArtTest { } @Test fun anArtChoiceOnAStoreGameIsKeptShownAndResetsWithoutTouchingItsTitle() { - for ((store, id, title) in listOf(Triple(Store.GOG, "1207658924", "Heroes III"), Triple(Store.EPIC, "abc", "Celeste"), Triple(Store.AMAZON, "amzn1.x", "Yakuza 0"))) { + for ((store, id, title) in listOf(Triple(Store.GOG, "1207658924", "Heroes III"), Triple(Store.EPIC, "abc", "Celeste"), Triple(Store.AMAZON, "amzn1.x", "Yakuza 0"), Triple(Store.XBOX, "9NBLGGH4X1", "Forza Horizon"), Triple(Store.ITCH, "12345", "Night in the Woods"))) { val folder = install(store, id, title) val image = File(tmp.root, "$id.png").apply { writeBytes(ByteArray(9) { 1 }) } val option = AddedGameArt.Option(AddedGameArt.FILE, image.path, image.path) diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt index 47f6b7a7c..f2b097e74 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt @@ -13,6 +13,11 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -232,4 +237,95 @@ class GlassesCallbacksTest { field("sink").set(null, null) } } + + @Test fun pro2CallbacksUseSharedPoseAndRejectThePreviousConnection() { + fun v2(gen: Long) = Glasses::class.java.getDeclaredMethod("v2Listener", Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, gen) as VitureV2Link.Listener + var delivered: FloatArray? = null + var timestamp = 0L + field("sink").set(null, Glasses.PoseSink { x, y, z, w, time -> + delivered = floatArrayOf(x, y, z, w) + timestamp = time + }) + try { + val q = floatArrayOf(0f, 0f, 0.6f, 0.8f) + v2(0).onPose(ByteArray(24), q, 100) + assertEquals(null, delivered) + v2(1).onPose(ByteArray(24), q, 101) + org.junit.Assert.assertArrayEquals(q, delivered, 0f) + assertEquals(101L, timestamp) + assertEquals(1L, field("imuPackets").getLong(null)) + v2(1).onValue(VitureProtocol.MSG_GET_DISPLAY, 0x32) + shadowOf(Looper.getMainLooper()).idle() + assertFalse(Glasses.state.value.sbs) + v2(1).onValue(VitureProtocol.MSG_GET_BRIGHTNESS, 8) + v2(1).onReady() + v2(1).onTracking(true) + v2(1).onLost() + generation(2) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(2, Glasses.state.value.brightness) + assertTrue(Glasses.state.value.connected) + assertFalse(Glasses.state.value.supportsScreen3dof) + assertFalse(Glasses.state.value.tracking) + } finally { field("sink").set(null, null) } + } + + @Test fun pro2DemandEndingBeforeTheFirstRestartPoseStillStopsTheStream() { + val writes = LinkedBlockingQueue() + val incoming = LinkedBlockingQueue() + val firstPose = CountDownLatch(1) + val firstRead = CountDownLatch(1) + val delegate = Glasses::class.java.getDeclaredMethod("v2Listener", Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, 1L) as VitureV2Link.Listener + val listener = object : VitureV2Link.Listener by delegate { + override fun onPose(raw: ByteArray, q: FloatArray, timeNs: Long) { + delegate.onPose(raw, q, timeNs) + firstPose.countDown() + } + } + val transport = object : VitureV2Link.Transport { + override fun write(packet: ByteArray): Boolean { writes += packet; return true } + override fun read(timeoutMs: Long): VitureV2Link.Received? { + firstRead.countDown() + return incoming.poll(timeoutMs, TimeUnit.MILLISECONDS) + } + override fun close() {} + } + fun expectStream(stream: Int) = org.junit.Assert.assertArrayEquals( + VitureV2Protocol.imuControl(stream), writes.poll(2, TimeUnit.SECONDS)) + fun updateImu() = Glasses::class.java.getDeclaredMethod("updateImu") + .apply { isAccessible = true }.invoke(Glasses) + val link = VitureV2Link(transport, listener) + field("link").set(null, link) + field("sink").set(null, null) + try { + expectStream(VitureV2Protocol.STREAM_POSE) + assertTrue("Bootstrap write did not complete", firstRead.await(2, TimeUnit.SECONDS)) + val payload = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN) + .putInt(0).putInt(1).putFloat(1f).putFloat(0f).putFloat(0f).putFloat(0f).array() + incoming += VitureV2Link.Received(VitureV2Protocol.packet(VitureV2Protocol.EVENT_POSE, payload), System.nanoTime()) + assertTrue(firstPose.await(2, TimeUnit.SECONDS)) + shadowOf(Looper.getMainLooper()).idle() + expectStream(VitureV2Protocol.STREAM_OFF) + assertTrue(Glasses.state.value.supportsScreen3dof) + assertFalse(Glasses.state.value.tracking) + + field("sink").set(null, Glasses.PoseSink { _, _, _, _, _ -> }) + updateImu() + assertFalse(Glasses.state.value.tracking) + field("sink").set(null, null) + updateImu() + expectStream(VitureV2Protocol.STREAM_POSE) + expectStream(VitureV2Protocol.STREAM_OFF) + assertFalse(link.trackingActive) + assertFalse(Glasses.state.value.tracking) + } finally { + generation(2) + link.close() + field("link").set(null, null) + field("sink").set(null, null) + shadowOf(Looper.getMainLooper()).idle() + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt index 359fcfe30..b8306b936 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt @@ -62,6 +62,12 @@ class GlassesMirrorPacingTest { assertEquals(30, SessionState.fpsLimit) } + @Test fun pacingFollowsThePanelWhenTheSystemKeepsALowerRateThanRequested() { + Shadow.extract(activity.display).current = mode(2, 90f) + apply(GlassesState(), settings) + expect(3, 90f, 0) + } + private fun apply(state: GlassesState, config: GlassesSettings) { ReflectionHelpers.getField>(activity, "glasses\$delegate").value = state ReflectionHelpers.getField>(activity, "glassesSettings\$delegate").value = config @@ -83,5 +89,6 @@ class GlassesMirrorPacingTest { var supported = emptyArray() @Implementation protected fun getMode(): Display.Mode = current @Implementation protected fun getSupportedModes(): Array = supported + @Implementation override fun getRefreshRate(): Float = current.refreshRate } } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt index 401d24701..51d9aa7b7 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt @@ -5,6 +5,14 @@ import org.junit.Assert.* import org.junit.Test class GlassesRenderingTest { + @Test fun modelTwoDRatesLimitChoicesAndSavedRatesFallBackToAnOfferedOne() { + val rokid = GlassesState(displayId = 2, width = 1920, height = 1080, hz = 60f, rates2d = listOf(60, 120)) + assertEquals(listOf(60, 120), GlassesRendering.refreshRates(GlassesSettings(), rokid)) + assertEquals(60, GlassesRendering.outputHz(GlassesSettings(refreshHz = 90), rokid)) + assertEquals(120, GlassesRendering.outputHz(GlassesSettings(refreshHz = 120), rokid)) + assertEquals(listOf(60, 90, 120), GlassesRendering.refreshRates(GlassesSettings(), rokid.copy(rates2d = listOf(60, 90, 120)))) + } + private val ultra = GlassesState( model = "VITURE Luma Ultra", connected = true, controllable = true, displayId = 2, width = 1920, height = 1200, diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt index d4ee21f79..225805dc6 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt @@ -155,6 +155,58 @@ class GlassesUsbTest { assertEquals(listOf(gt), manager.requests) } + @Test fun pro2RequestsPermissionWithoutTheSdkAndDoesNotClaimCompanionProducts() { + val pro2 = device(0x1301, name = "VITURE Luma Ultra") + manager.devices[pro2.deviceName] = pro2 + refresh(ask = true) + assertEquals("VITURE Pro 2", state.value.model) + assertTrue(state.value.controllable) + assertTrue(state.value.experimental) + assertFalse(state.value.hardwareControls) + assertFalse(state.value.supportsDisplayModeControl) + assertFalse(state.value.supportsScreen3dof) + assertFalse(state.value.supportsSpatial) + assertEquals(listOf(pro2), manager.requests) + assertEquals(null, Glasses.deviceModel(device(0x1301, 0x1234, "VITURE Pro 2"))) + assertEquals(null, Glasses.deviceModel(device(0x1102, name = "VITURE Pro 2"))) + assertEquals(null, Glasses.deviceModel(device(0x1302, name = "VITURE Pro 2"))) + val ultra = Glasses.deviceModel(device(0x1104)) as VitureProtocol.Model + assertEquals(VitureProtocol.Family.ULTRA, ultra.family) + assertTrue(ultra.needsSdk) + } + + @Test fun pro2PoseAvailabilityDoesNotEnableDisplayOrHardwareWrites() { + val pro2 = device(0x1301) + manager.devices[pro2.deviceName] = pro2 + manager.granted.add(pro2.deviceName) + val sent = mutableListOf() + field("linkDevice").set(null, pro2) + field("link").set(null, object : GlassesLink { + override val hasImu = true + override fun send(msgId: Int, payload: ByteArray): Boolean { sent += msgId; return true } + override fun close() {} + }) + try { + refresh() + assertTrue(state.value.canControl) + assertTrue(state.value.supportsScreen3dof) + assertFalse(state.value.can3d) + assertFalse(state.value.supportsDisplayModeControl) + assertFalse(state.value.hardwareControls) + assertEquals(0, state.value.brightnessMax) + assertEquals(0, state.value.volumeMax) + Glasses.sendMode(0x32) + Glasses.setBrightness(5) + Glasses.setVolume(5) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(sent.isEmpty()) + } finally { + field("link").set(null, null) + field("linkDevice").set(null, null) + (field("handler").get(null) as Handler).removeCallbacksAndMessages(null) + } + } + @Test fun gtVendorRequestsPermissionAndRetainsMaxProductName() { val gt = device(0xaf50, 0x3941, "RayNeo GT MAX") manager.devices[gt.deviceName] = gt @@ -209,6 +261,14 @@ class GlassesUsbTest { assertEquals(null, Glasses.deviceModel(device(0x043e, 0x1234, "XREAL 1S"))) } + @Test fun rokidMaxRoutesThroughItsOwnModelAndSharedVendorIdNeedsTheName() { + val max = Glasses.deviceModel(device(0x162f, 0x04d2, "Rokid Max")) + assertTrue(max is RokidModel) + assertEquals("Rokid Max", max?.name) + assertEquals(null, Glasses.deviceModel(device(0x162f, 0x04d2, "Generic HID"))) + assertTrue(Glasses.isGlassesDisplayName("Rokid Max")) + } + private fun device(pid: Int, vid: Int = VitureProtocol.VENDOR_ID, name: String = "VITURE device"): UsbDevice = ReflectionHelpers.callConstructor( UsbDevice::class.java, from(String::class.java, "/dev/bus/usb/$pid"), diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RokidProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RokidProtocolTest.kt new file mode 100644 index 000000000..c2cf38e33 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RokidProtocolTest.kt @@ -0,0 +1,94 @@ +package com.droiddeck.launcher.glasses + +import com.droiddeck.launcher.gpu.HeadPose +import org.junit.Assert.* +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class RokidProtocolTest { + private fun hex(s: String) = s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun matrix(q: FloatArray) = HeadPose.fromGlasses(q[0], q[1], q[2], q[3]) + + @Test fun identityRequiresRokidProductString() { + assertEquals("Rokid Max", RokidModel.model(0x04d2, 0x162f, "Rokid Max")?.name) + assertEquals("Rokid Air", RokidModel.model(0x04d2, 0x162f, "Rokid Air")?.name) + assertNull(RokidModel.model(0x04d2, 0x162f, "USB Keyboard")) + assertNull(RokidModel.model(0x04d2, 0x162f, null)) + assertNull(RokidModel.model(0x04d2, 0x1234, "Rokid Max")) + assertTrue(RokidModel.isDisplay("Rokid Max")) + } + + @Test fun maxModesFollowRefreshAndStereoResolution() { + val max = RokidModel.model(0x04d2, 0x162f, "Rokid Max")!! + assertEquals(RokidModel.MODE_2D_60, max.mode(60, false, null)) + assertEquals(RokidModel.MODE_2D_120, max.mode(120, false, null)) + assertEquals(RokidModel.MODE_2D_60, max.mode(90, false, null)) + assertEquals(listOf(60, 120), max.rates2d) + assertEquals(RokidModel.MODE_SBS_1200_60, max.mode(60, true, null)) + assertEquals(RokidModel.MODE_SBS_1200_90, max.mode(90, true, null)) + assertEquals(RokidModel.MODE_SBS_1080_60, max.mode(90, true, GlassesResolution(1920, 1080))) + assertTrue(max.sbs90) + assertFalse(max.hasSunblock) + } + + @Test fun airStaysOnSixtyHertzModes() { + val air = RokidModel.model(0x04d2, 0x162f, "Rokid Air")!! + assertEquals(RokidModel.MODE_2D_60, air.mode(120, false, null)) + assertEquals(RokidModel.MODE_SBS_1080_60, air.mode(90, true, null)) + assertFalse(air.sbs90) + assertEquals(listOf(60), air.rates2d) + } + + @Test fun brightnessStepsRoundTripTheDevicePercent() { + assertEquals(8, RokidModel.brightnessStep(80)) + assertEquals(10, RokidModel.brightnessStep(100)) + assertEquals(3, RokidModel.brightnessStep(32)) + assertEquals(0, RokidModel.brightnessStep(0)) + assertEquals(30, RokidModel.brightnessRaw(3)) + assertEquals(100, RokidModel.brightnessRaw(12)) + } + + @Test fun capturedMaxCombinedReportParsesAccelGyroAndBrightness() { + val packet = hex("11d09b3e3700000000bb9eb03f9147f0c06e86c340306a43bc9c227b3ad3e6aebb000055c29a99abc1cdccfac10000000000000000000000000000503c000000") + val sample = RokidProtocol.Parser().parse(packet)!! + assertEquals(0x373e9bd0L, sample.timeNs) + assertEquals(1.380f, sample.accel.x, 0.001f) + assertEquals(-7.509f, sample.accel.y, 0.001f) + assertEquals(6.110f, sample.accel.z, 0.001f) + assertEquals(-0.0119f, sample.gyro.x, 0.0001f) + assertEquals(80, sample.brightness) + assertNull(RokidProtocol.Parser().parse(packet.copyOf(32))) + } + + @Test fun airSeparateReportsPairOnSharedTimestamp() { + fun report(type: Int, time: Long, v: Vec3) = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN).apply { + put(0, 4); put(1, type.toByte()); putLong(9, time) + putFloat(21, v.x); putFloat(25, v.y); putFloat(29, v.z) + }.array() + val parser = RokidProtocol.Parser() + assertNull(parser.parse(report(1, 500, Vec3(0f, 9.81f, 0f)))) + val sample = parser.parse(report(2, 500, Vec3(0.1f, 0f, 0f)))!! + assertEquals(500_000L, sample.timeNs) + assertEquals(9.81f, sample.accel.y, 0f) + assertEquals(0.1f, sample.gyro.x, 0f) + assertNull(sample.brightness) + assertNull(parser.parse(report(2, 600, Vec3(0f, 0f, 0f)))) + } + + @Test fun capturedRestingGravityStartsUpright() { + val pose = ImuFusion().update(1_000_000, Vec3(0.01f, 9.58f, -2.20f), Vec3(0f, 0f, 0f))!! + assertTrue(kotlin.math.abs(pose[3]) > 0.97f) + } + + @Test fun capturedLeftTurnSignLooksLeft() { + val fusion = ImuFusion() + val first = matrix(fusion.update(1_000_000, Vec3(0f, 9.81f, 0f), Vec3(0f, 0f, 0f))!!) + var last = FloatArray(4) + repeat(1000) { + last = fusion.update((it + 2) * 1_000_000L, Vec3(0f, 9.81f, 0f), Vec3(0f, (Math.PI / 6).toFloat(), 0f))!! + } + val view = HeadPose.relative(HeadPose.recenter(first), matrix(last)) + assertArrayEquals(floatArrayOf(-0.5f, 0f, -0.8660254f), HeadPose.apply(view, floatArrayOf(0f, 0f, -1f)), 0.01f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt index 565e8b614..cefc2e9e4 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt @@ -130,6 +130,31 @@ class VitureProtocolTest { assertArrayEquals(floatArrayOf(0f, 0f, sin(half).toFloat(), cos(half).toFloat()), q, 1e-5f) } + @Test fun directPro2KeepsSdkOnlyFamiliesAndUnknownProductsIsolated() { + val direct = VitureProtocol.model(0x1301, "VITURE Luma Ultra")!! + assertEquals("VITURE Pro 2", direct.name) + assertEquals(VitureProtocol.Family.PRO2, direct.family) + assertFalse(direct.needsSdk) + assertFalse(direct.hardwareControls) + assertFalse(direct.supportsDisplayModeControl) + assertFalse(direct.hasSunblock) + assertEquals(0, direct.brightnessMax) + assertEquals(0, direct.volumeMax) + + for (pid in listOf(0x1101, 0x1104, 0x1201, 0x1211)) { + val model = VitureProtocol.model(pid, null)!! + assertTrue(model.needsSdk) + assertTrue(model.hardwareControls) + assertTrue(model.supportsDisplayModeControl) + } + assertNull(VitureProtocol.model(0x1102, "VITURE Pro 2")) + assertNull(VitureProtocol.model(0x1302, "VITURE Pro 2")) + val sdk = VitureProtocol.sdkModel("Pro 2")!! + assertTrue(sdk.needsSdk) + assertTrue(sdk.hardwareControls) + assertTrue(sdk.supportsDisplayModeControl) + } + @Test fun imuFallsBackToEuler() { val payload = ByteBuffer.allocate(12).putFloat(0f).putFloat(0f).putFloat(30f).array() val q = VitureProtocol.imuQuaternion(payload)!! diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2LinkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2LinkTest.kt new file mode 100644 index 000000000..475a0c4d4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2LinkTest.kt @@ -0,0 +1,404 @@ +package com.droiddeck.launcher.glasses + +import com.droiddeck.launcher.gpu.HeadPose +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.sqrt + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class VitureV2LinkTest { + private val transport = FakeTransport() + private val listener = RecordingListener() + private var link: VitureV2Link? = null + + @After fun close() { link?.close() } + + @Test fun writeAndAckDoNotBecomeReadyWithoutAValidPose() { + val link = start() + transport.feed(ack()) + transport.feed(pose(1).also { it[8] = (it[8].toInt() xor 1).toByte() }) + transport.feed(pose(2, floatArrayOf(0f, 0f, 0f, 0f))) + transport.feed(VitureV2Protocol.packet(VitureV2Protocol.EVENT_RAW, ByteArray(56))) + transport.flush() + assertFalse(link.ready) + assertFalse(link.hasImu) + assertFalse(link.trackingActive) + assertEquals(0, listener.readyCount.get()) + assertTrue(listener.poses.isEmpty()) + assertEquals(2L, link.badFrames) + + val receiptTime = System.nanoTime() + val valid = pose(3) + transport.feed(valid, receiptTime) + transport.flush() + assertTrue(link.ready) + assertTrue(link.hasImu) + assertTrue(link.trackingActive) + assertEquals(1, listener.readyCount.get()) + assertEquals(listOf(true), listener.tracking) + assertEquals(1, listener.poses.size) + assertEquals(receiptTime, listener.poses.single().timeNs) + assertArrayEquals(valid, listener.poses.single().raw) + } + + @Test fun duplicatesAndBackwardTicksAreDroppedWhileUnsignedWrapAdvances() { + start() + for (tick in listOf(0xFFFFFFFEL, 0xFFFFFFFEL, 0xFFFFFFFDL, 0L, 0L, 0xFFFFFFFFL, 1L)) { + transport.feed(pose(tick)) + } + transport.flush() + assertEquals(3, listener.poses.size) + assertEquals(1, listener.readyCount.get()) + assertEquals(listOf(true), listener.tracking) + assertEquals(0L, link!!.badFrames) + } + + @Test fun poseCollectedDuringBootstrapWriteDoesNotEstablishReadiness() { + transport.collectDuringNextWrite(pose(1)) + val link = start() + assertFalse(link.ready) + assertFalse(link.trackingActive) + assertTrue(listener.poses.isEmpty()) + + transport.feed(pose(2)) + transport.flush() + assertTrue(link.ready) + assertTrue(link.trackingActive) + assertEquals(1, listener.readyCount.get()) + assertEquals(1, listener.poses.size) + } + + @Test fun poseCollectedDuringResumeWriteDoesNotRestartTracking() { + val link = startReady(100) + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(0))) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + transport.flush() + + transport.collectDuringNextWrite(pose(101)) + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(1))) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE)) + transport.flush() + assertFalse(link.trackingActive) + assertEquals(1, listener.poses.size) + + transport.feed(pose(102)) + transport.flush() + assertTrue(link.trackingActive) + assertEquals(2, listener.poses.size) + assertEquals(listOf(true, false, true), listener.tracking) + } + + @Test fun stoppingAndRestartingRequiresANewPoseAndRejectsQueuedOldSamples() { + val link = startReady(100) + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(0))) + assertFalse(link.trackingActive) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + transport.feed(pose(101)) + transport.feed(ack()) + transport.flush() + assertEquals(1, listener.poses.size) + assertTrue(link.ready) + assertFalse(link.trackingActive) + + val beforeRestart = System.nanoTime() + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(1))) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE)) + transport.feed(pose(102), beforeRestart) + transport.feed(ack()) + transport.flush() + assertFalse(link.trackingActive) + assertEquals(1, listener.poses.size) + + transport.feed(pose(0)) + transport.flush() + assertTrue(link.trackingActive) + assertEquals(2, listener.poses.size) + assertEquals(1, listener.readyCount.get()) + assertEquals(listOf(true, false, true), listener.tracking) + } + + @Test fun onReadyCanStopBootstrapBeforeTheFirstPoseIsForwarded() { + val link = start() + listener.whenReady = { assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(0))) } + transport.feed(pose(1)) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + transport.flush() + assertEquals(1, listener.readyCount.get()) + assertTrue(link.ready) + assertFalse(link.trackingActive) + assertTrue(listener.poses.isEmpty()) + assertEquals(listOf(true, false), listener.tracking) + } + + @Test fun rapidStopStartCannotAcceptThePreviousStreamsPendingRead() { + val link = startReady() + val held = transport.hold(pose(2)) + try { + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(0))) + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(1))) + } finally { + held.release.countDown() + } + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE)) + transport.flush() + assertFalse(link.trackingActive) + assertEquals(1, listener.poses.size) + assertEquals(listOf(true, false), listener.tracking) + + transport.feed(pose(3)) + transport.flush() + assertTrue(link.trackingActive) + assertEquals(2, listener.poses.size) + } + + @Test fun onReadyCanCloseWithoutForwardingAPoseOrReportingLoss() { + val link = start() + listener.whenReady = link::close + transport.feed(pose(1)) + transport.awaitClosed() + assertFalse(link.ready) + assertFalse(link.trackingActive) + assertTrue(listener.poses.isEmpty()) + assertEquals(0, listener.lostCount.get()) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + } + + @Test fun unsupportedSettersNeverProduceWritesAndQueriesKeepGenericIds() { + val link = startReady() + for ((id, payload) in listOf( + VitureProtocol.MSG_BRIGHTNESS to byteArrayOf(3), + VitureProtocol.MSG_VOLUME to byteArrayOf(3), + VitureProtocol.MSG_DISPLAY to byteArrayOf(0x32), + VitureProtocol.MSG_FILM to byteArrayOf(1), + VitureProtocol.MSG_IMU_RATE to byteArrayOf(3), + VitureProtocol.MSG_IMU to byteArrayOf(2), + VitureProtocol.MSG_IMU to byteArrayOf(), + VitureProtocol.MSG_GET_VOLUME to byteArrayOf(1), + )) assertFalse("msgId=$id", link.send(id, payload)) + assertTrue(link.send(VitureProtocol.MSG_GET_IMU, byteArrayOf())) + transport.flush() + assertTrue(transport.writes.isEmpty()) + + val queries = listOf( + Triple(VitureProtocol.MSG_GET_BRIGHTNESS, VitureV2Protocol.MSG_GET_BRIGHTNESS, 3), + Triple(VitureProtocol.MSG_GET_VOLUME, VitureV2Protocol.MSG_GET_VOLUME, 5), + Triple(VitureProtocol.MSG_GET_DISPLAY, VitureV2Protocol.MSG_GET_DISPLAY, 0x31), + ) + for ((genericId, wireId, value) in queries) { + assertTrue(link.send(genericId, byteArrayOf())) + transport.expectWrite(VitureV2Protocol.packet(wireId)) + transport.feed(VitureV2Protocol.packet(wireId + VitureV2Protocol.RESPONSE_OFFSET, + byteArrayOf(0, value.toByte()))) + } + transport.feed(VitureV2Protocol.packet(VitureV2Protocol.MSG_GET_VOLUME + VitureV2Protocol.RESPONSE_OFFSET, + byteArrayOf(1, 7))) + transport.feed(VitureV2Protocol.packet(VitureV2Protocol.MSG_GET_VOLUME + VitureV2Protocol.RESPONSE_OFFSET, + byteArrayOf(0, 7, 8))) + transport.flush() + assertEquals(queries.map { it.first to it.third }, listener.values) + } + + @Test fun failureToWriteBootstrapClosesTransportAndReportsLoss() { + transport.failNextWrite.set(true) + val link = VitureV2Link(transport, listener).also { this.link = it } + transport.awaitClosed() + assertFalse(link.ready) + assertFalse(link.hasImu) + assertFalse(link.trackingActive) + assertEquals(0, listener.readyCount.get()) + assertEquals(1, listener.lostCount.get()) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE)) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + } + + @Test fun rejectedStreamAckClosesWithoutMarkingTrackingActive() { + val link = start() + transport.feed(ack(status = 7)) + transport.awaitClosed() + assertFalse(link.ready) + assertFalse(link.trackingActive) + assertEquals(1, listener.lostCount.get()) + assertEquals(0, listener.readyCount.get()) + assertTrue(listener.tracking.isEmpty()) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + } + + @Test fun readFailureClearsTrackingAndClosesTransport() { + val link = startReady() + transport.disconnect() + transport.awaitClosed() + assertFalse(link.ready) + assertFalse(link.trackingActive) + assertEquals(listOf(true, false), listener.tracking) + assertEquals(1, listener.lostCount.get()) + assertFalse(link.send(VitureProtocol.MSG_IMU, byteArrayOf(1))) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + } + + @Test fun explicitCloseSendsOffOnceAndDoesNotReportLoss() { + val link = startReady() + link.close() + transport.awaitClosed() + assertFalse(link.ready) + assertFalse(link.trackingActive) + assertEquals(listOf(true, false), listener.tracking) + assertEquals(0, listener.lostCount.get()) + assertEquals(1, transport.closeCount.get()) + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + link.close() + assertFalse(link.send(VitureProtocol.MSG_GET_BRIGHTNESS, byteArrayOf())) + assertEquals(1, transport.closeCount.get()) + assertTrue(transport.writes.isEmpty()) + } + + @Test fun sharedPoseProducesTheSourceRightUpBackRelativeRotation() { + startReady() + val baseline = listener.poses.single().q + val reference = HeadPose.recenter(HeadPose.fromGlasses(baseline[0], baseline[1], baseline[2], baseline[3]), + centerView = true) + val half = sqrt(0.5f) + val rotations = listOf( + floatArrayOf(half, half, 0f, 0f), + floatArrayOf(half, 0f, half, 0f), + floatArrayOf(half, 0f, 0f, half), + floatArrayOf(0.5f, -0.5f, 0.5f, -0.5f), + ) + rotations.forEachIndexed { index, q -> transport.feed(pose(index + 2L, q)) } + transport.flush() + for ((source, event) in rotations.zip(listener.poses.drop(1))) { + val q = event.q + val relative = HeadPose.relative(reference, HeadPose.fromGlasses(q[0], q[1], q[2], q[3])) + assertArrayEquals(HeadPose.fromQuaternion(source[1], source[2], source[3], source[0]), relative, 1e-5f) + } + assertEquals(rotations.size + 1, listener.poses.size) + } + + private fun start(): VitureV2Link = VitureV2Link(transport, listener).also { + link = it + transport.expectWrite(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE)) + transport.flush() + } + + private fun startReady(tick: Long = 1): VitureV2Link = start().also { + transport.feed(pose(tick)) + transport.flush() + assertTrue(it.ready) + } + + private fun ack(status: Int = 0) = VitureV2Protocol.packet( + VitureV2Protocol.MSG_IMU_CONTROL + VitureV2Protocol.RESPONSE_OFFSET, byteArrayOf(status.toByte())) + + private fun pose(tick: Long, wxyz: FloatArray = floatArrayOf(1f, 0f, 0f, 0f)): ByteArray { + val payload = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN) + .putInt(0x126).putInt(tick.toInt()) + wxyz.forEach(payload::putFloat) + return VitureV2Protocol.packet(VitureV2Protocol.EVENT_POSE, payload.array()) + } + + private data class PoseEvent(val raw: ByteArray, val q: FloatArray, val timeNs: Long) + + private class RecordingListener : VitureV2Link.Listener { + val readyCount = AtomicInteger() + val lostCount = AtomicInteger() + val tracking = CopyOnWriteArrayList() + val values = CopyOnWriteArrayList>() + val poses = CopyOnWriteArrayList() + @Volatile var whenReady: (() -> Unit)? = null + override fun onReady() { readyCount.incrementAndGet(); whenReady?.invoke() } + override fun onValue(msgId: Int, value: Int) { values += msgId to value } + override fun onPose(raw: ByteArray, q: FloatArray, timeNs: Long) { poses += PoseEvent(raw.copyOf(), q.copyOf(), timeNs) } + override fun onTracking(active: Boolean) { tracking += active } + override fun onLost() { lostCount.incrementAndGet() } + } + + private sealed interface ReadStep { + data class Packet(val received: VitureV2Link.Received) : ReadStep + data class Barrier(val reached: CountDownLatch) : ReadStep + data class HeldPacket( + val received: VitureV2Link.Received, + val entered: CountDownLatch = CountDownLatch(1), + val release: CountDownLatch = CountDownLatch(1), + ) : ReadStep + data object Disconnected : ReadStep + } + + private class FakeTransport : VitureV2Link.Transport { + val writes = LinkedBlockingQueue(64) + val closeCount = AtomicInteger() + val failNextWrite = AtomicBoolean() + private val collectedDuringWrite = AtomicReference() + private val incoming = LinkedBlockingQueue(128) + private val closed = CountDownLatch(1) + + override fun write(packet: ByteArray): Boolean { + check(writes.offer(packet.copyOf())) + val success = !failNextWrite.getAndSet(false) + if (success) collectedDuringWrite.getAndSet(null)?.let { feed(it, System.nanoTime()) } + return success + } + + override fun read(timeoutMs: Long): VitureV2Link.Received? = when ( + val step = incoming.poll(timeoutMs, TimeUnit.MILLISECONDS) + ) { + is ReadStep.Packet -> step.received + is ReadStep.Barrier -> { step.reached.countDown(); null } + is ReadStep.HeldPacket -> { + step.entered.countDown() + check(step.release.await(2, TimeUnit.SECONDS)) + step.received + } + ReadStep.Disconnected -> throw IOException("Device disconnected") + null -> null + } + + override fun close() { closeCount.incrementAndGet(); closed.countDown() } + + fun feed(packet: ByteArray, timeNs: Long = System.nanoTime()) { + check(incoming.offer(ReadStep.Packet(VitureV2Link.Received(packet.copyOf(), timeNs)))) + } + + fun collectDuringNextWrite(packet: ByteArray) { + check(collectedDuringWrite.compareAndSet(null, packet.copyOf())) + } + + fun disconnect() { check(incoming.offer(ReadStep.Disconnected)) } + + fun hold(packet: ByteArray): ReadStep.HeldPacket { + val held = ReadStep.HeldPacket(VitureV2Link.Received(packet.copyOf(), System.nanoTime())) + check(incoming.offer(held)) + assertTrue("Worker did not enter the held read", held.entered.await(2, TimeUnit.SECONDS)) + return held + } + + fun expectWrite(expected: ByteArray) { + assertArrayEquals(expected, writes.poll(2, TimeUnit.SECONDS)) + } + + fun flush() { + val reached = CountDownLatch(1) + check(incoming.offer(ReadStep.Barrier(reached))) + assertTrue("Worker did not consume queued reports", reached.await(2, TimeUnit.SECONDS)) + } + + fun awaitClosed() { assertTrue("Transport did not close", closed.await(2, TimeUnit.SECONDS)) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2ProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2ProtocolTest.kt new file mode 100644 index 000000000..95f0cfb55 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureV2ProtocolTest.kt @@ -0,0 +1,175 @@ +// Fixtures adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. +// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/. +package com.droiddeck.launcher.glasses + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.sqrt + +class VitureV2ProtocolTest { + @Test fun requestsMatchUpstreamWireExamples() { + assertArrayEquals(hex("10 00 01 03 02 00 03 00 01 02"), + VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE)) + assertArrayEquals(hex("10 00 01 03 02 00 04 00 02 02"), + VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_RAW)) + assertArrayEquals(hex("10 00 01 03 02 00 00 00 00 00"), + VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) + assertArrayEquals(hex("10 00 22 31 00 00 00 00"), + VitureV2Protocol.packet(VitureV2Protocol.MSG_GET_BRIGHTNESS)) + } + + @Test fun requestBoundsAndStreamLimitsAreEnforced() { + assertThrows(IllegalArgumentException::class.java) { VitureV2Protocol.packet(-1) } + assertThrows(IllegalArgumentException::class.java) { VitureV2Protocol.packet(0x10000) } + assertThrows(IllegalArgumentException::class.java) { VitureV2Protocol.packet(0, ByteArray(57)) } + assertThrows(IllegalArgumentException::class.java) { VitureV2Protocol.imuControl(3) } + assertThrows(IllegalArgumentException::class.java) { + VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE, VitureV2Protocol.Rate.HZ_500) + } + assertArrayEquals(hex("10 00 01 03 02 00 07 00 02 05"), + VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_RAW, VitureV2Protocol.Rate.HZ_1000)) + val maximum = VitureV2Protocol.packet(0xFFFF, ByteArray(56) { 0xFF.toByte() }) + assertEquals(64, maximum.size) + assertEquals(0x37C8, (maximum[6].toInt() and 0xFF) or ((maximum[7].toInt() and 0xFF) shl 8)) + assertNotNull(VitureV2Protocol.parse(maximum)) + } + + @Test fun brightnessReplyMatchesUpstreamAndKeepsFailureStatus() { + val frame = VitureV2Protocol.parse(hex("10 00 22 51 02 00 03 00 00 03"))!! + val reply = VitureV2Protocol.reply(frame, VitureV2Protocol.MSG_GET_BRIGHTNESS)!! + assertTrue(reply.success) + assertEquals(0, reply.status) + assertArrayEquals(byteArrayOf(3), reply.body) + assertNull(VitureV2Protocol.reply(frame, VitureV2Protocol.MSG_GET_VOLUME)) + assertNull(VitureV2Protocol.reply(frame, -1)) + val failure = VitureV2Protocol.parse(hex("10 00 01 23 01 00 07 00 07"))!! + val failedReply = VitureV2Protocol.reply(failure, VitureV2Protocol.MSG_IMU_CONTROL)!! + assertFalse(failedReply.success) + assertEquals(7, failedReply.status) + assertTrue(failedReply.body.isEmpty()) + val noStatus = VitureV2Protocol.parse(hex("10 00 01 23 00 00 00 00"))!! + assertNull(VitureV2Protocol.reply(noStatus, VitureV2Protocol.MSG_IMU_CONTROL)) + } + + @Test fun validatesReceiveBoundsBeforeReadingHeader() { + val valid = hex("10 00 22 51 02 00 03 00 00 03") + for (size in listOf(-1, 0, 7, 9, 11, 65, Int.MAX_VALUE)) { + assertNull("size=$size", VitureV2Protocol.parse(valid, size)) + } + assertNull(VitureV2Protocol.parse(ByteArray(0))) + assertNull(VitureV2Protocol.parse(valid.copyOf(65))) + assertNull(VitureV2Protocol.parse(valid.copyOf().also { it[0] = 0x11 })) + assertNull(VitureV2Protocol.parse(valid.copyOf().also { it[1] = 1 })) + assertNull(VitureV2Protocol.parse(valid.copyOf().also { it[6] = 0xFF.toByte() })) + assertNull(VitureV2Protocol.parse(valid.copyOf().also { it[9] = 4 })) + } + + @Test fun rejectsOversizedAndTruncatedDeclaredPayloads() { + val valid = hex("10 00 22 51 02 00 03 00 00 03") + assertNull(VitureV2Protocol.parse(valid.copyOf(9))) + val tooLong = valid.copyOf(64).also { it[4] = 57 } + assertNull(VitureV2Protocol.parse(tooLong)) + tooLong[4] = 0xFF.toByte() + tooLong[5] = 0xFF.toByte() + assertNull(VitureV2Protocol.parse(tooLong)) + val padded = valid.copyOf(64) + val parsed = VitureV2Protocol.parse(padded)!! + assertArrayEquals(byteArrayOf(0, 3), parsed.payload) + padded[9] = 8 + assertArrayEquals(byteArrayOf(0, 3), parsed.payload) + } + + @Test fun poseFixturePreservesDeviceCounterAndConvertsWxyzToNormalizedXyzw() { + val frame = VitureV2Protocol.parse(hex( + "10 00 08 73 18 00 9c 08 " + + "26 01 00 00 59 d6 21 00 dd e3 2b 3e 52 5d 7c 3f dd 45 9a 3b e8 27 52 3a" + ))!! + val pose = VitureV2Protocol.pose(frame)!! + assertEquals(0x0021D659L, pose.deviceTick) + assertEquals(0x126L, pose.unknown) + val expected = floatArrayOf(Float.fromBits(0x3F7C5D52), Float.fromBits(0x3B9A45DD), + Float.fromBits(0x3A5227E8), Float.fromBits(0x3E2BE3DD)) + val norm = sqrt(expected.sumOf { it.toDouble() * it }).toFloat() + assertArrayEquals(expected.map { it / norm }.toFloatArray(), pose.quaternion, 1e-7f) + assertEquals(1.0, pose.quaternion.sumOf { it.toDouble() * it }, 1e-6) + } + + @Test fun poseNormalizesSmallScaleErrorsWithoutChangingAxesOrCounter() { + val payload = posePayload(0.5f, -0.5f, 0.5f, -0.5f) + ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN).putInt(4, -1) + val pose = VitureV2Protocol.pose(event(VitureV2Protocol.EVENT_POSE, payload))!! + assertEquals(0xFFFFFFFFL, pose.deviceTick) + assertArrayEquals(floatArrayOf(-0.5f, 0.5f, -0.5f, 0.5f), pose.quaternion, 0f) + val scaled = VitureV2Protocol.pose(event(VitureV2Protocol.EVENT_POSE, posePayload(1.01f, 0f, 0f, 0f)))!! + assertArrayEquals(floatArrayOf(0f, 0f, 0f, 1f), scaled.quaternion, 1e-7f) + } + + @Test fun rejectsMalformedPoseAndInvalidQuaternions() { + val valid = posePayload(1f, 0f, 0f, 0f) + assertNull(VitureV2Protocol.pose(event(VitureV2Protocol.EVENT_RAW, valid))) + for (size in listOf(0, 23, 25, 56)) { + assertNull(VitureV2Protocol.pose(event(VitureV2Protocol.EVENT_POSE, valid.copyOf(size)))) + } + for (bad in listOf(0f, 0.5f, 2f, Float.MAX_VALUE, Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY)) { + assertNull("w=$bad", VitureV2Protocol.pose(event(VitureV2Protocol.EVENT_POSE, posePayload(bad, 0f, 0f, 0f)))) + } + for (component in 1..3) { + val payload = valid.copyOf() + ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN).putFloat(8 + component * 4, Float.NaN) + assertNull(VitureV2Protocol.pose(event(VitureV2Protocol.EVENT_POSE, payload))) + } + } + + @Test fun rawReadsUnalignedFloatsAndPreservesUnknownBytes() { + val payload = rawPayload() + val frame = event(VitureV2Protocol.EVENT_RAW, payload) + val sample = VitureV2Protocol.raw(frame)!! + assertEquals(0xFFFFFFFEL, sample.deviceTick) + assertArrayEquals(floatArrayOf(1.5f, -2f, 0.25f), sample.gyroRadPerSec, 0f) + assertArrayEquals(floatArrayOf(0.0024f, -0.3221f, -0.9287f), sample.accelG, 0f) + assertArrayEquals(payload, sample.payload) + frame.payload.fill(0) + assertArrayEquals(payload, sample.payload) + } + + @Test fun rejectsMalformedRawAndNonfiniteMeasurementsButKeepsOpaqueTail() { + val payload = rawPayload() + assertNull(VitureV2Protocol.raw(event(VitureV2Protocol.EVENT_POSE, payload))) + for (size in listOf(0, 24, 33, 34, 55)) { + assertNull(VitureV2Protocol.raw(event(VitureV2Protocol.EVENT_RAW, payload.copyOf(size)))) + } + for (offset in listOf(10, 14, 18, 22, 26, 30)) { + for (bad in listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY)) { + val invalid = payload.copyOf() + ByteBuffer.wrap(invalid).order(ByteOrder.LITTLE_ENDIAN).putFloat(offset, bad) + assertNull("offset=$offset, value=$bad", VitureV2Protocol.raw(event(VitureV2Protocol.EVENT_RAW, invalid))) + } + } + ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN).putFloat(46, Float.NaN) + assertNotNull(VitureV2Protocol.raw(event(VitureV2Protocol.EVENT_RAW, payload))) + } + + private fun event(msgId: Int, payload: ByteArray) = + VitureV2Protocol.parse(VitureV2Protocol.packet(msgId, payload))!! + + private fun posePayload(w: Float, x: Float, y: Float, z: Float) = + ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN) + .putInt(0x126).putInt(123).putFloat(w).putFloat(x).putFloat(y).putFloat(z).array() + + private fun rawPayload() = ByteBuffer.allocate(56).order(ByteOrder.LITTLE_ENDIAN) + .putInt(0x126).putInt(-2).putShort(187) + .putFloat(1.5f).putFloat(-2f).putFloat(0.25f) + .putFloat(0.0024f).putFloat(-0.3221f).putFloat(-0.9287f) + .putFloat(117.30f).putFloat(60.75f).putFloat(254.85f) + .put(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)).array() + + private fun hex(value: String) = value.split(' ').map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/AutoDriverTrialTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/AutoDriverTrialTest.kt index d38d96b3f..c9a0fa7b4 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/AutoDriverTrialTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/AutoDriverTrialTest.kt @@ -62,4 +62,25 @@ class AutoDriverTrialTest { assertNull(AutoDriverTrial.rollbackDue(context)) assertEquals("banner@r5", AutoDriverTrial.current(context)?.release) } + + @Test fun displayDriverCrashesBeforeSessionStartStillCount() { + AutoDriverTrial.displayStarting(context, "new-display") + assertNull(AutoDriverTrial.rollbackDue(context)) + AutoDriverTrial.displayStarting(context, "new-display") + assertEquals(2, AutoDriverTrial.rollbackDue(context)?.starts) + } + + @Test fun aNormalStartIsCountedOnce() { + AutoDriverTrial.displayStarting(context, "new-display") + AutoDriverTrial.sessionStarting(context, "new-display", "new-linux") + assertEquals(1, AutoDriverTrial.current(context)?.starts) + AutoDriverTrial.displayStarting(context, "other-display") + assertEquals(1, AutoDriverTrial.current(context)?.starts) + } + + @Test fun aTrialStartedBeforeDisplayCountingStillRollsBackOnTheNextCrash() { + AutoDriverTrial.sessionStarting(context, "new-display", "new-linux") + AutoDriverTrial.displayStarting(context, "new-display") + assertEquals(2, AutoDriverTrial.rollbackDue(context)?.starts) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt new file mode 100644 index 000000000..83f32a36e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt @@ -0,0 +1,67 @@ +package com.droiddeck.launcher.gpu + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class AutoPicksTest { + private fun gpu(model: Int) = GpuInfo("Adreno $model", model, GpuInfo.familyOf(true, model), "", false) + + private fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000, + false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true) + + private fun half(name: String, tag: String, linux: Boolean, pair: String) = + TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair) + + private val pairs = DriverPairs.from(TurnipReleases.Check( + listOf( + bundle("DD-Turnip-v0.3.0"), + half("Turnip-r6.zip", "r6", false, DriverPairs.BANNER), + half("Turnip-r6-Linux.zip", "r6", true, DriverPairs.BANNER), + ), + emptyList(), emptyList(), 0L, + )) + + private val manifest = """ + {"schemaVersion": 1, "rules": [ + {"families": ["A7XX"], "models": [740], "blobMin": 700, "prefer": ["banner"]}, + {"families": ["A7XX", "A6XX"], "prefer": ["dd-turnip", "banner"]}, + {"families": ["A9XX"], "prefer": ["banner"]} + ]} + """.trimIndent() + + @Test fun theManifestOrdersPreferencesAndTheNewestDdTurnipResolves() { + val rules = AutoPicks.parse(manifest)!! + assertEquals(2, rules.size) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(650), pairs, rules)) + } + + @Test fun aReleaseMarkedBadFallsThroughToTheNextPreference() { + val rules = AutoPicks.parse(manifest)!! + val dd = pairs.first { it.key.startsWith(DriverPairs.DD_TURNIP) } + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), pairs, rules, bad = setOf(AutoDriverTrial.release(dd)))) + } + + @Test fun systemDriverRulesOnlyMatchAKnownBlob() { + val rules = AutoPicks.parse(manifest)!! + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = 744)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = 676)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = null)) + assertEquals(744, AutoPicks.blobVersion("512.744.0")) + } + + @Test fun aGpuTheManifestDoesNotCoverUsesTheBuiltInTable() { + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs, AutoPicks.parse(manifest)!!)) + } + + @Test fun anotherSchemaOrNoUsableRulesIsIgnored() { + assertNull(AutoPicks.parse("""{"schemaVersion": 2, "rules": [{"families": ["A7XX"], "prefer": ["banner"]}]}""")) + assertNull(AutoPicks.parse("""{"schemaVersion": 1, "rules": [{"families": ["A9XX"], "prefer": ["banner"]}]}""")) + assertNull(AutoPicks.parse("not json")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt index fe3c9773d..f4c3b79ea 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt @@ -36,7 +36,13 @@ class FrameGenTest { @Test fun onlyLsfgIsAdaptive() { context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit() - .putString("engine", FrameGen.ENGINE_WINFG).putInt("multiplier", 4).putInt("target", 60).commit() - assertEquals(FrameGen.Mode(FrameGen.ENGINE_WINFG, 4), FrameGen.mode(context)) + .putString("engine", FrameGen.ENGINE_DDFG).putInt("multiplier", 4).putInt("target", 60).commit() + assertEquals(FrameGen.Mode(FrameGen.ENGINE_DDFG, 4), FrameGen.mode(context)) + } + + @Test fun aSavedWinFgChoiceBecomesDdFg() { + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit() + .putString("engine", "winfg").putInt("multiplier", 2).commit() + assertEquals(FrameGen.Mode(FrameGen.ENGINE_DDFG, 2), FrameGen.mode(context)) } } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt index c3b8a1dd4..2b10ca81a 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt @@ -9,7 +9,7 @@ class GpuDetectionTest { private fun info(adreno: Boolean, model: Int) = GpuInfo(name = "x", model = model, family = GpuInfo.familyOf(adreno, model), soc = "", oneUi8Gen2 = false) - @Test fun modelIsTheFirstThreeDigitsOfWhateverTheKernelOrVulkanReports() { + @Test fun modelIsParsedOnlyFromAnAdrenoName() { assertEquals(650, GpuInfo.threeDigits("Adreno650")) assertEquals(830, GpuInfo.threeDigits("Adreno (TM) 830")) assertEquals(740, GpuInfo.threeDigits("Adreno740v2")) @@ -19,6 +19,12 @@ class GpuDetectionTest { assertEquals(0, GpuInfo.threeDigits(" ")) assertEquals(0, GpuInfo.threeDigits("Adreno")) assertEquals(0, GpuInfo.threeDigits("Adreno 65")) + assertEquals(850, GpuInfo.threeDigits("Adreno850v2")) + assertEquals(850, GpuInfo.threeDigits("Adreno (TM) 850")) + assertEquals(0, GpuInfo.threeDigits("44051431")) + assertEquals(0, GpuInfo.threeDigits("0x2a02be7")) + assertEquals(0, GpuInfo.threeDigits("Adreno 8500")) + assertEquals(0, GpuInfo.threeDigits("Mali-G850")) } @Test fun nonAdrenoRenderersAreNeverAdrenoWhateverNumberTheyCarry() { @@ -53,12 +59,14 @@ class GpuDetectionTest { assertEquals(Support.TESTED, info(true, 740).support) assertEquals(Support.TESTED, info(true, 750).support) assertEquals(Support.TESTED, info(true, 830).support) + assertEquals(Support.UNTESTED, info(true, 850).support) assertEquals(Support.UNSUPPORTED, info(false, 0).support) assertEquals(Support.UNSUPPORTED, info(false, 830).support) } @Test fun userFacingTextPerTier() { assertEquals("Supported", info(true, 830).supportText) + assertEquals("Experimental Adreno 850 support: requires compatible experimental display and game drivers; not verified on this device", info(true, 850).supportText) assertEquals("Experimental: its drivers are test builds", info(true, 720).supportText) assertEquals("Outside tested hardware (Adreno 650, 725 and newer): it may not run", info(true, 618).supportText) assertEquals("Not supported: no compatible graphics backend is available for this GPU", info(false, 0).supportText) diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/GpuModelFallbackTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/GpuModelFallbackTest.kt index 0ce3bdc6c..24a38bd75 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/GpuModelFallbackTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/GpuModelFallbackTest.kt @@ -11,4 +11,61 @@ class GpuModelFallbackTest { assertEquals(0, GpuInfo.platformModel("somethingelse")) assertEquals(GpuInfo.Family.A7XX, GpuInfo.familyOf(true, GpuInfo.platformModel("pineapple"))) } + @Test fun sm8975IsTheOnlyNewSocFallback() { + assertEquals(850, GpuInfo.platformModel("unknown", "", "SM8975")) + assertEquals(850, GpuInfo.platformModel("unknown", "", "QTI SM8975")) + assertEquals(850, GpuInfo.platformModel("unknown", "QTI SM8975", "")) + assertEquals(850, GpuInfo.platformModel("unknown", "", " qti sm8975 ")) + assertEquals(0, GpuInfo.platformModel("unknown", "", "SM89750")) + assertEquals(0, GpuInfo.platformModel("unknown", "", "SM8975-PRO")) + assertEquals(0, GpuInfo.platformModel("unknown", "", "Qualcomm SM8975")) + assertEquals(0, GpuInfo.platformModel("unknown", "", "SM8850")) + assertEquals(850, GpuInfo.platformModel("sun", "", "SM8975")) + assertEquals(830, GpuInfo.platformModel("sun", "", "unknown")) + } + + @Test fun aKernelModelDoesNotNeedVulkanOrSocFallback() { + assertEquals(850 to "kernel", GpuInfo.modelFromNames("Adreno850v2", + { error("Vulkan must not be queried when the kernel identifies the GPU") }, + { error("SoC must not override the kernel model") })) + } + + @Test fun aMissingKernelModelUsesVulkanBeforeSocFallback() { + assertEquals(830 to "vulkan", GpuInfo.modelFromNames("Adreno", { "Adreno (TM) 830" }, + { error("SoC must not override the Vulkan model") })) + assertEquals(850 to "vulkan", GpuInfo.modelFromNames(null, { "Adreno850v2" }, { 0 })) + } + + @Test fun numericChipIdsCannotHideTheSocFallback() { + for (name in listOf(null, "", "Adreno", "44051431", "0x2a02be7")) { + assertEquals(850 to "platform", GpuInfo.modelFromNames(name, { "Adreno" }, + { GpuInfo.platformModel("unknown", "", "QTI SM8975") })) + } + assertEquals(0 to "", GpuInfo.modelFromNames(null, { null }, { 0 })) + } + + @Test fun sm8975StaysExperimentalWhenTheDriverReportsA840() { + val fallback = GpuInfo.platformModel("sun", "", "SM8975") + for (kernel in listOf("Adreno", "Adreno (TM) 840")) { + val (model, source) = GpuInfo.modelFromNames(kernel, { "Adreno (TM) 840" }, { fallback }) + val gpu = GpuInfo("Adreno $model", model, GpuInfo.familyOf(true, model), "SM8975", false, + modelSource = source, platformGpuModel = fallback) + assertEquals(840, gpu.model) + assertEquals(if (kernel == "Adreno") "vulkan" else "kernel", gpu.modelSource) + assertEquals(GpuInfo.Support.UNTESTED, gpu.support) + assertEquals("Experimental Adreno 850 support: requires compatible experimental display and game drivers; not verified on this device", gpu.supportText) + } + } + + @Test fun anA840WithoutSm8975KeepsItsExistingSupportStatus() { + for (fallback in listOf(0, 830, 840)) { + val gpu = GpuInfo("Adreno 840", 840, GpuInfo.Family.A8XX, "", false, + platformGpuModel = fallback) + assertEquals(GpuInfo.Support.TESTED, gpu.support) + assertEquals("Supported", gpu.supportText) + } + val otherGpu = GpuInfo("Mali", 0, GpuInfo.Family.NOT_ADRENO, "", false, + platformGpuModel = 850) + assertEquals(GpuInfo.Support.UNSUPPORTED, otherGpu.support) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SocNamesTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SocNamesTest.kt index 8919b79a7..963f9f739 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/SocNamesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SocNamesTest.kt @@ -21,4 +21,13 @@ class SocNamesTest { assertEquals("Snapdragon 8 Gen 3 family (SG8350P)", SocNames.label("SG8350P", "pineapple")) assertEquals("XYZ123", SocNames.label("XYZ123", "")) } + @Test fun qtiPrefixIsNormalizedWithoutGuessingOtherModelVariants() { + assertEquals("Snapdragon 8 Elite Extreme Gen 6", SocNames.name("SM8975", "")) + assertEquals("Snapdragon 8 Elite Extreme Gen 6", SocNames.name("QTI SM8975", "")) + assertEquals("Snapdragon 8 Elite Extreme Gen 6", SocNames.name(" qti sm8975 ", "")) + assertEquals("Snapdragon 8 Elite Extreme Gen 6 (QTI SM8975)", SocNames.label("QTI SM8975", "")) + assertNull(SocNames.name("SM89750", "")) + assertNull(SocNames.name("SM8975-PRO", "")) + assertNull(SocNames.name("Qualcomm SM8975", "")) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt index ab037e152..fe5183cfa 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt @@ -13,9 +13,15 @@ class TpUbwcHintTest { assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.762.10")) } + @Test fun a740OnOneUiTurnsTheHintOnWhateverTheBlobVersion() { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.53", samsung = true)) + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, null, samsung = true)) + } + @Test fun otherGpusKeepTheirDriverDefault() { assertNull(TpUbwcHint.features(750, "512.762.10")) assertNull(TpUbwcHint.features(830, "512.676.53")) + assertNull(TpUbwcHint.features(750, "512.676.53", samsung = true)) } @Test fun anUnreadableSystemDriverKeepsTheDriverDefault() { diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TurnipDriverAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TurnipDriverAutoTest.kt index b15e19630..95713589e 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TurnipDriverAutoTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TurnipDriverAutoTest.kt @@ -30,4 +30,17 @@ class TurnipDriverAutoTest { @Test fun otherDevicesRetainTheGenericFallback() { assertEquals("turnip25.1.0", TurnipDriver(RuntimeEnvironment.getApplication()).autoId()) } + + @Test fun normalizedGpuModelsSelectExistingGenerationDrivers() { + for (sdk in listOf(28, 36)) { + assertEquals("turnip26.3.0-a6xx", TurnipDriver.chooseByModel(650, sdk)) + assertEquals("turnip25.1.0", TurnipDriver.chooseByModel(730, sdk)) + assertEquals("turnip-sdk36", TurnipDriver.chooseByModel(850, sdk)) + } + } + + @Test fun unknownGpuModelsRetainTheSdkFallback() { + assertEquals("turnip25.1.0", TurnipDriver.chooseByModel(0, 28)) + assertEquals("turnip-sdk36", TurnipDriver.chooseByModel(0, 36)) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/handtracking/HandTrackingTest.kt b/app/src/test/java/com/droiddeck/launcher/handtracking/HandTrackingTest.kt index 3456857d7..614269942 100644 --- a/app/src/test/java/com/droiddeck/launcher/handtracking/HandTrackingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/handtracking/HandTrackingTest.kt @@ -45,11 +45,12 @@ class HandTrackingTest { assertEquals(TrackingCameras.REAR, s.side) } - @Test fun trackingNeedsThePermissionAndACamera() { + @Test fun trackingNeedsThePermissionACameraAndTheTrackingPack() { val context = RuntimeEnvironment.getApplication() - assertFalse(both.copy(enabled = true).ready) - assertTrue(both.copy(enabled = true, permitted = true).ready) - assertFalse(HandTracking.Settings(enabled = true, permitted = true).ready) + assertFalse(both.copy(enabled = true, downloaded = true).ready) + assertFalse(both.copy(enabled = true, permitted = true).ready) + assertTrue(both.copy(enabled = true, permitted = true, downloaded = true).ready) + assertFalse(HandTracking.Settings(enabled = true, permitted = true, downloaded = true).ready) shadowOf(context).grantPermissions(Manifest.permission.CAMERA) HandTracking.refreshPermission(context) assertTrue(HandTracking.settings.value.permitted) diff --git a/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt new file mode 100644 index 000000000..d7dd5307a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.view.MotionEvent +import java.time.Duration +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class DirectTouchHoldTest { + private val events = mutableListOf() + private val hold = DirectTouchHold(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { + assertEquals(PointerGestures.BTN_RIGHT, button) + events += "$pressed at $x,$y" + } + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + }) { events += "cancel touch" } + + @Test fun holdReleasesRawTouchBeforeClickAndConsumesTheLift() { + assertFalse(touch(MotionEvent.ACTION_DOWN)) + advance(500) + assertEquals(listOf("cancel touch", "true at 40.0,30.0"), events) + assertTrue(touch(MotionEvent.ACTION_UP)) + advance(50) + assertEquals("false at 40.0,30.0", events.last()) + assertFalse(touch(MotionEvent.ACTION_DOWN)) + hold.cancel() + } + + @Test fun tapDragSecondFingerAndCancellationDoNotRightClick() { + for (action in listOf(MotionEvent.ACTION_UP, MotionEvent.ACTION_MOVE, + MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_CANCEL)) { + touch(MotionEvent.ACTION_DOWN) + assertFalse(touch(action, 60f)) + advance(600) + assertTrue(events.isEmpty()) + hold.cancel() + } + } + + @Test fun modeChangeCancelsPendingHoldAndReleasesAnActiveClick() { + touch(MotionEvent.ACTION_DOWN) + hold.cancel() + advance(600) + assertTrue(events.isEmpty()) + touch(MotionEvent.ACTION_DOWN) + advance(500) + hold.cancel() + advance(100) + assertEquals(listOf("cancel touch", "true at 40.0,30.0", "false at 40.0,30.0"), events) + } + + private fun advance(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)) + private fun touch(action: Int, x: Float = 40f): Boolean { + val event = MotionEvent.obtain(0, 0, action, x, 30f, 0) + return hold.onTouch(event).also { event.recycle() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt new file mode 100644 index 000000000..b210be520 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.input + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class GesturePrefsTest { + @Test fun removedOptionsAreIgnoredWhenLoadingExistingSettings() { + val old = """{"holdMouseButtonWhileTouchingEnabled":true,"holdMouseButtonWhileTouchingAction":"right_click", + "showClickHighlight":true,"showCursorInTouchscreenMode":true,"tapAction":"key_E","longPressDelay":650}""" + val config = TouchGestureConfig.fromJson(old) + assertEquals("key_E", config.tapAction) + assertEquals(650, config.longPressDelay) + assertFalse(config.toJson().contains("holdMouseButtonWhileTouching")) + assertFalse(config.toJson().contains("showClickHighlight")) + assertFalse(config.toJson().contains("showCursorInTouchscreenMode")) + } + + @Test fun gameOverridesFallBackToDefaultsAndSurviveSerialization() { + val context = RuntimeEnvironment.getApplication() + val defaults = TouchGestureConfig(tapAction = "key_E", mouseDragMovementMode = "relative") + GesturePrefs.save(context, null, defaults) + assertEquals(defaults, GesturePrefs.read(context, "123")) + val game = defaults.copy(twoFingerTapAction = "show_keyboard", longPressDelay = 650) + GesturePrefs.save(context, "123", game) + assertEquals(game, GesturePrefs.read(context, "123")) + assertEquals(defaults, GesturePrefs.read(context, "456")) + GesturePrefs.clear(context, "123") + assertEquals(defaults, GesturePrefs.read(context, "123")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/GestureRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/GestureRoutingTest.kt new file mode 100644 index 000000000..a9e672ea5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/GestureRoutingTest.kt @@ -0,0 +1,61 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.os.SystemClock +import android.view.Gravity +import android.view.InputDevice +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.FrameLayout +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class GestureRoutingTest { + @Test fun controlFingerDoesNotCountTowardGestureWhenControlIsTouchedFirst() = checkOrder(controlFirst = true) + @Test fun controlFingerDoesNotCountTowardGestureWhenScreenIsTouchedFirst() = checkOrder(controlFirst = false) + + private fun checkOrder(controlFirst: Boolean) { + val context = RuntimeEnvironment.getApplication() + val buttons = mutableListOf>() + val gestures = TouchscreenGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(), object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) {} + override fun moveBy(dx: Float, dy: Float, relative: Boolean) {} + override fun button(code: Int, down: Boolean) { buttons += code to down } + override fun key(code: Int, down: Boolean) {} + override fun wheel(steps: Int) {} + override fun showKeyboard() {} + }) + val root = FrameLayout(context).apply { isMotionEventSplittingEnabled = true } + root.addView(View(context).apply { setOnTouchListener { _, e -> gestures.onTouch(e) } }, FrameLayout.LayoutParams(1000, 600)) + root.addView(View(context).apply { setOnTouchListener { _, _ -> true } }, FrameLayout.LayoutParams(200, 600, Gravity.RIGHT)) + root.measure(View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)) + root.layout(0, 0, 1000, 600) + val firstX = if (controlFirst) 900f else 400f + val secondX = if (controlFirst) 400f else 900f + val downTime = SystemClock.uptimeMillis() + fun send(action: Int, index: Int, vararg points: Pair) { + val properties = points.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + val coords = points.map { (_, px) -> MotionEvent.PointerCoords().apply { x = px; y = 300f; pressure = 1f; size = 1f } }.toTypedArray() + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), points.size, + properties, coords, 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, 0).also { + root.dispatchTouchEvent(it); it.recycle() + } + } + send(MotionEvent.ACTION_DOWN, 0, 3 to firstX) + send(MotionEvent.ACTION_POINTER_DOWN, 1, 3 to firstX, 8 to secondX) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(30)) + send(MotionEvent.ACTION_POINTER_UP, 1, 3 to firstX, 8 to secondX) + send(MotionEvent.ACTION_UP, 0, 3 to firstX) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100)) + assertEquals(listOf(272 to true, 272 to false), buttons) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt index 8ff62e170..1d2e1a10e 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeRoutingTest.kt @@ -71,6 +71,17 @@ class PadBridgeRoutingTest { InputDevice.KEYBOARD_TYPE_NONE, id = -1) assertTrue(virtual.isVirtual) assertFalse(PadBridge.isFromController(virtual)) + for (uinputName in listOf("uinput-xiaomi", "uinput-fpc", "uinput_nav")) { + val phantom = device( + InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK, + InputDevice.KEYBOARD_TYPE_NONE, + name = uinputName, + ) + assertFalse(phantom.isVirtual) + assertFalse(PadBridge.isFromController(phantom)) + assertFalse(PadBridge.isControllerKeyEvent(key(phantom, KeyEvent.KEYCODE_BUTTON_A, KeyEvent.ACTION_DOWN))) + } + assertFalse(PadBridge.anyControllerConnected()) } @Test fun keyAndMotionOwnershipDoesNotFollowEverySourceOnTheDevice() { @@ -122,6 +133,21 @@ class PadBridgeRoutingTest { } } + @Test fun onlyAxesTheDeviceReportsReplaceKeyDrivenTriggersAndDpad() { + val js = InputDevice.SOURCE_JOYSTICK + val sticksOnly = device(js or InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC, rangeSource = js, axis = MotionEvent.AXIS_X) + val withTrigger = device(js or InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC, rangeSource = js, axis = MotionEvent.AXIS_LTRIGGER) + val withHat = device(js or InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC, rangeSource = js, axis = MotionEvent.AXIS_HAT_Y) + val triggers = intArrayOf(MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS) + val hat = intArrayOf(MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y) + assertFalse(PadBridge.hasAxis(sticksOnly, js, *triggers)) + assertFalse(PadBridge.hasAxis(sticksOnly, js, *hat)) + assertTrue(PadBridge.hasAxis(withTrigger, js, *triggers)) + assertFalse(PadBridge.hasAxis(withTrigger, js, *hat)) + assertTrue(PadBridge.hasAxis(withHat, js, *hat)) + assertFalse(PadBridge.hasAxis(null, js, *hat)) + } + private fun key(device: InputDevice, code: Int, action: Int, source: Int = InputDevice.SOURCE_KEYBOARD): KeyEvent { val event = KeyEvent(0L, 0L, action, code, 0, 0, device.id, 0, 0, source) assertSame(device, event.device) @@ -131,8 +157,8 @@ class PadBridgeRoutingTest { private var nextId = 10 private fun device(sources: Int, type: Int, keys: IntArray = intArrayOf(KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_DPAD_UP), - rangeSource: Int? = null, axis: Int = MotionEvent.AXIS_X, id: Int = nextId++): InputDevice { - val builder = InputDeviceBuilder.newBuilder().setId(id).setName("routing fixture $id") + rangeSource: Int? = null, axis: Int = MotionEvent.AXIS_X, id: Int = nextId++, name: String = "routing fixture $id"): InputDevice { + val builder = InputDeviceBuilder.newBuilder().setId(id).setName(name) .setDescriptor("routing-$id").setExternal(true).setSources(sources).setKeyboardType(type) .setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)) if (rangeSource != null) builder.addMotionRange(axis, rangeSource, -1f, 1f, 0f, 0f, 0f) diff --git a/app/src/test/java/com/droiddeck/launcher/input/PadPartialAxesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PadPartialAxesTest.kt new file mode 100644 index 000000000..a0c5bc986 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/PadPartialAxesTest.kt @@ -0,0 +1,128 @@ +package com.droiddeck.launcher.input + +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.InputDeviceBuilder + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter"], + shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class, PadBridgeRoutingTest.RegisteredMotionEvent::class, + JoyConPadTest.RecordingWriter::class]) +class PadPartialAxesTest { + private val directory = Files.createTempDirectory("partial-pad").toFile() + private lateinit var bridge: PadBridge + + @Before fun start() { + JoyConPadTest.RecordingWriter.pads.clear() + bridge = PadBridge(directory) + assertTrue(bridge.start()) + } + + @After fun stop() { + bridge.stop() + directory.deleteRecursively() + } + + @Test fun leftTriggerAxisPreservesRightTriggerKey() = trigger(MotionEvent.AXIS_LTRIGGER, false) + @Test fun brakeAxisPreservesRightTriggerKey() = trigger(MotionEvent.AXIS_BRAKE, false) + @Test fun rightTriggerAxisPreservesLeftTriggerKey() = trigger(MotionEvent.AXIS_RTRIGGER, true) + @Test fun gasAxisPreservesLeftTriggerKey() = trigger(MotionEvent.AXIS_GAS, true) + + private fun trigger(axis: Int, leftKey: Boolean) { + val device = device(axis) + key(device, if (leftKey) KeyEvent.KEYCODE_BUTTON_L2 else KeyEvent.KEYCODE_BUTTON_R2) + motion(device, axis to 0.75f) + assertEquals(1f, if (leftKey) pad().leftTrigger else pad().rightTrigger) + assertEquals(0.75f, if (leftKey) pad().rightTrigger else pad().leftTrigger) + motion(device, axis to 0f) + assertEquals(1f, if (leftKey) pad().leftTrigger else pad().rightTrigger) + assertEquals(0f, if (leftKey) pad().rightTrigger else pad().leftTrigger) + } + + @Test fun horizontalHatPreservesVerticalKeys() { + val device = device(MotionEvent.AXIS_HAT_X) + key(device, KeyEvent.KEYCODE_DPAD_UP) + motion(device, MotionEvent.AXIS_HAT_X to 1f) + assertTrue(pad().up) + assertTrue(pad().right) + motion(device, MotionEvent.AXIS_HAT_X to 0f) + assertTrue(pad().up) + assertFalse(pad().right) + } + + @Test fun verticalHatPreservesHorizontalKeys() { + val device = device(MotionEvent.AXIS_HAT_Y) + key(device, KeyEvent.KEYCODE_DPAD_LEFT) + motion(device, MotionEvent.AXIS_HAT_Y to -1f) + assertTrue(pad().left) + assertTrue(pad().up) + motion(device, MotionEvent.AXIS_HAT_Y to 0f) + assertTrue(pad().left) + assertFalse(pad().up) + } + + @Test fun wiredXboxTriggersStillUseTheirSignedZAxes() { + val device = device(MotionEvent.AXIS_RX, MotionEvent.AXIS_RY, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, wired = true) + motion(device, MotionEvent.AXIS_Z to -1f, MotionEvent.AXIS_RZ to 1f, + MotionEvent.AXIS_RX to 0.6f, MotionEvent.AXIS_RY to -0.6f) + assertEquals(0f, pad().leftTrigger) + assertEquals(1f, pad().rightTrigger) + assertEquals(0.6f, pad().rightX) + assertEquals(-0.6f, pad().rightY) + } + + @Test fun sticksWithoutTriggerOrHatAxesPreserveAllDigitalControls() { + val device = device() + key(device, KeyEvent.KEYCODE_BUTTON_L2) + key(device, KeyEvent.KEYCODE_BUTTON_R2) + key(device, KeyEvent.KEYCODE_DPAD_DOWN) + motion(device) + assertEquals(1f, pad().leftTrigger) + assertEquals(1f, pad().rightTrigger) + assertTrue(pad().down) + } + + private fun pad(): PadState = requireNotNull(JoyConPadTest.RecordingWriter.pads[0]) + + private fun key(device: InputDevice, code: Int) { + assertTrue(bridge.onKeyEvent(KeyEvent(0L, 0L, KeyEvent.ACTION_DOWN, code, 0, 0, + device.id, 0, 0, InputDevice.SOURCE_GAMEPAD))) + } + + private fun motion(device: InputDevice, vararg values: Pair) { + val coords = MotionEvent.PointerCoords().apply { + setAxisValue(MotionEvent.AXIS_X, 0.5f) + for ((axis, value) in values) setAxisValue(axis, value) + } + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 1, + arrayOf(MotionEvent.PointerProperties().apply { id = 0 }), arrayOf(coords), + 0, 0, 1f, 1f, device.id, 0, InputDevice.SOURCE_JOYSTICK, 0) + try { assertTrue(bridge.onMotionEvent(event)) } finally { event.recycle() } + } + + private fun device(vararg axes: Int, wired: Boolean = false): InputDevice { + val builder = InputDeviceBuilder.newBuilder().setId(60).setName("partial pad") + .setDescriptor("partial-pad").setExternal(true) + .setSources(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK) + .setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC) + .setVendorId(if (wired) 0x20d6 else 0).setProductId(if (wired) 0x2063 else 0) + for (axis in axes.toList() + MotionEvent.AXIS_X) { + builder.addMotionRange(axis, InputDevice.SOURCE_JOYSTICK, -1f, 1f, 0f, 0f, 0f) + } + return builder.build().also { + shadowOf(RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java)).addInputDevice(it) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt new file mode 100644 index 000000000..7b2a55e2e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt @@ -0,0 +1,266 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.os.SystemClock +import android.content.res.Configuration +import android.view.InputDevice +import android.view.MotionEvent +import android.view.SurfaceView +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.time.Duration +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, shadows = [SessionGestureRoutingTest.CompositorShadow::class]) +class SessionGestureRoutingTest { + private lateinit var activity: SessionActivity + private lateinit var gestures: TouchscreenGestures + private lateinit var steamTap: ThreeFingerTap + private lateinit var launcherScroll: ScrollTouchGestures + private lateinit var directHold: DirectTouchHold + private val keys = mutableListOf>() + private val directButtons = mutableListOf>() + private val scrollPositions = mutableListOf>() + private val scrollDeltas = mutableListOf>() + private var keyboards = 0 + + @After fun tearDown() { + if (::gestures.isInitialized) gestures.cancel() + if (::launcherScroll.isInitialized) launcherScroll.cancel() + if (::directHold.isInitialized) directHold.cancel() + SessionState.mode = SessionService.MODE_STEAM + SessionState.steamUi = null + SessionState.suspended = false + } + + @Test fun configuredThreeFingerActionTakesPrecedenceOverSteamShortcut() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(threeFingerTapAction = "key_E")) + tapThreeFingers(expectSteamGesture = false) + assertEquals(listOf(18 to true, 18 to false), keys) + assertEquals(0, keyboards) + } + + @Test fun disablingThreeFingerTapDoesNotFallBackToSteamShortcut() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(threeFingerTapEnabled = false)) + tapThreeFingers(expectSteamGesture = false) + assertTrue(keys.isEmpty()) + assertEquals(0, keyboards) + } + + @Test fun defaultThreeFingerActionOpensPcKeyboardOnce() { + prepare(SessionPrefs.TOUCH_GESTURES) + tapThreeFingers(expectSteamGesture = false) + assertEquals(1, keyboards) + assertTrue(keys.isEmpty()) + } + + @Test fun gestureModeKeepsItsBindingsWhileLauncherMouseIsAvailable() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(threeFingerTapAction = "key_E")) + setField("padMouseOn", true) + tapThreeFingers(expectSteamGesture = false) + assertEquals(listOf(18 to true, 18 to false), keys) + assertTrue(scrollPositions.isEmpty()) + assertTrue(scrollDeltas.isEmpty()) + } + + @Test fun directModeStillRoutesLauncherTouchesToScrolling() { + prepare(SessionPrefs.TOUCH_DIRECT) + setField("padMouseOn", true) + val down = SystemClock.uptimeMillis() + for ((index, action) in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP).withIndex()) { + val event = MotionEvent.obtain(down, down + index * 32L, action, 400f, if (index == 0) 300f else 200f, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + } + assertEquals(listOf(400f to 300f), scrollPositions) + assertTrue(scrollDeltas.isNotEmpty()) + assertTrue(keys.isEmpty()) + assertFalse(steamTap.active) + } + + @Test fun upstreamSteamShortcutStillReceivesTouchesInLegacyModes() { + for (mode in listOf(SessionPrefs.TOUCH_DIRECT, SessionPrefs.TOUCH_PAD)) { + prepare(mode) + tapThreeFingers(expectSteamGesture = true) + assertTrue(keys.isEmpty()) + assertEquals(0, keyboards) + } + } + + @Test fun configurationChangeReleasesHeldGestureAndResetsPointerAnchor() { + startLongPress() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertEquals(listOf(18 to true), keys) + activity.onConfigurationChanged(Configuration(activity.resources.configuration).apply { + orientation = Configuration.ORIENTATION_PORTRAIT + }) + assertEquals(listOf(18 to true, 18 to false), keys) + assertFalse(SessionActivity::class.java.getDeclaredField("gesturePointerReady").apply { isAccessible = true }.getBoolean(activity)) + } + + @Test fun surfaceResizeCancelsPendingHoldAndResetsPointerAnchor() { + startLongPress() + setField("surfaceW", 1000) + setField("surfaceH", 600) + val window = android.view.Surface(android.graphics.SurfaceTexture(0)) + var activeHolder: android.view.SurfaceHolder? = null + val surface = object : SurfaceView(RuntimeEnvironment.getApplication()) { + override fun getHolder() = activeHolder ?: super.getHolder() + }.apply { layout(0, 0, 1000, 600) } + activeHolder = object : android.view.SurfaceHolder by surface.holder { + override fun getSurface() = window + } + setField("surfaceView", surface) + try { activity.surfaceChanged(surface.holder, 0, 800, 600) } finally { window.release() } + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertTrue(keys.isEmpty()) + assertFalse(SessionActivity::class.java.getDeclaredField("gesturePointerReady").apply { isAccessible = true }.getBoolean(activity)) + } + + @Test fun unchangedSurfaceDimensionsDoNotInterruptPendingHold() { + startLongPress() + setField("surfaceW", 1000) + setField("surfaceH", 600) + val surface = SessionActivity::class.java.getDeclaredField("surfaceView").apply { isAccessible = true }.get(activity) as SurfaceView + activity.surfaceChanged(surface.holder, 0, 1000, 600) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertEquals(listOf(18 to true), keys) + } + + @Test fun inactiveSurfaceDestructionDoesNotInterruptCurrentGesture() { + startLongPress() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + val staleSurface = SurfaceView(RuntimeEnvironment.getApplication()) + activity.surfaceDestroyed(staleSurface.holder) + assertEquals(listOf(18 to true), keys) + assertTrue(SessionActivity::class.java.getDeclaredField("gesturePointerReady").apply { isAccessible = true }.getBoolean(activity)) + } + + @Test fun pausingCancelsPendingHoldsInBothTouchModes() { + for (mode in listOf(SessionPrefs.TOUCH_GESTURES, SessionPrefs.TOUCH_DIRECT)) { + prepare(mode, TouchGestureConfig(longPressEnabled = true, longPressAction = "key_E")) + val now = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 400f, 300f, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + ReflectionHelpers.callInstanceMethod(activity, "onPause") + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(700)) + assertTrue(keys.isEmpty()) + assertTrue(directButtons.isEmpty()) + } + } + + @Test fun pausingReleasesActiveHoldsInBothTouchModes() { + for (mode in listOf(SessionPrefs.TOUCH_GESTURES, SessionPrefs.TOUCH_DIRECT)) { + keys.clear() + directButtons.clear() + prepare(mode, TouchGestureConfig(longPressEnabled = true, longPressAction = "key_E")) + val now = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 400f, 300f, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + val gestureMode = mode == SessionPrefs.TOUCH_GESTURES + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(if (gestureMode) 350 else 500)) + assertEquals(if (gestureMode) listOf(18 to true) else emptyList(), keys) + assertEquals(if (gestureMode) emptyList() else listOf(PointerGestures.BTN_RIGHT to true), directButtons) + ReflectionHelpers.callInstanceMethod(activity, "onPause") + assertEquals(if (gestureMode) listOf(18 to true, 18 to false) else emptyList(), keys) + assertEquals(if (gestureMode) emptyList() else listOf(PointerGestures.BTN_RIGHT to true, PointerGestures.BTN_RIGHT to false), directButtons) + } + } + + private fun startLongPress() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(longPressEnabled = true, longPressAction = "key_E")) + val now = SystemClock.uptimeMillis() + MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 400f, 300f, 0).also { + try { assertTrue(activity.onTouchEvent(it)) } finally { it.recycle() } + } + setField("gesturePointerReady", true) + } + + private fun prepare(mode: String, config: TouchGestureConfig = TouchGestureConfig()) { + SessionState.mode = SessionService.MODE_STEAM + SessionState.steamUi = "bigpicture" + SessionState.suspended = false + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + steamTap = ThreeFingerTap(8f) + gestures = TouchscreenGestures(8f, object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) {} + override fun moveBy(dx: Float, dy: Float, relative: Boolean) {} + override fun button(code: Int, down: Boolean) { fail("Unexpected mouse button: $code $down") } + override fun key(code: Int, down: Boolean) { keys += code to down } + override fun wheel(steps: Int) { fail("Unexpected wheel input") } + override fun showKeyboard() { keyboards++ } + }).also { it.configure(config) } + setField("routedTouchMode", mode) + setField("padMouseCheckedAt", SystemClock.uptimeMillis()) + setField("threeFingerTap", steamTap) + setField("touchscreenGestures", gestures) + setField("surfaceView", SurfaceView(RuntimeEnvironment.getApplication()).apply { layout(0, 0, 1000, 600) }) + setField("touchpad", TouchpadGestures(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {} + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + })) + directHold = DirectTouchHold(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { directButtons += button to pressed } + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + }) {} + setField("directTouchHold", directHold) + launcherScroll = ScrollTouchGestures(8f, 50f, object : ScrollTouchGestures.Listener { + override fun onMove(x: Float, y: Float) { scrollPositions += x to y } + override fun onButton(button: Int, pressed: Boolean) {} + override fun onScroll(dx: Float, dy: Float) { scrollDeltas += dx to dy } + override fun onLongPress() {} + }) + setField("scrollTouch", launcherScroll) + } + + private fun tapThreeFingers(expectSteamGesture: Boolean) { + val down = SystemClock.uptimeMillis() + fun send(action: Int, index: Int, count: Int) { + val properties = Array(count) { MotionEvent.PointerProperties().apply { id = it; toolType = MotionEvent.TOOL_TYPE_FINGER } } + val coordinates = Array(count) { MotionEvent.PointerCoords().apply { x = 300f + it * 100; y = 300f; pressure = 1f } } + val event = MotionEvent.obtain(down, SystemClock.uptimeMillis(), action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), + count, properties, coordinates, 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + } + send(MotionEvent.ACTION_DOWN, 0, 1) + send(MotionEvent.ACTION_POINTER_DOWN, 1, 2) + send(MotionEvent.ACTION_POINTER_DOWN, 2, 3) + assertEquals(expectSteamGesture, steamTap.active) + send(MotionEvent.ACTION_POINTER_UP, 2, 3) + send(MotionEvent.ACTION_POINTER_UP, 1, 2) + send(MotionEvent.ACTION_UP, 0, 1) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(200)) + assertFalse(steamTap.active) + } + + private fun setField(name: String, value: Any) { + SessionActivity::class.java.getDeclaredField(name).apply { isAccessible = true }.set(activity, value) + } + + @Implements(value = WaylandCompositor::class, isInAndroidSdk = false) + class CompositorShadow { + companion object { + @JvmStatic @Implementation fun __staticInitializer__() {} + @JvmStatic @Implementation fun nativeSendTouch(action: Int, id: Int, x: Int, y: Int) {} + @JvmStatic @Implementation fun nativeSendSceneInput(type: Int, code: Int, value: Int) {} + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionKeyboardPadTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionKeyboardPadTest.kt new file mode 100644 index 000000000..7ba52e168 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/SessionKeyboardPadTest.kt @@ -0,0 +1,118 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.view.View +import androidx.compose.runtime.MutableState +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.agent.AgentGuest +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class SessionKeyboardPadTest { + private lateinit var activity: SessionActivity + private lateinit var controls: OnScreenControls + private val directory = Files.createTempDirectory("keyboard-pad").toFile() + + @Before fun setUp() { + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + activity.getSharedPreferences("session", 0).edit().clear().commit() + SessionState.mode = SessionService.MODE_STEAM + controls = OnScreenControls(activity, null).apply { visibility = View.GONE } + ReflectionHelpers.setField(activity, "onScreenControls", controls) + } + + @After fun tearDown() { + steamKeyboard(false) + SteamTouchDevice.release() + directory.deleteRecursively() + SessionState.mode = SessionService.MODE_STEAM + } + + @Test fun padStepsAsideWhileEitherKeyboardIsUpInSteam() { + SessionPrefs.setOscMode(activity, SessionPrefs.OSC_ALWAYS) + update() + assertEquals(View.VISIBLE, controls.visibility) + steamKeyboard(true) + update() + assertEquals(View.GONE, controls.visibility) + steamKeyboard(false) + update() + assertEquals(View.VISIBLE, controls.visibility) + pcKeyboard(true) + update() + assertEquals(View.GONE, controls.visibility) + pcKeyboard(false) + update() + assertEquals(View.VISIBLE, controls.visibility) + } + + @Test fun steamsKeyboardLeavesThePadAloneOutsideSteam() { + SessionState.mode = SessionService.MODE_DESKTOP + SessionPrefs.setOscMode(activity, SessionPrefs.OSC_ALWAYS) + steamKeyboard(true) + update() + assertEquals(View.VISIBLE, controls.visibility) + pcKeyboard(true) + update() + assertEquals(View.GONE, controls.visibility) + } + + @Test fun steamTouchControllerHidesAndHandsTouchBackWhileSteamsKeyboardIsUp() { + SteamTouchDevice.prepare(directory) + val steam = SteamTouchControls(activity, SteamTouchDevice.current!!, {}, {}) + ReflectionHelpers.setField(activity, "steamTouchControls", steam) + ReflectionHelpers.setField(activity, "steamTouchOptions", + SteamTouchConfig.Options(inputMode = SteamTouchConfig.INPUT_CONTROLLER)) + SessionPrefs.setTouchMode(activity, SessionPrefs.TOUCH_DIRECT) + SessionPrefs.setOscMode(activity, SessionPrefs.OSC_STEAM_TOUCH) + update() + assertEquals(View.VISIBLE, steam.visibility) + assertEquals(SessionPrefs.TOUCH_OFF, routedTouchMode()) + steamKeyboard(true) + update() + assertEquals(View.GONE, steam.visibility) + assertTrue(SteamTouchDevice.current!!.plugged) + assertEquals(SessionPrefs.TOUCH_DIRECT, routedTouchMode()) + steamKeyboard(false) + update() + assertEquals(View.VISIBLE, steam.visibility) + assertEquals(SessionPrefs.TOUCH_OFF, routedTouchMode()) + } + + @Test fun endingTheAgentForgetsSteamsKeyboard() { + steamKeyboard(true) + AgentGuest.stop() + shadowOf(Looper.getMainLooper()).idle() + assertFalse(AgentGuest.steamKeyboard) + } + + private fun update() = ReflectionHelpers.callInstanceMethod(activity, "updateOnScreenControls") + + private fun routedTouchMode() = ReflectionHelpers.getField(activity, "routedTouchMode") + + private fun pcKeyboard(open: Boolean) { + ReflectionHelpers.getField>(activity, "pcKeyboardOpen\$delegate").value = open + } + + @Suppress("UNCHECKED_CAST") + private fun steamKeyboard(showing: Boolean) { + val field = AgentGuest::class.java.getDeclaredField("steamKeyboard\$delegate").apply { isAccessible = true } + (field.get(AgentGuest) as MutableState).value = showing + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt new file mode 100644 index 000000000..610f7499e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt @@ -0,0 +1,69 @@ +package com.droiddeck.launcher.input + +import android.view.View +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class SessionTouchRoutingTest { + private lateinit var activity: SessionActivity + private lateinit var steam: SteamTouchControls + private lateinit var touchpad: TouchpadGestures + private val directory = Files.createTempDirectory("touch-routing").toFile() + + @Before fun setUp() { + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + activity.getSharedPreferences("session", 0).edit().clear().commit() + SessionState.mode = SessionService.MODE_STEAM + SteamTouchDevice.prepare(directory) + steam = SteamTouchControls(activity, SteamTouchDevice.current!!, {}, {}) + steam.visibility = View.VISIBLE + touchpad = TouchpadGestures(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {} + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + }) + field("onScreenControls", OnScreenControls(activity, null).apply { visibility = View.GONE }) + field("steamTouchControls", steam) + field("steamTouchOptions", SteamTouchConfig.Options(inputMode = SteamTouchConfig.INPUT_MOUSE, mouseMode = SteamTouchConfig.MOUSE_ABSOLUTE, trackpadSensitivity = 2f)) + field("touchpad", touchpad) + field("routedTouchMode", SessionPrefs.TOUCH_DIRECT) + } + + @After fun tearDown() { + SteamTouchDevice.release() + directory.deleteRecursively() + SessionState.mode = SessionService.MODE_STEAM + } + + @Test fun disablingSteamTouchRestoresEverySavedModeEvenWhenRegularControlsAreAlreadyHidden() { + for (mode in listOf(SessionPrefs.TOUCH_PAD, SessionPrefs.TOUCH_DIRECT, SessionPrefs.TOUCH_AUTO, SessionPrefs.TOUCH_OFF)) { + SessionPrefs.setTouchMode(activity, mode) + SessionPrefs.setOscMode(activity, SessionPrefs.OSC_NEVER) + steam.visibility = View.VISIBLE + invoke("readPrefs") + assertEquals(SessionPrefs.TOUCH_DIRECT, ReflectionHelpers.getField(activity, "routedTouchMode")) + invoke("updateOnScreenControls") + assertEquals(View.GONE, steam.visibility) + assertEquals(mode, ReflectionHelpers.getField(activity, "routedTouchMode")) + assertEquals(1.15f, touchpad.sensitivity, 0f) + } + } + + private fun field(name: String, value: Any) = ReflectionHelpers.setField(activity, name, value) + private fun invoke(name: String) = ReflectionHelpers.callInstanceMethod(activity, name) +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchBindingsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchBindingsTest.kt index 0ad76cb70..5ce1de632 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchBindingsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchBindingsTest.kt @@ -115,4 +115,20 @@ class SteamTouchBindingsTest { assertEquals("Jump", SteamTouchBindings.bindingAt(reparsed, ref)!!.label) assertNull(SteamTouchBindings.rewrite(config, SteamTouchBindings.Ref("5", "button_a")) { it }) } + + @Test + fun labelsDropQuotesBackslashesCommasAndControls() { + assertEquals("a b c d e", SteamTouchBindings.cleanLabel("a\"b\\c,d\n e")) + assertEquals(32, SteamTouchBindings.cleanLabel("x".repeat(40)).length) + val b = SteamTouchBindings.Binding("key_press SPACE", "Ju\"mp", "", "", "") + assertEquals("Ju mp", SteamTouchBindings.parse(b.compose()).label) + val comma = SteamTouchBindings.Binding("key_press SPACE", "Jump,Run", "icon", "#FFFFFF", "") + assertEquals(SteamTouchBindings.Binding("key_press SPACE", "Jump Run", "icon", "#FFFFFF", ""), SteamTouchBindings.parse(comma.compose())) + } + + @Test + fun labelsAreCutAtACharacterBoundary() { + val label = SteamTouchBindings.cleanLabel("a".repeat(31) + "🎮" + "b") + assertEquals("a".repeat(31) + "🎮", label) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt index eb48d0964..96ff9a1c4 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt @@ -57,4 +57,60 @@ class SteamTouchConfigFilesTest { assertTrue(device.contains("\"8400\"")) assertEquals(saved, SteamTouchConfig.load(context, 204360).file) } + + private val template get() = File(steam, "controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf") + + private fun shortcut(name: String): Int { + val shortcut = 2702387189L.toInt() + template.apply { parentFile!!.mkdirs(); writeText(touch("Gamepad with Joystick")) } + File(steam, "logs/console_log.txt").apply { parentFile!!.mkdirs() }.writeText( + "!! Controller 0 attributes:\n Type: 43\n" + + "Loaded Config for Last Resort Path for App ID 2702387189, Controller 0: /root/.local/share/Steam//controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf\n", + ) + File(steam, "config/loginusers.vdf").apply { parentFile!!.mkdirs() } + .writeText("\"users\"\n{\n\t\"76561197960265851\"\n\t{\n\t\t\"MostRecent\"\t\t\"1\"\n\t}\n}\n") + val vdf = java.io.ByteArrayOutputStream().apply { + write("\u0000shortcuts\u0000\u00000\u0000\u0002appid\u0000".toByteArray()) + write(java.nio.ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(shortcut).array()) + write("\u0001AppName\u0000$name\u0000\u0008\u0008\u0008".toByteArray()) + } + File(steam, "userdata/123/config/shortcuts.vdf").apply { parentFile!!.mkdirs() }.writeBytes(vdf.toByteArray()) + return shortcut + } + + @Test fun aShortcutFindsSteamsConfigAndSavesUnderItsName() { + val shortcut = shortcut("Epic Games Launcher") + val config = SteamTouchConfig.load(context, shortcut) + assertEquals(template, config.file) + assertEquals("Epic Games Launcher", SteamTouchConfig.gameName(context, shortcut)) + val saved = SteamTouchConfig.saveLayouts(context, shortcut, config, mapOf(1 to emptyList())) + assertEquals(File(dir, "epic games launcher/controller_mobile_touch.vdf"), saved) + assertTrue(File(dir, "configset_controller_mobile_touch.vdf").readText().contains("\"epic games launcher\"")) + assertEquals(saved, SteamTouchConfig.load(context, shortcut).file) + } + + @Test fun aShortcutNameThatIsNoPlainFolderNameUsesTheAppId() { + for (name in listOf("../escape", "a/b", "..", "Tab\tName")) { + assertEquals(name, "2702387189", SteamTouchConfig.steamKey(context, shortcut(name))) + } + } + + @Test fun aShortcutNameWithADollarSavesAgain() { + val shortcut = shortcut("Game $1") + val config = SteamTouchConfig.load(context, shortcut) + SteamTouchConfig.saveLayouts(context, shortcut, config, mapOf(1 to emptyList())) + val saved = SteamTouchConfig.saveLayouts(context, shortcut, SteamTouchConfig.load(context, shortcut), mapOf(1 to emptyList())) + assertEquals(File(dir, "game $1/controller_mobile_touch.vdf"), saved) + assertEquals(1, Regex("\"game \\$1\"").findAll(File(dir, "configset_controller_mobile_touch.vdf").readText()).count()) + } + + @Test fun aMixedCaseEntryOfTheShortcutIsReplacedNotDuplicated() { + val shortcut = shortcut("Epic Games Launcher") + configset("configset_controller_mobile_touch.vdf", "Epic Games Launcher" to "\"template\"\t\t\"controller_mobile_touch_gamepad_joystick.vdf\"") + configset("configset_MT-DROIDDECK0001.vdf", "Epic Games Launcher" to "\"workshop\"\t\t\"1\"") + SteamTouchConfig.saveLayouts(context, shortcut, SteamTouchConfig.load(context, shortcut), mapOf(1 to emptyList())) + val entries = Regex("\"epic games launcher\"", RegexOption.IGNORE_CASE) + assertEquals(1, entries.findAll(File(dir, "configset_controller_mobile_touch.vdf").readText()).count()) + assertEquals(0, entries.findAll(File(dir, "configset_MT-DROIDDECK0001.vdf").readText()).count()) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt index 8addf24e0..f34e0fc8a 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt @@ -65,4 +65,27 @@ class SteamTouchConfigTest { val config = SteamTouchConfig.Config(null, null, null, emptyMap(), kv) assertEquals(listOf(1 to "Desktop", 2 to "Gamepad"), SteamTouchConfig.actionSets(config)) } + + @Test + fun remapHeldKeepsSurvivingControlsAndDropsRemovedOrNullOnes() { + val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved)) + val baseConfig = SteamTouchConfig.Config(null, null, layouts, mapOf(0 to setOf(SteamTouchConfig.A, SteamTouchConfig.JOYSTICK_LEFT))) + val layerConfig = SteamTouchConfig.Config(null, null, layouts, mapOf(0 to setOf(SteamTouchConfig.A), 1 to setOf(SteamTouchConfig.B))) + val after = SteamTouchConfig.elementsFor(layerConfig, 0, listOf(2)) + + val remapped = SteamTouchConfig.remapHeld( + mapOf( + 7 to SteamTouchConfig.A, + 8 to SteamTouchConfig.JOYSTICK_LEFT, + 9 to null, + 10 to SteamTouchConfig.B, + ), + after, + ) + + assertEquals(setOf(7, 10), remapped.keys) + assertEquals(after.first { it.type == SteamTouchConfig.A }, remapped[7]) + assertEquals(after.first { it.type == SteamTouchConfig.B }, remapped[10]) + assertFalse(baseConfig.availableFor(0, emptyList()).isEmpty()) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt index ddbf4d7f9..a036849b1 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt @@ -5,6 +5,8 @@ import android.os.Looper import android.view.MotionEvent import java.time.Duration import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -109,6 +111,96 @@ class TouchpadGesturesTest { assertEquals(listOf("left down", "left up"), events) } + @Test fun liftingTheSecondFingerAfterAScrollKeepsTheCursorStill() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + assertTrue(events.contains("wheel -8")) + advance(10) + touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + val sx = touchpad.x + val sy = touchpad.y + touch(MotionEvent.ACTION_MOVE, 0, 1 to (401f to 601f)) + assertEquals(sx, touchpad.x, 0.01f) + assertEquals(sy, touchpad.y, 0.01f) + assertFalse(events.contains("move")) + } + + @Test fun trackingResumesNormallyAfterTheScroll() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + val sy = touchpad.y + touch(MotionEvent.ACTION_MOVE, 0, 1 to (401f to 621f)) + val delta = touchpad.y - sy + assertTrue(delta in 20f..65f) + assertTrue(events.contains("move")) + } + + @Test fun liftingTheAnchorFirstAfterAScrollKeepsTheCursorStill() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_UP, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + val sx = touchpad.x + val sy = touchpad.y + touch(MotionEvent.ACTION_MOVE, 0, 2 to (601f to 611f)) + assertEquals(sx, touchpad.x, 0.01f) + assertEquals(sy, touchpad.y, 0.01f) + assertFalse(events.contains("move")) + } + + @Test fun twoFingerScrollWithLeadingFingerDoesNotPressLeft() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 303f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 340f), 2 to (600f to 350f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 380f), 2 to (600f to 390f)) + assertFalse(events.contains("left down")) + assertTrue(events.any { it.startsWith("wheel ") }) + } + + @Test fun holdDragStillWorks() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 300f), 2 to (620f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 300f), 2 to (640f to 300f)) + assertEquals("left down", events.firstOrNull()) + assertTrue(events.contains("move")) + touch(MotionEvent.ACTION_POINTER_UP, 0, 1 to (400f to 300f), 2 to (640f to 300f)) + assertTrue(events.contains("left up")) + } + + @Test fun anchorMovedLocksOutHoldDrag() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 309f), 2 to (600f to 302f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 309f), 2 to (640f to 302f)) + assertFalse(events.contains("left down")) + } + private fun tap() { touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) advance(80) diff --git a/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt new file mode 100644 index 000000000..ad85d6245 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt @@ -0,0 +1,1055 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewConfiguration +import java.time.Duration +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class TouchscreenGesturesTest { + private val events = mutableListOf() + private data class ButtonEvent(val time: Long, val down: Boolean, val position: Pair) + private val leftClicks = mutableListOf() + private val rightClicks = mutableListOf() + private val timedKeys = mutableListOf>() + private val absoluteMoves = mutableListOf>() + private val relativeMoves = mutableListOf>() + private val recognized = mutableListOf() + private var cursor = 0f to 0f + private lateinit var gestures: TouchscreenGestures + private var downTime = 0L + private val a = 7 to (400f to 300f) + private val b = 12 to (600f to 300f) + private val c = 19 to (800f to 300f) + private val d = 24 to (1000f to 300f) + + @Before fun setUp() { + advance(1000) + gestures = TouchscreenGestures(ViewConfiguration.get(RuntimeEnvironment.getApplication()).scaledTouchSlop.toFloat(), object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) { + cursor = x to y; absoluteMoves += cursor; events += "move" + } + override fun moveBy(dx: Float, dy: Float, relative: Boolean) { + if (relative) relativeMoves += dx to dy + events += "delta $relative" + } + override fun button(code: Int, down: Boolean) { + events += "button $code $down" + if (code == 272) leftClicks += ButtonEvent(SystemClock.uptimeMillis(), down, cursor) + if (code == 273) rightClicks += ButtonEvent(SystemClock.uptimeMillis(), down, cursor) + } + override fun key(code: Int, down: Boolean) { + events += "key $code $down" + timedKeys += Triple(SystemClock.uptimeMillis(), code, down) + } + override fun wheel(steps: Int) { events += "wheel $steps" } + override fun showKeyboard() { events += "keyboard" } + override fun prepareRelativePan() { events += "prepare pan" } + override fun gesture(gesture: TouchscreenGestures.Gesture) { recognized += gesture } + }) + } + + @Test fun tapHasOnePressAndADelayedRelease() { + tap() + assertEquals(listOf("button 272 true"), buttons()) + advance(60) + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun recognizedFeedbackIsTypedAndOnlyEmittedWhenEnabled() { + tap(); advance(150) + assertTrue(recognized.isEmpty()) + gestures.configure(TouchGestureConfig(showGestureDebugOverlay = true)) + tap() + assertEquals(listOf(TouchscreenGestures.Gesture.TAP), recognized) + } + + @Test fun doubleTapProducesTwoClicksRatherThanThree() { + tap(); advance(80); tap(); advance(80) + assertEquals(listOf("button 272 true", "button 272 false", "button 272 true", "button 272 false"), buttons()) + } + + @Test fun aVeryFastDoubleTapKeepsFullPressesAndAReleaseGap() { + tap(); tap(); advance(250) + assertEquals(4, leftClicks.size) + assertTrue(leftClicks[1].time - leftClicks[0].time >= 50) + assertTrue(leftClicks[2].time - leftClicks[1].time >= 50) + assertTrue(leftClicks[3].time - leftClicks[2].time >= 50) + } + + @Test fun doubleTapUsesTheFirstClickLocationDespiteNormalFingerDrift() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, 7 to (404f to 303f)) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (406f to 304f)) + advance(30) + touch(MotionEvent.ACTION_UP, 0, 7 to (406f to 304f)) + advance(200) + assertEquals(4, leftClicks.size) + assertTrue(leftClicks.all { it.position == a.second }) + } + + @Test fun quickTapsOnVerticallyAdjacentIconsClickEachIconOnce() { + val below = 7 to (400f to 345f) + tap(); tap(below); advance(250) + assertEquals(listOf(a.second, below.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun quickTapsOnHorizontallyAdjacentIconsClickEachIconOnce() { + val beside = 7 to (445f to 300f) + tap(); tap(beside); advance(250) + assertEquals(listOf(a.second, beside.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun fastClicksAcrossThreeIconsDoNotSnapBackToEarlierIcons() { + val below = 7 to (400f to 345f) + val bottom = 7 to (400f to 390f) + tap(); tap(below); tap(bottom); advance(350) + assertEquals(listOf(a.second, below.second, bottom.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun aDoubleTapWithoutASingleTapHasAReleaseGapBetweenSynthesizedClicks() { + gestures.configure(TouchGestureConfig(tapEnabled = false)) + tap(); tap(); advance(250) + assertEquals(4, leftClicks.size) + assertTrue(leftClicks[2].time - leftClicks[1].time >= 50) + } + + @Test fun aRapidThirdTapDoesNotDiscardThePendingSecondClick() { + tap(); tap(); tap(); advance(350) + assertEquals(6, leftClicks.size) + assertEquals(listOf(true, false, true, false, true, false), leftClicks.map { it.down }) + } + + @Test fun cancellingBetweenDoubleClicksStopsTheQueuedClick() { + tap(); tap() + gestures.cancel() + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun aTouchCancelDoesNotPrimeTheFollowingTapAsADoubleTap() { + tap(); advance(80) + touch(MotionEvent.ACTION_CANCEL, 0, a) + events.clear() + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(100) + assertTrue(buttons().isEmpty()) + } + + @Test fun firstTapNearOriginIsNotMistakenForADoubleTap() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (1f to 1f)) + assertTrue(buttons().isEmpty()) + } + + @Test fun doubleClickIsNotCommittedUntilTheSecondFingerLifts() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(30) + assertEquals(2, leftClicks.size) + touch(MotionEvent.ACTION_UP, 0, a) + advance(60) + assertEquals(listOf(true, false, true, false), leftClicks.map { it.down }) + } + + @Test fun aTapFollowedByADragAtTheSamePlaceDoesNotSwallowTheDrag() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + assertEquals(2, leftClicks.size) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + assertEquals(3, leftClicks.size) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (520f to 300f)) + assertEquals(520f to 300f, cursor) + touch(MotionEvent.ACTION_UP, 0, 7 to (520f to 300f)) + advance(500) + assertEquals(listOf(true, false, true, false), leftClicks.map { it.down }) + } + + @Test fun aTapFollowedByARelativeDragStartsAtTheNewTouchOrigin() { + gestures.configure(TouchGestureConfig(mouseDragMovementMode = "relative")) + tap(); advance(80) + val second = 7 to (404f to 303f) + touch(MotionEvent.ACTION_DOWN, 0, second) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (484f to 303f)) + assertEquals(second.second, leftClicks.last().position) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (494f to 303f)) + assertEquals(10f to 0f, relativeMoves.last()) + gestures.cancel() + assertEquals(4, leftClicks.size) + } + + @Test fun aTapFollowedByALongPressUsesItsHoldBindingInsteadOfDoubleClicking() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "right_click")) + tap(); advance(80) + val second = 7 to (404f to 303f) + touch(MotionEvent.ACTION_DOWN, 0, second) + advance(350) + assertEquals(2, leftClicks.size) + assertEquals(listOf("button 273 true"), buttons().filter { it.contains("273") }) + assertEquals(second.second, rightClicks.single().position) + touch(MotionEvent.ACTION_UP, 0, second) + advance(500) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + assertEquals(2, leftClicks.size) + } + + @Test fun aLongSecondTouchIsNotASynthesizedDoubleTapWhenHoldsAreDisabled() { + gestures.configure(TouchGestureConfig(tapEnabled = false, longPressEnabled = false)) + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(ViewConfiguration.getLongPressTimeout().toLong() + 20) + touch(MotionEvent.ACTION_UP, 0, a) + advance(500) + assertTrue(buttons().isEmpty()) + } + + @Test fun addingAnotherFingerToAPotentialDoubleTapDoesNotClickLeftAgain() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(80) + assertEquals(2, leftClicks.size) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + } + + @Test fun movingTheSecondTouchToANeighboringIconKeepsThatTarget() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + val beside = 7 to (445f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, beside) + touch(MotionEvent.ACTION_UP, 0, beside) + advance(80) + assertEquals(listOf(a.second, beside.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun endingTheSecondTouchOnAnotherIconWithoutAMoveEventKeepsItsTarget() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + val beside = 7 to (445f to 300f) + touch(MotionEvent.ACTION_UP, 0, beside) + advance(80) + assertEquals(listOf(a.second, beside.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun cancellingAPotentialDoubleTapCannotLeaveAQueuedClick() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + gestures.cancel(); advance(1000) + assertEquals(listOf(true, false), leftClicks.map { it.down }) + } + + @Test fun doubleTapStillDoubleClicksWhenSingleTapIsDisabled() { + gestures.configure(TouchGestureConfig(tapEnabled = false)) + tap(); advance(80); tap(); advance(150) + assertEquals(listOf("button 272 true", "button 272 false", "button 272 true", "button 272 false"), buttons()) + } + + @Test fun doubleTapStillDoubleClicksWhenSingleTapIsAKeyboardAction() { + gestures.configure(TouchGestureConfig(tapAction = "key_E")) + tap(); advance(80); tap(); advance(150) + assertEquals(listOf("button 272 true", "button 272 false", "button 272 true", "button 272 false"), buttons()) + assertEquals(listOf("key 18 true", "key 18 false"), events.filter { it.startsWith("key") }) + } + + @Test fun shortDragPressesBeforeLiftAndDoesNotPrimeADoubleTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + assertEquals(listOf("button 272 true"), buttons()) + touch(MotionEvent.ACTION_UP, 0, 7 to (480f to 300f)) + touch(MotionEvent.ACTION_DOWN, 0, 7 to (480f to 300f)) + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun twoFingerTapIsRightClickWithoutALeftClickOnFinalLift() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(80) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(80) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun twoFingerTapKeepsItsPressAfterTheLastFingerLifts() { + twoFingerTap() + assertEquals(listOf("button 273 true"), buttons()) + advance(49) + assertEquals(1, rightClicks.size) + advance(1) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + assertEquals(50L, rightClicks[1].time - rightClicks[0].time) + } + + @Test fun threeFingerKeyboardTapHasAnObservablePressAndReleases() { + gestures.configure(TouchGestureConfig(threeFingerTapAction = "key_E")) + threeFingerTap() + assertEquals(listOf("key 18 true"), events.filter { it.startsWith("key") }) + advance(50) + assertEquals(listOf("key 18 true", "key 18 false"), events.filter { it.startsWith("key") }) + assertEquals(50L, timedKeys.last().first - timedKeys.first().first) + } + + @Test fun twoFingerKeyboardTapHasAnObservablePressAndReleases() { + gestures.configure(TouchGestureConfig(twoFingerTapAction = "key_E")) + twoFingerTap(); advance(50) + assertEquals(listOf(true, false), timedKeys.map { it.third }) + assertEquals(50L, timedKeys.last().first - timedKeys.first().first) + } + + @Test fun threeFingerMouseTapHasAnObservablePressAndReleases() { + gestures.configure(TouchGestureConfig(threeFingerTapAction = "right_click")) + threeFingerTap() + assertEquals(listOf("button 273 true"), buttons()) + advance(50) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + assertEquals(50L, rightClicks[1].time - rightClicks[0].time) + } + + @Test fun cancellingAMultiFingerTapReleasesItsButtonAndStopsCallbacks() { + twoFingerTap(); advance(10) + gestures.cancel() + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun changingConfigDuringAThreeFingerKeyTapReleasesTheKey() { + gestures.configure(TouchGestureConfig(threeFingerTapAction = "key_E")) + threeFingerTap(); advance(10) + gestures.configure(TouchGestureConfig()) + assertEquals(listOf("key 18 true", "key 18 false"), events.filter { it.startsWith("key") }) + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun rapidRightClicksKeepAReleaseGapBetweenThem() { + gestures.configure(TouchGestureConfig(tapAction = "right_click", doubleTapEnabled = false)) + tap(); tap(); advance(250) + assertEquals(listOf(true, false, true, false), rightClicks.map { it.down }) + assertTrue(rightClicks[2].time - rightClicks[1].time >= 50) + } + + @Test fun aFastTwoFingerSwipeDuringSettlingDoesNotClick() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(1000) + assertTrue(buttons().isEmpty()) + assertFalse(events.any { it.startsWith("key") || it.startsWith("wheel") }) + } + + @Test fun aFastPinchDuringSettlingDoesNotClick() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + val movedA = 7 to (370f to 300f) + val movedB = 12 to (630f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(1000) + assertTrue(buttons().isEmpty()) + } + + @Test fun earlySwipeMovementCancelsTheHoldEvenIfFingersThenStop() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + advance(600) + assertTrue(buttons().isEmpty()) + gestures.cancel() + } + + @Test fun movementOnlyReportedOnLiftCannotBecomeATwoFingerTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (480f to 300f), 12 to (680f to 300f)) + touch(MotionEvent.ACTION_UP, 0, 7 to (480f to 300f)) + advance(600) + assertTrue(buttons().isEmpty()) + } + + @Test fun smallTwoFingerPlacementJitterStillAllowsATap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + val movedA = 7 to (404f to 303f) + val movedB = 12 to (596f to 297f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(80) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun smallTwoFingerPlacementJitterStillAllowsAHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + val movedA = 7 to (404f to 303f) + val movedB = 12 to (596f to 297f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + advance(350) + assertEquals(listOf("button 274 true"), buttons()) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + assertEquals(listOf("button 274 true", "button 274 false"), buttons()) + } + + @Test fun smallThreeFingerPlacementJitterStillAllowsAHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(20) + val movedA = 7 to (404f to 303f) + val movedB = 12 to (604f to 303f) + val movedC = 19 to (804f to 303f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + advance(350) + assertEquals(listOf("key 1 true"), events.filter { it.startsWith("key") }) + touch(MotionEvent.ACTION_POINTER_UP, 2, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + assertEquals(listOf("key 1 true", "key 1 false"), events.filter { it.startsWith("key") }) + assertFalse(events.contains("keyboard")) + } + + @Test fun aFastThreeFingerSwipeDuringSettlingDoesNotTapOrHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(20) + val movedA = 7 to (460f to 300f) + val movedB = 12 to (660f to 300f) + val movedC = 19 to (860f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_UP, 2, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(1000) + assertFalse(events.any { it == "keyboard" || it.startsWith("key") || it.startsWith("button") }) + } + + @Test fun addingAThirdFingerResetsTheMovementOriginsForItsTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + val movedA = 7 to (435f to 300f) + val movedB = 12 to (635f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, movedA, movedB, c) + advance(20) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (440f to 300f), 12 to (640f to 300f), 19 to (805f to 300f)) + touch(MotionEvent.ACTION_POINTER_UP, 2, 7 to (440f to 300f), 12 to (640f to 300f), 19 to (805f to 300f)) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (440f to 300f), 12 to (640f to 300f)) + touch(MotionEvent.ACTION_UP, 0, 7 to (440f to 300f)) + advance(80) + assertEquals(1, events.count { it == "keyboard" }) + assertTrue(buttons().isEmpty()) + } + + @Test fun replacingAFingerAfterAPanCannotRestartATapBeforeAllFingersLift() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, movedA, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, b) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(600) + assertTrue(buttons().isEmpty()) + assertEquals(listOf("key 106 true", "key 106 false"), events.filter { it.startsWith("key") }) + } + + @Test fun cancellingAnActiveLongPressReleasesItsButton() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "right_click")) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(350) + assertEquals(listOf("button 273 true"), buttons()) + gestures.cancel(); advance(2000) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun changingConfigurationReleasesAnActiveHold() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "left_click")) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(350) + gestures.configure(TouchGestureConfig()) + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun relativeDragEmitsDeltasInsteadOfAbsoluteMotion() { + gestures.configure(TouchGestureConfig(mouseDragMovementMode = "relative")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + events.clear() + touch(MotionEvent.ACTION_MOVE, 0, 7 to (490f to 300f)) + assertEquals(listOf("delta true"), events) + } + + @Test fun directSelectionBoxTracksEveryMoveBackThroughItsStartingPoint() { + checkDirectDragThroughOrigin("left_click_drag", 272) + } + + @Test fun directRightDragAlsoTracksEveryMoveBackThroughItsStartingPoint() { + checkDirectDragThroughOrigin("right_click_drag", 273) + } + + private fun checkDirectDragThroughOrigin(action: String, code: Int) { + gestures.configure(TouchGestureConfig(dragAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 330f)) + absoluteMoves.clear() + val path = listOf(430f to 315f, 410f to 305f, 400f to 300f, 390f to 295f, 370f to 285f, 320f to 270f) + path.forEach { touch(MotionEvent.ACTION_MOVE, 0, 7 to it) } + assertEquals(path, absoluteMoves) + assertEquals(listOf("button $code true"), buttons()) + touch(MotionEvent.ACTION_UP, 0, 7 to path.last()) + advance(100) + assertEquals(listOf("button $code true", "button $code false"), buttons()) + } + + @Test fun relativeDragReportsSmallMovementsWhileCrossingItsStartingPoint() { + gestures.configure(TouchGestureConfig(mouseDragMovementMode = "relative")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + relativeMoves.clear() + listOf(430f, 410f, 400f, 390f, 370f, 320f).forEach { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (it to 300f)) + } + assertEquals(listOf(-50f, -20f, -10f, -10f, -20f, -50f), relativeMoves.map { it.first }) + assertTrue(relativeMoves.all { it.second == 0f }) + gestures.cancel() + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun longPressCanSendAClickInsteadOfHolding() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressMouseBehavior = "click")) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(400) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + touch(MotionEvent.ACTION_UP, 0, a); advance(80) + assertEquals(2, buttons().size) + } + + @Test fun aThirdFingerReleasesTheTwoFingerHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(350) + assertEquals(listOf("button 274 true"), buttons()) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + assertEquals(listOf("button 274 true", "button 274 false"), buttons()) + gestures.cancel() + } + + @Test fun threeFingerTapOpensKeyboardOnceAndDoesNotClick() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(80) + touch(MotionEvent.ACTION_POINTER_UP, 0, a, b, c) + touch(MotionEvent.ACTION_POINTER_UP, 0, b, c) + touch(MotionEvent.ACTION_UP, 0, c) + advance(400) + assertEquals(1, events.count { it == "keyboard" }) + assertTrue(buttons().isEmpty()) + } + + @Test fun fourToThreeFingersDoesNotReleaseTheThreeFingerHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(350) + assertTrue(events.contains("key 1 true")) + touch(MotionEvent.ACTION_POINTER_DOWN, 3, a, b, c, d) + touch(MotionEvent.ACTION_POINTER_UP, 3, a, b, c, d) + assertFalse(events.contains("key 1 false")) + gestures.cancel() + assertTrue(events.contains("key 1 false")) + } + + @Test fun cancelStopsPendingHoldAndTapCallbacks() { + gestures.configure(TouchGestureConfig(longPressEnabled = true)) + touch(MotionEvent.ACTION_DOWN, 0, a) + gestures.cancel() + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun twoFingerPanHoldsTheArrowAndReleasesItOnLift() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + assertTrue(events.contains("key 106 true")) + assertFalse(events.any { it.startsWith("wheel") }) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (480f to 300f), 12 to (680f to 300f)) + assertTrue(events.contains("key 106 false")) + touch(MotionEvent.ACTION_UP, 0, 7 to (480f to 300f)) + advance(400) + assertTrue(buttons().isEmpty()) + } + + @Test fun pinchZoomDoesNotAlsoStartAPan() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (320f to 300f), 12 to (680f to 300f)) + assertTrue(events.contains("wheel -1")) + assertFalse(events.any { it.startsWith("key") }) + gestures.cancel(); advance(400) + assertTrue(buttons().isEmpty()) + } + + @Test fun threeFingerRightDragReleasesOnCancel() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "right_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + assertEquals(listOf("button 273 true"), buttons()) + gestures.cancel() + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun slowMiddlePanRetainsOnePixelMovesAfterRecognition() { + startTwoFingerPan("middle_mouse_pan") + relativeMoves.clear() + for (offset in 81..90) { + advance(8) + moveTwoFingerPan(offset.toFloat()) + } + assertEquals(List(10) { 1f to 0f }, relativeMoves) + gestures.cancel() + assertEquals("button 274 false", buttons().last()) + } + + @Test fun slowInvertedMiddlePanRetainsInvertedOnePixelMoves() { + startTwoFingerPan("inverted_middle_mouse_pan") + relativeMoves.clear() + for (offset in 81..90) moveTwoFingerPan(offset.toFloat()) + assertEquals(List(10) { -1f }, relativeMoves.map { it.first }) + assertTrue(relativeMoves.all { it.second == 0f }) + gestures.cancel() + } + + @Test fun slowThreeFingerMiddlePanRetainsSmallMoves() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "middle_mouse_pan")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + relativeMoves.clear() + for (offset in 81..85) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (400f + offset to 300f), 12 to (600f + offset to 300f), 19 to (800f + offset to 300f)) + } + assertEquals(List(5) { 1f to 0f }, relativeMoves) + gestures.cancel() + } + + @Test fun slowKeyboardPanCanStartUsingOnlySmallMovementEvents() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + for (offset in 1..45) { advance(8); moveTwoFingerPan(offset.toFloat()) } + assertTrue(events.contains("key 106 true")) + gestures.cancel() + assertEquals("key 106 false", events.filter { it.startsWith("key") }.last()) + } + + @Test fun slowKeyboardPanReversalReleasesTheOldDirection() { + startTwoFingerPan("arrow_keys") + events.clear() + for (offset in 79 downTo 75) moveTwoFingerPan(offset.toFloat()) + assertEquals(listOf("key 106 false", "key 105 true"), events.filter { it.startsWith("key") }) + gestures.cancel() + assertEquals("key 105 false", events.filter { it.startsWith("key") }.last()) + } + + @Test fun slowDiagonalKeyboardPanCanHoldBothDirections() { + startTwoFingerPan("arrow_keys") + events.clear() + for (i in 1..4) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f + i to 300f + i), 12 to (680f + i to 300f + i)) + } + assertEquals(listOf("key 108 true"), events.filter { it.startsWith("key") }) + gestures.cancel() + assertTrue(events.contains("key 106 false")) + assertTrue(events.contains("key 108 false")) + } + + @Test fun keyboardPanRemainderDoesNotSurviveCancellation() { + startTwoFingerPan("arrow_keys") + for (offset in 79 downTo 76) moveTwoFingerPan(offset.toFloat()) + gestures.cancel() + startTwoFingerPan("arrow_keys") + events.clear() + moveTwoFingerPan(79f) + assertTrue(events.none { it.startsWith("key") }) + gestures.cancel() + } + + @Test fun slowPinchAccumulatesFractionalMovementIntoAWheelStep() { + startPinch() + events.clear() + for (i in 1..20) { advance(8); movePinch(i * .25f) } + assertEquals(listOf("wheel -1"), events.filter { it.startsWith("wheel") }) + gestures.cancel() + } + + @Test fun slowPinchCanStartUsingOnlySmallMovementEvents() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + for (offset in 1..25) { + advance(8) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (400f - offset to 300f), 12 to (600f + offset to 300f)) + } + assertEquals(listOf("wheel -1"), events.filter { it.startsWith("wheel") }) + assertTrue(events.none { it.startsWith("key") }) + gestures.cancel() + } + + @Test fun pinchReversalDoesNotConsumeOldDirectionRemainder() { + startPinch() + events.clear() + movePinch(4.5f) // Nine pixels toward zooming in, short of a step. + assertTrue(events.none { it.startsWith("wheel") }) + for (i in 1..10) movePinch(4.5f - i * .5f) + assertEquals(listOf("wheel 1"), events.filter { it.startsWith("wheel") }) + gestures.cancel() + } + + @Test fun pinchRemainderDoesNotLeakIntoTheNextGesture() { + startPinch() + movePinch(4.5f) + gestures.cancel() + startPinch() + events.clear() + movePinch(.5f) + assertTrue(events.none { it.startsWith("wheel") }) + gestures.cancel() + } + + @Test fun aLargePinchStillEmitsOneStepWithoutQueuedZoomAfterRelease() { + startPinch() + events.clear() + movePinch(40f) + assertEquals(listOf("wheel -1"), events.filter { it.startsWith("wheel") }) + gestures.cancel() + events.clear(); advance(1000) + assertTrue(events.isEmpty()) + } + + @Test fun slowPinchKeyboardBindingsReleaseTheirKeys() { + startPinch("plus_minus") + events.clear(); timedKeys.clear() + for (i in 1..20) { advance(8); movePinch(i * .25f) } + assertEquals(listOf("key 13 true"), events.filter { it.startsWith("key") }) + advance(50) + assertEquals(listOf("key 13 true", "key 13 false"), events.filter { it.startsWith("key") }) + gestures.cancel() + events.clear(); advance(1000) + assertTrue(events.isEmpty()) + } + + @Test fun middlePanDoesNotJumpBackToTheTouchAnchorAfterTheClientRecenters() { + checkRecenteredPan("middle_mouse_pan", 1040f) + } + + @Test fun invertedMiddlePanUsesOnlyInvertedFingerDeltasAfterRecentering() { + checkRecenteredPan("inverted_middle_mouse_pan", 880f) + } + + private fun checkRecenteredPan(action: String, expected: Float) { + var cachedX = 0f + var clientX = 0f + var middleDown = false + gestures = TouchscreenGestures(ViewConfiguration.get(RuntimeEnvironment.getApplication()).scaledTouchSlop.toFloat(), object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) { cachedX = x; clientX = x } + override fun prepareRelativePan() { cachedX = 960f; clientX = 960f } + override fun moveBy(dx: Float, dy: Float, relative: Boolean) { + // Before pointer lock, the compositor delivers an absolute + // position even when we inject a relative delta. The game + // only requests lock after seeing this first pan movement. + cachedX += dx + clientX = cachedX + } + override fun button(code: Int, down: Boolean) { + if (code == 274) { + middleDown = down + if (down) clientX = 960f // The client warps to the middle when pan starts. + } + } + override fun key(code: Int, down: Boolean) {} + override fun wheel(steps: Int) {} + override fun showKeyboard() {} + }) + gestures.configure(TouchGestureConfig(twoFingerDragAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, 7 to (1200f to 300f)) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 7 to (1200f to 300f), 12 to (1400f to 300f)) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (1280f to 300f), 12 to (1480f to 300f)) + assertTrue(middleDown) + assertEquals(expected, clientX, 0.01f) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (1280f to 300f), 12 to (1480f to 300f)) + assertFalse(middleDown) + } + + @Test fun middlePanPreparesItsOriginBeforePressAndOnlyOncePerDrag() { + gestures.configure(TouchGestureConfig(twoFingerDragAction = "middle_mouse_pan")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + events.clear() + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (490f to 300f), 12 to (690f to 300f)) + assertEquals(listOf("prepare pan", "button 274 true", "delta true", "delta true"), events) + gestures.cancel() + assertEquals("button 274 false", events.last()) + } + + @Test fun leftClickDragDoesNotPrepareARelativePanOrigin() { + gestures.configure(TouchGestureConfig(twoFingerDragAction = "left_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + events.clear() + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + assertFalse(events.contains("prepare pan")) + assertTrue(buttons().contains("button 272 true")) + } + + @Test fun keyboardActionsUseEvdevCodesAndReleaseOnCancel() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "key_CTRL_R")) + touch(MotionEvent.ACTION_DOWN, 0, a); advance(350) + assertTrue(events.contains("key 97 true")) + gestures.cancel() + assertTrue(events.contains("key 97 false")) + assertEquals(96, GestureKeys.code("KP_ENTER")) + assertEquals(99, GestureKeys.code("PRTSCN")) + } + + + @Test fun replacingAnOriginalFingerDoesNotInjectThreeFingerPanMotion() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "middle_mouse_pan")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + val movedC = 19 to (880f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_DOWN, 3, movedA, movedB, movedC, d) + relativeMoves.clear() + touch(MotionEvent.ACTION_POINTER_UP, 0, movedA, movedB, movedC, d) + touch(MotionEvent.ACTION_MOVE, 0, movedB, movedC, d) + assertTrue("No finger moved; observed deltas: $relativeMoves", relativeMoves.isEmpty()) + touch(MotionEvent.ACTION_MOVE, 0, 12 to (690f to 300f), 19 to (890f to 300f), 24 to (1010f to 300f)) + assertEquals(listOf(10f to 0f), relativeMoves) + assertEquals(1, events.count { it == "prepare pan" }) + gestures.cancel() + } + + @Test fun twoFingerDirectDragStartsAtItsOwnCentroid() { + gestures.configure(TouchGestureConfig(twoFingerDragAction = "left_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + assertEquals(500f to 300f, leftClicks.single().position) + gestures.cancel() + } + + @Test fun threeFingerDirectDragStartsAtItsOwnCentroid() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "right_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + assertEquals(600f to 300f, rightClicks.single().position) + gestures.cancel() + } + + @Test fun batchedTwoFingerExcursionCannotBecomeATap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + batchedReturn(7 to (480f to 300f), 12 to (680f to 300f)) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(1000) + assertTrue("An out-and-back swipe generated: ${buttons()}", buttons().isEmpty()) + } + + @Test fun batchedSingleFingerExcursionCannotBecomeATapWithDragDisabled() { + gestures.configure(TouchGestureConfig(dragEnabled = false)) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(20) + batchedReturn(7 to (480f to 300f)) + touch(MotionEvent.ACTION_UP, 0, a) + advance(1000) + assertTrue("An out-and-back swipe generated: ${buttons()}", buttons().isEmpty()) + } + + @Test fun replacingAFingerDuringDirectDragPreservesPositionAndContinuesMoving() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "right_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + val movedC = 19 to (880f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + val before = cursor + touch(MotionEvent.ACTION_POINTER_DOWN, 3, movedA, movedB, movedC, d) + touch(MotionEvent.ACTION_POINTER_UP, 0, movedA, movedB, movedC, d) + touch(MotionEvent.ACTION_MOVE, 0, movedB, movedC, d) + assertEquals(before, cursor) + touch(MotionEvent.ACTION_MOVE, 0, 12 to (692f to 300f), 19 to (892f to 300f), 24 to (1012f to 300f)) + assertEquals(before.first + 12f to before.second, cursor) + gestures.cancel() + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun batchedPinchExcursionCancelsTapEvenWhenEachFingerStaysInsidePanTolerance() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + batchedReturn(7 to (375f to 300f), 12 to (625f to 300f)) + advance(350) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(100) + assertTrue(events.none { it.startsWith("button") || it.startsWith("key") || it == "keyboard" }) + } + + @Test fun batchedSingleFingerExcursionCancelsPendingLongPress() { + gestures.configure(TouchGestureConfig(dragEnabled = false, longPressEnabled = true, longPressAction = "key_E")) + touch(MotionEvent.ACTION_DOWN, 0, a) + batchedReturn(7 to (480f to 300f)) + advance(350) + assertTrue(timedKeys.isEmpty()) + gestures.cancel() + } + + @Test fun batchedJitterPreservesTwoFingerHold() { + gestures.configure(TouchGestureConfig(twoFingerHoldAction = "key_E")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + batchedReturn(7 to (403f to 302f), 12 to (603f to 302f)) + advance(350) + assertTrue(events.contains("key 18 true")) + gestures.cancel() + assertEquals("key 18 false", events.last()) + } + + @Test fun batchedThreeFingerExcursionCannotBecomeATapOrHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + batchedReturn(7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + advance(350) + touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(100) + assertTrue(events.none { it.startsWith("button") || it.startsWith("key") || it == "keyboard" }) + } + + private fun batchedReturn(vararg excursion: Pair>) { + val originals = listOf(a, b, c).take(excursion.size) + val properties = excursion.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + fun coords(points: List>>) = points.map { (_, pos) -> MotionEvent.PointerCoords().apply { x = pos.first; y = pos.second; pressure = 1f; size = 1f } }.toTypedArray() + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_MOVE, excursion.size, + properties, coords(excursion.toList()), 0, 0, 1f, 1f, 0, 0, 0, 0) + advance(10) + event.addBatch(SystemClock.uptimeMillis(), coords(originals), 0) + assertEquals(1, event.historySize) + gestures.onTouch(event) + event.recycle() + } + + private fun buttons() = events.filter { it.startsWith("button") } + private fun startTwoFingerPan(action: String) { + gestures.configure(TouchGestureConfig(twoFingerDragAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + moveTwoFingerPan(80f) + advance(110) + } + private fun moveTwoFingerPan(offset: Float) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (400f + offset to 300f), 12 to (600f + offset to 300f)) + } + private fun startPinch(action: String = "scroll_wheel") { + gestures.configure(TouchGestureConfig(pinchAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + movePinch(0f) + advance(110) + } + private fun movePinch(offset: Float) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (370f - offset to 300f), 12 to (630f + offset to 300f)) + } + private fun twoFingerTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + } + private fun threeFingerTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + } + private fun tap(point: Pair> = a) { + touch(MotionEvent.ACTION_DOWN, 0, point) + advance(30) + touch(MotionEvent.ACTION_UP, 0, point) + } + private fun advance(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)) + private fun touch(action: Int, index: Int, vararg points: Pair>) { + if (action == MotionEvent.ACTION_DOWN) downTime = SystemClock.uptimeMillis() + val properties = points.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + val coords = points.map { (_, pos) -> MotionEvent.PointerCoords().apply { x = pos.first; y = pos.second; pressure = 1f; size = 1f } }.toTypedArray() + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), points.size, + properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0).also { event -> + gestures.onTouch(event); event.recycle() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/VrControllersTest.kt b/app/src/test/java/com/droiddeck/launcher/input/VrControllersTest.kt new file mode 100644 index 000000000..3f74f20fc --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/VrControllersTest.kt @@ -0,0 +1,13 @@ +package com.droiddeck.launcher.input + +import org.junit.Assert.assertEquals +import org.junit.Test + +class VrControllersTest { + @Test fun encodesTheKeysTheSteamVrDriverReads() { + assertEquals("emulate=index\nsticks=game\n", VrControllers.encode(VrControllers.Settings())) + assertEquals("emulate=touch\nsticks=hands\n", + VrControllers.encode(VrControllers.Settings(VrControllers.Emulate.TOUCH, sticksMoveHands = true))) + assertEquals(listOf("off", "index", "touch", "vive"), VrControllers.Emulate.entries.map { it.key }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt index e1e6827ab..2cb5e5edd 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt @@ -118,4 +118,31 @@ class BwrapSpawnerPlanTest { assertEquals("/app", plan.cwd) assertEquals("org.example.Game", plan.env["FLATPAK_ID"]) } + + @Test fun zypakLaunchesTheBrowserWithoutItsSandbox() { + val context = RuntimeEnvironment.getApplication() + val app = File(runtime, "app").apply { File(this, "bin").mkdirs() } + File(app, "bin/zypak-wrapper.sh").writeText("#!/usr/bin/bash\nexec zypak-helper host - \"\$@\"\n") + Files.createSymbolicLink(File(app, "bin/zypak-wrapper").toPath(), java.nio.file.Paths.get("/app/bin/zypak-wrapper.sh")) + val plan = BwrapSpawner.plan(context, request( + op("t" to "bind", "src" to "/runtime-files/app", "dest" to "/app", "ro" to true), + ), dir) + + val shim = plan.binds.single { it.guest == "/app/bin/zypak-wrapper.sh" } + assertTrue(shim.host.startsWith(dir.path)) + assertTrue(File(shim.host).readText().contains("exec \"\$program\" --no-sandbox \"\$@\"")) + assertFalse(plan.binds.any { it.guest == "/app/bin/zypak-wrapper" }) + assertTrue(File(app, "bin/zypak-wrapper.sh").readText().contains("zypak-helper")) + } + + @Test fun appsWithoutZypakKeepTheirFiles() { + val context = RuntimeEnvironment.getApplication() + File(runtime, "app/bin").mkdirs() + val plan = BwrapSpawner.plan(context, request( + op("t" to "bind", "src" to "/runtime-files/app", "dest" to "/app", "ro" to true), + ), dir) + + assertFalse(plan.binds.any { it.guest.startsWith("/app/bin/") }) + assertFalse(File(dir, "zypak-wrapper").exists()) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DeckyChannelTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DeckyChannelTest.kt new file mode 100644 index 000000000..025fc6379 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DeckyChannelTest.kt @@ -0,0 +1,32 @@ +package com.droiddeck.launcher.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DeckyChannelTest { + private fun release(tag: String, prerelease: Boolean) = + DeckyManager.Release(tag, prerelease, "PluginLoader-arm64", "https://example.invalid/$tag", null, null, 1, 183) + + @Test fun stableSkipsPrereleasesAndPrereleaseTakesTheNewestTag() { + val pre = release("v3.4.0-pre1", true) + val stable = release("v3.3.0", false) + val channels = DeckyManager.ReleaseChannels(listOf(stable), listOf(pre), listOf(pre, stable)) + + assertEquals("v3.3.0", DeckyManager.latest(channels, DeckyManager.BRANCH_STABLE)?.tag) + assertEquals("v3.4.0-pre1", DeckyManager.latest(channels, DeckyManager.BRANCH_PRERELEASE)?.tag) + } + + @Test fun prereleaseFollowsAStableThatShippedAfterTheLastPrerelease() { + val stable = release("v3.3.0", false) + val pre = release("v3.3.0-pre1", true) + val channels = DeckyManager.ReleaseChannels(listOf(stable), listOf(pre), listOf(stable, pre)) + + assertEquals("v3.3.0", DeckyManager.latest(channels, DeckyManager.BRANCH_PRERELEASE)?.tag) + } + + @Test fun stableHasNothingWhenOnlyPrereleasesExist() { + val pre = release("v3.4.0-pre1", true) + assertNull(DeckyManager.latest(DeckyManager.ReleaseChannels(emptyList(), listOf(pre), listOf(pre)), DeckyManager.BRANCH_STABLE)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt index f59263ac5..5738b50c4 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt @@ -2,12 +2,16 @@ package com.droiddeck.launcher.runtime import com.droiddeck.launcher.BuildConfig import java.io.File +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class DesktopCatalogTest { @get:Rule val temporary = TemporaryFolder() @@ -30,4 +34,26 @@ class DesktopCatalogTest { kwin.delete() assertFalse(DesktopCatalog.desktopInstalled(root)) } + + @Test fun anInterruptedInstallIsFoundAgainWithEverythingNeededToFinishIt() { + val root = temporary.root + val entry = DesktopCatalog.Entry( + DesktopCatalog.DESKTOP_ID, "Desktop", 1, "desktop-kde-r2", "tar", + "https://example.invalid/desktop-kde.tar.zst", "a".repeat(64), 846933224L, "", "", "", + ) + assertTrue(DesktopCatalog.interrupted(root).isEmpty()) + File(root, ".droiddeck-pkg-${entry.id}.installing").writeText(DesktopCatalog.entryJson(entry).toString()) + File(root, ".droiddeck-pkg-emulators.installing").writeText("not json") + File(root, ".droiddeck-pkg-emulators").writeText("1") + + val found = DesktopCatalog.interrupted(root).single() + + assertEquals(entry.id, found.id) + assertEquals(entry.version, found.version) + assertEquals(entry.url, found.url) + assertEquals(entry.sha256, found.sha256) + assertEquals(entry.size, found.size) + assertEquals(entry.kind, found.kind) + } } + diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopRemovalTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopRemovalTest.kt new file mode 100644 index 000000000..250a65535 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopRemovalTest.kt @@ -0,0 +1,122 @@ +package com.droiddeck.launcher.runtime + +import android.content.ContextWrapper +import com.droiddeck.launcher.BuildConfig +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import java.util.zip.GZIPInputStream +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DesktopRemovalTest { + private val dir = Files.createTempDirectory("desktop-removal").toFile() + private val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) { + override fun getFilesDir() = File(dir, "files").apply { mkdirs() } + override fun getCacheDir() = File(dir, "cache").apply { mkdirs() } + } + private val root = LinuxRuntime.rootDir(context).apply { mkdirs() } + private val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}") + private val journal = File(root, marker.name + ".installing") + private val pending = File(root, DesktopRemoval.PENDING) + private val listener = LinuxRuntimeInstaller.ProgressListener { _, _ -> } + + @After fun cleanup() { dir.deleteRecursively() } + + @Test fun removesTheDesktopWhileKeepingRuntimeAppsGamesAndSettings() { + val desktop = desktopFiles() + marker.writeText(BuildConfig.DESKTOP_KDE_TAG) + val keep = listOf("root/.local/share/Steam/steamapps/common/game/data", "root/.config/kdeglobals", + "opt/appimages/custom.AppImage", "usr/lib/shared.so", "usr/share/plasma/custom") + .map { file(it, "keep") } + + assertTrue(DesktopCatalog.desktopPresent(context)) + assertNull(DesktopCatalog.removeDesktop(context, listener)) + + assertFalse(Files.exists(desktop.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) + keep.forEach { assertEquals("keep", it.readText()) } + assertFalse(DesktopCatalog.desktopPresent(context)) + assertFalse(DesktopCatalog.desktopInstalled(context)) + } + + @Test fun interruptedInstallCanBeRemovedWithoutBeingResumedAtTheNextLaunch() { + desktopFiles() + val entry = DesktopCatalog.desktopEntry(context) + journal.writeText(DesktopCatalog.entryJson(entry).toString()) + val download = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download").apply { writeText("download") } + val resume = File(download.path + ".resume.properties").apply { writeText("resume") } + val unrelated = file(".droiddeck-pkg-emulators.installing", DesktopCatalog.entryJson(DesktopCatalog.Entry( + "emulators", "Emulators", 1, "1", "tar", "https://example.invalid/emulators", "a".repeat(64), 1L, "", "", "", + )).toString()) + assertTrue(DesktopCatalog.desktopPresent(context)) + + assertNotNull(DesktopCatalog.removeDesktop(context) { _, _ -> error("interrupted") }) + assertTrue(pending.exists()) + assertTrue(journal.exists()) + assertTrue(DesktopCatalog.desktopPresent(context)) + assertNull(DesktopCatalog.removeDesktop(context, listener)) + + assertFalse(journal.exists()) + assertFalse(pending.exists()) + assertFalse(download.exists()) + assertFalse(resume.exists()) + assertFalse(DesktopCatalog.desktopPresent(context)) + assertEquals(listOf("emulators"), DesktopCatalog.interrupted(context).map { it.id }) + assertTrue(unrelated.exists()) + } + + @Test fun refusesUnknownDesktopVersionsAndMissingRuntimeOwnershipBeforeDeletingAnything() { + val desktop = desktopFiles() + marker.writeText("future-desktop") + assertNotNull(DesktopCatalog.removeDesktop(context, listener)) + assertEquals("future-desktop", marker.readText()) + marker.writeText(BuildConfig.DESKTOP_KDE_TAG) + journal.writeText(DesktopCatalog.entryJson(DesktopCatalog.desktopEntry(context)).put("version", "future-desktop").toString()) + assertNotNull(DesktopCatalog.removeDesktop(context, listener)) + assertTrue(journal.exists()) + journal.delete() + File(root, "var/lib/pacman/local/base-1/files").delete() + assertNotNull(DesktopCatalog.removeDesktop(context, listener)) + assertEquals(BuildConfig.DESKTOP_KDE_TAG, marker.readText()) + assertFalse(pending.exists()) + assertTrue(Files.isSymbolicLink(desktop.toPath())) + } + + @Test fun failedDownloadCleanupKeepsTheInstallRecordSoRemovalCanRetry() { + val entry = DesktopCatalog.desktopEntry(context) + val download = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download") + for (blocked in listOf(download, File(download.path + ".resume.properties"))) { + desktopFiles() + journal.writeText(DesktopCatalog.entryJson(entry).toString()) + blocked.mkdirs() + val blocker = File(blocked, "blocker").apply { writeText("fixture") } + + assertNotNull(DesktopCatalog.removeDesktop(context, listener)) + assertTrue(journal.exists()) + assertTrue(pending.exists()) + assertTrue(blocker.delete()) + assertNull(DesktopCatalog.removeDesktop(context, listener)) + assertFalse(blocked.exists()) + assertFalse(journal.exists()) + assertFalse(pending.exists()) + } + } + + private fun file(path: String, text: String) = File(root, path).apply { parentFile!!.mkdirs(); writeText(text) } + + private fun desktopFiles(): File { + file("var/lib/pacman/local/base-1/files", "%FILES%\nusr/lib/shared.so\n") + val entry = GZIPInputStream(context.assets.open("desktop-removal/kde-remove.tsv.gzip")).bufferedReader().use { reader -> + reader.lineSequence().map(DesktopFileRemoval.Entry::parse).first { it.kind == "L" && it.path.startsWith("usr/") } + } + return File(root, entry.path).apply { + parentFile!!.mkdirs() + Files.createSymbolicLink(toPath(), Paths.get(entry.identity)) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/LinuxRuntimeProcFilesTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/LinuxRuntimeProcFilesTest.kt new file mode 100644 index 000000000..1638132af --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/LinuxRuntimeProcFilesTest.kt @@ -0,0 +1,38 @@ +package com.droiddeck.launcher.runtime + +import android.content.ContextWrapper +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class LinuxRuntimeProcFilesTest { + private val root = Files.createTempDirectory("guest-proc-files").toFile() + private val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) { + override fun getFilesDir() = File(root, "files").apply { mkdirs() } + override fun getCacheDir() = File(root, "cache").apply { mkdirs() } + override fun getNoBackupFilesDir() = File(root, "no_backup").apply { mkdirs() } + } + private val fakeProc get() = File(LinuxRuntime.rootDir(context), "etc/droiddeck/proc") + + @After fun tearDown() { + root.deleteRecursively() + } + + @Test fun theGuestGetsAnOvercommitPolicyItCanRead() { + LinuxRuntime.binds(context, null, null, null, null) + assertEquals("0\n", File(fakeProc, "overcommit_memory").readText()) + assertEquals("9223372036854775807\n", File(fakeProc, "file-max").readText()) + } + + @Test fun aSeededFileIsKept() { + File(fakeProc, "overcommit_memory").apply { parentFile!!.mkdirs(); writeText("1\n") } + LinuxRuntime.binds(context, null, null, null, null) + assertEquals("1\n", File(fakeProc, "overcommit_memory").readText()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/PackageExtractTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/PackageExtractTest.kt new file mode 100644 index 000000000..fb4e908c1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/PackageExtractTest.kt @@ -0,0 +1,118 @@ +package com.droiddeck.launcher.runtime + +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class PackageExtractTest { + private val dir = Files.createTempDirectory("package-extract").toFile() + private val root = File(dir, "linuxfs").apply { mkdirs() } + private val context = RuntimeEnvironment.getApplication() + + @After fun tearDown() { dir.deleteRecursively() } + + private sealed class Item(val name: String) { + class Link(name: String, val target: String) : Item(name) + class Data(name: String, val text: String) : Item(name) + } + + private fun archive(vararg items: Item): File { + val tar = ByteArrayOutputStream() + TarArchiveOutputStream(tar).use { out -> + for (item in items) { + when (item) { + is Item.Link -> out.putArchiveEntry(TarArchiveEntry(item.name, TarArchiveEntry.LF_SYMLINK).apply { linkName = item.target }) + is Item.Data -> { + val bytes = item.text.toByteArray() + out.putArchiveEntry(TarArchiveEntry(item.name).apply { size = bytes.size.toLong(); mode = 0b111_101_101 }) + out.write(bytes) + } + } + out.closeArchiveEntry() + } + } + return File(dir, "pkg-${System.nanoTime()}.tar.zst").apply { + ZstdCompressorOutputStream(outputStream()).use { it.write(tar.toByteArray()) } + } + } + + private fun lib(name: String) = File(root, "usr/lib/$name") + + private fun installOld() { + lib("").mkdirs() + lib("libvulkan.so.1.4.357").writeText("old") + Files.createSymbolicLink(lib("libvulkan.so.1").toPath(), Paths.get("libvulkan.so.1.4.357")) + } + + @Test fun linksListedBeforeTheirTargetsResolveAfterExtraction() { + installOld() + val pkg = archive( + Item.Link("usr/lib/libvulkan.so.1", "libvulkan.so.1.4.363"), + Item.Data("usr/lib/libvulkan.so.1.4.363", "new"), + ) + + assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertEquals("new", lib("libvulkan.so.1").readText()) + assertEquals(emptyList(), RuntimeHealth.brokenLibraryLinks(root)) + assertFalse(lib("").list()!!.any { it.startsWith(".dd-part-") }) + } + + @Test fun anExtractionStoppedBetweenALinkAndItsTargetLeavesTheOldLinkWorking() { + installOld() + val pkg = archive( + Item.Link("usr/lib/libvulkan.so.1", "libvulkan.so.1.4.363"), + Item.Data("usr/lib/libharfbuzz.so.0", "harfbuzz"), + Item.Data("usr/lib/libharfbuzz.so.0/cannot-be-written", "stops here"), + Item.Data("usr/lib/libvulkan.so.1.4.363", "new"), + ) + + assertFalse(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertEquals("old", lib("libvulkan.so.1").readText()) + assertFalse(lib("libvulkan.so.1.4.363").exists()) + assertEquals(emptyList(), RuntimeHealth.brokenLibraryLinks(root)) + } + + @Test fun aFileReplacesAnExistingLinkInsteadOfWritingThroughIt() { + installOld() + val pkg = archive(Item.Data("usr/lib/libvulkan.so.1", "now a file")) + + assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertFalse(Files.isSymbolicLink(lib("libvulkan.so.1").toPath())) + assertEquals("now a file", lib("libvulkan.so.1").readText()) + assertEquals("old", lib("libvulkan.so.1.4.357").readText()) + assertTrue(lib("libvulkan.so.1").canExecute()) + } + + @Test fun theLastEntryForAPathWins() { + lib("").mkdirs() + lib("target").writeText("t") + val pkg = archive( + Item.Link("usr/lib/libdup.so", "target"), + Item.Data("usr/lib/libdup.so", "file"), + Item.Data("usr/lib/libother.so", "file"), + Item.Link("usr/lib/libother.so", "target"), + ) + + assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertFalse(Files.isSymbolicLink(lib("libdup.so").toPath())) + assertEquals("file", lib("libdup.so").readText()) + assertTrue(Files.isSymbolicLink(lib("libother.so").toPath())) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeHealthTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeHealthTest.kt new file mode 100644 index 000000000..4f0b95264 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeHealthTest.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.runtime + +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class RuntimeHealthTest { + @get:Rule val temporary = TemporaryFolder() + + private fun link(root: File, name: String, target: String) = + Files.createSymbolicLink(File(root, "usr/lib/$name").toPath(), Paths.get(target)) + + @Test fun reportsOnlyLibraryLinksWhoseTargetIsMissingInsideTheRuntime() { + val root = temporary.root + val lib = File(root, "usr/lib").apply { mkdirs() } + File(lib, "libvulkan.so.1.4.357").writeText("lib") + File(root, "etc").mkdirs() + File(root, "etc/ld.conf").writeText("conf") + link(root, "libvulkan.so.1", "libvulkan.so.1.4.357") + link(root, "libvulkan.so", "libvulkan.so.1") + link(root, "libabsolute.so", "/etc/ld.conf") + link(root, "libharfbuzz.so.0", "libharfbuzz.so.0.61460.0") + link(root, "libchain.so", "libharfbuzz.so.0") + link(root, "libhost.so", File(lib, "libvulkan.so.1.4.357").absolutePath) + + assertEquals(listOf("libchain.so", "libharfbuzz.so.0", "libhost.so"), RuntimeHealth.brokenLibraryLinks(root)) + } + + @Test fun aMissingLibraryFolderIsNotReportedAsDamage() { + assertEquals(emptyList(), RuntimeHealth.brokenLibraryLinks(temporary.root)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt new file mode 100644 index 000000000..ca68f5867 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt @@ -0,0 +1,310 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class BootMovieLibraryTest { + private val app get() = RuntimeEnvironment.getApplication() + private val movies get() = BootMovieLibrary.moviesDir(app) + + @Before fun clear() { + app.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + File(app.filesDir, "boot-movie").deleteRecursively() + movies.deleteRecursively() + BootMovieLibrary.libraryDir(app).deleteRecursively() + File(BootMovieLibrary.steamRoot(app), "steamui").deleteRecursively() + File(BootMovieLibrary.steamRoot(app), "appcache").deleteRecursively() + BootMovieLibrary.forgetScan() + BootMovieLibrary.steamConfig(app).delete() + } + + private fun movie(name: String) = File(movies, name).also { it.parentFile!!.mkdirs(); it.writeText("movie") } + + /** A movie DroidDeck itself imported: kept in the library, copied into Steam's folder and noted in the list. */ + private fun imported(title: String, source: String = BootMovieLibrary.SOURCE_IMPORT, repo: String? = null): String { + val staged = File(BootMovieLibrary.libraryDir(app), ".t-${System.nanoTime()}-${Thread.currentThread().id}.staged").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + return BootMovieLibrary.commit(app, staged, title, source, repo)!! + } + + private fun steamPicksAt(path: String) { + BootMovieLibrary.steamConfig(app).also { it.parentFile!!.mkdirs() }.writeText( + "\"InstallConfigStore\"\n{\n\t\"StartupMovie\"\n\t{\n\t\t\"MovieID\"\t\t\"x\"\n\t\t\"LocalPath\"\t\t\"$path\"\n\t}\n}\n", + ) + } + + private fun steamPicks(name: String) = steamPicksAt("/uioverrides/movies/$name") + + @Test fun theActiveX64ClientsOwnMoviesAreListedAndRemovedToo() { + SessionPrefs.setSteamClientArch(app, "x86_64") + val x64 = File(SessionPrefs.steamRoot(app, "x86_64"), "config/uioverrides/movies") + try { + File(x64, "Mine.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + movie("Main.webm") + val entries = BootMovieLibrary.list(app).filter { it.id.startsWith(BootMovieLibrary.PREFIX) } + assertEquals(listOf("file:Main.webm", "file:Mine.webm"), entries.map { it.id }) + assertTrue(entries.all { it.removable }) + assertEquals("Mine.webm", BootMovieLibrary.file(app, "file:Mine.webm")?.name) + assertEquals("Mine (2).webm", BootMovieLibrary.freeName(app, "Mine")) + assertTrue(BootMovieLibrary.remove(app, "file:Mine.webm")) + assertFalse(File(x64, "Mine.webm").exists()) + } finally { + x64.deleteRecursively() + } + } + + @Test fun removingAMovieKeepsTheSameNamedOneInTheOtherSteamFolder() { + SessionPrefs.setSteamClientArch(app, "x86_64") + val x64 = File(SessionPrefs.steamRoot(app, "x86_64"), "config/uioverrides/movies") + try { + File(x64, "Intro.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + movie("Intro.webm") + assertTrue(BootMovieLibrary.remove(app, "file:Intro.webm")) + assertFalse(File(movies, "Intro.webm").exists()) + assertTrue(File(x64, "Intro.webm").isFile) + assertTrue(BootMovieLibrary.list(app).any { it.id == "file:Intro.webm" }) + assertTrue(BootMovieLibrary.remove(app, "file:Intro.webm")) + assertFalse(File(x64, "Intro.webm").exists()) + } finally { + x64.deleteRecursively() + } + } + + @Test fun aSavedChoiceKeepsMeaningTheSameFileWhicheverClientRuns() { + val x64 = File(SessionPrefs.steamRoot(app, "x86_64"), "config/uioverrides/movies") + try { + File(x64, "Intro.webm").also { it.parentFile!!.mkdirs(); it.writeText("other") } + val main = movie("Intro.webm") + SessionPrefs.setSteamClientArch(app, "x86_64") + assertEquals(main.path, BootMovieLibrary.file(app, "file:Intro.webm")?.path) + SessionPrefs.setSteamClientArch(app, "arm64") + assertEquals(main.path, BootMovieLibrary.file(app, "file:Intro.webm")?.path) + } finally { + x64.deleteRecursively() + } + } + + @Test fun listsTheDroidDeckMoviesAndEveryWebmButNotTheStagedCopies() { + movie("Intro.webm") + movie("bigpicture_startup.webm") + movie("steam_os_startup.webm") + movie("notes.txt") + movie(".hidden.webm") + val ids = BootMovieLibrary.list(app).map { it.id } + assertEquals(listOf(BootMovie.DROIDDECK_30, BootMovie.DROIDDECK_60, "file:Intro.webm"), ids) + } + + @Test fun usesTheTitleItWasRegisteredWithAndFallsBackToTheFileName() { + imported("Fancy", BootMovieLibrary.SOURCE_REPO, "id1"); movie("b.webm") + val list = BootMovieLibrary.list(app).associateBy { it.id } + assertEquals("Fancy", list["file:Fancy.webm"]!!.title) + assertEquals(BootMovieLibrary.SOURCE_REPO, list["file:Fancy.webm"]!!.source) + assertTrue(list["file:Fancy.webm"]!!.removable) + assertTrue(list["file:b.webm"]!!.removable) + assertEquals("b", list["file:b.webm"]!!.title) + assertEquals(BootMovieLibrary.SOURCE_IMPORT, list["file:b.webm"]!!.source) + assertEquals(setOf("id1"), BootMovieLibrary.installedRepoIds(app)) + assertEquals("Fancy.webm", BootMovieLibrary.nameForRepo(app, "id1")) + } + + @Test fun aChosenFileMovieIsKeptAndFallsBackOnceItIsGone() { + val name = imported("Mine") + BootMovie.select(app, "file:$name") + assertEquals("file:$name", BootMovie.choice(app)) + assertTrue(BootMovieLibrary.remove(app, "file:$name")) + assertEquals(BootMovie.DROIDDECK_30, BootMovie.choice(app)) + assertFalse(File(movies, name).exists()) + assertFalse(File(BootMovieLibrary.libraryDir(app), name).exists()) + } + + @Test fun aMovieInSteamsCustomFolderCanBeRemovedEvenWithoutANote() { + movie("by-hand.webm") + assertTrue(BootMovieLibrary.list(app).first { it.id == "file:by-hand.webm" }.removable) + assertTrue(BootMovieLibrary.remove(app, "file:by-hand.webm")) + assertFalse(File(movies, "by-hand.webm").exists()) + } + + @Test fun steamsOwnMoviesAreNeverDeleted() { + val own = File(BootMovieLibrary.steamRoot(app), "steamui/movies/deck_startup.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + val entry = BootMovieLibrary.list(app).first { it.id == "steam:/movies/deck_startup.webm" } + assertFalse(entry.removable) + assertFalse(BootMovieLibrary.remove(app, entry.id)) + assertTrue(own.exists()) + } + + @Test fun anImportSurvivesTheRuntimeBeingReset() { + val name = imported("Keep") + movies.deleteRecursively() + assertTrue(BootMovieLibrary.list(app).any { it.id == "file:$name" }) + assertTrue(File(movies, name).isFile) + } + + @Test fun importsAtTheSameTimeAllLandInTheList() { + val names = java.util.Collections.synchronizedList(ArrayList()) + (1..8).map { n -> Thread { names.add(imported("Movie")) } }.onEach { it.start() }.forEach { it.join() } + assertEquals(8, names.toSet().size) + assertEquals(8, BootMovieLibrary.list(app).count { it.source == BootMovieLibrary.SOURCE_IMPORT }) + } + + @Test fun steamIsPointedBackAtItsDefaultWhenTheStagedMovieIsGone() { + steamPicksAt(SessionFiles.BOOT_MOVIE_PATH) + assertTrue(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), false)) + assertNull(SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(app))) + } + + @Test fun aMissingConfigNeedsNoReset() { + assertTrue(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), true)) + } + + @Test fun aStagedMovieThatIsThereIsLeftAlone() { + movie("bigpicture_startup.webm") + steamPicksAt(SessionFiles.BOOT_MOVIE_PATH) + assertFalse(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), false)) + assertEquals(SessionFiles.BOOT_MOVIE_PATH, SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(app))) + } + + @Test fun aPendingPickResetsASteamMovieThatWasRemoved() { + steamPicksAt("/uioverrides/movies/gone.webm") + assertFalse(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), false)) + assertTrue(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), true)) + assertNull(SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(app))) + } + + @Test fun onlyTheThreeStartupMoviesOfSteamAreListed() { + listOf("deck_startup", "oled_startup", "startup_machine", "controller_generic_thumbstick_move").forEach { + File(BootMovieLibrary.steamRoot(app), "steamui/movies/$it.webm").also { f -> f.parentFile!!.mkdirs(); f.writeText("movie") } + } + val ids = BootMovieLibrary.list(app).map { it.id }.filter { it.startsWith("steam:") } + assertEquals( + listOf("steam:/movies/deck_startup.webm", "steam:/movies/oled_startup.webm", "steam:/movies/startup_machine.webm"), + ids.sorted(), + ) + } + + @Test fun aMovieBoughtInThePointsShopIsListedAndAdopted() { + val file = File(BootMovieLibrary.steamRoot(app), "appcache/startupmovies/Bought.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + val entry = BootMovieLibrary.list(app).first { it.id == "steam:/appcache/startupmovies/Bought.webm" } + assertFalse(entry.removable) + assertEquals(BootMovieLibrary.SOURCE_POINTSHOP, entry.source) + steamPicksAt("/appcache/startupmovies/Bought.webm") + assertTrue(BootMovie.syncFromSteam(app)) + assertEquals("steam:/appcache/startupmovies/Bought.webm", BootMovie.choice(app)) + assertEquals(file, BootMovie.currentFile(app)) + assertNull(BootMovieLibrary.file(app, "steam:/../x.webm")) + } + + @Test fun aBoughtMovieNamedByIdAndHashIsShownByItsId() { + File(BootMovieLibrary.steamRoot(app), "appcache/startupmovies/39900045022_590371b7f10882d09ac59e5d3691b462c0091a6d.webm") + .also { it.parentFile!!.mkdirs(); it.writeText("movie") } + val entry = BootMovieLibrary.list(app).first { it.source == BootMovieLibrary.SOURCE_POINTSHOP } + assertEquals("#39900045022", entry.title) + } + + @Test fun theReportNamesWhatStageDidAndWhereMoviesAre() { + File(BootMovieLibrary.steamRoot(app), "appcache/startupmovies/1_ab.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + BootMovieReport.note(app, "stage test") + val report = BootMovieReport.build(app) + assertTrue(report.contains("== DroidDeck state")) + assertTrue(report.contains("startupmovies/1_ab.webm")) + assertTrue(report.contains("stage test")) + } + + @Test fun pathTricksAreNotMovies() { + movie("a.webm") + assertNull(BootMovieLibrary.file(app, "file:../a.webm")) + assertNull(BootMovieLibrary.file(app, "file:bigpicture_startup.webm")) + assertFalse(BootMovieLibrary.remove(app, "file:../a.webm")) + } + + @Test fun freeNameNeverReusesAFileOrAStagedName() { + movie("Intro.webm") + assertEquals("Intro (2).webm", BootMovieLibrary.freeName(app, "Intro")) + assertEquals("movie.webm", BootMovieLibrary.freeName(app, "///")) + } + + @Test fun aMovieChosenInsideSteamIsAdoptedOnce() { + movie("a.webm"); movie("b.webm") + BootMovie.select(app, "file:a.webm") + steamPicks("b.webm") + assertTrue(BootMovie.syncFromSteam(app)) + assertEquals("file:b.webm", BootMovie.choice(app)) + // Picking another one here is not undone by the same Steam setting. + BootMovie.select(app, "file:a.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + // A new pick inside Steam is adopted again. + movie("c.webm") + steamPicks("c.webm") + assertTrue(BootMovie.syncFromSteam(app)) + assertEquals("file:c.webm", BootMovie.choice(app)) + } + + @Test fun aSteamPickIsTakenOverWithoutBeingStagedOverSteamsOwnSetting() { + movie("a.webm") + BootMovie.select(app, "file:a.webm") + SessionPrefs.setBootMovieApplyNext(app, false) + steamPicks("a.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + assertFalse(SessionPrefs.bootMovieApplyNext(app)) + } + + @Test fun theStagedCopyNamedInSteamsConfigIsNotAPick() { + movie("a.webm") + BootMovie.select(app, "file:a.webm") + steamPicksAt(SessionFiles.BOOT_MOVIE_PATH) + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + } + + @Test fun aBoughtMovieIsShownUnderItsPointsShopTitle() { + File(BootMovieLibrary.steamRoot(app), "config/communityitemscache/startupmovies/39900045022_ab12.webm") + .also { it.parentFile!!.mkdirs(); it.writeText("movie") } + File(BootMovieLibrary.steamRoot(app), "userdata/1/config/localconfig.vdf").also { it.parentFile!!.mkdirs() }.writeText( + "\"GetStartupMovies\"\t\t\"[{\\\"communityitemid\\\":\\\"39900045022\\\",\\\"image_small\\\":\\\"x.png\\\",\\\"name\\\":\\\"Turret Factory\\\",\\\"item_title\\\":\\\"Turret Factory\\\"}]\"\n", + ) + val entry = BootMovieLibrary.list(app).first { it.source == BootMovieLibrary.SOURCE_POINTSHOP } + assertEquals("Turret Factory", entry.title) + } + + @Test fun notesKeptInSteamsMoviesFolderByAnEarlierVersionAreTakenOver() { + movie("Old.webm") + File(movies, ".droiddeck-movies.json").writeText("{\"Old.webm\":{\"title\":\"Old one\",\"source\":\"repo\",\"repo\":\"r1\"}}") + val entry = BootMovieLibrary.list(app).first { it.id == "file:Old.webm" } + assertEquals("Old one", entry.title) + assertEquals(BootMovieLibrary.SOURCE_REPO, entry.source) + assertEquals(setOf("r1"), BootMovieLibrary.installedRepoIds(app)) + assertFalse(File(movies, ".droiddeck-movies.json").exists()) + } + + @Test fun aPickMadeHereBeatsAnOlderSteamPickThatWasNeverSeen() { + movie("a.webm"); movie("b.webm") + steamPicks("b.webm") + BootMovie.select(app, "file:a.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + } + + @Test fun steamPointingAtAMovieThatIsNotThereChangesNothing() { + steamPicks("gone.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals(BootMovie.DROIDDECK_30, BootMovie.choice(app)) + } + + @Test fun volumeAndLengthDefaultToTheWholeMovieAtFullVolume() { + assertEquals(100, SessionPrefs.bootMovieVolume(app)) + assertEquals(0, SessionPrefs.bootMovieSeconds(app)) + SessionPrefs.setBootMovieVolume(app, 40) + SessionPrefs.setBootMovieSeconds(app, 3) + assertEquals(40, SessionPrefs.bootMovieVolume(app)) + assertEquals(3, SessionPrefs.bootMovieSeconds(app)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMoviePlanTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMoviePlanTest.kt new file mode 100644 index 000000000..e9341851d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMoviePlanTest.kt @@ -0,0 +1,59 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.session.BootMovie.Step +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BootMoviePlanTest { + @Test + fun fullVolumeWithoutLimitPassesTheSourceThrough() { + val plan = BootMovie.plan(100, 0) + assertTrue(plan.passthrough) + assertTrue(plan.steps.isEmpty()) + } + + @Test + fun fullVolumeWithLimitKeepsSoundAndFallsBackToTheSource() { + val plan = BootMovie.plan(100, 5) + assertFalse(plan.passthrough) + assertEquals(listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), plan.steps) + assertTrue(plan.sourceOnFailure) + } + + @Test + fun silentGoesWithoutSoundAndNeverFallsBackToTheSource() { + val plan = BootMovie.plan(0, 0) + assertEquals(listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), plan.steps) + assertFalse(plan.sourceOnFailure) + } + + @Test + fun partialVolumeDegradesToSilenceNotToFullVolume() { + val plan = BootMovie.plan(40, 0) + assertEquals(listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), plan.steps) + assertFalse(plan.passthrough) + assertFalse(plan.sourceOnFailure) + } + + @Test + fun framesBeyondFullHdAreOversizedInEitherOrientation() { + assertTrue(BootMovie.oversized(3840, 2160)) + assertTrue(BootMovie.oversized(2160, 3840)) + assertFalse(BootMovie.oversized(1920, 1080)) + assertFalse(BootMovie.oversized(1280, 800)) + assertFalse(BootMovie.oversized(1080, 1920)) + } + + @Test + fun anOversizedMovieIsAlwaysConvertedAndNeverFallsBackToTheSource() { + for (volume in listOf(0, 40, 100)) { + val plan = BootMovie.oversizedPlan(volume) + assertFalse(plan.passthrough) + assertFalse(plan.sourceOnFailure) + } + assertEquals(listOf(Step.TRANSCODE_SILENT), BootMovie.oversizedPlan(0).steps) + assertEquals(listOf(Step.TRANSCODE_AUDIO), BootMovie.oversizedPlan(60).steps) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMovieTranscoderTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMovieTranscoderTest.kt new file mode 100644 index 000000000..bb31c7eaa --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMovieTranscoderTest.kt @@ -0,0 +1,17 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class BootMovieTranscoderTest { + @Test fun scaleQuietsAndCutsTheSoundAtTheSameTime() { + val pcm = ShortArray(48_000 * 2) { 1000 } + assertSame(pcm, BootMovieTranscoder.scale(pcm, 2, 1f, Long.MAX_VALUE)) + val quieter = BootMovieTranscoder.scale(pcm, 2, 0.5f, Long.MAX_VALUE) + assertEquals(pcm.size, quieter.size) + assertEquals(500.toShort(), quieter[0]) + val cut = BootMovieTranscoder.scale(pcm, 2, 1f, 500_000L) + assertEquals(24_000 * 2, cut.size) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsNightlyCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsNightlyCatalogTest.kt new file mode 100644 index 000000000..8fbb0bacd --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsNightlyCatalogTest.kt @@ -0,0 +1,73 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComponentsNightlyCatalogTest { + @Test + fun nightlyFilesGoToTheirComponentList() { + val n = ComponentsManager.DD_NIGHTLY + assertEquals("dxvk", ComponentsManager.assetComp(n, "DD-dxvk-3.1.1+153-nightly.wcp")) + assertEquals("dxvk", ComponentsManager.assetComp(n, "DD-dxvk-arm64ec-3.1.1+153-nightly.wcp")) + assertEquals("dxvk", ComponentsManager.assetComp(n, "DD-dxvk-gplasync-3.1.1+153-nightly.wcp")) + assertEquals("vkd3d", ComponentsManager.assetComp(n, "DD-vkd3d-proton-arm64ec-3.0.1+455-nightly.wcp")) + assertEquals("fex", ComponentsManager.assetComp(n, "DD-FEXCore-2610+58-nightly.wcp")) + assertNull(ComponentsManager.assetComp(n, "state.json")) + assertNull(ComponentsManager.assetComp(n, "something-else.wcp")) + assertEquals("dxvk", ComponentsManager.assetComp("Dxvk-gplasync-Linux", "dxvk-gplasync-3.1.1-1-linux.wcp")) + assertNull(ComponentsManager.assetComp("Unknown-Release", "dxvk-3.1.1-linux.wcp")) + } + + @Test + fun nightliesListBelowTheStablePackages() { + fun item(file: String, release: String) = ComponentsManager.CatalogItem(file, "dxvk", release, "", 0, "") + val sorted = listOf( + item("DD-dxvk-3.1.1+153-nightly.wcp", ComponentsManager.DD_NIGHTLY), + item("dxvk-2.7.1-linux.wcp", "Dxvk-Linux"), + item("dxvk-3.1.1-linux.wcp", "Dxvk-Linux"), + ).sortedWith(ComponentsManager.CATALOG_ORDER).map { it.file } + assertEquals(listOf("dxvk-3.1.1-linux.wcp", "dxvk-2.7.1-linux.wcp", "DD-dxvk-3.1.1+153-nightly.wcp"), sorted) + } + + @Test + fun nightliesPairWithTheStableOfTheSameFlavorAndVersion() { + val n = ComponentsManager.DD_NIGHTLY + fun item(file: String, comp: String, release: String) = ComponentsManager.CatalogItem(file, comp, release, "", 0, "") + val dxvk = item("dxvk-3.1.1-linux.wcp", "dxvk", "Dxvk-Linux") + val ec = item("dxvk-arm64ec-3.1.1-linux.wcp", "dxvk", "Dxvk-arm64ec-Linux") + val gpl = item("dxvk-gplasync-3.1.1-1-linux.wcp", "dxvk", "Dxvk-gplasync-Linux") + val fex = item("FEXCore-2610-linux.wcp", "fex", "FexCore-Linux") + val vk = item("vkd3d-proton-3.0.1-linux.wcp", "vkd3d", "Vkd3d-proton-Linux") + val others = listOf( + item("dxvk-low-latency-3.1.1-2-linux.wcp", "dxvk", "Dxvk-Linux"), + item("dxvk-3.1-linux.wcp", "dxvk", "Dxvk-Linux"), + item("dxvk-gplasync-3.1-1-linux.wcp", "dxvk", "Dxvk-gplasync-Linux"), + item("FEXCore-2610-d2rfix28-linux.wcp", "fex", "FexCore-Linux"), + ) + val nDxvk = item("DD-dxvk-3.1.1+153-nightly.wcp", "dxvk", n) + val nEc = item("DD-dxvk-arm64ec-3.1.1+153-nightly.wcp", "dxvk", n) + val nGpl = item("DD-dxvk-gplasync-3.1.1+153-nightly.wcp", "dxvk", n) + val nFex = item("DD-FEXCore-2610+58-nightly.wcp", "fex", n) + val nVk = item("DD-vkd3d-proton-3.0.1+455-nightly.wcp", "vkd3d", n) + val nVkEc = item("DD-vkd3d-proton-arm64ec-3.0.1+455-nightly.wcp", "vkd3d", n) + val pairs = ComponentsManager.nightlyPairs(listOf(dxvk, ec, gpl, fex, vk) + others + listOf(nDxvk, nEc, nGpl, nFex, nVk, nVkEc)) + assertEquals(mapOf(dxvk to nDxvk, ec to nEc, gpl to nGpl, fex to nFex, vk to nVk), pairs) + // Two nightlies for one stable build: the newer one wins. + val nDxvkNewer = item("DD-dxvk-3.1.1+154-nightly.wcp", "dxvk", n) + assertEquals(mapOf(dxvk to nDxvkNewer), ComponentsManager.nightlyPairs(listOf(dxvk, nDxvkNewer, nDxvk))) + assertEquals(mapOf(dxvk to nDxvkNewer), ComponentsManager.nightlyPairs(listOf(dxvk, nDxvk, nDxvkNewer))) + // A nightly of a version with no stable build pairs with nothing. + assertEquals(emptyMap(), ComponentsManager.nightlyPairs(listOf(item("dxvk-3.1-linux.wcp", "dxvk", "Dxvk-Linux"), nDxvk))) + } + + @Test + fun downloadsOnlyFromTheTwoRepositories() { + assertTrue(ComponentsManager.allowedDownload("https://github.com/The412Banner/Nightlies/releases/download/Dxvk-Linux/dxvk-3.1.1-linux.wcp")) + assertTrue(ComponentsManager.allowedDownload("https://github.com/Droid-Deck/DroidDeck-Components/releases/download/linux-nightly/DD-FEXCore-2610+58-nightly.wcp")) + assertFalse(ComponentsManager.allowedDownload("https://github.com/someone/else/releases/download/x/y.wcp")) + assertFalse(ComponentsManager.allowedDownload("https://github.com/Droid-Deck/DroidDeck-Components-evil/releases/download/x/y.wcp")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/InputSysfsComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/InputSysfsComponentTest.kt new file mode 100644 index 000000000..0cb8e2843 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/InputSysfsComponentTest.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.input.FakeInputWriter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputSysfsComponentTest { + @Test fun steamInputsVirtualPadKeepsItsIdentity() { + val pad = InputSysfsComponent.virtualPad("PRODUCT=3/28de/11ff/1\nNAME=\"Microsoft X-Box 360 pad 0\"\nPHYS=\"\"\n")!! + assertEquals(listOf(3, 0x28de, 0x11ff, 1), listOf(pad.bus, pad.vendor, pad.product, pad.version)) + assertEquals("Microsoft X-Box 360 pad 0", pad.name) + assertEquals("", pad.phys) + assertTrue(pad.uevent().startsWith("PRODUCT=3/28de/11ff/1\nNAME=\"Microsoft X-Box 360 pad 0\"\n")) + } + + @Test fun anUnreadableDescriptionIsNotListed() { + assertNull(InputSysfsComponent.virtualPad("")) + assertNull(InputSysfsComponent.virtualPad("PRODUCT=3/28de\nNAME=\"pad\"\n")) + assertNull(InputSysfsComponent.virtualPad("PRODUCT=3/28de/11ff/1\n")) + } + + @Test fun ringPadsMatchWhatTheirIoctlsAnswer() { + val xbox = InputSysfsComponent.ringPad(1, steamVirtual = false) + assertEquals("Xbox 360 Controller (1)", xbox.name) + assertEquals(listOf(0x045e, 0x028e, 0x110), listOf(xbox.vendor, xbox.product, xbox.version)) + val steam = InputSysfsComponent.ringPad(0, steamVirtual = true) + assertEquals("Microsoft X-Box 360 pad 0", steam.name) + assertEquals(listOf(0x28de, 0x11ff), listOf(steam.vendor, steam.product)) + } + + @Test fun ringPadsAreHiddenWhereGamesCannotOpenThem() { + assertFalse(InputSysfsComponent.ringWithdrawn(deckPad = false, type = FakeInputWriter.PAD_TYPE_DECK)) + assertFalse(InputSysfsComponent.ringWithdrawn(deckPad = true, type = FakeInputWriter.PAD_TYPE_XBOX)) + assertTrue(InputSysfsComponent.ringWithdrawn(deckPad = true, type = FakeInputWriter.PAD_TYPE_DECK)) + assertTrue(InputSysfsComponent.ringWithdrawn(deckPad = true, type = -1)) + } + + @Test fun onlyThePadsOwnUdevRecordsAreCleared() { + assertTrue(InputSysfsComponent.isPadRecord("c13:64")) + assertTrue(InputSysfsComponent.isPadRecord("c13:80")) + assertTrue(InputSysfsComponent.isPadRecord("+input:input16")) + assertFalse(InputSysfsComponent.isPadRecord("c13:63")) + assertFalse(InputSysfsComponent.isPadRecord("c13:128")) + assertFalse(InputSysfsComponent.isPadRecord("c240:16")) + assertFalse(InputSysfsComponent.isPadRecord("+input:input64")) + } + + @Test fun theUdevRecordMarksAnInitializedJoystick() { + val record = InputSysfsComponent.udevRecord(42).lines() + assertEquals("I:42", record.first()) + assertTrue("E:ID_INPUT_JOYSTICK=1" in record) + assertTrue("E:ID_INPUT=1" in record) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt index 5d67d041a..5ce8030d5 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt @@ -20,7 +20,7 @@ class OrientationTest { } @Test fun landscapeChoicesPersistGloballyAndCanRestoreAutomaticRotation() { - assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context)) + assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context)) for (value in listOf( ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE, @@ -34,7 +34,7 @@ class OrientationTest { @Test fun invalidStoredOrientationFallsBackAndInvalidChoicePreservesTheSavedValue() { val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) prefs.edit().putInt("orientation", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT).commit() - assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context)) + assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context)) SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) assertThrows(IllegalArgumentException::class.java) { SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt index a68458125..48d77d0b0 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -40,6 +40,33 @@ class SessionDisplayTest { assertEquals(864, SessionDisplay.unfoldedGameHeight(1920, 1080)) } + private fun unfoldedWanted( + enabled: Boolean = true, + controller: Boolean = false, + keepWithController: Boolean = false, + foldable: Boolean = true, + steamDeckPad: Boolean = true, + pip: Boolean = false, + smallestWidthDp: Int = 700, + ) = SessionDisplay.unfoldedControlsWanted(enabled, foldable, controller, keepWithController, steamDeckPad, pip, + 2448, 1848, smallestWidthDp) + + @Test fun unfoldedControlsHideForAControllerUnlessKeptWithOne() { + assertTrue(unfoldedWanted()) + assertFalse(unfoldedWanted(controller = true)) + assertTrue(unfoldedWanted(controller = true, keepWithController = true)) + assertTrue(unfoldedWanted(keepWithController = true)) + } + + @Test fun keepingUnfoldedControlsWithAControllerLeavesTheOtherConditions() { + assertFalse(unfoldedWanted(enabled = false, controller = true, keepWithController = true)) + assertFalse(unfoldedWanted(foldable = false, keepWithController = true)) + assertFalse(unfoldedWanted(steamDeckPad = false, keepWithController = true)) + assertFalse(unfoldedWanted(pip = true, keepWithController = true)) + assertFalse(unfoldedWanted(smallestWidthDp = 599, keepWithController = true)) + assertFalse(SessionDisplay.unfoldedControlsWanted(true, true, true, true, true, false, 0, 1848, 700)) + } + @Test fun followScreenKeepsTheWindowShapeAndSkipsTinyWindows() { assertEquals(2520 to 1080, SessionDisplay.resolveChoice(1080 to 2520, SessionDisplay.FOLLOW_SCREEN)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt index b91e5bcf6..bc1718bff 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt @@ -32,6 +32,35 @@ class SessionLogShareTest { "rules $rules\n" + files.joinToString("") { val f = File(folder, it); "${f.length()}\t${f.lastModified()}\t$it\n" }) } + @Test fun aLogPastTheInMemoryCapIsStillScrubbedIntoTheZip() { + val folder = tmp.newFolder("2026-10-18-01-steam") + val line = ("x".repeat(1023) + "\n").toByteArray() + File(folder, "session.log").outputStream().buffered().use { out -> + out.write("$secret\n".toByteArray()) + repeat(65 * 1024) { out.write(line) } + } + assertTrue(File(folder, "session.log").length() >= LogRedactor.IN_MEMORY_SCRUB_MAX) + val out = zip(folder) + assertTrue(out.containsKey("session.log")) + assertFalse(out.getValue("session.log").contains(leaked)) + } + + @Test fun aLogWithANulByteIsScrubbedNotCopiedRaw() { + val folder = tmp.newFolder("2026-10-18-02-steam") + File(folder, "crash.log").writeBytes(byteArrayOf(0) + "$secret\n".toByteArray()) + val out = zip(folder) + assertTrue(out.containsKey("crash.log")) + assertFalse(out.getValue("crash.log").contains(leaked)) + } + + @Test fun utf16SecretsDoNotEscapeInTheSharedZip() { + val folder = tmp.newFolder("2026-10-18-03-steam") + File(folder, "crash.log").writeBytes("sessionid=$leaked".toByteArray(Charsets.UTF_16LE)) + val output = zip(folder).getValue("crash.log") + assertFalse(output.replace("\u0000", "").contains(leaked)) + assertTrue(output.contains("withheld")) + } + @Test fun aRecordedUnchangedFileIsScrubbedAgainOnTheWayIntoTheZip() { val folder = tmp.newFolder("2026-10-06-01-steam") File(folder, "session.log").writeText("$secret\n") diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt index 62d8e4769..03a324391 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt @@ -40,6 +40,19 @@ class SessionScalingPrefsTest { assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080)) } + @Test fun userAppsTakeTheDesktopResolutionOnceItIsChosen() { + val context = RuntimeEnvironment.getApplication() + val script = com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER + assertEquals("steam", SessionPrefs.displayMode(context, "run", script)) + SessionPrefs.setResolutionChoice(context, "lxqt", SessionDisplay.MATCH_SCREEN) + assertEquals("lxqt", SessionPrefs.displayMode(context, "run", script)) + assertEquals("lxqt", SessionPrefs.displayMode(context, "run", com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER)) + val choice = SessionPrefs.resolutionChoice(context, SessionPrefs.displayMode(context, "run", script), 1240 to 1080) + assertEquals(1240 to 1080, SessionDisplay.resolveChoice(1240 to 1080, choice)) + assertEquals("steam", SessionPrefs.displayMode(context, "run", "/usr/bin/retroarch")) + assertEquals("steam", SessionPrefs.displayMode(context, "steam", script)) + } + @Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() { val context = RuntimeEnvironment.getApplication() assertEquals(SessionDisplay.DEFAULT_RESOLUTION, SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt new file mode 100644 index 000000000..61e95da49 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt @@ -0,0 +1,379 @@ +package com.droiddeck.launcher.session + +import java.io.File +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SessionSuspendControllerTest { + @get:Rule val temporary = TemporaryFolder() + + @Test fun everyWorkerThreadIsStoppedAfterTheRoot() { + for (count in listOf(17, 68)) { + val fixture = fixture(count) + fixture.controller.freeze { assertTrue(it) } + assertTrue(fixture.game.threads.all { it.state == 't' }) + assertTrue(fixture.signals.count { it.pid == GAME && it.signal == STOP } >= count) + assertEquals(ROOT, fixture.signals.first { it.signal == STOP }.pid) + assertTrue(fixture.now < 1000) + assertEquals(1, fixture.audioSuspends) + } + } + + @Test fun cpuMustBeStableAcrossTwoCompleteSnapshots() { + val fixture = fixture(17) + var changes = 0 + fixture.afterSleep = { + if (fixture.game.threads.all { it.state == 't' } && changes < 3) { + fixture.game.threads.last().cpu++ + changes++ + } + } + fixture.controller.freeze { assertTrue(it) } + assertEquals(3, changes) + assertTrue(fixture.signals.count { it.pid == GAME && it.signal == STOP } >= 17) + } + + @Test fun reusedTidAndNewThreadMustJoinTheStableInventory() { + val fixture = fixture(17) + var changed = false + fixture.afterSleep = { + if (!changed && fixture.game.threads.all { it.state == 't' }) { + changed = true + fixture.game.threads.last().apply { started++; state = 'S'; cpu = 0 } + fixture.game.threads += Task(500, 9000, 'S') + } + } + fixture.controller.freeze { assertTrue(it) } + assertTrue(changed) + assertTrue(fixture.game.threads.all { it.state == 't' }) + assertTrue(fixture.signals.count { it.pid == GAME && it.signal == STOP } >= 19) + } + + @Test fun zombieLeaderDoesNotHideRunningWorkers() { + val fixture = fixture(17) + fixture.game.threads.first().state = 'Z' + fixture.sync() + fixture.controller.freeze { assertTrue(it) } + assertEquals('Z', fixture.game.threads.first().state) + assertTrue(fixture.game.threads.drop(1).all { it.state == 't' }) + assertTrue(fixture.signals.count { it.pid == GAME && it.signal == STOP } >= 16) + } + + @Test fun lateReparentedTraceeIsDiscoveredAndResumed() { + val fixture = fixture(17) + val late = Group(600, 1, ROOT, mutableListOf(Task(600, 6000, 'S'))) + fixture.afterSleep = { + if (fixture.root.threads.first().state == 'T' && late !in fixture.groups) fixture.groups += late + } + fixture.controller.freeze { assertTrue(it) } + assertEquals('t', late.threads.first().state) + fixture.controller.resume { assertTrue(it) } + assertEquals('S', late.threads.first().state) + } + + @Test fun descendantHelperKeepsRootStopAndResumeOrdering() { + val fixture = fixture(17) + val helper = Group(700, ROOT, 0, mutableListOf(Task(700, 7000, 'S'))) + fixture.groups += helper + fixture.helpers += helper.pid + fixture.sync() + fixture.controller.freeze { assertTrue(it) } + val stops = fixture.signals.filter { it.signal == STOP } + assertTrue(stops.indexOfFirst { it.pid == ROOT } < stops.indexOfFirst { it.pid == helper.pid }) + val before = fixture.signals.size + fixture.controller.resume { assertTrue(it) } + assertEquals(listOf(ROOT, helper.pid, GAME), fixture.signals.drop(before).take(3).map { it.pid }) + } + + @Test fun retainedHelperDoesNotAdoptAReusedPidOnRefreeze() { + val fixture = fixture(17) + val helper = Group(700, 9000, 0, mutableListOf(Task(700, 7000, 'S'))) + fixture.groups += helper + fixture.helpers += helper.pid + fixture.sync() + fixture.controller.freeze { assertTrue(it) } + fixture.blockGameContinue = true + fixture.controller.resume { assertFalse(it) } + helper.threads.first().apply { started++; state = 'S' } + fixture.sync() + val before = fixture.signals.size + fixture.controller.freeze { assertTrue(it) } + assertFalse(fixture.signals.drop(before).any { it.pid == helper.pid }) + } + + @Test fun timeoutRollsBackAllKnownProcessesAndAudio() { + val fixture = fixture(17) + fixture.neverStop = true + fixture.controller.freeze { assertFalse(it) } + assertTrue(fixture.now <= 8100) + assertEquals(ROOT, fixture.signals.first { it.signal == CONT }.pid) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + assertEquals(1, fixture.audioResumes) + } + + @Test fun missingKnownStatCannotBeMistakenForAnExitedProcess() { + val fixture = fixture(17) + fixture.afterSleep = { + if (fixture.root.threads.first().state == 'T') fixture.missingGameStat = true + } + fixture.controller.freeze { assertFalse(it) } + assertEquals(1, fixture.audioResumes) + fixture.missingGameStat = false + fixture.sync() + fixture.controller.resume { assertTrue(it) } + assertTrue(fixture.game.threads.all { it.state == 'S' }) + } + + @Test fun unavailableOrEmptyThreadInventoryCannotReportSuccess() { + for (empty in listOf(false, true)) { + val fixture = fixture(17) + fixture.emptyGameTasks = empty + fixture.missingGameTasks = !empty + fixture.sync() + fixture.controller.freeze { assertFalse(it) } + assertEquals(1, fixture.audioResumes) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + } + } + + @Test fun disappearingRootFailsAndStillResumesKnownChildren() { + val fixture = fixture(17) + fixture.afterSleep = { + if (fixture.root.threads.first().state == 'T') fixture.rootGone = true + } + fixture.controller.freeze { assertFalse(it) } + assertTrue(fixture.game.threads.all { it.state == 'S' }) + assertEquals(1, fixture.audioResumes) + } + + @Test fun replacementRootIsNeverStoppedOrContinued() { + val fixture = fixture(17) + var atReplacement = -1 + fixture.afterSleep = { + if (atReplacement < 0 && fixture.root.threads.first().state == 'T') { + atReplacement = fixture.signals.size + fixture.root.threads.first().apply { started++; state = 'S' } + } + } + fixture.controller.freeze { assertFalse(it) } + assertTrue(atReplacement > 0) + assertFalse(fixture.signals.drop(atReplacement).any { it.pid == ROOT }) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + } + + @Test fun reusedChildPidIsNotContinuedDuringResume() { + val fixture = fixture(17) + fixture.controller.freeze { assertTrue(it) } + fixture.game.threads.first().apply { started++; state = 'S' } + fixture.game.parent = 1 + fixture.game.tracer = 0 + fixture.sync() + val before = fixture.signals.size + fixture.controller.resume { assertTrue(it) } + assertFalse(fixture.signals.drop(before).any { it.pid == GAME }) + } + + @Test fun closeCancelsFreezeAndRestoresSavedRootAfterServiceClearsIt() { + val executor = QueueExecutor() + val fixture = fixture(68, executor) + var closed = false + var requested = false + fixture.afterSleep = { + if (!requested && fixture.root.threads.first().state == 'T') { + requested = true + fixture.rootPid = -1 + fixture.controller.closeAndResume { closed = true } + } + } + fixture.controller.freeze { assertFalse(it) } + executor.drain() + assertTrue(closed) + assertTrue(executor.isShutdown) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + assertEquals(1, fixture.audioResumes) + } + + @Test fun successfulFreezeIsRecheckedAndAudioIsReassertedAfterFailedResume() { + val fixture = fixture(17) + fixture.controller.freeze { assertTrue(it) } + fixture.blockGameContinue = true + fixture.controller.resume { assertFalse(it) } + assertEquals(1, fixture.audioResumes) + fixture.blockGameContinue = false + fixture.game.threads.last().state = 'S' + fixture.sync() + fixture.controller.freeze { assertTrue(it) } + assertTrue(fixture.game.threads.all { it.state == 't' }) + assertEquals(2, fixture.audioSuspends) + } + + @Test fun failedRollbackCanBeRefrozenAndAudioIsSuspendedAgain() { + val fixture = fixture(17) + fixture.neverStop = true + fixture.blockGameContinue = true + fixture.controller.freeze { assertFalse(it) } + fixture.neverStop = false + fixture.blockGameContinue = false + fixture.controller.freeze { assertTrue(it) } + assertTrue(fixture.game.threads.all { it.state == 't' }) + assertEquals(2, fixture.audioSuspends) + assertEquals(1, fixture.audioResumes) + } + + @Test fun repeatedFreezeResumeKeepsOriginalRootFirstOrdering() { + val fixture = fixture(17) + repeat(2) { + fixture.controller.freeze { assertTrue(it) } + val before = fixture.signals.size + fixture.controller.resume { assertTrue(it) } + assertEquals(ROOT, fixture.signals.drop(before).first().pid) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + } + fixture.controller.freeze { assertTrue(it) } + fixture.rootPid = -1 + var closed = false + fixture.controller.closeAndResume { closed = true } + assertTrue(closed) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + } + + @Test fun rejectedExecutorDoesNotFreezeOnTheCallingThread() { + val executor = QueueExecutor().apply { shutdown() } + val fixture = fixture(17, executor) + fixture.controller.freeze { assertFalse(it) } + assertTrue(fixture.signals.isEmpty()) + assertEquals(0, fixture.audioSuspends) + } + + private fun fixture(count: Int, executor: QueueExecutor = InlineExecutor()) = + Fixture(temporary.newFolder(), count, executor) + + private data class Task(val tid: Int, var started: Long, var state: Char, var cpu: Long = 0) + private data class Group(val pid: Int, var parent: Int, var tracer: Int, val threads: MutableList) + private data class SentSignal(val pid: Int, val signal: Int) + + private class Fixture(val proc: File, count: Int, executor: QueueExecutor) { + val root = Group(ROOT, 9000, 0, mutableListOf(Task(ROOT, 1000, 'S'))) + val game = Group(GAME, ROOT, ROOT, MutableList(count) { Task(GAME + it, 2000L + it, 'S') }) + val groups = mutableListOf(root, game) + val helpers = mutableListOf() + val signals = mutableListOf() + var rootPid = ROOT + var now = 0L + var audioSuspends = 0 + var audioResumes = 0 + var neverStop = false + var blockGameContinue = false + var missingGameStat = false + var missingGameTasks = false + var emptyGameTasks = false + var rootGone = false + var afterSleep: (() -> Unit)? = null + private var continuing = false + val controller = SessionSuspendController( + { rootPid }, { helpers }, { audioSuspends++ }, { audioResumes++ }, + proc, ::signal, ::threadSignal, ::sleep, { now }, executor, + ) + + init { sync() } + + private fun signal(pid: Int, signal: Int) { + signals += SentSignal(pid, signal) + val group = groups.first { it.pid == pid } + if (signal == STOP) { + continuing = false + if (pid == ROOT) group.threads.forEach { it.state = 'T' } + else group.threads.firstOrNull { + it.state !in listOf('T', 't', 'Z', 'X') && !(neverStop && it == game.threads.last()) + }?.state = 't' + } else if (signal == CONT) { + continuing = true + if (!(pid == GAME && blockGameContinue)) group.threads.forEach { if (it.state != 'Z') it.state = 'S' } + } + sync() + } + + private fun threadSignal(pid: Int, tid: Int, signal: Int) { + signals += SentSignal(pid, signal) + val task = groups.first { it.pid == pid }.threads.first { it.tid == tid } + if (signal == STOP && task.state !in listOf('T', 't', 'Z', 'X') && !(neverStop && task == game.threads.last())) { + task.state = 't' + } + sync() + } + + private fun sleep(millis: Long) { + now += millis + if (root.threads.first().state != 'T' && !continuing) game.threads.forEach { + if (it.state != 'Z') { it.state = 'S'; it.cpu++ } + } + afterSleep?.invoke() + sync() + } + + fun sync() { + for (group in groups) { + val dir = File(proc, group.pid.toString()) + if (group == root && rootGone) { dir.deleteRecursively(); continue } + dir.mkdirs() + val stat = File(dir, "stat") + if (group == game && missingGameStat) stat.delete() + else stat.writeText(stat(group, group.threads.first())) + File(dir, "status").writeText("TracerPid: ${group.tracer}\n") + val taskDir = File(dir, "task") + if (group == game && missingGameTasks) { taskDir.deleteRecursively(); continue } + taskDir.mkdirs() + if (group == game && emptyGameTasks) { taskDir.listFiles().orEmpty().forEach { it.deleteRecursively() }; continue } + for (task in group.threads) { + val entry = File(taskDir, task.tid.toString()).apply { mkdirs() } + File(entry, "stat").writeText(stat(group, task)) + } + } + } + + private fun stat(group: Group, task: Task): String { + val fields = MutableList(20) { "0" } + fields[0] = task.state.toString() + fields[1] = group.parent.toString() + fields[11] = task.cpu.toString() + fields[19] = task.started.toString() + return "${task.tid} (fixture (worker)) ${fields.joinToString(" ")}" + } + } + + private open class QueueExecutor : AbstractExecutorService() { + private val queue = ArrayDeque() + private var stopped = false + override fun execute(command: Runnable) { + if (stopped) throw RejectedExecutionException() + queue.add(command) + } + fun drain() { while (queue.isNotEmpty()) queue.removeFirst().run() } + override fun shutdown() { stopped = true } + override fun shutdownNow(): MutableList { + stopped = true + return queue.toMutableList().also { queue.clear() } + } + override fun isShutdown() = stopped + override fun isTerminated() = stopped && queue.isEmpty() + override fun awaitTermination(timeout: Long, unit: TimeUnit) = isTerminated + } + + private class InlineExecutor : QueueExecutor() { + override fun execute(command: Runnable) { super.execute(command); drain() } + } + + private companion object { + const val ROOT = 100 + const val GAME = 200 + const val STOP = 19 + const val CONT = 18 + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityPolicyTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityPolicyTest.kt new file mode 100644 index 000000000..708241662 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityPolicyTest.kt @@ -0,0 +1,27 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionVisibilityPolicyTest { + @Test fun handsetScreenStateControlsActivityOnDefaultDisplay() { + assertFalse(SessionVisibilityPolicy.isHidden(true, true, true, false)) + assertTrue(SessionVisibilityPolicy.isHidden(true, false, true, true)) + } + + @Test fun externalDisplayStateControlsActivityIndependentlyOfHandset() { + assertFalse(SessionVisibilityPolicy.isHidden(true, false, false, true)) + assertTrue(SessionVisibilityPolicy.isHidden(true, true, false, false)) + } + + @Test fun missingActivityDisplayIsHiddenUntilActivityMoves() { + assertTrue(SessionVisibilityPolicy.isHidden(true, true, false, false)) + assertFalse(SessionVisibilityPolicy.isHidden(true, true, true, false)) + } + + @Test fun stoppedActivityIsHiddenButVisibleXrKeepsSessionVisible() { + assertTrue(SessionVisibilityPolicy.isHidden(false, true, true, true)) + assertFalse(SessionVisibilityPolicy.isHidden(false, false, true, false, xrActivityVisible = true)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt index b35ea287e..3f34e5223 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.session +import android.view.Display import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -20,6 +21,34 @@ class SessionVisibilityTest { assertFalse(sessionHasVisibleSurface(false, false, false)) } + @Test fun phoneDisplayFollowsTheInteractiveState() { + assertTrue(sessionScreenOn(true, true, null)) + assertFalse(sessionScreenOn(true, false, null)) + assertFalse(sessionScreenOn(true, false, Display.STATE_ON)) + } + + @Test fun externalDisplayKeepsTheSessionAwakeWhileThePhoneScreenIsOff() { + assertTrue(sessionScreenOn(false, false, Display.STATE_ON)) + assertTrue(sessionScreenOn(false, false, Display.STATE_ON_SUSPEND)) + assertTrue(sessionScreenOn(false, false, Display.STATE_VR)) + } + + @Test fun externalDisplayTurnedOffSuspendsEvenWithThePhoneScreenOn() { + assertFalse(sessionScreenOn(false, true, Display.STATE_OFF)) + assertFalse(sessionScreenOn(false, true, Display.STATE_DOZE)) + assertFalse(sessionScreenOn(false, true, Display.STATE_DOZE_SUSPEND)) + } + + @Test fun missingExternalDisplayFallsBackToThePhone() { + assertTrue(sessionScreenOn(false, true, null)) + assertFalse(sessionScreenOn(false, false, null)) + } + + @Test fun unknownExternalDisplayStateFallsBackToThePhone() { + assertTrue(sessionScreenOn(false, true, Display.STATE_UNKNOWN)) + assertFalse(sessionScreenOn(false, false, Display.STATE_UNKNOWN)) + } + @Test fun losingXrVisibilityReturnsToNormalSuspendRules() { assertTrue(sessionHasVisibleSurface(false, false, true)) assertFalse(sessionHasVisibleSurface(false, false, false)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt index e636bee70..e73905cbe 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt @@ -27,24 +27,38 @@ class SettingsResetTest { @Test fun settingsGoBackToTheirDefaults() { SessionPrefs.setTheme(app, "ocean") + SessionPrefs.setUnfoldedControlsWithController(app, true) SessionPrefs.setNoXalia(app, false) SessionPrefs.setFpsLimit(app, SessionService.MODE_STEAM, 30) SessionPrefs.setHdr(app, SessionService.MODE_DESKTOP, true) SessionPrefs.setResolutionChoice(app, SessionService.MODE_STEAM, "1280x800") SessionPrefs.setGuestHostname(app, "MyDeck") + SessionPrefs.setHudShowFps(app, false) + SessionPrefs.setHudShowBattery(app, false) + SessionPrefs.setHudShowGraph(app, false) + SessionPrefs.setHudColor(app, "amber") + SessionPrefs.setHudOpacity(app, 40) ControllerPrefs.setOpacity(app, 40) AppUiPrefs.setScale(app, 150) - FrameGen.set(app, FrameGen.Mode(FrameGen.ENGINE_WINFG, 3)) + FrameGen.set(app, FrameGen.Mode(FrameGen.ENGINE_DDFG, 3)) prefs("steam_touch").edit().putBoolean("gyro", false).commit() assertTrue(SettingsReset.resetAll(app, gameProfiles = false)) assertEquals("graphite", SessionPrefs.theme(app)) + assertFalse(SessionPrefs.unfoldedControlsWithController(app)) assertTrue(SessionPrefs.noXalia(app)) assertEquals(0, SessionPrefs.fpsLimit(app, SessionService.MODE_STEAM)) assertFalse(SessionPrefs.hdr(app, SessionService.MODE_DESKTOP)) assertFalse(SessionPrefs.resolutionChosen(app, SessionService.MODE_STEAM)) assertEquals(SessionPrefs.DEFAULT_GUEST_HOSTNAME, SessionPrefs.guestHostname(app)) + assertTrue(SessionPrefs.hudShowFps(app)) + assertTrue(SessionPrefs.hudShowBattery(app)) + assertTrue(SessionPrefs.hudShowGraph(app)) + assertEquals(SessionPrefs.HUD_COLOR_MINT, SessionPrefs.hudColor(app)) + assertEquals(SessionPrefs.HUD_OPACITY_DEFAULT, SessionPrefs.hudOpacity(app)) + assertEquals("32.4°C", PerfHud.batteryCelsius(324)) + assertNull(PerfHud.batteryCelsius(0)) assertEquals(100, ControllerPrefs.read(app).opacity) assertEquals(AppUiPrefs.DEFAULT_SCALE, AppUiPrefs.scale(app)) assertEquals(FrameGen.Mode.OFF.engine, FrameGen.engine(app)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/StartupMovieConfigTest.kt b/app/src/test/java/com/droiddeck/launcher/session/StartupMovieConfigTest.kt new file mode 100644 index 000000000..c14fc0231 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/StartupMovieConfigTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class StartupMovieConfigTest { + @get:Rule val folder = TemporaryFolder() + + private val older = "\t\t\t\t\"StartupMovie\"\n\t\t\t\t{\n\t\t\t\t\t\"MovieID\"\t\t\"0\"\n\t\t\t\t\t\"LocalPath\"\t\t\"%s\"\n\t\t\t\t}\n" + private val customization = "\t\"Customization\"\n\t{\n\t\t\"StartupMovie\"\n\t\t{\n\t\t\t\"MovieID\"\t\t\"%s\"\n\t\t\t\"LocalPath\"\t\t\"%s\"\n\t\t}\n\t}\n" + + private fun config(olderPath: String?, id: String?, path: String?): File { + val text = StringBuilder("\"InstallConfigStore\"\n{\n\t\"Software\"\n\t{\n\t\t\"Valve\"\n\t\t{\n\t\t\t\"Steam\"\n\t\t\t{\n") + if (olderPath != null) text.append(older.format(olderPath)) + text.append("\t\t\t}\n\t\t}\n\t}\n") + if (id != null && path != null) text.append(customization.format(id, path)) + text.append("}\n") + return folder.newFile().also { it.writeText(text.toString()) } + } + + @Test fun steamReadsTheMovieUnderCustomizationNotTheOlderOne() { + val file = config(SessionFiles.BOOT_MOVIE_PATH, "39900045022", "/communityitemscache/startupmovies/39900045022_ab.webm") + assertEquals("/communityitemscache/startupmovies/39900045022_ab.webm", SessionFiles.startupMoviePath(file)) + } + + @Test fun aPickIsWrittenWhereSteamReadsItAndTheOlderPlaceIsKeptInStep() { + val file = config("/uioverrides/movies/x.webm", "39900045022", "/communityitemscache/startupmovies/39900045022_ab.webm") + assertTrue(SessionFiles.ensureStartupMovieDefault(file, force = true)) + val text = file.readText() + assertEquals(SessionFiles.BOOT_MOVIE_PATH, SessionFiles.startupMoviePath(file)) + assertFalse(text.contains("39900045022")) + assertEquals(2, Regex("\"LocalPath\"\\s+\"" + Regex.escape(SessionFiles.BOOT_MOVIE_PATH) + "\"").findAll(text).count()) + } + + @Test fun aPickMadeInSteamIsLeftAloneWithoutAPendingPick() { + val file = config(SessionFiles.BOOT_MOVIE_PATH, "0", "/uioverrides/movies/Undertale.webm") + assertFalse(SessionFiles.ensureStartupMovieDefault(file, force = false)) + assertEquals("/uioverrides/movies/Undertale.webm", SessionFiles.startupMoviePath(file)) + } + + @Test fun aConfigWithoutCustomizationGetsOne() { + val file = config(null, null, null) + assertTrue(SessionFiles.ensureStartupMovieDefault(file, force = false)) + val text = file.readText() + assertTrue(text.contains("\"Customization\"")) + assertEquals(SessionFiles.BOOT_MOVIE_PATH, SessionFiles.startupMoviePath(file)) + assertEquals(2, Regex("\"StartupMovie\"").findAll(text).count()) + } + + @Test fun aMissingStagedMovieResetsTheSectionSteamReads() { + val file = config(SessionFiles.BOOT_MOVIE_PATH, "0", SessionFiles.BOOT_MOVIE_PATH) + assertTrue(SessionFiles.resetMissingStartupMovie(file, false)) + assertNull(SessionFiles.startupMoviePath(file)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamClientSetupFailureTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamClientSetupFailureTest.kt new file mode 100644 index 000000000..fd6400c60 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamClientSetupFailureTest.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SteamClientSetupFailureTest { + private fun find(vararg lines: String) = SteamClientSetupFailure.find(lines.asSequence()) + + @Test fun aBlockedDownloadCarriesTheLastCurlError() { + val failure = find( + "== STEP 12:35:22 checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)", + "curl: (7) Failed to connect to client-update.steamstatic.com:443 after 3 ms: Could not connect to server", + "curl: (35) Recv failure: Connection reset by peer", + "droiddeck-steam-install: no Steam client CDN answered for steam_client_steamdeck_publicbeta_linuxarm64", + "== steam: the Steam client could not be set up (status 1)", + "[gamescope] [Info] launch: Primary child shut down!", + )!! + assertTrue(failure.download) + assertEquals("curl: (35) Recv failure: Connection reset by peer", failure.curlError) + } + + @Test fun aFailureWithoutCurlErrorIsNotCalledADownloadProblem() { + val failure = find( + "== STEP 12:35:22 checking the Steam client (a first run downloads it)", + "== STEP 12:35:30 downloading Steam: bins_linuxarm64_linuxarm64 (2/17)", + "error: cannot create /root/.local/share/Steam/ubuntu12_32/steam-runtime.tar.xz", + "== steam: the Steam client could not be set up (status 50)", + )!! + assertFalse(failure.download) + assertNull(failure.curlError) + } + + @Test fun aReadyClientIsNoFailure() { + assertNull(find( + "== STEP 12:35:22 checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)", + "curl: (35) Recv failure: Connection reset by peer", + "== STEP 12:35:40 Steam client ready", + )) + } + + @Test fun onlyTheLatestCheckCounts() { + assertNull(find( + "== STEP 10:00:00 checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)", + "curl: (6) Could not resolve host: client-update.steamstatic.com", + "== steam: the Steam client could not be set up (status 1)", + "== STEP 10:05:00 checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)", + "== STEP 10:06:00 Steam client ready", + )) + val failure = find( + "== STEP 10:00:00 checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)", + "curl: (6) Could not resolve host: client-update.steamstatic.com", + "== steam: the Steam client could not be set up (status 1)", + "== STEP 10:05:00 checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)", + "== steam: the Steam client could not be set up (status 9)", + )!! + assertNull(failure.curlError) + } + + @Test fun anArm64SyncErrorIsNotBlamedForTheX64Client() { + val failure = find( + "== STEP 10:00:00 checking the Steam client's arm64 parts (first run downloads them, ~1-2 min)", + "curl: (6) Could not resolve host: client-update.steamstatic.com", + "== STEP 10:00:05 could not update the arm64 client's parts; the x86-64 client keeps its own web helper", + "== STEP 10:00:05 checking the x86-64 Steam client (FEX, experimental; the first run downloads ~2 GB)", + "== steam: the Steam client could not be set up (status 3)", + )!! + assertNull(failure.curlError) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamDeckRepoTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamDeckRepoTest.kt new file mode 100644 index 000000000..4458985f8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamDeckRepoTest.kt @@ -0,0 +1,92 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SteamDeckRepoTest { + private val sample = """ + {"posts":[ + {"id":"p1","title":"Cool","content":"c","user":{"steam_name":"Ann"},"thumbnail":"https://x/t.png","video":"https://x/v.webm", + "video_duration":7,"likes":3,"downloads":9,"devices":["steam_deck"],"url":"https://steamdeckrepo.com/post/p1"}, + {"id":"p2","title":"","video":"http://insecure/v.webm"}, + {"id":"","video":"https://x/v.webm"}, + {"id":"p3","video":"https://x/v3.webm","thumbnail":"ftp://bad"} + ],"currentSort":"trending"} + """.trimIndent() + + @Test fun parsesPostsAndSkipsBrokenOnes() { + val posts = SteamDeckRepo.parse(sample)!! + assertEquals(listOf("p1", "p3"), posts.map { it.id }) + val first = posts[0] + assertEquals("Cool", first.title) + assertEquals("Ann", first.author) + assertEquals(7, first.seconds) + assertEquals(listOf("steam_deck"), first.devices) + assertEquals("p3", posts[1].title) + assertEquals("", posts[1].thumbnail) + } + + @Test fun notJsonIsNotAPage() { + assertNull(SteamDeckRepo.parse("")) + assertNull(SteamDeckRepo.parse("{}")) + } + + @Test fun buildsTheSameAddressesAsTheWebsite() { + assertEquals("https://steamdeckrepo.com/api/posts?sort=trending", SteamDeckRepo.url(SteamDeckRepo.Sort.TRENDING, null, "")) + assertEquals( + "https://steamdeckrepo.com/api/posts?sort=downloads-desc&device=steam_machine&search=a%20b%26c&page=3", + SteamDeckRepo.url(SteamDeckRepo.Sort.DOWNLOADS, "steam_machine", " a b&c ", 3), + ) + } + + @Test fun readsAreCutOffAtTheLimit() { + assertEquals(10, SteamDeckRepo.readLimited(ByteArray(10).inputStream(), 10)!!.size) + assertNull(SteamDeckRepo.readLimited(ByteArray(11).inputStream(), 10)) + } + + @Test fun jsonNullsAreEmptyTextNotTheWordNull() { + val posts = SteamDeckRepo.parse( + """{"posts":[{"id":"p9","title":null,"content":null,"user":{"steam_name":null},"thumbnail":null,"video":"https://x/v.webm","url":null}]}""", + )!! + val post = posts.single() + assertEquals("p9", post.title) + assertEquals("", post.content) + assertEquals("", post.author) + assertEquals("", post.thumbnail) + assertEquals("", post.url) + assertTrue(SteamDeckRepo.parse("""{"posts":[{"id":null,"video":"https://x/v.webm"}]}""")!!.isEmpty()) + } + + @Test fun aPageHoldsTwelveMovies() { + assertEquals(12, SteamDeckRepo.PAGE_SIZE) + } + + private fun lastPageOf(pages: Int, tail: Int = SteamDeckRepo.PAGE_SIZE) = SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "") { p -> + when { + p < pages -> SteamDeckRepo.PAGE_SIZE + p == pages -> tail + else -> 0 + } + } + + @Test fun theLastPageIsFoundByAskingForPages() { + for (pages in listOf(2, 3, 7, 8, 9, 33, 100, 500)) assertEquals(pages, lastPageOf(pages)) + } + + @Test fun aShortLastPageEndsTheSearchAtOnce() { + assertEquals(1, lastPageOf(1, tail = 5)) + assertEquals(5, lastPageOf(5, tail = 3)) + assertEquals(37, lastPageOf(37, tail = 1)) + } + + @Test fun aSearchWithNothingHasOnePageAndAnUnreachableSiteHasNone() { + assertEquals(1, SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "x") { 0 }) + assertNull(SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "x") { null }) + assertNull(SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "x") { p -> if (p < 4) SteamDeckRepo.PAGE_SIZE else null }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt index c39210737..e60c6dcd9 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt @@ -57,6 +57,25 @@ class SteamDownloadMonitorTest { } } + @Test fun readsBoundCardLibrariesFromTheirHostPath() { + val root = Files.createTempDirectory("droiddeck-steam-download-").toFile() + try { + val card = File(root, "card").apply { mkdirs() } + File(root, "root/.local/share/Steam/steamapps").apply { mkdirs() } + .resolve("libraryfolders.vdf") + .writeText("\"libraryfolders\" { \"1\" { \"path\" \"/mnt/droiddeck-sd\" } }") + val manifest = manifest(card, "44", downloaded = 10, total = 100, flags = 6) + val monitor = SteamDownloadMonitor() + val mounts = mapOf("/mnt/droiddeck-sd" to card) + + assertFalse(monitor.poll(root, now = 1_000L, mounts = mounts)) + manifest.writeText(appManifest(downloaded = 20, total = 100, flags = 6)) + assertTrue(monitor.poll(root, now = 3_000L, mounts = mounts)) + } finally { + root.deleteRecursively() + } + } + @Test fun followsSteamContentActivityWhenManifestProgressIsNotFlushed() { val root = Files.createTempDirectory("droiddeck-steam-download-").toFile() try { diff --git a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt index 023013ed5..0c3044743 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt @@ -1,6 +1,7 @@ package com.droiddeck.launcher.stores import java.io.File +import java.util.concurrent.Executor import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -11,7 +12,7 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) -@Config(sdk = [33]) +@Config(sdk = [33], application = android.app.Application::class) class CloudSavesTest { private val app get() = RuntimeEnvironment.getApplication() @@ -83,9 +84,11 @@ class CloudSavesTest { } @Test fun aFirstLaunchWithoutAPrefixIsDeferredNotBlocked() { + val deferred = ArrayList() val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "ELDERBORN").apply { mkdirs() } File(folder, "ELDERBORN.exe").writeText("MZ") StoreGameSidecar(Store.GOG, "1207664643", "ELDERBORN", "ELDERBORN.exe").write(folder) - assertEquals("deferred", CloudSaves.downloadOrDefer(app, Store.GOG, "1207664643").result) + assertEquals("deferred", CloudSaves.downloadOrDefer(app, Store.GOG, "1207664643", Executor { deferred += it }).result) + assertEquals(1, deferred.size) } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/DetailCatalogItemTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/DetailCatalogItemTest.kt new file mode 100644 index 000000000..dc5512171 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/DetailCatalogItemTest.kt @@ -0,0 +1,29 @@ +package com.droiddeck.launcher.stores + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DetailCatalogItemTest { + private fun item(store: Store, id: String, title: String) = CatalogItem(store, id, title, imageUrl = null) + private val shelves = StoreShelves(trending = listOf(item(Store.XBOX, "9SHELF000001", "Shelf game"))) + + @Test fun aSearchOnlyResultKeepsItsMetadataOnTheDetailPage() { + val found = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver")) + assertEquals("Echo Weaver", detailCatalogItem(Store.XBOX, "9SEARCH00001", emptyList(), found, shelves)?.title) + } + + @Test fun theLibraryWinsOverSearchAndShelvesForTheSameProduct() { + val library = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver").copy(owned = true, sizeBytes = 42)) + val found = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver").copy(owned = false)) + val resolved = detailCatalogItem(Store.XBOX, "9SEARCH00001", library, found, shelves)!! + assertEquals(true, resolved.owned) + assertEquals(42L, resolved.sizeBytes) + } + + @Test fun shelvesRemainAFallbackAndTheItemStaysInItsStore() { + assertEquals("Shelf game", detailCatalogItem(Store.XBOX, "9SHELF000001", emptyList(), emptyList(), shelves)?.title) + assertNull(detailCatalogItem(Store.GOG, "9SEARCH00001", emptyList(), listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver")), shelves)) + assertNull(detailCatalogItem(Store.XBOX, "9UNKNOWN0001", emptyList(), emptyList(), shelves)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt index aed81f155..e0c657a14 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt @@ -112,6 +112,25 @@ class StoreAccountsCipherTest { assertEquals(creds.toString(), StoreAccounts.read(app, Store.EPIC).toString()) } + @Test fun xboxNeverFallsBackToPlaintextWhenKeystoreFails() { + StoreAccounts.keys = broken + org.junit.Assert.assertThrows(IllegalStateException::class.java) { + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "test@example.test").put("slots", JSONObject().put("user-tokens", "synthetic-secret"))) + } + assertFalse(file(Store.XBOX).exists()) + StoreAccounts.keys = fake + StoreAccounts.write(app, Store.XBOX, creds) + assertTrue(CredentialCipher.isEnvelope(JSONObject(file(Store.XBOX).readText()))) + } + + @Test fun xboxUsernameAndBackendSlotsAreEncryptedTogether() { + val xbox = JSONObject().put("username", "test@example.test").put("slots", JSONObject().put("user-tokens", "synthetic-secret")) + StoreAccounts.write(app, Store.XBOX, xbox) + assertFalse(file(Store.XBOX).readText().contains("synthetic-secret")) + assertFalse(file(Store.XBOX).readText().contains("test@example.test")) + assertEquals("test@example.test", StoreAccounts.signedInAs(app, Store.XBOX)) + } + @Test fun signOutRemovesTheFile() { StoreAccounts.write(app, Store.GOG, creds) StoreAccounts.clear(app, Store.GOG) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreCatalogPatchTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreCatalogPatchTest.kt new file mode 100644 index 000000000..e545cc77d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreCatalogPatchTest.kt @@ -0,0 +1,29 @@ +package com.droiddeck.launcher.stores + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** Filling a grid one card at a time: what is on screen stays put, what is new lands where its title belongs. */ +class StoreCatalogPatchTest { + private fun item(id: String, title: String, image: String? = null) = CatalogItem(Store.GOG, id, title, imageUrl = image, owned = true) + + @Test fun onlyTheCardItWasGivenIsReplaced() { + val alpha = item("1", "Alpha") + val beta = item("2", "Beta") + val next = patchItems(listOf(alpha, beta), listOf(item("2", "Beta", "wide.jpg"))) + assertEquals(2, next.size) + assertSame(alpha, next[0]) + assertEquals("wide.jpg", next[1].imageUrl) + } + + @Test fun aCardTheGridDoesNotHaveYetIsAddedInTitleOrder() { + val next = patchItems(listOf(item("1", "Alpha"), item("3", "Gamma")), listOf(item("2", "Beta"))) + assertEquals(listOf("Alpha", "Beta", "Gamma"), next.map { it.title }) + } + + @Test fun anEmptyGridTakesTheCardAsItComes() { + val next = patchItems(null, listOf(item("2", "Beta"))) + assertEquals(listOf("Beta"), next.map { it.title }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLanguageTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLanguageTest.kt new file mode 100644 index 000000000..c97ac2a3a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLanguageTest.kt @@ -0,0 +1,93 @@ +package com.droiddeck.launcher.stores + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = android.app.Application::class) +class StoreLanguageTest { + @Test + fun polishDepotIsKeptWhenPolishIsChosen() { + assertTrue(StoreLanguage.depotAccepted(arrayOf("pl-PL"), "pl")) + assertTrue(StoreLanguage.depotAccepted(arrayOf("pl"), "pl-PL")) + assertTrue(StoreLanguage.depotAccepted(arrayOf("polish"), "pl-PL")) + assertTrue(StoreLanguage.depotAccepted(arrayOf("*"), "pl")) + assertTrue(StoreLanguage.depotAccepted(emptyArray(), "pl")) + assertTrue(StoreLanguage.depotAccepted(null, "pl")) + } + + @Test + fun englishDepotIsSkippedWhenPolishIsChosen() { + assertFalse(StoreLanguage.depotAccepted(arrayOf("en-US"), "pl-PL")) + assertFalse(StoreLanguage.depotAccepted(arrayOf("en", "english"), "pl")) + assertTrue(StoreLanguage.depotAccepted(arrayOf("en-US"), "en")) + assertTrue(StoreLanguage.depotAccepted(arrayOf("en"), "en-US")) + assertTrue(StoreLanguage.depotAccepted(arrayOf("english"), "en-US")) + } + + @Test + fun pickPrefersSystemLanguageThenEnglish() { + val available = listOf("en-US", "de-DE", "pl-PL") + assertEquals("pl-PL", StoreLanguage.pick(available, "pl")) + assertEquals("en-US", StoreLanguage.pick(available, "ja")) + assertEquals("de-DE", StoreLanguage.pick(listOf("de-DE", "fr-FR"), "de")) + assertEquals("wanted", StoreLanguage.pick(emptyList(), "wanted")) + } + + @Test + fun pickPrefersExactCodeBeforeTheBaseLanguage() { + val both = listOf("pl", "pl-PL", "en-US") + assertEquals("pl-PL", StoreLanguage.pick(both, "pl-PL")) + assertEquals("pl", StoreLanguage.pick(both, "pl")) + assertEquals("pl-PL", StoreLanguage.pick(listOf("en-US", "pl-PL"), "pl")) + } + + @Test + fun resolvedDepotKeepsSharedFilesAndOnlyTheChosenVariant() { + assertTrue(StoreLanguage.depotMatchesResolved(arrayOf("*"), "pl-PL")) + assertTrue(StoreLanguage.depotMatchesResolved(emptyArray(), "pl-PL")) + assertTrue(StoreLanguage.depotMatchesResolved(null, "pl-PL")) + assertTrue(StoreLanguage.depotMatchesResolved(arrayOf("pl-PL", "*"), "pl-PL")) + assertTrue(StoreLanguage.depotMatchesResolved(arrayOf("pl-PL"), "pl-PL")) + assertFalse(StoreLanguage.depotMatchesResolved(arrayOf("pl"), "pl-PL")) + assertFalse(StoreLanguage.depotMatchesResolved(arrayOf("en-US"), "pl-PL")) + assertTrue(StoreLanguage.depotMatchesResolved(arrayOf("polish"), "pl")) + } + + @Test + fun tagForFollowsLocale() { + assertEquals("pl-PL", StoreLanguage.tagFor(Locale("pl", "PL"))) + assertEquals("pl", StoreLanguage.tagFor(Locale("pl"))) + assertEquals("pt-BR", StoreLanguage.tagFor(Locale("pt", "BR"))) + assertEquals("es-419", StoreLanguage.tagFor(Locale("es", "MX"))) + assertEquals("zh-Hans", StoreLanguage.tagFor(Locale.SIMPLIFIED_CHINESE)) + assertEquals("zh-Hant", StoreLanguage.tagFor(Locale.TRADITIONAL_CHINESE)) + } + + @Test + fun codesFromDepotsSkipStarAndDuplicates() { + val depots = JSONArray() + .put(JSONObject().put("languages", JSONArray().put("*").put("en-US"))) + .put(JSONObject().put("languages", JSONArray().put("pl-PL").put("en-US"))) + .put(JSONObject().put("languages", JSONArray())) + assertEquals(listOf("en-US", "pl-PL"), StoreLanguage.codesFromDepots(depots)) + assertTrue(StoreLanguage.codesFromDepots(null).isEmpty()) + } + + @Test + fun selectableStoresAreGogAndEpic() { + assertTrue(StoreLanguage.selectable(Store.GOG)) + assertTrue(StoreLanguage.selectable(Store.EPIC)) + assertFalse(StoreLanguage.selectable(Store.AMAZON)) + assertFalse(StoreLanguage.selectable(Store.XBOX)) + assertFalse(StoreLanguage.selectable(Store.ITCH)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt new file mode 100644 index 000000000..898e074f6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt @@ -0,0 +1,56 @@ +package com.droiddeck.launcher.stores + +import android.app.Application +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class StoreLaunchTest { + private val app get() = RuntimeEnvironment.getApplication() + + private fun install(state: String = StoreGameSidecar.STATE_INSTALLED): File { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), Store.XBOX), "Deep Rock Galactic") + File(folder, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("licensed image") } + StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe", state = state).write(folder) + return folder + } + + @Test fun aCompletedInstallCanLaunchBeforeTheFrontendLibraryRefreshes() { + val folder = install() + val game = StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")!! + val added = AddedGames.single(app, folder.path)!! + assertEquals("xbox", game.source) + assertEquals("9NHFVWX1V7QJ", game.storeId) + assertEquals(folder, game.gameFiles) + assertEquals(added.gameId, game.gameId) + assertTrue(added.guestExe.endsWith("/FSD/Binaries/WinGDK/FSD.exe")) + } + + @Test fun unfinishedPackagesCannotBecomePlayableThroughTheFallback() { + for (state in listOf(StoreGameSidecar.STATE_INSTALLING, StoreGameSidecar.STATE_UPDATING)) { + install(state) + assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")) + } + } + + @Test fun anotherStoreOrProductCannotUseTheXboxInstall() { + install() + assertNull(StoreLaunch.installedGame(app, Store.GOG, "9NHFVWX1V7QJ")) + assertNull(StoreLaunch.installedGame(app, Store.XBOX, "OTHERPRODUCT")) + } + + @Test fun aMissingExecutableDoesNotBecomeALaunchTarget() { + val folder = install() + File(folder, "FSD/Binaries/WinGDK/FSD.exe").delete() + assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLoginUserAgentTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLoginUserAgentTest.kt new file mode 100644 index 000000000..bf0c264f2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLoginUserAgentTest.kt @@ -0,0 +1,27 @@ +package com.droiddeck.launcher.stores + +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StoreLoginUserAgentTest { + @Test fun theDevicesWebViewAgentIsSentAsChrome() { + assertEquals( + "Mozilla/5.0 (Linux; Android 16; NP06J) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.7390.122 Mobile Safari/537.36", + StoreLoginActivity.chromeUserAgent( + "Mozilla/5.0 (Linux; Android 16; NP06J Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/141.0.7390.122 Mobile Safari/537.36", + ), + ) + } + + @Test fun noWebViewAgentFallsBackToChromeWithoutAnotherDevicesModel() { + for (agent in listOf(null, "", "Dalvik/2.1.0 (Linux; U; Android 16)")) { + val ua = StoreLoginActivity.chromeUserAgent(agent) + assertTrue(ua, "Chrome/" in ua) + assertFalse(ua, "SM-S918B" in ua) + assertTrue(ua, "Android ${Build.VERSION.RELEASE}; ${Build.MODEL})" in ua) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/WebCookiesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/WebCookiesTest.kt new file mode 100644 index 000000000..ec8c63d10 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/WebCookiesTest.kt @@ -0,0 +1,18 @@ +package com.droiddeck.launcher.stores + +import com.droiddeck.launcher.stores.gog.GogAuth +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebCookiesTest { + @Test fun gogLoginCookiesAreExpiredOnTheLoginHostAndTheDomain() { + val lines = WebCookies.expired("galaxy-login-s=a; galaxy-login-tsa=b; galaxy-login-al=c", GogAuth.COOKIE_DOMAIN, listOf("/"), "login.gog.com") + assertEquals(9, lines.size) + assertTrue(lines.contains("galaxy-login-al=; Max-Age=0; Path=/; Secure")) + assertTrue(lines.contains("galaxy-login-al=; Max-Age=0; Path=/; Secure; Domain=.gog.com")) + assertTrue(lines.contains("galaxy-login-al=; Max-Age=0; Path=/; Secure; Domain=login.gog.com")) + assertTrue(lines.none { it.startsWith("galaxy-login-al=c") }) + assertEquals(emptyList(), WebCookies.expired(null, GogAuth.COOKIE_DOMAIN, listOf("/"), "login.gog.com")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt new file mode 100644 index 000000000..0c6502e93 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt @@ -0,0 +1,79 @@ +package com.droiddeck.launcher.stores.gog + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The account listing: everything the tab needs to draw a library before a product is asked about. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class GogListingTest { + @Test + fun aPageCarriesTheGamesTheAppCanInstall() { + val page = GogLibrary.parseListing(PAGE)!! + assertEquals(2, page.totalPages) + assertEquals(listOf("Shadowrun Returns", "Witcher 3: Wild Hunt, The"), page.games.map { it.title }) + val shadowrun = page.games[0] + assertEquals("1207660413", shadowrun.gameId) + assertEquals("https://images-2.gog.com/6c35ecb988f57725cc0f385acf860241082da16eda9fab66115f4da883dae3d1_glx_vertical_cover.webp", shadowrun.verticalCover) + assertEquals("", shadowrun.imageUrl) + assertEquals("Role-playing", shadowrun.category) + assertEquals("2013-07-25", shadowrun.releaseDate) + assertEquals("", shadowrun.description) + } + + @Test + fun moviesOtherPlatformsAndGalaxysOwnEntryAreLeftOut() { + assertEquals(listOf("1207660413", "1207664663"), GogLibrary.parseListing(PAGE)!!.games.map { it.gameId }) + } + + @Test + fun aResponseThatIsNotAListingIsNull() { + assertNull(GogLibrary.parseListing("""{"error":"unauthorized"}""")) + assertNull(GogLibrary.parseListing("not json")) + } + + private companion object { + val PAGE = """ + { + "sortBy": "title", + "page": 1, + "totalProducts": 6, + "totalPages": 2, + "productsPerPage": 100, + "products": [ + { + "id": 1207660413, + "title": "Shadowrun Returns", + "image": "//images-2.gog.com/6c35ecb988f57725cc0f385acf860241082da16eda9fab66115f4da883dae3d1", + "worksOn": { "Windows": true, "Mac": true, "Linux": true }, + "category": "Role-playing", + "isComingSoon": false, + "isMovie": false, + "isGame": true, + "slug": "shadowrun_returns", + "dlcCount": 0, + "releaseDate": { "date": "2013-07-25 00:00:00.000000", "timezone_type": 3, "timezone": "Europe/Nicosia" } + }, + { + "id": 1207664663, + "title": " Witcher 3: Wild Hunt, The ", + "image": "//images-3.gog.com/60c724a052275a049d857d53957dc38e9347742f52372bb956d992b43efa8fb5", + "worksOn": { "Windows": true, "Mac": false, "Linux": false }, + "category": "Role-playing", + "isMovie": false, + "isGame": true, + "releaseDate": { "date": "2015-05-19 02:00:00.000000", "timezone_type": 3, "timezone": "Europe/Nicosia" } + }, + { "id": 1582718039, "title": "A Movie", "image": "//images-1.gog.com/movie", "isMovie": true, "isGame": false }, + { "id": 1430747193, "title": "Mac Only", "image": "//images-1.gog.com/mac", "worksOn": { "Windows": false, "Mac": true }, "isMovie": false, "isGame": true }, + { "id": 1801418160, "title": "GOG Galaxy", "image": "//images-1.gog.com/galaxy", "worksOn": { "Windows": true }, "isMovie": false, "isGame": true }, + { "id": 1401362385, "title": "Soundtrack", "image": "//images-1.gog.com/ost", "worksOn": { "Windows": true }, "isMovie": false, "isGame": false } + ] + } + """.trimIndent() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt new file mode 100644 index 000000000..ea8f45877 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.stores.xbox + +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.* +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxAccountLifecycleTest { + private val app = ApplicationProvider.getApplicationContext() + + @Test fun staleSignInCommitsAndBackendStartsCannotAcquireANewerAccount() { + val original = XboxCredentials.lifecycle(app) { it } + XboxCredentials.invalidate(app) + var committed = false + assertThrows(IllegalStateException::class.java) { + XboxCredentials.lifecycle(app, original) { committed = true } + } + assertFalse(committed) + assertThrows(IllegalStateException::class.java) { XboxCredentials(app, original) } + XboxCredentials.lifecycle(app) { current -> assertNotEquals(original, current) } + } + + @Test fun accountCommitAndLaunchAuthorizationWaitUntilRevocationFinishes() { + val revoked = CountDownLatch(1) + val releaseStop = CountDownLatch(1) + val attempted = CountDownLatch(1) + val committed = AtomicBoolean(false) + val authorized = CountDownLatch(1) + val signOut = Thread { + XboxCredentials.invalidate(app) { + revoked.countDown() + check(releaseStop.await(5, TimeUnit.SECONDS)) + } + }.apply { start() } + assertTrue(revoked.await(5, TimeUnit.SECONDS)) + val signIn = Thread { + attempted.countDown() + XboxCredentials.lifecycle(app) { + committed.set(true) + authorized.countDown() + } + }.apply { start() } + try { + assertTrue(attempted.await(5, TimeUnit.SECONDS)) + assertFalse(authorized.await(100, TimeUnit.MILLISECONDS)) + assertFalse(committed.get()) + } finally { + releaseStop.countDown() + signOut.join(5000) + signIn.join(5000) + } + assertTrue(committed.get()) + assertFalse(signOut.isAlive) + assertFalse(signIn.isAlive) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt new file mode 100644 index 000000000..ca0a3fe62 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt @@ -0,0 +1,23 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test + +class XboxBackendMappingTest { + private val path = "/runtime/xodus-cli" + private fun mapping(permissions: String = "r-xp", inode: Long = 42, name: String = path) = + "7a0000-7b0000 $permissions 00001000 00:31 $inode $name\n" + + @Test fun prootLoaderDoesNotHideTheVerifiedBackendImage() { + val maps = mapping(name = "/android/libproot-loader.so", inode = 1) + mapping() + assertTrue(XboxCredentials.mapsBackend(maps, path, 42)) + } + @Test fun loaderAloneOrAnotherGuestExecutableCannotAuthenticate() { + assertFalse(XboxCredentials.mapsBackend(mapping(name = "/android/libproot-loader.so"), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(name = "/runtime/other-cli"), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(inode = 43), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(permissions = "r--p"), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(name = "$path (deleted)"), path, 42)) + assertFalse(XboxCredentials.mapsBackend("malformed\n", path, 42)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt new file mode 100644 index 000000000..c4f95c091 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -0,0 +1,168 @@ +package com.droiddeck.launcher.stores.xbox + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxCatalogTest { + private val now = Instant.parse("2026-10-19T12:00:00Z") + private val game = "9AAAAAAAAAAA" + private val parent = "9BBBBBBBBBBB" + private val plan = "CFQ7TTC0KGQ8" + private fun row(id: String, parent: String? = null) = JSONObject().put("productId", id).put("status", "Active") + .put("isTrial", false).put("special", false).put("sharingSource", "None") + .put("endDate", "2099-01-01T00:00:00Z").put("satisfiedByProductId", parent ?: JSONObject.NULL) + private fun envelope(vararg rows: JSONObject) = JSONObject().put("schema", 1).put("account", "test@example.test") + .put("market", "US").put("checkedAt", now.toString()).put("entitlements", JSONArray(rows.toList())) + private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now) + private fun membership(id: String = plan) = JSONObject().put("productId", id).put("status", "Active").put("endDate", "2099-01-01T00:00:00Z") + private fun withMembership(json: JSONObject, vararg plans: JSONObject) = json.put("subscriptions", JSONArray(plans.toList())) + + @Test fun separateMembershipRestoresSubscriptionChildrenWithoutInventingOwnership() { + val json = withMembership(envelope(row(game), row(parent, plan)), membership()) + assertEquals(mapOf(game to "owned", parent to "gamepass"), XboxCatalog.access(json, "test@example.test", "US", now)) + assertTrue(XboxCatalog.access(withMembership(envelope(), membership()), "test@example.test", "US", now).isEmpty()) + assertEquals(mapOf(game to "gamepass", parent to "gamepass"), XboxCatalog.access( + withMembership(envelope(row(game, parent), row(parent, plan)), membership()), "test@example.test", "US", now)) + } + + @Test fun separateMembershipPreservesProductRestrictionsAndExistingPlanEvidence() { + for (member in listOf(membership().put("status", "Expired"), membership().put("endDate", now.toString()))) { + assertTrue(XboxCatalog.access(withMembership(envelope(row(game, plan)), member), "test@example.test", "US", now).isEmpty()) + } + assertEquals(mapOf(game to "gamepass"), XboxCatalog.access( + withMembership(envelope(row(game, plan), row(plan))), "test@example.test", "US", now)) + for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true, + "sharingSource" to "Family", "endDate" to now.toString())) { + for (rows in listOf(arrayOf(row(game, plan).put(key, value)), arrayOf(row(game, plan), row(plan).put(key, value)))) { + assertTrue(XboxCatalog.access(withMembership(envelope(*rows), membership()), "test@example.test", "US", now).isEmpty()) + } + } + } + + @Test fun malformedMembershipFailsTheRefreshInsteadOfBecomingAnEmptySuccessfulLibrary() { + for (json in listOf( + withMembership(envelope(row(game, plan)), membership().put("endDate", "invalid")), + withMembership(envelope(row(game, plan)), membership(), membership()), + withMembership(envelope(row(game, plan)), membership().put("productId", game)), + envelope(row(game, plan)).put("subscriptions", JSONObject.NULL), + )) { + assertThrows(Exception::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun membershipCannotCrossAccountMarketOrFreshnessBoundaries() { + for ((key, value) in listOf("account" to "other@example.test", "market" to "GB", "checkedAt" to "2020-01-01T00:00:00Z")) { + val json = withMembership(envelope(row(game, plan)).put(key, value), membership()) + assertThrows(IllegalArgumentException::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun libraryDiagnosticsContainCountsWithoutAccountOrProductIdentifiers() { + val lines = mutableListOf() + XboxCatalog.access(envelope(row(game), row(parent, plan)), "test@example.test", "US", now, lines::add) + assertEquals(listOf("library entitlements=2 invalid=0 restricted=0 unverified-plan=1 owned=1 gamepass=0"), lines) + assertFalse(lines.single().contains(game) || lines.single().contains(parent) || lines.single().contains("@")) + } + + @Test fun purchasedAndSubscriptionAccessRemainDistinct() { + assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan), row(plan))) + assertFalse(access(row(plan)).containsKey(plan)) + } + + @Test fun subscriptionRequiresAnActivePlanRow() { + assertTrue(access(row(game, plan)).isEmpty()) + for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true, + "sharingSource" to "Family", "endDate" to now.toString())) { + assertTrue(access(row(game, plan), row(plan).put(key, value)).isEmpty()) + } + assertEquals(mapOf(game to "gamepass", parent to "gamepass"), access(row(game, parent), row(parent, plan), row(plan))) + } + + @Test fun onlyPcPlansGrantSubscriptionAccess() { + for (pc in listOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC10QFD")) { + assertEquals(mapOf(game to "gamepass"), access(row(game, pc), row(pc))) + } + val console = "CFQ7TTC0K6L8" + assertTrue(access(row(game, console), row(console)).isEmpty()) + assertTrue(XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console)), + "test@example.test", "US", now).isEmpty()) + } + + @Test fun backendRfc3339OffsetsAndNanosecondsAreAccepted() { + val checked = "2026-10-19T19:02:38.173609086+00:00" + val response = envelope(row(game).put("endDate", "2099-01-01T01:00:00.000000001+01:00")) + .put("checkedAt", checked) + assertEquals(mapOf(game to "owned"), XboxCatalog.access(response, "test@example.test", "US", + Instant.parse("2026-10-19T19:02:39Z"))) + } + + @Test fun expiredTrialSharedAndUnknownFlagsDoNotGrantAccess() { + for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true, + "sharingSource" to "Family", "endDate" to "2020-01-01T00:00:00Z")) { + assertTrue(access(row(game).put(key, value)).isEmpty()) + } + } + + @Test fun malformedAndConflictingParentsCannotGrantDependentAccess() { + assertTrue(access(row(game, parent), row(parent).put("isTrial", JSONObject.NULL)).isEmpty()) + assertTrue(access(row(game, plan), row(plan).put("endDate", "invalid")).isEmpty()) + assertTrue(access(row(game), row(game, plan)).isEmpty()) + assertTrue(access(row(game, parent), row(parent, game)).isEmpty()) + } + + @Test fun mismatchedAccountAndStaleResultsAreRejected() { + for (json in listOf(envelope(row(game)).put("account", "other@example.test"), + envelope(row(game)).put("checkedAt", "2020-01-01T00:00:00Z"))) { + assertThrows(IllegalArgumentException::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun installedSubscriptionDoesNotBecomeOwnershipWithoutACachedLibrary() { + val sidecar = com.droiddeck.launcher.stores.StoreGameSidecar(com.droiddeck.launcher.stores.Store.XBOX, + game, "A game", "Game.exe", extra = mapOf("access" to "gamepass")) + val card = com.droiddeck.launcher.stores.installedCards(com.droiddeck.launcher.stores.Store.XBOX, + listOf(com.droiddeck.launcher.stores.InstalledStoreGame(sidecar, java.io.File("/synthetic-game"))), emptyList()).single() + assertFalse(card.owned); assertTrue(card.subscription) + } + + @Test fun pcSubscriptionAffirmationsOfferAnInstallWithoutClaimingOwnership() { + fun offer(vararg affirmations: String) = JSONObject().put("ProductId", game).put("ProductType", "Game") + .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A Pass game") + .put("EligibilityProperties", JSONObject().put("Affirmations", JSONArray(affirmations.map { + JSONObject().put("AffirmationProductId", it) + }))))) + .put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject() + .put("Packages", JSONArray().put(JSONObject().put("PackageFormat", "MSIXVC").put("PlatformDependencies", + JSONArray().put(JSONObject().put("PlatformName", "Windows.Desktop"))))))))) + + val pc = XboxCatalog.product(offer("CFQ7TTC0KGQ8"), emptyMap())!! + assertEquals("PC Game Pass", pc.includedWith) + assertFalse(pc.accessible); assertTrue(pc.installable) + + assertEquals("Game Pass Ultimate", XboxCatalog.product(offer("CFQ7TTC0KHS0"), emptyMap())!!.includedWith) + assertEquals("Xbox Game Pass Premium", XboxCatalog.product(offer("CFQ7TTC0P85B"), emptyMap())!!.includedWith) + assertEquals("Xbox Game Pass Essential", XboxCatalog.product(offer("CFQ7TTC0K5DJ"), emptyMap())!!.includedWith) + assertEquals("Xbox Game Pass (Starter Edition)", XboxCatalog.product(offer("CFQ7TTC10QFD"), emptyMap())!!.includedWith) + val console = XboxCatalog.product(offer("CFQ7TTC0K6L8"), emptyMap())!! + assertEquals("", console.includedWith); assertFalse(console.installable) + assertFalse(XboxCatalog.product(offer(), emptyMap())!!.installable) + } + + @Test fun consoleAndUwpOffersAreFilteredOut() { + fun product(platform: String, format: String) = JSONObject().put("ProductId", game).put("ProductType", "Game") + .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A PC game"))) + .put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject() + .put("Packages", JSONArray().put(JSONObject().put("PackageFormat", format).put("PlatformDependencies", + JSONArray().put(JSONObject().put("PlatformName", platform))))))))) + assertNull(XboxCatalog.product(product("Xbox.One", "MSIXVC"), mapOf(game to "owned"))) + assertNull(XboxCatalog.product(product("Windows.Desktop", "APPX"), mapOf(game to "owned"))) + val pc = XboxCatalog.product(product("Windows.Desktop", "MSIXVC"), mapOf(game to "gamepass"))!! + assertEquals(game, XboxCatalog.product(product("Windows.Desktop", "MSIXVC").put("ProductId", game.lowercase()), mapOf(game to "owned"))!!.id) + assertTrue(pc.accessible); assertTrue(pc.subscription); assertFalse(pc.owned) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt new file mode 100644 index 000000000..d5a5d553b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt @@ -0,0 +1,91 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test +import java.io.Closeable + +class XboxLaunchLeasesTest { + private var time = 0L + private val leases = XboxLaunchLeases { time } + private var closed = 0 + private fun launch(account: String = "account", session: String = "session"): String { + return leases.begin("game", account, session).also { leases.attach(it, Closeable { closed++ }) } + } + private fun reap(account: String? = "account", session: String? = "session", suspended: Boolean = false) { + leases.stale(account, session, suspended).forEach { it.close() } + } + + @Test fun steamLaunchCanCreateASessionBoundLease() { + val token = leases.begin("game", "account", "session") + assertTrue(leases.active("game")) + assertFalse(leases.renew(token, "game", "account", "other")) + assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") } + } + @Test fun releaseIsIdempotentAndCannotDecrementANewLaunch() { + val old = launch() + leases.release(old)?.close() + leases.release(old)?.close() + assertEquals(1, closed) + assertFalse(leases.active("game")) + val current = launch() + leases.release(old)?.close() + assertTrue(leases.active("game")) + leases.release(current)?.close() + assertEquals(2, closed) + } + @Test fun renewalCannotCrossAccountSessionOrGameBoundaries() { + val token = launch() + assertFalse(leases.renew(token, "other", "account", "session")) + assertFalse(leases.renew(token, "game", "other", "session")) + assertFalse(leases.renew(token, "game", "account", "other")) + assertTrue(leases.renew(token, "game", "account", "session")) + time = 60_000 + assertFalse(leases.renew(token, "game", "account", "session")) + reap() + assertEquals(1, closed) + assertFalse(leases.active("game")) + } + @Test fun abandonedPreparationAndSessionEndCleanUp() { + launch() + time = 180_000 + reap() + assertEquals(1, closed) + launch() + reap(session = null) + assertEquals(2, closed) + } + @Test fun accountSwitchInvalidatesOldLaunchWithoutAffectingItsReplacement() { + val old = launch() + reap(account = "next") + launch("next") + leases.release(old)?.close() + assertEquals(1, closed) + assertTrue(leases.active("game")) + } + @Test fun stopRemovesLeaseAndRejectsLateAttachment() { + val token = leases.begin("game", "account", "session") + leases.clear().forEach { it.close() } + assertFalse(leases.attach(token, Closeable { closed++ })) + assertFalse(leases.active("game")) + val next = leases.begin("game", "account", "new-session") + assertTrue(leases.active("game")) + assertFalse(leases.attach(token, Closeable { closed++ })) + assertTrue(leases.attach(next, Closeable { closed++ })) + } + @Test fun suspensionKeepsLiveLeasesUntilResume() { + val token = launch() + time = 200_000 + reap(suspended = true) + time += 20_000 + assertTrue(leases.renew(token, "game", "account", "session")) + reap() + assertEquals(0, closed) + } + @Test fun activityQueriesDoNotWaitForBackendClose() { + val token = launch() + val detached = leases.release(token) + val gate = java.util.concurrent.CountDownLatch(1) + val worker = Thread { gate.await(); detached?.close() }.apply { start() } + try { assertFalse(leases.active("game")); launch("next") } finally { gate.countDown(); worker.join(1000) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt new file mode 100644 index 000000000..f14d294c0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test +import java.nio.file.Files + +class XboxLaunchViewsTest { + @Test fun cleanupRemovesLicensedCopiesWithoutFollowingGameLinks() { + val root = Files.createTempDirectory("xbox-view-test").toFile() + try { + val game = root.resolve("installed").apply { mkdirs() } + game.resolve("Game.exe").writeText("encrypted original") + val view = XboxLaunchViews().create(root) + view.resolve("Game.exe").writeText("licensed copy") + Files.createSymbolicLink(view.resolve("originals").toPath(), game.toPath()) + XboxLaunchViews.remove(view) + XboxLaunchViews.remove(view) + assertFalse(view.exists()) + assertEquals("encrypted original", game.resolve("Game.exe").readText()) + } finally { root.deleteRecursively() } + } + + @Test fun recoveryRejectsARedirectedStorageRoot() { + val root = Files.createTempDirectory("xbox-view-links").toFile() + try { + val outside = root.resolve("installed").apply { mkdirs() } + val game = outside.resolve("launch-game").apply { mkdirs() }.resolve("Game.exe") + game.writeText("preserve") + val runtime = root.resolve("xbox-runtime").apply { mkdirs() } + Files.createSymbolicLink(runtime.resolve("views").toPath(), outside.toPath()) + assertThrows(IllegalStateException::class.java) { XboxLaunchViews().create(root) } + assertEquals("preserve", game.readText()) + } finally { root.deleteRecursively() } + } + + @Test fun recoveryRemovesAbandonedViewsAndPreservesNewLiveViews() { + val root = Files.createTempDirectory("xbox-view-recovery").toFile() + try { + val old = XboxLaunchViews().create(root) + old.resolve("Game.exe").writeText("abandoned copy") + val evidence = old.parentFile!!.resolve("diagnostics").apply { mkdirs() }.resolve("report") + evidence.writeText("preserve") + val fresh = XboxLaunchViews() + val first = fresh.create(root) + first.resolve("Game.exe").writeText("running copy") + val second = fresh.create(root) + assertFalse(old.exists()) + assertEquals("running copy", first.resolve("Game.exe").readText()) + assertTrue(second.isDirectory) + assertEquals("preserve", evidence.readText()) + } finally { root.deleteRecursively() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt new file mode 100644 index 000000000..8d545d20c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.stores.xbox + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import org.junit.Assert.* +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxLibraryCacheTest { + private val account = "test@example.test" + private val now = 1_792_440_000_000L + private fun game(id: String, subscription: Boolean) = CatalogItem(Store.XBOX, id, "A game", "https://example.test/hero", + tallImageUrl = "https://example.test/poster", description = "Description", developer = "Developer", publisher = "Publisher", + sizeBytes = 12345, owned = !subscription, subscription = subscription, storeUrl = "https://www.xbox.com/games/store/$id", + extra = mapOf("market" to "US", "access" to if (subscription) "gamepass" else "owned")) + + @Test fun restartRestoresMetadataWithoutTurningSubscriptionsIntoPurchases() { + val games = listOf(game("9AAAAAAAAAAA", false), game("9BBBBBBBBBBB", true)) + val text = XboxLibraryCache.encode(account, "US", games, now) + val cached = XboxLibraryCache.decode(text, account, "US", now)!! + assertEquals(games, cached.games) + assertFalse(cached.games[1].owned) + assertTrue(cached.games[1].subscription) + } + + @Test fun anotherAccountMarketOrInvalidEnvelopeCannotRestoreTheLibrary() { + val text = XboxLibraryCache.encode(account, "US", listOf(game("9AAAAAAAAAAA", false)), now) + assertNull(XboxLibraryCache.decode(text, "other@example.test", "US", now)) + assertNull(XboxLibraryCache.decode(text, account, "GB", now)) + assertNull(XboxLibraryCache.decode(text.replace("\"schema\":1", "\"schema\":2"), account, "US", now)) + assertNull(XboxLibraryCache.decode("invalid", account, "US", now)) + assertNull(XboxLibraryCache.decode(text, account, "US", now - 120_000)) + } + + @Test fun freshCacheAvoidsNetworkAndForceOrStalenessRequestsARefresh() { + val cached = XboxLibraryCache.Snapshot(emptyList(), now) + assertFalse(cached.needsRefresh(false, now + 899_999)) + assertTrue(cached.needsRefresh(false, now + 900_000)) + assertTrue(cached.needsRefresh(true, now)) + val old = XboxLibraryCache.decode(XboxLibraryCache.encode(account, "US", emptyList(), now), account, "US", now + 86_400_000)!! + assertTrue(old.needsRefresh(false, now + 86_400_000)) + assertTrue(old.games.isEmpty()) + } + + @Test fun clearingTheCacheRemovesThePersistedAccountLibrary() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + XboxLibraryCache.write(app, account, "US", listOf(game("9AAAAAAAAAAA", false))) + assertNotNull(XboxLibraryCache.read(app, account, "US")) + XboxLibraryCache.clear(app) + assertNull(XboxLibraryCache.read(app, account, "US")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt new file mode 100644 index 000000000..99ab0a0eb --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxManifestTest { + @get:Rule val temporary = TemporaryFolder() + @Test fun resolvesTheManifestExecutableWithWindowsLetterCasing() { + val root = temporary.newFolder() + java.io.File(root, "Bin").mkdir() + java.io.File(root, "Bin/Game.exe").writeText("encrypted") + java.io.File(root, "MicrosoftGame.Config").writeText("") + assertEquals("Bin/Game.exe", XboxInstaller.executable(root)) + } + @Test fun rejectsTraversalAndExternalEntities() { + for (xml in listOf("", + "]>&external;", + "", + "]>")) { + val root = temporary.newFolder() + java.io.File(root, "MicrosoftGame.config").writeText(xml) + assertThrows(Exception::class.java) { XboxInstaller.executable(root) } + } + } + @Test fun androidManifestParserPreservesIdentityAndSelectsTheRetailPcExecutable() { + val root = temporary.newFolder() + java.io.File(root, "Retail.exe").writeText("encrypted") + java.io.File(root, "MicrosoftGame.Config").writeText(""" + + + + + + 9AAAAAAAAAAA9BBBBBBBBBBB + """) + val info = XboxManifest.read(root) + assertEquals("9AAAAAAAAAAA", info.storeId) + assertEquals("Test.Game", info.name) + assertEquals("CN=A & B", info.publisher) + assertEquals("1.2.3.0", info.version) + assertEquals("Test.Base", info.dependency) + assertEquals(listOf("9BBBBBBBBBBB"), info.related) + assertEquals(listOf("Retail.exe"), info.executables) + assertEquals("Retail.exe", XboxManifest.executable(root)) + } + @Test fun signInBridgeRejectsUntrustedAndNonHttpsOrigins() { + assertTrue(XboxLoginActivity.trusted("https://login.live.com/ppsecure/InlineLogin.srf")) + for (url in listOf("http://login.live.com/", "https://login.live.com.evil.test/", "https://evil.test@login.live.com/", "https://login.live.com:8443/")) + assertFalse(XboxLoginActivity.trusted(url)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt new file mode 100644 index 000000000..f2e7efbef --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt @@ -0,0 +1,21 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test +import java.io.File + +class XboxServiceSocketTest { + @Test fun serviceSocketsFitUnixAddressesForEveryPackageAndRemainDistinct() { + val variants = generateSequence(File(requireNotNull(System.getProperty("user.dir")))) { it.parentFile } + .map { File(it, "tools/release/variants.txt") }.first { it.isFile } + val packages = variants.readLines().filter { it.isNotBlank() && !it.startsWith('#') } + .map { it.trim().split(Regex("\\s+"))[1] } + for (pkg in packages) for (user in listOf(0, 99999)) { + val files = File("/data/user/$user/$pkg/files") + val first = XboxRuntime.newServiceSocket(files) + val second = XboxRuntime.newServiceSocket(files) + assertTrue(first.path, first.path.toByteArray(Charsets.UTF_8).size < 108) + assertNotEquals(first, second) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/CloudMessageTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/CloudMessageTest.kt new file mode 100644 index 000000000..2bf7360ed --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/CloudMessageTest.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.ui + +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CloudSaves +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class CloudMessageTest { + private val ctx get() = RuntimeEnvironment.getApplication() + private fun msg(result: String, files: Int, conflicts: Int, reason: String) = + cloudMessage(ctx, CloudSaves.Result(result, files, 0L, reason, conflicts)) + + @Test fun aSkippedSyncNeverReadsAsZeroFilesDone() { + assertEquals(ctx.getString(R.string.cloud_signed_out), msg("skipped", 0, 0, "not-signed-in")) + assertEquals(ctx.getString(R.string.cloud_none), msg("skipped", 0, 0, "no-cloud-saves")) + for (reason in listOf("off", "no-baseline", "nothing-local", "store-unreachable", "unresolved")) { + assertEquals(reason, ctx.getString(R.string.cloud_not_synced), msg("skipped", 0, 0, reason)) + } + assertEquals(ctx.getString(R.string.cloud_not_synced), msg("failed", 0, 0, "SocketTimeoutException")) + } + + @Test fun okResultsSayWhatHappened() { + assertEquals(ctx.getString(R.string.cloud_up_to_date), msg("ok", 0, 0, "up-to-date")) + assertEquals(ctx.getString(R.string.cloud_done, 3), msg("ok", 3, 0, "uploaded")) + assertEquals(ctx.getString(R.string.cloud_conflict_files, 2), msg("ok", 1, 2, "conflict")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ComponentDownloadsDrawerTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ComponentDownloadsDrawerTest.kt index 8da1e7fa3..4d3f1852a 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/ComponentDownloadsDrawerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/ComponentDownloadsDrawerTest.kt @@ -4,11 +4,20 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.Modifier +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.InputModeManager +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus import com.droiddeck.launcher.R import com.droiddeck.launcher.session.ComponentsManager import org.junit.Assert.assertEquals @@ -19,10 +28,12 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +@OptIn(ExperimentalTestApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [28]) class ComponentDownloadsDrawerTest { @get:Rule val compose = createRobolectricComposeRule() + private lateinit var inputMode: InputModeManager private val dxvk = item("dxvk-2.4.1-linux.wcp", "dxvk") private val stored = item("dxvk-2.3-linux.wcp", "dxvk") @@ -42,6 +53,40 @@ class ComponentDownloadsDrawerTest { compose.onNodeWithText("45%").assertExists() } + @Test fun pairedNightlyRidesOnTheStableRowBehindTheSwitch() { + val stable = ComponentsManager.CatalogItem("dxvk-3.1.1-linux.wcp", "dxvk", "Dxvk-Linux", "https://github.com/example/s", 100, "sha256:test") + val nightly = ComponentsManager.CatalogItem("DD-dxvk-3.1.1+153-nightly.wcp", "dxvk", ComponentsManager.DD_NIGHTLY, "https://github.com/example/n", 100, "sha256:test") + val requested = mutableListOf() + show(catalog = listOf(stable, nightly), onDownload = requested::add) + compose.onNodeWithTag("cmp-download-dxvk").performClick() + compose.onNodeWithText("dxvk-3.1.1-linux").assertExists() + compose.onNodeWithText("DD-dxvk-3.1.1+153-nightly").assertDoesNotExist() + compose.onNodeWithText(RuntimeEnvironment.getApplication().getString(R.string.comp_channel_nightly)).performClick() + compose.onNodeWithText("dxvk-3.1.1-linux").assertDoesNotExist() + compose.onNodeWithText("DD-dxvk-3.1.1+153-nightly").performClick() + assertEquals(listOf(nightly), requested) + } + + @Test fun controllerPicksNightlyOnItsTabThenDownloadsItFromTheRow() { + val stable = ComponentsManager.CatalogItem("dxvk-3.1.1-linux.wcp", "dxvk", "Dxvk-Linux", "https://github.com/example/s", 100, "sha256:test") + val nightly = ComponentsManager.CatalogItem("DD-dxvk-3.1.1+153-nightly.wcp", "dxvk", ComponentsManager.DD_NIGHTLY, "https://github.com/example/n", 100, "sha256:test") + val requested = mutableListOf() + show(catalog = listOf(stable, nightly), onDownload = requested::add) + compose.onNodeWithTag("cmp-download-dxvk").performClick() + compose.runOnIdle { inputMode.requestInputMode(InputMode.Keyboard) } + compose.onNodeWithText("dxvk-3.1.1-linux").requestFocus().assertIsFocused() + compose.onNodeWithText("dxvk-3.1.1-linux").performKeyInput { pressKey(Key.DirectionRight) } + val nightlyTab = RuntimeEnvironment.getApplication().getString(R.string.comp_channel_nightly) + compose.onNodeWithText(RuntimeEnvironment.getApplication().getString(R.string.comp_channel_stable)).assertIsFocused() + .performKeyInput { pressKey(Key.DirectionRight) } + compose.onNodeWithText(nightlyTab).assertIsFocused().performKeyInput { pressKey(Key.ButtonA) } + compose.onNodeWithText("DD-dxvk-3.1.1+153-nightly").assertExists() + assertEquals(emptyList(), requested) + compose.onNodeWithText(nightlyTab).performKeyInput { pressKey(Key.DirectionLeft); pressKey(Key.DirectionLeft) } + compose.onNodeWithText("DD-dxvk-3.1.1+153-nightly").assertIsFocused().performKeyInput { pressKey(Key.ButtonA) } + assertEquals(listOf(nightly), requested) + } + @Test fun emptyCatalogOffersRefreshAndDisablesVersionChoicesWhileChecking() { var refreshes = 0 show(catalog = emptyList(), checking = false, onRefresh = { refreshes++ }) @@ -64,6 +109,7 @@ class ComponentDownloadsDrawerTest { onDownload: (ComponentsManager.CatalogItem) -> Unit = {}, ) { compose.setContent { + inputMode = LocalInputModeManager.current DroidDeckTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ComponentDownloadsDrawerGroup( diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt index 377771bf7..8d9d59f8d 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onLast +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -30,9 +31,59 @@ import org.robolectric.annotation.Config @Config(sdk = [28], application = Application::class) class ComponentsPageProfileTest { @get:Rule val compose = createRobolectricComposeRule() + private val gameA = Library.SteamGame(42, "Game A", null, "internal") + private val gameB = Library.SteamGame(43, "Game B", null, "internal") @Test fun gameProfilesCoexistWithTheDefaultComponentStack() { val context = RuntimeEnvironment.getApplication() + show() + val stackHeading = context.getString(R.string.comp_this_proton).uppercase() + compose.onNodeWithText(stackHeading).assertExists() + compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick() + compose.onNodeWithText("Game A").performClick() + compose.onNodeWithText(context.getString(R.string.comp_game_hint)).assertExists() + compose.onNodeWithText(stackHeading).assertDoesNotExist() + + compose.onNodeWithText("Game A").performClick() + compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick() + compose.onNodeWithText(stackHeading).assertExists() + } + + @Test fun opensTheRequestedGameProfileAndAllowsSwitchingToDefault() { + val context = RuntimeEnvironment.getApplication() + show { gameB.profileKey } + compose.onNodeWithText("Game B").assertExists() + compose.onNodeWithText(context.getString(R.string.comp_game_hint)).assertExists() + compose.onNodeWithText(context.getString(R.string.comp_this_proton).uppercase()).assertDoesNotExist() + + compose.onNodeWithText("Game B").performClick() + compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick() + compose.onNodeWithText(context.getString(R.string.comp_this_proton).uppercase()).assertExists() + } + + @Test fun aNewNavigationTargetReplacesThePreviousProfile() { + val context = RuntimeEnvironment.getApplication() + val target = mutableStateOf(gameA.profileKey) + show { target.value } + compose.onNodeWithText("Game A").assertExists() + + compose.runOnIdle { target.value = gameB.profileKey } + compose.onNodeWithText("Game B").assertExists() + compose.onNodeWithText("Game A").assertDoesNotExist() + + compose.runOnIdle { target.value = null } + compose.onNodeWithText(context.getString(R.string.comp_scope_general)).assertExists() + compose.onNodeWithText(context.getString(R.string.comp_this_proton).uppercase()).assertExists() + } + + @Test fun anUnavailableGameFallsBackToTheDefaultProfile() { + val context = RuntimeEnvironment.getApplication() + show { "missing-game" } + compose.onNodeWithText(context.getString(R.string.comp_scope_general)).assertExists() + compose.onNodeWithText(context.getString(R.string.comp_this_proton).uppercase()).assertExists() + } + + private fun show(initialGameKey: () -> String? = { null }) { compose.setContent { DroidDeckTheme { ComponentsPage( @@ -55,21 +106,12 @@ class ComponentsPageProfileTest { onRefresh = {}, onImport = {}, onBack = {}, - games = listOf(Library.SteamGame(42, "Game A", null, "internal")), + games = listOf(gameA, gameB), + initialGameKey = initialGameKey(), requestInitialFocus = false, ) } } - val stackHeading = context.getString(R.string.comp_this_proton).uppercase() - compose.onNodeWithText(stackHeading).assertExists() - compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick() - compose.onNodeWithText("Game A").performClick() - compose.onNodeWithText(context.getString(R.string.comp_game_hint)).assertExists() - compose.onNodeWithText(stackHeading).assertDoesNotExist() - - compose.onNodeWithText("Game A").performClick() - compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick() - compose.onNodeWithText(stackHeading).assertExists() } @Test @Config(qualifiers = "w1280dp-h1000dp") diff --git a/app/src/test/java/com/droiddeck/launcher/ui/DesktopUninstallTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/DesktopUninstallTest.kt new file mode 100644 index 000000000..3f76cac3b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/DesktopUninstallTest.kt @@ -0,0 +1,101 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w1200dp-h752dp-land", application = android.app.Application::class) +class DesktopUninstallTest { + @get:Rule val compose = createRobolectricComposeRule() + private var removals = 0 + private var runtimeBlocked by mutableStateOf(false) + + @After fun closeConfirmation() { Steps.drop() } + + @Test fun uninstallIsAvailableOnTheDesktopPageAndRequiresConfirmation() { + show() + compose.onNodeWithText("Open desktop").assertIsDisplayed() + compose.onNodeWithText("Uninstall").assertIsDisplayed().performClick() + settle() + assertEquals(0, removals) + compose.onNodeWithText("Uninstall the desktop?").assertIsDisplayed() + compose.onAllNodesWithText("Uninstall").onLast().performClick() + assertEquals(1, removals) + } + + @Test fun uninstallIsDisabledWhileRuntimeChangesAreBlocked() { + show(blocked = true) + compose.onNodeWithText("Uninstall").assertIsNotEnabled() + } + + @Test fun aPartialDesktopStillOffersUninstall() { + show(installed = false) + compose.onNodeWithText("Uninstall").assertIsDisplayed() + } + + @Test fun anUninstalledDesktopNoLongerOffersUninstall() { + show(installed = false, present = false) + compose.onNodeWithText("Uninstall").assertDoesNotExist() + } + + @Test fun startingRuntimeWorkWhileConfirmingDisablesUninstall() { + show() + compose.onNodeWithText("Uninstall").performClick() + settle() + compose.runOnIdle { runtimeBlocked = true } + settle() + compose.onAllNodesWithText("Uninstall").onLast().assertIsNotEnabled() + assertEquals(0, removals) + } + + private fun settle() = compose.mainClock.advanceTimeBy(2_000) + + private fun show(installed: Boolean = true, blocked: Boolean = false, present: Boolean = true) { + runtimeBlocked = blocked + compose.mainClock.autoAdvance = false + compose.setContent { + DroidDeckTheme { + CompositionLocalProvider(LocalFrontFocus provides FrontFocus()) { + FocusGlideHost { + Box(Modifier.fillMaxSize()) { + DesktopPage(FrontEndState( + installed = "r9", ready = true, available = null, busy = false, stage = "", percent = -1, + desktopInstalled = installed, desktopPresent = present, runtimeActionsBlocked = runtimeBlocked, + offlineAccount = null, offline = false, frameGenLabel = "", romsDir = null, logsEnabled = false, + steamGames = emptyList(), emulators = emptyList(), running = null, + ), FrontEndActions( + onPlay = {}, onPlayDesktopUi = {}, onSteamGame = {}, onDesktop = {}, onEmulator = {}, onRom = {}, onResume = {}, + onSteamSettings = {}, onDesktopSettings = {}, onInstallPackage = {}, onRemovePackage = {}, + onRuntime = {}, onFrameGenPick = {}, onImportLossless = {}, onProtons = {}, onComponents = {}, + onPerformance = {}, onRoms = {}, onFiles = {}, onLogs = {}, onOffline = {}, + onWirelessAdbPair = { _, _, _, _ -> }, onWirelessAdbApply = { _, _, _, _ -> }, + onSetPhantomProcessLimit = { _, _ -> }, onRemoveDesktop = { removals++ }, + ), Modifier.fillMaxSize(), onSelect = {}) + StepsLayer(Offset.Zero) + } + } + } + } + } + settle() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/DialogSemanticsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/DialogSemanticsTest.kt new file mode 100644 index 000000000..c08c0f234 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/DialogSemanticsTest.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class DialogSemanticsTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun dialogTextNodesStaySeparateAndNonClickableInMergedSemanticsTree() { + var closed = 0 + compose.setContent { + DroidDeckTheme { + val shown = remember { MutableTransitionState(true) } + AppDialog(shown = shown, close = { closed++ }, label = "testDialog", wide = false) { + Text("Dialog Title") + Text("Dialog Hint") + Text("Body Copy") + } + } + } + compose.onNodeWithText("Dialog Title").assertExists().assertHasNoClickAction() + compose.onNodeWithText("Dialog Hint").assertExists().assertHasNoClickAction() + compose.onNodeWithText("Body Copy").assertExists().assertHasNoClickAction() + + compose.onNodeWithText("Dialog Title").performClick() + compose.onNodeWithText("Body Copy").performClick() + compose.waitForIdle() + assertEquals(0, closed) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GestureActionsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GestureActionsTest.kt new file mode 100644 index 000000000..4111aec79 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GestureActionsTest.kt @@ -0,0 +1,37 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.* +import org.junit.Test + +class GestureActionsTest { + @Test fun categoryActionsAreAlphabeticalIncludingFilteredResults() { + val actions = listOf("Z", "C", "A", "B").map { GestureAction("key_$it", it, GestureActionGroup.LETTERS) } + + listOf("Right click", "Middle click", "Left click").map { GestureAction(it, it, GestureActionGroup.MOUSE) } + assertEquals(listOf("A", "B", "C", "Z"), filterGestureActions(actions, "", GestureActionGroup.LETTERS).map { it.title }) + assertEquals(listOf("Left click", "Middle click", "Right click"), filterGestureActions(actions, "click", null).map { it.title }) + } + + @Test fun numberedFunctionKeysStayInNumericOrder() { + val actions = listOf(12, 2, 10, 1).map { GestureAction("key_F$it", "F$it", GestureActionGroup.FUNCTION) } + assertEquals(listOf("F1", "F2", "F10", "F12"), filterGestureActions(actions, "", null).map { it.title }) + } + + @Test fun actionGroupsKeepKeysAndMouseBindingsInTheirOwnCategories() { + assertEquals(GestureActionGroup.MOUSE, gestureActionGroup("middle_mouse_pan")) + assertEquals(GestureActionGroup.LETTERS, gestureActionGroup("key_F")) + assertEquals(GestureActionGroup.FUNCTION, gestureActionGroup("key_F12")) + assertEquals(GestureActionGroup.NUMPAD, gestureActionGroup("key_KP_ENTER")) + assertEquals(GestureActionGroup.MODIFIERS, gestureActionGroup("key_CTRL_R")) + } + + @Test fun searchCombinesWordsAndCategoryWithoutChangingTheSelection() { + val actions = listOf( + GestureAction("key_ENTER", "Enter", GestureActionGroup.NAVIGATION), + GestureAction("key_KP_ENTER", "Numpad Enter", GestureActionGroup.NUMPAD), + GestureAction("key_KP_1", "Numpad 1", GestureActionGroup.NUMPAD), + ) + assertEquals(listOf(actions[1]), filterGestureActions(actions, " ENTER numpad ", null)) + assertEquals(listOf(actions[1]), filterGestureActions(actions, "enter", GestureActionGroup.NUMPAD)) + assertTrue(filterGestureActions(actions, "not a key", null).isEmpty()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GestureLocalizationTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GestureLocalizationTest.kt new file mode 100644 index 000000000..02d34e808 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GestureLocalizationTest.kt @@ -0,0 +1,89 @@ +package com.droiddeck.launcher.ui + +import android.app.Application +import android.view.View +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.* +import androidx.compose.ui.unit.Density +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.GesturePrefs +import com.droiddeck.launcher.input.TouchGestureConfig +import com.droiddeck.launcher.input.TouchscreenGestures.Gesture +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class, qualifiers = "w400dp-h850dp-port") +class GestureLocalizationTest { + @get:Rule val compose = createRobolectricComposeRule() + private val context get() = RuntimeEnvironment.getApplication() + + @Before fun clearPrefs() { + context.getSharedPreferences("touch_gestures", 0).edit().clear().commit() + } + + @Test + @Config(qualifiers = "fr-rFR-w400dp-h850dp-port") + fun pageKeysAndModifierNamesUseLocalizedResources() { + compose.setContent { + DroidDeckTheme { + Column { + Text(gestureActionTitle("page_up_down")) + Text(gestureActionTitle("key_SHIFT_L")) + } + } + } + compose.onNodeWithText("Page précédente / Page suivante").assertIsDisplayed() + compose.onNodeWithText("Maj gauche").assertIsDisplayed() + } + + @Test + @Config(qualifiers = "de-rDE-w400dp-h850dp-port") + fun longTranslationsKeepDefaultRestorationAndSaveUsable() { + GesturePrefs.save(context, "123", TouchGestureConfig(tapAction = "key_E")) + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) { + DroidDeckTheme { TouchGestureDialog("123", {}, {}) } + } + } + val restore = compose.onNodeWithTag("gesture-inherit") + restore.assertIsDisplayed() + val scope = compose.onNodeWithTag("gesture-scope-game").fetchSemanticsNode().boundsInRoot + assertTrue("The restore action must have room for its translated label", restore.fetchSemanticsNode().boundsInRoot.width > scope.width) + restore.performClick() + compose.onNodeWithTag("gesture-save").assertIsDisplayed().performClick() + compose.runOnIdle { assertFalse(GesturePrefs.hasOverride(context, "123")) } + } + + @Test + @Config(qualifiers = "ar-rEG-w400dp-h850dp-port") + fun arabicPickerSearchPreservesBindingsAndMirrorsFooter() { + assertEquals(View.LAYOUT_DIRECTION_RTL, context.resources.configuration.layoutDirection) + compose.setContent { DroidDeckTheme { TouchGestureDialog(null, {}, {}) } } + val save = compose.onNodeWithTag("gesture-save").fetchSemanticsNode().boundsInRoot + val cancel = compose.onNodeWithText(context.getString(R.string.gesture_cancel)).fetchSemanticsNode().boundsInRoot + assertTrue("Save $save and Cancel $cancel should follow the right-to-left layout", save.left < cancel.left) + compose.onNodeWithTag("gesture-choice-tap").performScrollTo().performClick() + compose.onNodeWithTag("gesture-action-search").performTextInput("هروب") + compose.onNodeWithTag("gesture-action-key_ESC").assertIsDisplayed().performClick() + compose.onNodeWithTag("gesture-save").performClick() + compose.runOnIdle { assertEquals("key_ESC", GesturePrefs.read(context).tapAction) } + } + + @Test + @Config(qualifiers = "ja-rJP-w400dp-h850dp-port") + fun recognizedGestureFeedbackUsesTheActiveLanguage() { + compose.setContent { DroidDeckTheme { GestureFeedback(Gesture.TWO_FINGER_HOLD) } } + compose.onNodeWithText("2本指で長押し").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GlassesPanelTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GlassesPanelTest.kt index 701d9d163..97d049676 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/GlassesPanelTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/GlassesPanelTest.kt @@ -30,6 +30,21 @@ import org.robolectric.annotation.Config class GlassesPanelTest { @get:Rule val compose = createRobolectricComposeRule() + @Test fun trackingOnlyDeviceHidesDisplayWritesButKeepsHeadTracking() { + show(supportsDisplayModeControl = false) + compose.onNodeWithTag("setting-glasses-hz").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-resolution-two_d").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-3d").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-3dof").assertExists() + } + + @Test fun trackingOnlyDeviceHidesVrDisplayWrites() { + show(supportsDisplayModeControl = false, tab = GlassesTab.VR, steamVrRunning = true) + compose.onNodeWithTag("setting-glasses-resolution-vr").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-3d-hz").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-auto3d").assertDoesNotExist() + } + @Test fun flatResolutionMenuShowsPerEyeResolutionsBesideRefresh() { show() compose.onNodeWithTag("setting-glasses-hz").assertExists() @@ -397,6 +412,7 @@ class GlassesPanelTest { outputActive: Boolean = inSession, controlAvailable: Boolean = true, supportsScreen3dof: Boolean = true, + supportsDisplayModeControl: Boolean = true, supportsSpatial: Boolean = false, supports30Hz: Boolean = false, sbs90: Boolean = true, @@ -406,6 +422,7 @@ class GlassesPanelTest { onRecenter: (() -> Unit)? = {}, ) { val state = GlassesState(present = true, connected = controlAvailable, controllable = true, + supportsDisplayModeControl = supportsDisplayModeControl, displayId = Display.DEFAULT_DISPLAY + 1, width = if (stereo) 3840 else 1920, height = 1200, model = "VITURE Luma Ultra", sbs = stereo, sbs90 = sbs90, supportsScreen3dof = supportsScreen3dof, supports30Hz = supports30Hz, supportsSpatial = supportsSpatial, resolutions = listOf( diff --git a/app/src/test/java/com/droiddeck/launcher/ui/HeadingLocaleTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/HeadingLocaleTest.kt new file mode 100644 index 000000000..9b32b5036 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/HeadingLocaleTest.kt @@ -0,0 +1,44 @@ +package com.droiddeck.launcher.ui + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.assertCountEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class HeadingLocaleTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun headingsFollowTheAppLanguageAndRecomposeWhenItChanges() { + val appLocale = mutableStateOf(Locale.forLanguageTag("tr")) + compose.setContent { + val configuration = Configuration(LocalConfiguration.current).apply { + setLocales(LocaleList(appLocale.value)) + } + CompositionLocalProvider(LocalConfiguration provides configuration) { + DroidDeckTheme { + Column { + Eyebrow("Bileşenler Iİıi") + SectionTitle("Bileşenler Iİıi", null) + } + } + } + } + compose.onAllNodesWithText("BİLEŞENLER IİIİ").assertCountEquals(2) + compose.runOnIdle { appLocale.value = Locale.ENGLISH } + compose.onAllNodesWithText("BILEŞENLER IİII").assertCountEquals(2) + compose.runOnIdle { appLocale.value = Locale.forLanguageTag("tr") } + compose.onAllNodesWithText("BİLEŞENLER IİIİ").assertCountEquals(2) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LaunchSettingsComponentsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LaunchSettingsComponentsTest.kt new file mode 100644 index 000000000..37544588f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/LaunchSettingsComponentsTest.kt @@ -0,0 +1,78 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.Library +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class LaunchSettingsComponentsTest { + @get:Rule val compose = createRobolectricComposeRule() + private var openedGame: Library.SteamGame? = null + private var defaultOpens = 0 + + @Test fun gameComponentsCardPassesItsGameInsteadOfOpeningDefault() { + val game = Library.SteamGame(42, "Game A", null, "internal") + show(game) + clickComponents() + assertEquals(game, openedGame) + assertEquals(0, defaultOpens) + } + + @Test fun addedGameComponentsCardPreservesItsUnsignedProfileKey() { + val game = Library.SteamGame(-42, "Added game", null, Library.ADDED) + show(game) + clickComponents() + assertEquals(game.profileKey, openedGame?.profileKey) + assertEquals("4294967254", openedGame?.profileKey) + assertEquals(0, defaultOpens) + } + + @Test fun generalComponentsCardStillOpensDefault() { + show(null) + clickComponents() + assertNull(openedGame) + assertEquals(1, defaultOpens) + } + + private fun clickComponents() { + compose.onNodeWithText(RuntimeEnvironment.getApplication().getString(R.string.setup_card_components)).performClick() + compose.waitForIdle() + } + + private fun show(game: Library.SteamGame?) { + compose.setContent { + DroidDeckTheme { + LaunchSettings( + FrontEndState( + installed = null, ready = false, available = null, busy = false, stage = "", percent = -1, + desktopInstalled = false, offlineAccount = null, offline = false, frameGenLabel = "", + romsDir = null, logsEnabled = false, steamGames = listOfNotNull(game), + emulators = emptyList(), running = null, + ), + FrontEndActions( + onPlay = {}, onPlayDesktopUi = {}, onSteamGame = {}, onDesktop = {}, onEmulator = {}, + onRom = {}, onResume = {}, onSteamSettings = {}, onDesktopSettings = {}, + onInstallPackage = {}, onRemovePackage = {}, onRuntime = {}, onFrameGenPick = {}, + onImportLossless = {}, onProtons = {}, onComponents = { defaultOpens++ }, + onGameComponents = { openedGame = it }, onPerformance = {}, + onRoms = {}, onFiles = {}, onLogs = {}, onOffline = {}, + onWirelessAdbPair = { _, _, _, _ -> }, onWirelessAdbApply = { _, _, _, _ -> }, + onSetPhantomProcessLimit = { _, _ -> }, + ), + rememberMenuHost(), + game, + ) + } + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ModeHeaderFocusTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ModeHeaderFocusTest.kt new file mode 100644 index 000000000..eb35cd29d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/ModeHeaderFocusTest.kt @@ -0,0 +1,97 @@ +package com.droiddeck.launcher.ui + +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], qualifiers = "w960dp-h540dp-land", application = android.app.Application::class) +class ModeHeaderFocusTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun reenteringCompositionWithNonEmptyQueryDoesNotStealFocus() { + lateinit var root: View + var show by mutableStateOf(true) + var query by mutableStateOf("celeste") + compose.setContent { + DroidDeckTheme { + val view = LocalView.current + SideEffect { root = view.rootView } + if (show) { + ModeHeader( + title = "Library", + query = query, + onQuery = { query = it }, + placeholder = "Search", + trailing = {}, + ) + } + } + } + compose.waitForIdle() + val initialField = editText(root)!! + compose.runOnIdle { + assertEquals("celeste", initialField.text.toString()) + assertFalse("Pre-populated search field must not auto-focus on mount", initialField.isFocused) + show = false + } + compose.waitForIdle() + compose.runOnIdle { show = true } + compose.waitForIdle() + val remountedField = editText(root)!! + compose.runOnIdle { + assertEquals("celeste", remountedField.text.toString()) + assertFalse("Remounted search field must not steal focus when scrolled back into view", remountedField.isFocused) + } + } + + @Test fun clickingSearchIconFocusesField() { + lateinit var root: View + var query by mutableStateOf("") + compose.setContent { + DroidDeckTheme { + val view = LocalView.current + SideEffect { root = view.rootView } + ModeHeader( + title = "Library", + query = query, + onQuery = { query = it }, + placeholder = "Search", + trailing = {}, + ) + } + } + val context = RuntimeEnvironment.getApplication() + compose.onNodeWithContentDescription(context.getString(R.string.stores_search_open)).performClick() + compose.waitForIdle() + val field = editText(root)!! + compose.runOnIdle { + assertTrue("Opening search via icon must focus the text field", field.isFocused) + } + } + + private fun editText(view: View): EditText? { + if (view is EditText) return view + if (view is ViewGroup) for (i in 0 until view.childCount) { + editText(view.getChildAt(i))?.let { return it } + } + return null + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/NavigationSelectionTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/NavigationSelectionTest.kt new file mode 100644 index 000000000..5332c83ab --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/NavigationSelectionTest.kt @@ -0,0 +1,100 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.foundation.layout.size +import androidx.compose.ui.unit.dp +import org.junit.Rule +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class NavigationSelectionTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Before fun controlAnimationFrames() { + compose.mainClock.autoAdvance = false + } + + @Test fun tabStripAnnouncesSelectionAndUpdatesAfterClick() { + val selected = mutableIntStateOf(0) + compose.setContent { DroidDeckTheme { TabStrip(listOf("First", "Second"), selected.intValue, { selected.intValue = it }) } } + verify("First", "Second") + } + + @Test fun sheetChipsAnnounceSelectionAndUpdateAfterClick() { + val selected = mutableIntStateOf(0) + compose.setContent { DroidDeckTheme { SheetChips(listOf("First", "Second"), selected.intValue) { selected.intValue = it } } } + verify("First", "Second") + } + + @Test fun segmentedTabsHaveOneTabRoleAndAnnounceTheirGroup() { + val selected = mutableIntStateOf(0) + compose.setContent { DroidDeckTheme { SegmentedTabs(listOf(0 to "First", 1 to "Second"), selected.intValue) { selected.intValue = it } } } + verify("First", "Second") + } + + @Test fun sideRailExposesSelectionEvenWhenOnlyIconsAreVisible() { + val selected = mutableStateOf("steam") + Hops.railPreview = null + compose.setContent { + DroidDeckTheme { + SideRail(FrontEndState( + installed = "r9", ready = true, available = null, busy = false, stage = "", percent = -1, + desktopInstalled = true, offlineAccount = null, offline = false, frameGenLabel = "", + romsDir = null, logsEnabled = false, steamGames = emptyList(), emulators = emptyList(), running = null, + ), selected.value, { selected.value = it }, {}, actions, Modifier.size(300.dp, 600.dp)) + } + } + compose.mainClock.advanceTimeBy(1_000) + val steam = compose.onNodeWithTag("rail-steam") + val games = compose.onNodeWithTag("rail-games") + assertTab(steam, true) + assertTab(games, false) + games.performClick() + compose.mainClock.advanceTimeBy(1_000) + assertTab(steam, false) + assertTab(games, true) + assertGroup() + } + + private fun verify(first: String, second: String) { + compose.mainClock.advanceTimeBy(1_000) + val a = compose.onNodeWithText(first) + val b = compose.onNodeWithText(second) + assertTab(a, true) + assertTab(b, false) + b.performClick() + compose.mainClock.advanceTimeBy(1_000) + assertTab(a, false) + assertTab(b, true) + assertGroup() + } + + private fun assertTab(node: SemanticsNodeInteraction, selected: Boolean) { + node.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab)).assertHasClickAction() + if (selected) node.assertIsSelected() else node.assertIsNotSelected() + } + + private fun assertGroup() { + compose.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.SelectableGroup)).assertCountEquals(1) + } + + private val actions = FrontEndActions( + onPlay = {}, onPlayDesktopUi = {}, onSteamGame = {}, onDesktop = {}, onEmulator = {}, + onRom = {}, onResume = {}, onSteamSettings = {}, onDesktopSettings = {}, + onInstallPackage = {}, onRemovePackage = {}, onRuntime = {}, onFrameGenPick = {}, + onImportLossless = {}, onProtons = {}, onComponents = {}, onPerformance = {}, + onRoms = {}, onFiles = {}, onLogs = {}, onOffline = {}, + onWirelessAdbPair = { _, _, _, _ -> }, onWirelessAdbApply = { _, _, _, _ -> }, + onSetPhantomProcessLimit = { _, _ -> }, + ) +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PrimaryButtonContrastTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PrimaryButtonContrastTest.kt new file mode 100644 index 000000000..0b7bf2901 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/PrimaryButtonContrastTest.kt @@ -0,0 +1,39 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrimaryButtonContrastTest { + @Test fun electricTextButtonsRemainReadableOnPageAndDialogSurfaces() { + val palette = Themes.byId(Themes.ELECTRIC) + for (background in listOf(palette.background, palette.surface)) { + assertTrue("Electric text button: ${contrast(palette.primary, background)}", + contrast(palette.primary, background) >= 4.5f) + } + } + + @Test fun normalSizedPrimaryTextMeetsContrastAcrossEveryThemeGradient() { + for (palette in Themes.all) { + for (step in 0..100) { + val fraction = step / 100f + val start = palette.primary + val end = palette.primary2 + val background = Color( + start.red + (end.red - start.red) * fraction, + start.green + (end.green - start.green) * fraction, + start.blue + (end.blue - start.blue) * fraction, + ) + val ratio = contrast(palette.onPrimary, background) + assertTrue("${palette.id} gradient $step: $ratio", ratio >= 4.5f) + } + } + } + + private fun contrast(a: Color, b: Color): Float { + val light = maxOf(a.luminance(), b.luminance()) + val dark = minOf(a.luminance(), b.luminance()) + return (light + 0.05f) / (dark + 0.05f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PrimaryButtonFocusContrastTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PrimaryButtonFocusContrastTest.kt new file mode 100644 index 000000000..51da840cb --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/PrimaryButtonFocusContrastTest.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class PrimaryButtonFocusContrastTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun focusedPrimaryButtonDoesNotClaimAColorOverlayAboveItsLabel() { + lateinit var glide: FocusGlide + var clicks = 0 + compose.setContent { + val view = LocalView.current + glide = remember(view) { FocusGlide(view) } + DroidDeckTheme(Themes.ELECTRIC) { + CompositionLocalProvider(LocalFocusGlide provides glide) { + PrimaryButton("Play") { clicks++ } + } + } + } + val button = compose.onNodeWithText("Play") + button.performSemanticsAction(SemanticsActions.RequestFocus) + button.assertIsFocused() + compose.runOnIdle { assertNull(glide.current) } + button.performClick() + compose.runOnIdle { + assertEquals(1, clicks) + assertNull(glide.current) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt new file mode 100644 index 000000000..04d3f5632 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt @@ -0,0 +1,158 @@ +package com.droiddeck.launcher.ui + +import android.hardware.display.DisplayManager +import android.hardware.input.InputManager +import android.view.Display +import android.view.InputDevice +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.compose.ui.test.* +import androidx.compose.runtime.MutableState +import com.droiddeck.launcher.R +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless +import com.droiddeck.launcher.input.SecondScreenDisplay +import com.droiddeck.launcher.input.SecondScreenMode +import com.droiddeck.launcher.input.PadBridgeRoutingTest +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.InputDeviceBuilder +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], qualifiers = "w800dp-h600dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SessionSecondScreenTest { + @get:Rule val compose = createRobolectricComposeRule() + private val context get() = RuntimeEnvironment.getApplication() + private var previousMode = "" + private var previousDeckPad = false + + @Before fun setUp() { + previousMode = SessionState.mode + previousDeckPad = SessionState.deckPad + SessionState.deckPad = false + } + + @After fun tearDown() { + SessionState.mode = previousMode + SessionState.deckPad = previousDeckPad + } + + @Test fun desktopMenuOffersTheExistingTrackpadAndTerminal() = checkModes(steam = false) + + @Test fun steamMenuKeepsTheExistingTrackpadAndTerminal() = checkModes(steam = true) + + @Test fun desktopWithoutASecondDisplayFallsBackToController() { + SessionState.mode = SessionService.MODE_DESKTOP + var page = SessionDrawerPage.SECOND_SCREEN + compose.setContent { + DroidDeckTheme { + SessionDrawer(true, page, false, { page = it }, actions(false, emptyList())) + } + } + compose.runOnIdle { assertEquals(SessionDrawerPage.CONTROLLER, page) } + compose.onNodeWithText(context.getString(R.string.second_mode_keyboard_trackpad)).assertDoesNotExist() + } + + @Test fun desktopTrackpadOmitsSteamShortcuts() = checkShortcuts(steam = false) + + @Test fun steamTrackpadKeepsWorkingSteamShortcuts() = checkShortcuts(steam = true) + + @Test + @Config(sdk = [34], shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) + fun controllerBumpersReachTheSecondScreenInBothSessionModes() { + val device = InputDeviceBuilder.newBuilder().setId(42).setName("Second screen test pad") + .setDescriptor("second-screen-pad").setExternal(true).setSources(InputDevice.SOURCE_GAMEPAD).build() + shadowOf(context.getSystemService(InputManager::class.java)).addInputDevice(device) + for (mode in listOf(SessionService.MODE_DESKTOP, SessionService.MODE_STEAM)) { + SessionState.mode = mode + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + fun state(name: String): MutableState = ReflectionHelpers.getField(activity, "$name\$delegate") + state("drawerOpen").value = true + state("drawerControllerActive").value = true + state>("secondScreenDisplays").value = listOf(SecondScreenDisplay(1, "Bottom screen")) + for ((button, page) in listOf(KeyEvent.KEYCODE_BUTTON_R1 to SessionDrawerPage.SECOND_SCREEN, + KeyEvent.KEYCODE_BUTTON_L1 to SessionDrawerPage.CONTROLLER)) { + assertTrue(activity.dispatchKeyEvent(KeyEvent(0L, 0L, KeyEvent.ACTION_DOWN, button, 0, 0, + device.id, 0, 0, InputDevice.SOURCE_GAMEPAD))) + assertEquals(page, state("drawerPage").value) + } + } + } + + private fun checkModes(steam: Boolean) { + SessionState.mode = if (steam) SessionService.MODE_STEAM else SessionService.MODE_DESKTOP + val selected = mutableListOf() + val displays = listOf(SecondScreenDisplay(1, "Bottom screen")) + compose.setContent { + DroidDeckTheme { + SessionDrawer(true, SessionDrawerPage.SECOND_SCREEN, false, {}, actions(steam, displays) { selected += it }) + } + } + for (mode in listOf(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL)) { + compose.onNodeWithText(context.getString(mode.label)).performScrollTo().assertIsDisplayed().performClick() + } + compose.onNodeWithText(context.getString(SecondScreenMode.DECK_CONTROLS.label)).assertDoesNotExist() + assertEquals(listOf(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL), selected) + } + + private fun checkShortcuts(steam: Boolean) { + SessionState.mode = if (steam) SessionService.MODE_STEAM else SessionService.MODE_DESKTOP + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = activity.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + var menus = 0 + var qams = 0 + val panel = SecondScreenPresentation(activity, display, { _, _, _, _ -> }, { _, _ -> }, {}, + { menus++ }, { qams++ }, {}) + try { + panel.create() + panel.showMode(SecondScreenMode.KEYBOARD_TRACKPAD) + val buttons = descendants(panel.window!!.decorView).filterIsInstance