diff --git a/.github/workflows/build-ubuntu-runtime.yml b/.github/workflows/build-ubuntu-runtime.yml new file mode 100644 index 000000000..f104e9cd7 --- /dev/null +++ b/.github/workflows/build-ubuntu-runtime.yml @@ -0,0 +1,95 @@ +name: Build Linux runtime (Ubuntu) + +on: + push: + paths: + - 'tools/ubuntu/**' + - '.github/workflows/build-ubuntu-runtime.yml' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish as (e.g. ubuntu-r1); empty = artifact only' + required: false + default: '' + +permissions: + contents: read + +jobs: + debs: + runs-on: ubuntu-24.04-arm + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build Mesa with KGSL and the patched KWin + run: | + set -euo pipefail + docker run --rm -v "$PWD:/work" -w /work ubuntu:26.04 bash tools/ubuntu/build-debs.sh /work/out/debs + sudo chown -R "$(id -u):$(id -g)" out + - uses: actions/upload-artifact@v4 + with: + name: ubuntu-runtime-debs + retention-days: 7 + compression-level: 0 + path: out/debs/ + rootfs: + needs: debs + runs-on: ubuntu-24.04-arm + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: ubuntu-runtime-debs + path: out/debs + - uses: actions/create-github-app-token@v2 + id: components-token + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + with: + app-id: ${{ vars.DROIDDECK_CI_APP_ID }} + private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }} + owner: Droid-Deck + repositories: DroidDeck-Components + - name: Refuse a published tag + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + env: + GH_TOKEN: ${{ steps.components-token.outputs.token }} + run: | + if gh release view '${{ inputs.tag }}' -R Droid-Deck/DroidDeck-Components >/dev/null 2>&1; then + echo "::error::${{ inputs.tag }} is already published, and published tags are never replaced: use a new tag" + exit 1 + fi + - name: Build + run: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq zstd gpgv ubuntu-keyring >/dev/null + tag='${{ inputs.tag }}' + tools/ubuntu/build.sh out/debs out "${tag:-ubuntu-dev}" + - uses: actions/upload-artifact@v4 + with: + name: ubuntu-runtime + retention-days: 7 + compression-level: 0 + path: | + out/*.tar.zst + out/*.sha256 + out/*.packages.txt + - name: Publish + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + env: + GH_TOKEN: ${{ steps.components-token.outputs.token }} + GH_REPO: Droid-Deck/DroidDeck-Components + run: | + set -euo pipefail + TAG='${{ inputs.tag }}' + prerelease=false + case $TAG in *-rc*) prerelease=true ;; esac + gh release create "$TAG" --latest=false --prerelease="$prerelease" --title "$TAG" \ + --notes "Ubuntu 26.04 LTS arm64 runtime for DroidDeck ($GITHUB_REPOSITORY tools/ubuntu, $GITHUB_SHA): size $(stat -c %s "out/$TAG.tar.zst"), sha256 $(cat "out/$TAG.sha256"). $TAG-desktop.tar.zst is the Kubuntu Plasma desktop built on top of it, holding only what the runtime does not already have: size $(stat -c %s "out/$TAG-desktop.tar.zst"), sha256 $(cat "out/$TAG-desktop.sha256"). The .packages.txt files list every installed package and version." \ + "out/$TAG.tar.zst" "out/$TAG.sha256" "out/$TAG.packages.txt" \ + "out/$TAG-desktop.tar.zst" "out/$TAG-desktop.sha256" "out/$TAG-desktop.packages.txt" out/debs/*.deb + gh release edit "$TAG" --latest=false --prerelease="$prerelease" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0dd6d06d1..8fee8a691 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,8 @@ on: - '**/*.md' - 'LICENSE' - '.gitignore' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' pull_request: branches: [ main ] types: [ opened, synchronize, reopened, ready_for_review ] @@ -15,6 +17,8 @@ on: - '**/*.md' - 'LICENSE' - '.gitignore' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' workflow_dispatch: inputs: rebuild_preloads: @@ -28,8 +32,8 @@ permissions: pull-requests: write concurrency: - group: build-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: build-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} jobs: source: @@ -45,6 +49,10 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + sparse-checkout: | + /* + !/docs/releases/media/ + sparse-checkout-cone-mode: false persist-credentials: false - name: Pin and merge the APK source id: merge @@ -78,6 +86,10 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + sparse-checkout: | + /* + !/docs/releases/media/ + sparse-checkout-cone-mode: false persist-credentials: false - name: Merge the pinned APK base if: github.event_name == 'pull_request' @@ -132,6 +144,10 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + sparse-checkout: | + /* + !/docs/releases/media/ + sparse-checkout-cone-mode: false persist-credentials: false - name: Merge the pinned APK base if: github.event_name == 'pull_request' @@ -283,6 +299,10 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + sparse-checkout: | + /* + !/docs/releases/media/ + sparse-checkout-cone-mode: false persist-credentials: false - name: Check human authorship @@ -404,7 +424,7 @@ jobs: - uses: gradle/actions/setup-gradle@v4 with: cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} - cache-cleanup: on-success + cache-cleanup: ${{ github.event_name == 'pull_request' && 'never' || 'on-success' }} - name: Resolve the pinned Android NDK run: bash tools/ci/resolve-ndk.sh @@ -526,13 +546,6 @@ jobs: git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \ || { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; } - - name: Store engines host unit tests - timeout-minutes: 10 - continue-on-error: true - run: | - cd app/src/main/rust/stores - cargo test --locked 2>&1 | tail -60 - - name: Upload the regenerated Cargo.lock if: failure() uses: actions/upload-artifact@v4 @@ -622,6 +635,63 @@ jobs: APK_OUTCOME: ${{ steps.apk-upload.outcome }} run: bash tools/release/report-build.sh + store-tests: + name: Store engines host tests + needs: source + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + continue-on-error: true + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + sparse-checkout: | + /* + !/docs/releases/media/ + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Merge the pinned APK base + if: github.event_name == 'pull_request' + env: + BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }} + run: | + set -euo pipefail + git fetch --no-tags origin "$BUILD_BASE_SHA" + git config user.name "ci" + git config user.email "ci@users.noreply.github.com" + git merge --no-ff --no-edit FETCH_HEAD + - name: Verify backend and test source match + env: + XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} + run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" + - name: Rust toolchain + id: rust + run: echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT" + - name: Cache cargo registry and the host test target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + app/src/main/rust/stores/target + key: stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs') }} + restore-keys: | + stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}- + stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}- + - name: Store engines host unit tests + timeout-minutes: 10 + continue-on-error: true + run: | + set -o pipefail + cd app/src/main/rust/stores + cargo test --locked 2>&1 | tail -60 + python-tests: name: Python and native regression tests needs: source @@ -633,6 +703,10 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + sparse-checkout: | + /* + !/docs/releases/media/ + sparse-checkout-cone-mode: false persist-credentials: false - name: Merge the pinned APK base if: github.event_name == 'pull_request' @@ -681,7 +755,7 @@ jobs: timeout 10 /tmp/test-presenter-shutdown - name: Steam import and game environment tests - run: python3 -m unittest discover -s tools/tests -p 'test_*.py' + run: bash tools/ci/run-python-tests.sh checks: name: Compile and test APK diff --git a/.github/workflows/publish-packs.yml b/.github/workflows/publish-packs.yml index 580b1c818..7bf219ef7 100644 --- a/.github/workflows/publish-packs.yml +++ b/.github/workflows/publish-packs.yml @@ -61,7 +61,7 @@ jobs: 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 + for name in proot directaudio tracking xbox comet butler; 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) diff --git a/LICENSE b/LICENSE index 5ae980bfc..3ffac1c5e 100644 --- a/LICENSE +++ b/LICENSE @@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - Pluvia is an unofficial Steam client app for Android - Copyright (C) 2024 Oxters Wyzgowski + DroidDeck brings the SteamOS experience to Android + Copyright (C) 2026 DroidDeck contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - Pluvia Copyright (C) 2024 Oxters Wyzgowski + DroidDeck Copyright (C) 2026 DroidDeck contributors This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. diff --git a/README.md b/README.md index 5d710bc67..3633fa9bc 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adre On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process. +## Physical button shortcuts + +Open the launcher's **Controls** settings, **Setup → Controller → Button mapping**, or the session drawer's **Controller** page. Each menu action has one row showing **Custom** or **Default**. Select the current value beside **Steam menu**, **Open Quick Access Menu**, or **DroidDeck menu**, then press and release a physical button. The assignment saves automatically on release; confirmation is only required if the button already opens another menu. Assignments are stored per controller; each action has one assigned button per controller. A mapped button performs the shortcut instead of sending its normal input to the game. Steam shortcuts only apply in Steam sessions, and shortcuts are inactive while loading, suspended, or using the on-screen keyboard or other input dialogs. + +Unassigned buttons keep their existing behavior, including the Back button's single/double-press setting and Select-to-Steam option. While the session drawer is open, A selects the focused item even if it has a shortcut assignment. Select an action's current value and use **Reset** beside an assignment to restore that behavior without changing touch layouts or other controller settings. **Close** exits capture without changing an assignment; it does not undo an assignment already applied. The touchscreen remains available for configuration and recovery. + +Extra buttons such as M1/M2 work only if Android exposes distinct key events to the app. Android Home, volume and other reserved system keys cannot be assigned; making DroidDeck the default launcher retains its existing Home-to-Steam behavior. Firmware that intercepts a button or makes it duplicate another button cannot be overridden here. During capture, the detected device and button name are shown. If a controller disconnects before release, use **Try another button** to restart capture; **Reset** remains available for existing assignments. Capture and shortcut-action diagnostics, including key and scan codes, are included in session logs when capture runs inside a session. If a button is not detected, record its physical label, firmware/controller mode, and whether it leaves the app, then share the session logs. + ## Smart glasses 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. @@ -61,7 +69,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. Its games install from Stores through Blizzard's own Agent and the local HTTP API it serves, driven the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) drives it, with the requests described on [wowdev.wiki](https://wowdev.wiki/Agent); how other launchers find, install and start Blizzard games was read in [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader, without taking their code. 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. x86 AppImages packed with [sharun](https://github.com/VHSgunzo/sharun) by VHSgunzo (MIT) start because DroidDeck's own [FEX](https://github.com/FEX-Emu/FEX) build (MIT) puts the platform name under `AT_PLATFORM`, which sharun's [userland-execve](https://crates.io/crates/userland-execve) loader (MIT) requires; FEX filed it under `AT_BASE_PLATFORM`. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc//status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. droiddeck-ntsync is checked against the object, wait and error semantics documented for the [Linux](https://github.com/torvalds/linux) ntsync driver in `Documentation/userspace-api/ntsync.rst` (GPL-2.0). Xbox games load the Windows modules of [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) runtime (LGPL-2.1-or-later), including its `wintypes.dll`, whose `ApiInformation` answer Minecraft for Windows needs to start; how Minecraft installs GameInput under Wine was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT). [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans is set up with the Steam launch option from its README and starts at D3D12 feature level 12_1, as its game crashes at 12_2 here. The KDE desktop's pacman repository is a snapshot of [Arch Linux ARM](https://archlinuxarm.org)'s core, extra and alarm repositories and installs their signed packages with [pacman](https://gitlab.archlinux.org/pacman/pacman) (GPL-2.0-or-later). [KWin](https://invent.kde.org/plasma/kwin) (GPL-2.0-or-later) is patched to pass on the scroll source, wheel steps and scroll stops its nested backend's `wayland_backend.cpp` leaves as a TODO. How Qt apps handle wheel and cancelled touch events was read in [Qt](https://code.qt.io/cgit/qt/qtbase.git)'s `qwaylandinputdevice.cpp` and `qguiapplication.cpp` and [Konsole](https://invent.kde.org/utilities/konsole)'s `TerminalDisplay.cpp`, and KWin's texture upload crash was traced through [Mesa](https://gitlab.freedesktop.org/mesa/mesa)'s `u_threaded_context.c` and Zink's `zink_resource.c`, without taking their code. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. Its games install from Stores through Blizzard's own Agent and the local HTTP API it serves, driven the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) drives it, with the requests described on [wowdev.wiki](https://wowdev.wiki/Agent); how other launchers find, install and start Blizzard games was read in [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader, without taking their code. 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. x86 AppImages packed with [sharun](https://github.com/VHSgunzo/sharun) by VHSgunzo (MIT) start because DroidDeck's own [FEX](https://github.com/FEX-Emu/FEX) build (MIT) puts the platform name under `AT_PLATFORM`, which sharun's [userland-execve](https://crates.io/crates/userland-execve) loader (MIT) requires; FEX filed it under `AT_BASE_PLATFORM`. 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. The in-game performance HUD is [@The412Banner](https://github.com/The412Banner)'s [FusionHUD](https://github.com/The412Banner/FusionHUD) (GPL-3.0 with an attribution term), Bannerlator's HUD; its frame rate, frame times and readings are taken from the same sources, and worked out the same way, as Valve's [MangoHud](https://github.com/flightlessmango/MangoHud) build (MIT) that runs Steam's performance overlay in a DroidDeck session, with the frame times shared by gamescope patch 0125; when gamescope sends no frames for two seconds, the HUD counts the frames DroidDeck's compositor shows instead, which stop at the screen's refresh rate. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc//status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. droiddeck-ntsync is checked against the object, wait and error semantics documented for the [Linux](https://github.com/torvalds/linux) ntsync driver in `Documentation/userspace-api/ntsync.rst` (GPL-2.0). Xbox games load the Windows modules of [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) runtime (LGPL-2.1-or-later), including its `wintypes.dll`, whose `ApiInformation` answer Minecraft for Windows needs to start; how Minecraft installs GameInput under Wine was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT). [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans is set up with the Steam launch option from its README and starts at D3D12 feature level 12_1, as its game crashes at 12_2 here. The KDE desktop's pacman repository is a snapshot of [Arch Linux ARM](https://archlinuxarm.org)'s core, extra and alarm repositories and installs their signed packages with [pacman](https://gitlab.archlinux.org/pacman/pacman) (GPL-2.0-or-later). [KWin](https://invent.kde.org/plasma/kwin) (GPL-2.0-or-later) is patched to pass on the scroll source, wheel steps and scroll stops its nested backend's `wayland_backend.cpp` leaves as a TODO. How Qt apps handle wheel and cancelled touch events was read in [Qt](https://code.qt.io/cgit/qt/qtbase.git)'s `qwaylandinputdevice.cpp` and `qguiapplication.cpp` and [Konsole](https://invent.kde.org/utilities/konsole)'s `TerminalDisplay.cpp`, and KWin's texture upload crash was traced through [Mesa](https://gitlab.freedesktop.org/mesa/mesa)'s `u_threaded_context.c` and Zink's `zink_resource.c`, without taking their code. The desktop's Steam Keyboard window rule follows Valve's `steamdeck-kde-presets` with the update from [Bazzite](https://github.com/ublue-os/bazzite)'s `0001-steam-virtual-keyboard.patch`, so the keyboard stays above other windows without taking focus. The butler add-on pack for itch.io patch updates ships butler's own `7z.so`, itch.io's build of [7-Zip](https://www.7-zip.org) by Igor Pavlov (LGPL-2.1, with the unRAR licence restriction on its RAR code), unmodified; DroidDeck unpacks itch.io ZIP, 7z, tar, gzip, bzip2 and xz downloads with Apache Commons Compress, and RAR, and 7z too large for it, with the runtime's bsdtar (libarchive). That AYN Odin 2 firmware lists a second built-in display with no panel behind it was confirmed by [@SapphireRhodonite](https://github.com/SapphireRhodonite)'s [WatermelonDS 0.0.5](https://github.com/SapphireRhodonite/WatermelonDS/releases/tag/0.0.5) notes and [iiSU issue #363](https://github.com/iisu-network/iiSU/issues/363), without taking their code. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/app/build.gradle b/app/build.gradle index 1c9b9bad8..7334c8db4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,7 +90,7 @@ def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLine def prootPackPin = rootProject.file('tools/packs/proot/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 addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'butler'].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") @@ -130,8 +130,8 @@ android { applicationId "com.droiddeck.launcher" minSdkVersion 26 targetSdkVersion 28 - versionCode 15 - versionName "0.6.0" + versionCode 16 + versionName "0.6.7" buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\"" buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\"" buildConfigField "int", "PR_NUMBER", "${prNumber}" @@ -234,6 +234,7 @@ android { } // Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB. maxHeapSize = '2g' + maxParallelForks = 2 def logTarget = providers.systemProperty('robolectric.logging').orNull if (logTarget != null) systemProperty 'robolectric.logging', logTarget } @@ -286,6 +287,13 @@ if (uvcDir) { } tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) } +def stageGogNotices = tasks.register('stageGogNotices', Copy) { + from(layout.projectDirectory.dir('src/main/rust/stores/vendor/xdelta3')) { include 'LICENSE', 'README.droiddeck.md' } + into(layout.buildDirectory.dir('gogNotices/licenses/xdelta3')) +} +android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices')) +tasks.named('preBuild').configure { dependsOn(stageGogNotices) } + def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) { mustRunAfter(stageSessionScripts, stageRuntimeAssets) workingDir rootProject.projectDir @@ -303,6 +311,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) { description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.' onlyIf { !skipRust } inputs.dir(rustCrateDir.dir('src')) + inputs.dir(rustCrateDir.dir('vendor')) + inputs.file(rustCrateDir.file('build.rs')) inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock')) inputs.property('ndkVersion', android.ndkVersion) if (!skipRust) inputs.property('rustToolchain', providers.exec { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 091811b04..95984f02e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + @@ -243,6 +244,26 @@ android:resizeableActivity="true" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" /> + + + + = UINPUT_EVENT_BASE; std::string ring_path = virtual_pad ? uinput_ring_path(slot) : get_ring_path_for_slot(slot); - size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE; + size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE; if (ring_path.empty()) { Logger::log("No input ring is configured for %s (slot %d)\n", event, slot); errno = ENODEV; @@ -651,9 +659,22 @@ open_fake_input_ring(const char *event, int flags) { controller->uinput = description; controller->mapping_size = mapping_size; controller->generation = ring_generation(ring); + if (!virtual_pad) { + uint32_t pad_type = __atomic_load_n( + reinterpret_cast(static_cast(mapping) + FAKE_INPUT_RING_SIZE + + FAKE_INPUT_RING_PAD_TYPE_OFFSET), + __ATOMIC_ACQUIRE); + controller->mouse = (pad_type & PAD_TYPE_SET) && (pad_type & ~PAD_TYPE_SET) == PAD_TYPE_MOUSE; + } SnapshotState snap; - if (read_snapshot(ring, snap) && snap.generation == controller->generation) - capture_keyframe(*controller, snap); + if (read_snapshot(ring, snap) && snap.generation == controller->generation) { + if (controller->mouse) { + controller->read_seq = snap.write_seq; + controller->needs_keyframe = false; + } else { + capture_keyframe(*controller, snap); + } + } { std::lock_guard guard(controller_mutex()); controller_map()[fd] = controller; @@ -1156,7 +1177,8 @@ struct DeckImu { uint32_t pad_type; uint32_t latency; }; -static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter"); +static_assert(offsetof(DeckImu, pad_type) == FAKE_INPUT_RING_PAD_TYPE_OFFSET, + "DeckImu::pad_type matches FakeInputWriter and the ring reader"); static_assert(offsetof(DeckImu, latency) == 48, "DeckImu::latency matches FakeInputWriter"); static constexpr uint32_t LATENCY_CONTROLLER = 1, LATENCY_TOUCH = 2; static constexpr int LOW_LATENCY_REPORT_INTERVAL_US = 1000; @@ -1173,7 +1195,6 @@ struct DeckExtras { uint32_t controls = 0; }; static constexpr uint32_t DECK_IMU_MAGIC = 0x31554D49; -static constexpr size_t DECK_IMU_BLOCK_SIZE = 64; static_assert(sizeof(DeckImu) <= DECK_IMU_BLOCK_SIZE, "the IMU block is 64 bytes in the ring file"); struct TritonSharedData { @@ -1270,10 +1291,6 @@ static bool fake_deck_enabled() { return enabled == 1; } -static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2, PAD_TYPE_DS4 = 3, - PAD_TYPE_DUALSENSE = 4, PAD_TYPE_LAST = PAD_TYPE_DUALSENSE; -static constexpr uint32_t PAD_TYPE_SET = 0x80000000u; - __attribute__((visibility("hidden"))) static uint32_t session_pad_type() { static int type = -1; if (type < 0) { @@ -1408,6 +1425,12 @@ __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const ch if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false; int number = get_event_number(event); if (number < 0 || number >= UINPUT_EVENT_BASE) return false; + if (number == MOUSE_EVENT_SLOT && !strncmp(event, "event", 5)) { + char *fake = from_real_to_fake_path(pathname); + bool exposed = fake && path_exists(fake); + free(fake); + return !exposed; + } return number == 0 || !process_is_steam_client(); } @@ -3351,6 +3374,85 @@ static int copy_ioctl_bits(int op, void *destination, const unsigned char (&bits return static_cast(size); } +static constexpr uint16_t MOUSE_VENDOR_ID = 0x1dd1; +static constexpr uint16_t MOUSE_PRODUCT_ID = 0x0004; + +static int ioctl_mouse(int op, void *argp) { + int type = (op >> 8 & 0xFF); + int number = (op >> 0 & 0xFF); + if (type != 0x45) { + errno = EINVAL; + return -1; + } + switch (number) { + case 0x01: { + int version = 65536; + memcpy(argp, &version, sizeof(int)); + return 0; + } + case 0x02: { + struct input_id id; + memset(&id, 0, sizeof(id)); + id.bustype = 0x03; + id.vendor = MOUSE_VENDOR_ID; + id.product = MOUSE_PRODUCT_ID; + id.version = 1; + memcpy(argp, &id, sizeof(id)); + return 0; + } + case 0x06: + if (argp && _IOC_SIZE(op)) snprintf(static_cast(argp), _IOC_SIZE(op), "DroidDeck Mouse"); + return 0; + case 0x07: + if (argp && _IOC_SIZE(op)) snprintf(static_cast(argp), _IOC_SIZE(op), "usb-droiddeck/mouse"); + return 0; + case 0x08: + if (argp && _IOC_SIZE(op)) static_cast(argp)[0] = '\0'; + return 0; + case 0x09: { + unsigned char bitmask[(INPUT_PROP_MAX + 8) / 8] = {}; + bitmask[INPUT_PROP_POINTER / 8] |= 1 << (INPUT_PROP_POINTER % 8); + return copy_ioctl_bits(op, argp, bitmask); + } + case 0x18: { + unsigned char bitmask[(KEY_MAX + 8) / 8] = {}; + return copy_ioctl_bits(op, argp, bitmask); + } + case 0x20: { + unsigned char bitmask[(EV_MAX + 8) / 8] = {}; + bitmask[EV_SYN / 8] |= 1 << (EV_SYN % 8); + bitmask[EV_KEY / 8] |= 1 << (EV_KEY % 8); + bitmask[EV_REL / 8] |= 1 << (EV_REL % 8); + bitmask[EV_MSC / 8] |= 1 << (EV_MSC % 8); + return copy_ioctl_bits(op, argp, bitmask); + } + case 0x21: { + unsigned char bitmask[(KEY_MAX + 8) / 8] = {}; + for (int button = BTN_LEFT; button <= BTN_TASK; button++) + if (button <= BTN_MIDDLE || button == BTN_SIDE || button == BTN_EXTRA) + bitmask[button / 8] |= 1 << (button % 8); + return copy_ioctl_bits(op, argp, bitmask); + } + case 0x22: { + unsigned char bitmask[(REL_MAX + 8) / 8] = {}; + for (uint16_t code : {REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES}) + bitmask[code / 8] |= 1 << (code % 8); + return copy_ioctl_bits(op, argp, bitmask); + } + case 0x23: { + unsigned char bitmask[(ABS_MAX + 8) / 8] = {}; + return copy_ioctl_bits(op, argp, bitmask); + } + case 0x35: { + unsigned char bitmask[(FF_MAX + 8) / 8] = {}; + return copy_ioctl_bits(op, argp, bitmask); + } + default: + errno = EINVAL; + return -1; + } +} + static bool wait_snapshot(std::shared_ptr fake, std::unique_lock &guard, SnapshotState &snap) { @@ -3417,6 +3519,13 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { int event_number = controller->second->slot; const UinputDescription *made = controller->second->uinput; + if (controller->second->mouse) { + int result = ioctl_mouse(op, argp); + if (result >= 0) return result; + guard.unlock(); + return forward_ioctl(fd, op, argp); + } + if (type == 0x45 && number == 0x1) { Logger::log("Hooking ioctl EVIOCGVERSION for event %s\n", event); int version = 65536; @@ -3680,6 +3789,12 @@ static ssize_t read_input(int fd, void *buf, size_t count) { if (fake.needs_keyframe || snap.resync_seq != fake.resync_seq || snap.write_seq < fake.read_seq || snap.write_seq - fake.read_seq > FAKE_INPUT_RING_CAPACITY) { + if (fake.mouse) { + fake.read_seq = snap.write_seq; + fake.resync_seq = snap.resync_seq; + fake.needs_keyframe = false; + continue; + } capture_keyframe(fake, snap); } else { size_t events = std::min(requested, snap.write_seq - fake.read_seq); diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index c2bfc8643..a4de38ca1 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -33,6 +33,8 @@ add_library(droiddeckwayland SHARED generated/banner-ahb-v1-protocol.c src/framegen_bridge.c src/framegen_jni.c + src/hudframes.c + src/hudframes_jni.c ) target_compile_options(droiddeckwayland PRIVATE $<$:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index bf29fc261..6a9fdacd2 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -263,9 +263,14 @@ static struct wl_event_source *g_render_idle, *g_frame_timer; static int g_dirty; static int64_t g_dirty_since_ns; +volatile int g_render_on_commit = 1; +static int64_t g_rendered_tick_ns; +static int g_tick_held; +static int64_t g_hold_missed_ns; +#define HOLD_RETRY_NS 1000000000LL #define PACE_BUFFERS 32 static struct { - unsigned ticks, ticks_late_2ms, renders, null_attaches, reattaches, presented, replaced, undrawn, distinct; + unsigned ticks, ticks_late_2ms, renders, commit_renders, null_attaches, reattaches, presented, replaced, undrawn, distinct; int64_t tick_late_ns, tick_late_max_ns, wait_ns, wait_max_ns; const void *buffers[PACE_BUFFERS]; uint64_t game_frames_seen; @@ -327,6 +332,7 @@ static struct { } g_perf; static void schedule_render(void); +static void on_commit_idle(void *data); static int64_t now_ns(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); @@ -497,6 +503,7 @@ struct active_touch { struct surface *surface; int pointer_fallback; }; static struct active_touch g_touch_points[MAX_TOUCH_POINTS]; static void touch_cancel_surface(struct surface *surface); static struct surface *g_grab; +static void publish_query_state(void); static struct surface *g_key_target; static struct surface *g_ime_click; static int g_input_pipe[2] = {-1, -1}; @@ -590,6 +597,7 @@ static void map_toplevel(struct surface *s) { raise_kept_on_top(); if (!g_desktop) g_key_target = s; apply_zorder(); + publish_query_state(); } static void unmap_toplevel(struct surface *s) { @@ -2116,7 +2124,21 @@ void droiddeck_stream_frame(AHardwareBuffer *ahb) { unsigned droiddeck_stream_frames(void) { return atomic_load(&g_stream_frames); } +static int64_t g_scene_due_ns; + +static int scene_held_for_limit(int64_t now, int64_t due, int limit, int unpaced) { + if (limit <= 0 || unpaced || due <= 0) return 0; + return now < due - 1000000LL; +} + +static int64_t scene_next_due(int64_t now, int64_t due, int limit) { + if (limit <= 0) return 0; + const int64_t interval = 1000000000LL / limit; + return due > 0 && now - due < interval ? due + interval : now + interval; +} + static void render_scene(void) { + g_scene_due_ns = scene_next_due(now_ns(), g_scene_due_ns, g_fps_limit); if (atomic_load(&g_stream_on)) { g_dirty_since_ns = 0; render_stream(); return; } struct draw_list dl = {0}; struct surface *s; @@ -2132,6 +2154,8 @@ static void render_scene(void) { g_dirty_since_ns = 0; } g_dirty = 0; + g_rendered_tick_ns = g_tick_vsync_ns; + g_tick_held = 0; g_hud_fresh = 0; g_hdr_unimported = NULL; wl_list_for_each(s, &g_surfaces, link) s->drawn = 0; @@ -2292,15 +2316,31 @@ static void render_scene(void) { static int on_fallback_timer(void *data) { g_fallback_armed = 0; - if (g_dirty) render_scene(); + if (!g_dirty) return 0; + if (scene_held_for_limit(now_ns(), g_scene_due_ns, g_fps_limit, g_release_unpaced)) schedule_render(); + else render_scene(); return 0; } +/* A new frame is drawn when it arrives instead of at the next screen tick, at most once per refresh, + while frames go straight back to the game (no compositor limit), without streaming or frame generation. */ +static int commit_renders_on(void) { + return g_render_on_commit && g_release_unpaced && !atomic_load(&g_stream_on) && !vkp_framegen_active(); +} + +static int render_on_commit(void) { + return commit_renders_on() && !g_render_idle && g_rendered_tick_ns != g_tick_vsync_ns; +} + static void schedule_render(void) { if (!g_dirty && !g_dirty_since_ns) g_dirty_since_ns = now_ns(); g_dirty = 1; if (!g_display) return; - if (g_last_vsync_ns && now_ns() - g_last_vsync_ns < 100000000LL) return; + if (g_last_vsync_ns && now_ns() - g_last_vsync_ns < 100000000LL) { + if (render_on_commit()) + g_render_idle = wl_event_loop_add_idle(wl_display_get_event_loop(g_display), on_commit_idle, NULL); + return; + } if (g_fallback_armed) return; if (!g_fallback_timer) g_fallback_timer = wl_event_loop_add_timer(wl_display_get_event_loop(g_display), @@ -2314,6 +2354,13 @@ static int scene_waits_for_game(int64_t now) { return g_hud_surface && !g_hud_fresh && now - g_hud_last_ns < 100000000LL && vkp_framegen_active(); } +static void on_commit_idle(void *data) { + g_render_idle = NULL; + if (!g_dirty || !commit_renders_on() || g_rendered_tick_ns == g_tick_vsync_ns || scene_waits_for_game(now_ns())) return; + g_pace.commit_renders++; + render_scene(); +} + static void on_vsync(int64_t frame_time_ns) { int64_t now = now_ns(); g_perf.ticks++; @@ -2330,9 +2377,23 @@ static void on_vsync(int64_t frame_time_ns) { } g_last_vsync_ns = now; g_tick_vsync_ns = frame_time_ns; - if (vkp_apply_window_request()) g_dirty = 1; - if (vkp_effects_sync()) g_dirty = 1; - if (vkp_cardboard_pose_changed()) g_dirty = 1; + int tick_change = 0; + if (vkp_apply_window_request()) tick_change = 1; + if (vkp_effects_sync()) tick_change = 1; + if (vkp_cardboard_pose_changed()) tick_change = 1; + if (tick_change) g_dirty = 1; + /* A frame that arrived after this refresh was already drawn is left for the next arrival to draw; + drawing it here would block that arrival too and keep every frame waiting for the tick. A client + that draws again only after its frame callback sends no such arrival: once a held frame is still + waiting at the next tick, the tick draws every frame for a second before holding one again. */ + if (g_tick_held && g_dirty) g_hold_missed_ns = now; + g_tick_held = 0; + if (!tick_change && commit_renders_on() && g_dirty_since_ns && now - g_dirty_since_ns < g_refresh_ns && + now - g_hold_missed_ns >= HOLD_RETRY_NS) { + g_tick_held = 1; + return; + } + if (g_dirty && scene_held_for_limit(now, g_scene_due_ns, g_fps_limit, g_release_unpaced)) return; if (g_dirty && !scene_waits_for_game(now)) render_scene(); } @@ -2390,6 +2451,7 @@ static const struct wl_touch_interface touch_impl = { .release = touch_release } static void touch_res_destroy(struct wl_resource *r) { for (int i = 0; i < g_ntouches; i++) if (g_touches[i].touch == r) { g_touches[i] = g_touches[--g_ntouches]; break; } + publish_query_state(); } static void seat_get_pointer(struct wl_client *c, struct wl_resource *r, uint32_t id) { @@ -2427,6 +2489,7 @@ static void seat_get_touch(struct wl_client *c, struct wl_resource *r, uint32_t g_touches[g_ntouches].focus = NULL; g_ntouches++; } + publish_query_state(); } static void seat_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } static const struct wl_seat_interface seat_impl = { @@ -2542,6 +2605,7 @@ struct relative_pointer { static struct wl_list g_relative_pointers; static double g_ptr_x, g_ptr_y; +static int g_ptr_known; static double g_raw_x, g_raw_y; static int g_raw_valid; @@ -2606,6 +2670,7 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh constraint_origin(k, &ox, &oy); g_ptr_x = ox + k->hint_x; g_ptr_y = oy + k->hint_y; + g_ptr_known = 1; } g_raw_valid = 0; if (k->gave_keys) { @@ -2702,6 +2767,7 @@ static void constraints_surface_commit(struct surface *s) { constraint_origin(k, &ox, &oy); g_ptr_x = ox + k->hint_x; g_ptr_y = oy + k->hint_y; + g_ptr_known = 1; if (log_budget()) droiddeck_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y); } @@ -2896,6 +2962,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int } if (k) confine_clamp(k, &x, &y); g_ptr_x = x; g_ptr_y = y; + g_ptr_known = 1; if (k) { target = k->surface; } else if (g_desktop) { @@ -2906,6 +2973,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int if (button && !pressed) g_grab = NULL; } } + publish_query_state(); if (!target) return; struct wl_client *client = wl_resource_get_client(target->resource); @@ -2921,9 +2989,9 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int // 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)) + int rel_consumed = send_relative_motion(client, dx, dy); + if (!(k && k->is_lock) && !(relative && rel_consumed) && (!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) { if (button) wl_pointer_send_button(sp->ptr, wl_display_next_serial(g_display), t, button, @@ -3062,6 +3130,63 @@ static struct surface *touch_target_at(double x, double y) { return g_grab ? g_grab : g_key_target; } +static pthread_mutex_t g_query_lock = PTHREAD_MUTEX_INITIALIZER; +struct query_snapshot { + int has_position; + int input_x, input_y; + int focus_takes_touch; +}; +static struct query_snapshot g_query = { 0, 0, 0, 1 }; + +static void publish_query_state(void) { + struct surface *target = g_key_target ? g_key_target : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y)); + int takes_touch = 1; + if (target) { + struct wl_client *client = wl_resource_get_client(target->resource); + struct seat_touch *st; + takes_touch = 0; + for_each_touch_of(client, st) takes_touch = 1; + } + int w = 0, h = 0, ow = 0, oh = 0, input_x = 0, input_y = 0, has_position = 0; + scene_size(&w, &h); + vkp_output_size(&ow, &oh); + if (g_ptr_known && w > 0 && h > 0) { + double ox, oy; + if (ow > 0 && oh > 0 && vkp_scene_to_output(g_ptr_x, g_ptr_y, &ox, &oy)) { + input_x = (int)(ox * INPUT_SPACE_W / ow); + input_y = (int)(oy * INPUT_SPACE_H / oh); + } else { + input_x = (int)(g_ptr_x * INPUT_SPACE_W / w); + input_y = (int)(g_ptr_y * INPUT_SPACE_H / h); + } + has_position = 1; + } + pthread_mutex_lock(&g_query_lock); + g_query.focus_takes_touch = takes_touch; + g_query.has_position = has_position; + g_query.input_x = input_x; + g_query.input_y = input_y; + pthread_mutex_unlock(&g_query_lock); +} + +int droiddeck_focus_takes_touch(void) { + pthread_mutex_lock(&g_query_lock); + int takes_touch = g_query.focus_takes_touch; + pthread_mutex_unlock(&g_query_lock); + return takes_touch; +} + +int droiddeck_cursor_input_pos(int *x, int *y) { + pthread_mutex_lock(&g_query_lock); + int has_position = g_query.has_position; + int input_x = g_query.input_x, input_y = g_query.input_y; + pthread_mutex_unlock(&g_query_lock); + if (!has_position) return 0; + *x = input_x; + *y = input_y; + return 1; +} + static void touch_down(int id, double x, double y) { if (id < 0 || id >= MAX_TOUCH_POINTS) return; struct surface *target = touch_target_at(x, y); @@ -3161,6 +3286,7 @@ static void deliver_touch(const struct input_msg *m) { else if (m->p1 == 1) touch_motion(m->p2, x, y); else if (m->p1 == 2) touch_up(m->p2, x, y); else if (m->p1 == 3) touch_cancel_all(); + publish_query_state(); } static void key_event(uint32_t evdev, int pressed); @@ -3458,13 +3584,13 @@ static int on_stats_timer(void *data) { if (g_pace.ticks || g_pace.renders || game || g_pace.reattaches || g_pace.null_attaches || g_pace.distinct || g_pace.presented || g_pace.replaced || g_pace.undrawn) { droiddeck_log("pacing", "last 10 s: tick reaches compositor %.2f/%.2f ms late (%u ticks, %u over 2 ms) | " - "new frame waits %.2f/%.2f ms to be drawn (%u scenes) | %llu game frames, %u re-attached after an " + "new frame waits %.2f/%.2f ms to be drawn (%u scenes, %u on arrival) | %llu game frames, %u re-attached after an " "empty attach, %u empty attaches, %u distinct buffers | feedback %u shown, %u replaced before drawing, " "%u not drawn", g_pace.ticks ? (double)g_pace.tick_late_ns / 1e6 / g_pace.ticks : 0.0, (double)g_pace.tick_late_max_ns / 1e6, g_pace.ticks, g_pace.ticks_late_2ms, g_pace.renders ? (double)g_pace.wait_ns / 1e6 / g_pace.renders : 0.0, (double)g_pace.wait_max_ns / 1e6, - g_pace.renders, (unsigned long long)game, g_pace.reattaches, g_pace.null_attaches, g_pace.distinct, + g_pace.renders, g_pace.commit_renders, (unsigned long long)game, g_pace.reattaches, g_pace.null_attaches, g_pace.distinct, g_pace.presented, g_pace.replaced, g_pace.undrawn); } { diff --git a/app/src/main/cpp/waylandcomp/src/hudframes.c b/app/src/main/cpp/waylandcomp/src/hudframes.c new file mode 100644 index 000000000..5a6fc4e99 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/hudframes.c @@ -0,0 +1,99 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "hudframes.h" + +/* Layout written by gamescope patch 0125 (tools/gamescope/patches). */ +#define HUD_FRAMES_MAGIC 0x46484444u +#define HUD_FRAMES_HEADER 128u +#define HUD_FRAMES_ENTRY 16u + +struct hud_frames { + const uint8_t *map; + size_t size; + uint64_t seen; +}; + +static uint32_t load32(const uint8_t *p) { return __atomic_load_n((const uint32_t *)p, __ATOMIC_ACQUIRE); } +static uint64_t load64(const uint8_t *p) { return __atomic_load_n((const uint64_t *)p, __ATOMIC_ACQUIRE); } + +struct hud_frames *hud_frames_open(const char *path) { + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) return NULL; + struct stat st; + void *map = MAP_FAILED; + if (fstat(fd, &st) == 0 && st.st_size >= (off_t)HUD_FRAMES_HEADER) + map = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_SHARED, fd, 0); + close(fd); + if (map == MAP_FAILED) return NULL; + struct hud_frames *h = calloc(1, sizeof(*h)); + if (!h) { + munmap(map, (size_t)st.st_size); + return NULL; + } + h->map = map; + h->size = (size_t)st.st_size; + return h; +} + +void hud_frames_close(struct hud_frames *h) { + if (!h) return; + munmap((void *)h->map, h->size); + free(h); +} + +static uint32_t capacity(const struct hud_frames *h) { + if (load32(h->map) != HUD_FRAMES_MAGIC) return 0; + uint32_t cap; + memcpy(&cap, h->map + 8, 4); + if (cap == 0 || HUD_FRAMES_HEADER + (size_t)cap * HUD_FRAMES_ENTRY > h->size) return 0; + return cap; +} + +int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max) { + uint32_t cap = capacity(h); + if (!cap || max <= 0) return -1; + uint64_t count = load64(h->map + 16); + if (count < h->seen) h->seen = 0; + uint64_t from = h->seen; + if (count - from > cap) from = count - cap; + if (count - from > (uint64_t)max) from = count - (uint64_t)max; + int n = 0; + for (uint64_t i = from; i < count; i++, n++) { + const uint8_t *e = h->map + HUD_FRAMES_HEADER + (i % cap) * HUD_FRAMES_ENTRY; + memcpy(&frametimes[n], e, 8); + memcpy(×[n], e + 8, 8); + } + /* The writer may have lapped the ring while we copied: drop what it overwrote. */ + uint64_t after = load64(h->map + 16); + uint64_t valid = after > cap ? after - cap : 0; + if (from < valid) { + int skip = (int)(valid - from < (uint64_t)n ? valid - from : (uint64_t)n); + memmove(frametimes, frametimes + skip, (size_t)(n - skip) * sizeof(int64_t)); + memmove(times, times + skip, (size_t)(n - skip) * sizeof(int64_t)); + n -= skip; + } + h->seen = count; + return n; +} + +int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len) { + if (!capacity(h) || engine_len == 0) return 0; + for (int tries = 0; tries < 8; tries++) { + uint32_t seq = load32(h->map + 24); + if (seq & 1u) continue; + memcpy(&info[0], h->map + 12, 4); + memcpy(&info[1], h->map + 28, 16); + size_t n = engine_len - 1 < 40 ? engine_len - 1 : 40; + memcpy(engine, h->map + 44, n); + engine[n] = '\0'; + __atomic_thread_fence(__ATOMIC_ACQUIRE); + if (__atomic_load_n((const uint32_t *)(h->map + 24), __ATOMIC_RELAXED) == seq) return 1; + } + return 0; +} diff --git a/app/src/main/cpp/waylandcomp/src/hudframes.h b/app/src/main/cpp/waylandcomp/src/hudframes.h new file mode 100644 index 000000000..d0b1ff5bd --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/hudframes.h @@ -0,0 +1,16 @@ +#ifndef DROIDDECK_HUDFRAMES_H +#define DROIDDECK_HUDFRAMES_H + +#include +#include + +struct hud_frames; + +struct hud_frames *hud_frames_open(const char *path); +void hud_frames_close(struct hud_frames *h); +/* New frames since the last poll, oldest first; -1 while the file holds no valid ring. */ +int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max); +/* info: pid, output width, output height, refresh mHz, Steam focused. */ +int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len); + +#endif diff --git a/app/src/main/cpp/waylandcomp/src/hudframes_jni.c b/app/src/main/cpp/waylandcomp/src/hudframes_jni.c new file mode 100644 index 000000000..7bd9149d9 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/hudframes_jni.c @@ -0,0 +1,55 @@ +#include +#include +#include + +#include "hudframes.h" + +JNIEXPORT jlong JNICALL +Java_com_droiddeck_launcher_hud_HudFrames_nativeOpen(JNIEnv *env, jclass clazz, jstring path) { + (void)clazz; + const char *p = path ? (*env)->GetStringUTFChars(env, path, NULL) : NULL; + if (!p) return 0; + struct hud_frames *h = hud_frames_open(p); + (*env)->ReleaseStringUTFChars(env, path, p); + return (jlong)(intptr_t)h; +} + +JNIEXPORT void JNICALL +Java_com_droiddeck_launcher_hud_HudFrames_nativeClose(JNIEnv *env, jclass clazz, jlong handle) { + (void)env; (void)clazz; + hud_frames_close((struct hud_frames *)(intptr_t)handle); +} + +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_hud_HudFrames_nativePoll(JNIEnv *env, jclass clazz, jlong handle, + jlongArray frametimes, jlongArray times) { + (void)clazz; + struct hud_frames *h = (struct hud_frames *)(intptr_t)handle; + if (!h || !frametimes || !times) return -1; + jsize max = (*env)->GetArrayLength(env, frametimes); + jsize max2 = (*env)->GetArrayLength(env, times); + if (max2 < max) max = max2; + if (max <= 0) return -1; + int64_t *ft = malloc(sizeof(int64_t) * (size_t)max); + int64_t *tm = malloc(sizeof(int64_t) * (size_t)max); + int n = ft && tm ? hud_frames_poll(h, ft, tm, max) : -1; + if (n > 0) { + (*env)->SetLongArrayRegion(env, frametimes, 0, n, (const jlong *)ft); + (*env)->SetLongArrayRegion(env, times, 0, n, (const jlong *)tm); + } + free(ft); + free(tm); + return n; +} + +JNIEXPORT jstring JNICALL +Java_com_droiddeck_launcher_hud_HudFrames_nativeInfo(JNIEnv *env, jclass clazz, jlong handle, jintArray info) { + (void)clazz; + const struct hud_frames *h = (const struct hud_frames *)(intptr_t)handle; + if (!h || !info || (*env)->GetArrayLength(env, info) < 5) return NULL; + int32_t values[5]; + char engine[41]; + if (!hud_frames_info(h, values, engine, sizeof(engine))) return NULL; + (*env)->SetIntArrayRegion(env, info, 0, 5, (const jint *)values); + return (*env)->NewStringUTF(env, engine); +} diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index c917afbba..8d6b3480a 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -384,6 +384,13 @@ int vkp_output_to_scene(double ox, double oy, double *sx, double *sy) { return 1; } +int vkp_scene_to_output(double sx, double sy, double *ox, double *oy) { + if (!g_map.valid || g_map.kx <= 0 || g_map.ky <= 0) return 0; + *ox = sx * g_map.kx + g_map.off_x; + *oy = sy * g_map.ky + g_map.off_y; + return 1; +} + void vkp_output_size(int *w, int *h) { *w = (int)g_extent.width; *h = (int)g_extent.height; diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 7271f468b..5cc06ddb3 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -16,6 +16,7 @@ enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, void vk_present_set_scale_mode(int mode, int alignment); int vkp_game_picture_rect(float rect[4]); int vkp_output_to_scene(double ox, double oy, double *sx, double *sy); +int vkp_scene_to_output(double sx, double sy, double *ox, double *oy); void vkp_output_size(int *w, int *h); struct vkp_image; diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 675d4ac2c..e3d0f5c5f 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -27,6 +27,8 @@ extern int droiddeck_cursor_snapshot(int *out, int cap); extern int droiddeck_cursor_serial(void); extern int droiddeck_hud_frame_times(float *out, int len, int64_t *total); extern void droiddeck_wayland_send_key(int evdev, int state); +extern int droiddeck_focus_takes_touch(void); +extern int droiddeck_cursor_input_pos(int *x, int *y); extern void droiddeck_wayland_send_scene_input(int type, int a, int b); extern void droiddeck_wayland_vsync(int64_t frame_time_ns); extern void droiddeck_stream_set(int on); @@ -36,6 +38,7 @@ extern void droiddeck_wayland_output_resized(void); extern void droiddeck_wayland_set_output_refresh_rate(int mhz); extern volatile int g_fps_limit; extern volatile int g_release_unpaced; +extern volatile int g_render_on_commit; extern volatile int g_hide_shell; extern volatile int g_zero_copy; extern volatile unsigned g_zero_copy_last; @@ -295,6 +298,25 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey( droiddeck_wayland_send_key(evdev, state); } +JNIEXPORT jboolean JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeFocusTakesTouch( + JNIEnv *env, jclass clazz) { + (void)env; (void)clazz; + return droiddeck_focus_takes_touch() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorInputPos( + JNIEnv *env, jclass clazz, jintArray out) { + (void)clazz; + if (!out || (*env)->GetArrayLength(env, out) < 2) return JNI_FALSE; + int pos[2]; + if (!droiddeck_cursor_input_pos(&pos[0], &pos[1])) return JNI_FALSE; + jint jpos[2] = {pos[0], pos[1]}; + (*env)->SetIntArrayRegion(env, out, 0, 2, jpos); + return JNI_TRUE; +} + JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput( JNIEnv *env, jclass clazz, jint type, jint a, jint b) { @@ -587,6 +609,12 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLookName(JNIEnv * free(s); } +JNIEXPORT void JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetRenderOnCommit(JNIEnv *env, jclass clazz, jboolean on) { + g_render_on_commit = on ? 1 : 0; + __android_log_print(ANDROID_LOG_INFO, TAG, "render on commit %s", on ? "on" : "off"); +} + JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFpsLimit(JNIEnv *env, jclass clazz, jint fps) { g_fps_limit = fps > 0 ? fps : 0; diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 324941bd4..ca648e3f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -46,6 +46,7 @@ class App : Application() { com.droiddeck.launcher.glasses.Glasses.init(this) com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this) BwrapSpawner.start(this) + com.droiddeck.launcher.runtime.RootSpawner.start(this) com.droiddeck.launcher.frontend.GameFileSync.start(this) com.droiddeck.launcher.stores.StoresState.init(this) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend) diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 157a8eb35..3c3a5fc79 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -44,6 +44,7 @@ import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.ui.ComponentsPage import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare +import com.droiddeck.launcher.session.SessionPaths import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SteamRepair import com.droiddeck.launcher.core.CpuCores @@ -108,6 +109,8 @@ class FrontEndActivity : ComponentActivity() { private var ready by mutableStateOf(false) private var removalPending by mutableStateOf(false) private var available by mutableStateOf(null) + private var catalogFresh = false + private var componentsQueueBusy by mutableStateOf(false) private var busy by mutableStateOf(false) private var stage by mutableStateOf("") private var percent by mutableIntStateOf(-1) @@ -212,6 +215,7 @@ class FrontEndActivity : ComponentActivity() { } private val mediaReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(context: android.content.Context?, intent: Intent?) { + usbScan++ ui.removeCallbacks(mediaRefresh) ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS) } @@ -471,6 +475,16 @@ class FrontEndActivity : ComponentActivity() { private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var backActionsInverted by mutableStateOf(false) private var gameStorage by mutableStateOf("") + private var slowUsb by mutableStateOf(null) + private var usbScan = 0 + private var slowUsbNever by mutableStateOf(false) + + private fun showSlowUsb(drive: com.droiddeck.launcher.session.UsbLink.SlowDrive?) { + val off = slowUsbNever + if (off) SessionPrefs.setSlowUsbWarningOff(this) + slowUsbNever = false + slowUsb = drive.takeUnless { off } + } private var storageDiagnostics by mutableStateOf(false) private var storageOptions by mutableStateOf>(emptyList()) private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> @@ -484,6 +498,8 @@ class FrontEndActivity : ComponentActivity() { private var emulatorList by mutableStateOf>(emptyList()) private var runningLabel by mutableStateOf(null) private var logsEnabled by mutableStateOf(true) + private var gameDebugLogsEnabled by mutableStateOf(false) + private var gameDebugLogsNeedsRestart by mutableStateOf(false) private var vacDiagnostics by mutableStateOf(false) private var agentCommands by mutableStateOf(false) private var clearCachedApps by mutableStateOf(false) @@ -511,12 +527,23 @@ class FrontEndActivity : ComponentActivity() { } } private var secondScreenDisplays by mutableStateOf>(emptyList()) + private var dualDisplayId by mutableStateOf(-1) + private var dualScreenDevice by mutableStateOf(false) + private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO) + private var dualScreenLauncher by mutableStateOf(true) + private var dualScreenControls by mutableStateOf(true) + private var launcherStarted = false private lateinit var displayManager: DisplayManager private val secondScreenDisplayListener = object : DisplayManager.DisplayListener { override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays() override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays() override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays() } + private val secondScreenTouchListener = object : android.hardware.input.InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) = refreshSecondScreenDisplays() + override fun onInputDeviceRemoved(deviceId: Int) = refreshSecondScreenDisplays() + override fun onInputDeviceChanged(deviceId: Int) = refreshSecondScreenDisplays() + } private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { refreshHomeAppState() @@ -531,6 +558,10 @@ class FrontEndActivity : ComponentActivity() { android.widget.Toast.makeText(this, R.string.main_wait_runtime, android.widget.Toast.LENGTH_SHORT).show() return } + SessionState.installing != null && !SessionState.running -> { + android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show() + return + } compat -> Unit pkgStage != null -> { android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show() @@ -556,12 +587,12 @@ class FrontEndActivity : ComponentActivity() { } private fun launchFlooded(f: PendingFlood) { - if (busy || LinuxRuntimeInstaller.isBusy() || protons.protonBusyId != null || - ProtonExtras.installInProgress || pkgStage != null) { + if (busy || LinuxRuntimeInstaller.isBusy() || (SessionState.installing != null && !SessionState.running) || + protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) { flood = null floodProgress = 0f com.droiddeck.launcher.ui.LaunchOrigin.flooding = null - if (!busy && !LinuxRuntimeInstaller.isBusy() && + if (!busy && !LinuxRuntimeInstaller.isBusy() && !(SessionState.installing != null && !SessionState.running) && (protons.protonBusyId != null || ProtonExtras.installInProgress)) { compatFlood = com.droiddeck.launcher.ui.Themes.byId(theme).signal waitForCompat(f.intent) @@ -595,7 +626,7 @@ class FrontEndActivity : ComponentActivity() { } if (!compatLaunchVisible) { ui.postDelayed(this, 250); return } val problem = protons.protonProblem.takeIf { waiting.menuInstall } - if (problem == null && (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null)) { + if (problem == null && (busy || LinuxRuntimeInstaller.isBusy() || (SessionState.installing != null && !SessionState.running) || pkgStage != null)) { waiting.loading.say(pkgStage ?: stage.takeIf { it.isNotBlank() } ?: getString(R.string.main_wait_runtime), com.droiddeck.launcher.session.LoadingState.Topic.RUNTIME, readable = true) waiting.loading.percent = if (pkgStage != null) pkgPercent else percent @@ -642,7 +673,23 @@ class FrontEndActivity : ComponentActivity() { private var shortcutPicker by mutableStateOf(false) private var shortcutLibraryScanning by mutableStateOf(false) @Volatile private var libraryScanGeneration = 0 + @Volatile private var lastComponentRestore = 0L + @Volatile private var componentRestoreRetry = false + private val componentRestoreLock = Any() + + private fun retryComponentRestore() { + val run = synchronized(componentRestoreLock) { + componentRestoreRetry = false + val now = android.os.SystemClock.elapsedRealtime() + (!isDestroyed && now - lastComponentRestore >= COMPONENT_RESTORE_MS).also { if (it) lastComponentRestore = now } + } + if (run) Thread({ + runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) } + }, "components-restore").start() + } private var pendingGameLink: String? = null + private var gameLinkReading = false + private var gameLinkTried = false private var gameSyncFolder by mutableStateOf(null) private fun pickGameExport(game: Library.SteamGame?) { onSavePicked = { folder -> @@ -683,16 +730,25 @@ class FrontEndActivity : ComponentActivity() { shortcutLibraryScanning = true } else shortcutLibraryScanning = false pendingGameLink = null + gameLinkReading = false + gameLinkTried = false if (GameLaunchIntent.accepts(request.action)) { + gameLinkReading = true + com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId) val copy = Intent(request) Thread({ val id = GameLaunchIntent.read(this, copy) ui.post { if (intent === request && !isDestroyed) { + gameLinkReading = false pendingGameLink = id if (id == null) { + syncDualPanel() android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show() - } else refresh() + } else { + handleGameLink(steamGames, scanned = false) + refresh() + } } } }, "game-launch-file").start() @@ -895,15 +951,21 @@ class FrontEndActivity : ComponentActivity() { ui.postDelayed(poll, SESSION_SWITCH_POLL_MS) } - private fun handleGameLink(games: List) { + private fun handleGameLink(games: List, scanned: Boolean = true) { if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return val id = pendingGameLink ?: return + if (!scanned && games.none { it.gameIdString == id }) return + gameLinkTried = true + ui.postDelayed({ + if (!isDestroyed && lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) syncDualPanel() + }, LINK_PANEL_MS) val game = games.firstOrNull { it.gameIdString == id } if (game == null) { pendingGameLink = null intent.data = null intent.action = Intent.ACTION_MAIN intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID) + syncDualPanel() android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show() } else if (launchGame(game, returnHome = true)) { pendingGameLink = null @@ -958,6 +1020,13 @@ class FrontEndActivity : ComponentActivity() { if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog( onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() }, ) + slowUsb?.let { drive -> + com.droiddeck.launcher.ui.SlowUsbDialog(drive, slowUsbNever, { slowUsbNever = it }) { + com.droiddeck.launcher.session.UsbLink.markShown(this, drive) + showSlowUsb(null) + checkUsbLink() + } + } val page: (@Composable () -> Unit)? = when { showPerformance -> { { PerformanceHost() } } showComponents -> { { ComponentsHost() } } @@ -975,6 +1044,7 @@ class FrontEndActivity : ComponentActivity() { desktopInstalled = desktopInstalled, desktopPresent = desktopPresent, offlineAccount = offlineAccount, offline = offline, frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, + gameDebugLogsEnabled = gameDebugLogsEnabled, gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart, vacDiagnostics = vacDiagnostics, agentCommands = agentCommands, clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit, steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel, @@ -991,6 +1061,11 @@ class FrontEndActivity : ComponentActivity() { integrationFocus = integrationFocus, androidApps = androidApps, secondScreenDisplays = secondScreenDisplays, + dualScreen = dualDisplayId >= 0 && com.droiddeck.launcher.ui.DualScreenPanel.available, + dualScreenDevice = dualScreenDevice, + dualScreenMode = dualScreenMode, + dualScreenLauncher = dualScreenLauncher, + dualScreenControls = dualScreenControls, packages = packageRows, packageCatalogLoading = catalogLoading, packageBusyId = pkgId, @@ -1131,8 +1206,21 @@ class FrontEndActivity : ComponentActivity() { .putExtra(com.droiddeck.launcher.files.FileManagerActivity.EXTRA_START_DIR, dir.absolutePath)) }, onLogs = { - SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this)) + val changed = SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this)) + if (!changed) android.widget.Toast.makeText( + this, R.string.setup_logging_update_failed, android.widget.Toast.LENGTH_LONG, + ).show() logsEnabled = SessionPrefs.logsEnabled(this) + gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this) + gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart() + }, + onGameDebugLogs = { + val changed = SessionPrefs.setGameDebugLogsEnabled(this, !SessionPrefs.gameDebugLogsEnabled(this)) + if (!changed) android.widget.Toast.makeText( + this, R.string.setup_logging_update_failed, android.widget.Toast.LENGTH_LONG, + ).show() + gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this) + gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart() }, onVacDiagnostics = { SessionPrefs.setVacDiagnostics(this, !SessionPrefs.vacDiagnostics(this)) @@ -1206,6 +1294,18 @@ class FrontEndActivity : ComponentActivity() { onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort }, onStoresView = { view -> SessionPrefs.setStoresView(this, view); storesView = view }, onLibraryChanged = { refreshAddedGames(); refresh() }, + onDualScreenMode = { mode -> + SessionPrefs.setDualScreenMode(this, mode) + refreshSecondScreenDisplays() + }, + onDualScreenLauncher = { on -> + SessionPrefs.setDualScreenLauncher(this, on) + refreshSecondScreenDisplays() + }, + onDualScreenControls = { on -> + SessionPrefs.setDualScreenSessionControls(this, on) + dualScreenControls = on + }, onAnimationsEnabled = { on -> SessionPrefs.setAnimationsEnabled(this, on) animationsEnabled = on @@ -1294,7 +1394,7 @@ class FrontEndActivity : ComponentActivity() { showPhantomGate = true }, onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) }, - onStartPairingGuide = { displayId -> PairingGuideActivity.start(this, displayId) }, + onStartPairingGuide = { displayId, toggleOnly -> PairingGuideActivity.start(this, displayId, toggleOnly) }, onOpenNotificationSettings = { startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName) @@ -1436,6 +1536,9 @@ class FrontEndActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) if (intent.action == SteamIntegration.ACTION_SETUP) { + gameLinkReading = false + pendingGameLink = null + syncDualPanel() readIntegrationIntent(intent) return } @@ -1529,12 +1632,14 @@ class FrontEndActivity : ComponentActivity() { override fun onPause() { compatLaunchVisible = false + if (android.os.Build.VERSION.SDK_INT >= 29) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true) super.onPause() } override fun onResume() { super.onResume() compatLaunchVisible = true + com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false) orientation = SessionPrefs.orientation(this) launcherOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this) requestedOrientation = launcherOrientation @@ -1552,14 +1657,21 @@ class FrontEndActivity : ComponentActivity() { drivers.checkOnOpen() resumeInterruptedPackages() protons.checkUpdates() - if (!SessionState.running) Thread({ - val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList()) - if (applied.isNotEmpty()) ui.post { - android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show() - } - ui.post { if (showComponents) components.refreshComponents() } - }, "components-queue").start() - else if (showComponents) components.refreshComponents() + catalogFresh = false + if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) { + componentsQueueBusy = true + Thread({ + val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList()) + if (applied.isNotEmpty()) ui.post { + android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show() + } + ui.post { + componentsQueueBusy = false + if (showComponents) components.refreshComponents() + autoUpdateRuntime() + } + }, "components-queue").start() + } else if (showComponents) components.refreshComponents() oscMode = SessionPrefs.oscMode(this) refreshController() refreshHomeAppState() @@ -1582,6 +1694,8 @@ class FrontEndActivity : ComponentActivity() { addDataScheme("file") }) displayManager.registerDisplayListener(secondScreenDisplayListener, ui) + getSystemService(android.hardware.input.InputManager::class.java)?.registerInputDeviceListener(secondScreenTouchListener, ui) + launcherStarted = true refreshSecondScreenDisplays() getSystemService(android.content.pm.LauncherApps::class.java).registerCallback(packageCallback, ui) HomeWidgets.startListening(this) @@ -1596,6 +1710,7 @@ class FrontEndActivity : ComponentActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) + if (slowUsbNever) SessionPrefs.setSlowUsbWarningOff(this) saveCompatLaunch(outState) pendingSwitch?.let { outState.putParcelable(KEY_PENDING_SWITCH, it) } switchingTo?.let { @@ -1623,6 +1738,9 @@ class FrontEndActivity : ComponentActivity() { com.droiddeck.launcher.stores.StoresState.libraryListener = null com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = null displayManager.unregisterDisplayListener(secondScreenDisplayListener) + getSystemService(android.hardware.input.InputManager::class.java)?.unregisterInputDeviceListener(secondScreenTouchListener) + launcherStarted = false + com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId) flood = null returning = null floodProgress = 0f @@ -1656,13 +1774,31 @@ class FrontEndActivity : ComponentActivity() { if (!::displayManager.isInitialized) return // The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen". val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId + dualScreenMode = SessionPrefs.dualScreenMode(this) secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId) + dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null + dualScreenLauncher = SessionPrefs.dualScreenLauncher(this) + dualScreenControls = SessionPrefs.dualScreenSessionControls(this) + val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId) + dualDisplayId = if (dualScreenLauncher) target?.displayId ?: -1 else -1 + syncDualPanel() } + private fun syncDualPanel() { + if (launchPending()) return + if (launcherStarted && dualDisplayId >= 0) com.droiddeck.launcher.ui.DualScreenPanel.show(this, dualDisplayId, taskId) + else com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId) + } + + private fun launchPending(): Boolean = gameLinkReading || (pendingGameLink != null && !gameLinkTried) + private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) { + val onOwnScreen = displayId == null || displayId == (display?.displayId ?: Display.DEFAULT_DISPLAY) + if (onOwnScreen) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true) try { HomeApp.launch(this, app, displayId) } catch (_: Exception) { + com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false) val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label val message = when { displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label) @@ -2177,6 +2313,7 @@ class FrontEndActivity : ComponentActivity() { } private fun refreshAddedGames() { + checkUsbLink() sgdbKeyKnown() fetchAddedGameArt() } @@ -2281,6 +2418,20 @@ class FrontEndActivity : ComponentActivity() { } SessionPrefs.setGameStorage(this, path, label) gameStorage = path + checkUsbLink() + } + + private fun checkUsbLink() { + if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { ui.post { checkUsbLink() }; return } + val scan = ++usbScan + Thread({ + val drive = runCatching { com.droiddeck.launcher.session.UsbLink.slowLibrary(this) }.getOrNull() + ui.post { + if (scan != usbScan || isFinishing) return@post + val due = drive?.takeIf { com.droiddeck.launcher.session.UsbLink.stillDue(this, it) } + if (due == null) { if (slowUsb != null) showSlowUsb(null) } else if (slowUsb?.key != due.key) showSlowUsb(due) + } + }, "usb-link").start() } private fun refreshCores() { @@ -2307,6 +2458,10 @@ class FrontEndActivity : ComponentActivity() { refreshPhantomStatus() } + private fun gameDebugLogsNeedsRestart(): Boolean = + SessionPrefs.logsEnabled(this) && SessionPrefs.gameDebugLogsEnabled(this) && + !SessionPaths.currentSessionLogsRetained(this) + private fun refresh() { // Until the scan finishes, the other client's games would launch in this one. val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64" @@ -2325,9 +2480,12 @@ class FrontEndActivity : ComponentActivity() { installed = LinuxRuntimeInstaller.installedVersion(this) ready = LinuxRuntime.isInstalled(this) removalPending = LinuxRuntimeInstaller.hasRemovalPending(this) + ui.post { autoUpdateRuntime() } frameGenLabel = FrameGen.label(this) romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() } logsEnabled = SessionPrefs.logsEnabled(this) + gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this) + gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart() vacDiagnostics = SessionPrefs.vacDiagnostics(this) agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this) clearCachedApps = SessionPrefs.clearCachedApps(this) @@ -2365,15 +2523,32 @@ class FrontEndActivity : ComponentActivity() { } val scannedArch = SessionPrefs.activeSteamClientArch(this) val scannedX64 = scannedArch == "x86_64" - val games = if (ready) Library.launchableGames(this, arch = scannedArch) else emptyList() + val games = if (ready || com.droiddeck.launcher.runtime.LinuxRuntime.hasRootfs(this)) Library.launchableGames(this, arch = scannedArch) else emptyList() val all = games.distinctBy { it.gameId } if (ready && scanGeneration == libraryScanGeneration) com.droiddeck.launcher.frontend.LibraryCache.save(this, all, scannedX64) val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession } - if (ready && (!componentSweepDone || endedSession != null)) { + val sweepDue = ready && (!componentSweepDone || endedSession != null) + if (sweepDue) { componentSweepDone = true endedSession?.let { componentSweepSession = it } - ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000) } + val restoreDue = synchronized(componentRestoreLock) { + val now = android.os.SystemClock.elapsedRealtime() + val due = ready && (lastComponentRestore == 0L || now - lastComponentRestore >= COMPONENT_RESTORE_MS) + if (due) { + lastComponentRestore = now + } else if (ready && !componentRestoreRetry) { + componentRestoreRetry = true + ui.postDelayed({ retryComponentRestore() }, (COMPONENT_RESTORE_MS - (now - lastComponentRestore)).coerceAtLeast(0L)) + } + due + } + if (restoreDue || sweepDue) ui.postDelayed({ + Thread({ + if (restoreDue) runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) } + if (sweepDue) com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) + }, "components-restore").start() + }, 10_000) ui.post { if (scanGeneration == libraryScanGeneration) { val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId } @@ -2414,6 +2589,10 @@ class FrontEndActivity : ComponentActivity() { private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean { if (busy || LinuxRuntimeInstaller.isBusy()) return false + if (SessionState.installing != null && !SessionState.running) { + android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show() + return false + } if (com.droiddeck.launcher.storage.StorageState.cleaning) { android.widget.Toast.makeText(this, R.string.storage_cleanup_busy, android.widget.Toast.LENGTH_SHORT).show() return false @@ -2463,7 +2642,7 @@ class FrontEndActivity : ComponentActivity() { private fun runtimeChangesBlocked(): Boolean = busy || LinuxRuntimeInstaller.isBusy() || SessionState.running || SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) || - protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null || + protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null || componentsQueueBusy || components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null private fun removeDesktop() { @@ -2523,15 +2702,32 @@ class FrontEndActivity : ComponentActivity() { }, "runtime-operation").start() } + private fun autoUpdateRuntime() { + val release = available?.takeIf { catalogFresh } ?: return + if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return + if (release.version == installed || installed == null) return + if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) { + Log.i(TAG, "runtime: updating $installed to ${release.version} by itself") + install(release) + } + } + private fun checkCatalog() { val release = LinuxRuntimeInstaller.fetchRelease() Log.i(TAG, "catalog: " + (release?.version ?: "unreachable")) - ui.post { if (release != null) available = release } + ui.post { + if (release == null) return@post + available = release + catalogFresh = true + autoUpdateRuntime() + } } companion object { private const val TAG = "MainActivity" private const val MEDIA_SETTLE_MS = 1500L + private const val COMPONENT_RESTORE_MS = 60_000L + private const val LINK_PANEL_MS = 1500L private const val SESSION_SWITCH_WAIT_MS = 30_000L private const val SESSION_SWITCH_POLL_MS = 250L private const val KEY_PENDING_SWITCH = "pending_switch" diff --git a/app/src/main/java/com/droiddeck/launcher/PairingGuideActivity.kt b/app/src/main/java/com/droiddeck/launcher/PairingGuideActivity.kt index 44fce026a..5632c0854 100644 --- a/app/src/main/java/com/droiddeck/launcher/PairingGuideActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/PairingGuideActivity.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.droiddeck.launcher.core.AppLanguage import com.droiddeck.launcher.core.GlobalSettings +import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.WirelessAdbPairingService import com.droiddeck.launcher.core.WirelessAdbPairingService.Stage import com.droiddeck.launcher.ui.DroidDeckTheme @@ -67,9 +68,10 @@ import com.droiddeck.launcher.ui.SecondaryButton import kotlinx.coroutines.delay class PairingGuideActivity : ComponentActivity() { - enum class Step { BUILD_NUMBER, WIRELESS_DEBUGGING, PAIR, CODE, WORKING, DONE, FAILED } + enum class Step { BUILD_NUMBER, CHILD_PROCESS, WIRELESS_DEBUGGING, PAIR, CODE, WORKING, DONE, FAILED } private var settingsDisplay: Int? = null + private var toggleOnly = false private var inPip by mutableStateOf(false) private var launchPending = false @@ -78,6 +80,7 @@ class PairingGuideActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) settingsDisplay = intent.getIntExtra(EXTRA_DISPLAY, -1).takeIf { it >= 0 } + toggleOnly = intent.getBooleanExtra(EXTRA_TOGGLE_ONLY, false) setContent { DroidDeckTheme { Guide() } } launchPending = savedInstanceState == null } @@ -86,6 +89,7 @@ class PairingGuideActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) settingsDisplay = intent.getIntExtra(EXTRA_DISPLAY, -1).takeIf { it >= 0 } + toggleOnly = intent.getBooleanExtra(EXTRA_TOGGLE_ONLY, false) launchPending = true } @@ -108,7 +112,7 @@ class PairingGuideActivity : ComponentActivity() { private fun launchSettings() { launchPending = false - openSettings(stepFor(this, WirelessAdbPairingService.stage.value)) + openSettings(stepFor(SettingsState.read(this), WirelessAdbPairingService.stage.value, toggleOnly)) } private fun enterPip() { @@ -121,7 +125,9 @@ class PairingGuideActivity : ComponentActivity() { val intent = if (step == Step.BUILD_NUMBER) { Intent(Settings.ACTION_DEVICE_INFO_SETTINGS).putExtra(HIGHLIGHT_KEY, "build_number") } else { - Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).putExtra(HIGHLIGHT_KEY, "toggle_adb_wireless") + Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { + if (step != Step.CHILD_PROCESS) putExtra(HIGHLIGHT_KEY, "toggle_adb_wireless") + } }.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val display = settingsDisplay runCatching { @@ -147,10 +153,10 @@ class PairingGuideActivity : ComponentActivity() { delay(500) } } - val step = stepFor(settingsState, stage) + val step = stepFor(settingsState, stage, toggleOnly) var previous by remember { mutableStateOf(step) } LaunchedEffect(step) { - if (previous == Step.BUILD_NUMBER && step == Step.WIRELESS_DEBUGGING) openSettings(step) + if (previous == Step.BUILD_NUMBER && (step == Step.WIRELESS_DEBUGGING || step == Step.CHILD_PROCESS)) openSettings(step) previous = step when (step) { Step.DONE -> { delay(1_500); returnToApp() } @@ -158,7 +164,7 @@ class PairingGuideActivity : ComponentActivity() { else -> Unit } } - LaunchedEffect(stage) { if (stage == Stage.Idle) finish() } + LaunchedEffect(stage) { if (stage == Stage.Idle && !toggleOnly) finish() } BackHandler(enabled = !inPip && settingsDisplay == null) { enterPip() } Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center) { @@ -199,7 +205,7 @@ class PairingGuideActivity : ComponentActivity() { openSettings(step) }) SecondaryButton(stringResource(R.string.common_cancel), onClick = { - WirelessAdbPairingService.cancel(this@PairingGuideActivity) + if (!toggleOnly) WirelessAdbPairingService.cancel(this@PairingGuideActivity) returnToApp() }) } @@ -210,6 +216,7 @@ class PairingGuideActivity : ComponentActivity() { @Composable private fun instruction(step: Step, stage: Stage): String = when (step) { Step.BUILD_NUMBER -> stringResource(R.string.pairguide_build_number) + Step.CHILD_PROCESS -> stringResource(R.string.pairguide_child_process) Step.WIRELESS_DEBUGGING -> stringResource(R.string.pairguide_wireless) Step.PAIR -> stringResource(R.string.pairguide_pair) Step.CODE -> (stage as? Stage.CodeNeeded)?.error ?: stringResource(R.string.gate_step3) @@ -226,6 +233,7 @@ class PairingGuideActivity : ComponentActivity() { val beat by pulse.animateFloat(0f, 1f, infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Restart), label = "beat") val label = when (step) { Step.BUILD_NUMBER -> stringResource(R.string.pairguide_row_build_number) + Step.CHILD_PROCESS -> stringResource(R.string.pairguide_row_child_process) Step.WIRELESS_DEBUGGING -> stringResource(R.string.pairguide_row_wireless) Step.PAIR -> stringResource(R.string.pairguide_row_pair) Step.CODE -> stringResource(R.string.adbpair_enter_code) @@ -240,7 +248,7 @@ class PairingGuideActivity : ComponentActivity() { Text(label, style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyLarge, color = colors.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) when (step) { - Step.WIRELESS_DEBUGGING -> Toggle(on = beat > 0.5f, accent = accent, compact = compact) + Step.WIRELESS_DEBUGGING, Step.CHILD_PROCESS -> Toggle(on = beat > 0.5f, accent = accent, compact = compact) else -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { if (step == Step.BUILD_NUMBER) Text("×7", style = MaterialTheme.typography.labelLarge, color = accent) Box(contentAlignment = Alignment.Center) { @@ -267,7 +275,8 @@ class PairingGuideActivity : ComponentActivity() { @Composable private fun StepDots(step: Step) { val accent = LocalPalette.current.signal - val shown = listOf(Step.BUILD_NUMBER, Step.WIRELESS_DEBUGGING, Step.PAIR, Step.CODE) + val shown = if (toggleOnly) listOf(Step.BUILD_NUMBER, Step.CHILD_PROCESS) + else listOf(Step.BUILD_NUMBER, Step.WIRELESS_DEBUGGING, Step.PAIR, Step.CODE) val at = shown.indexOf(step).takeIf { it >= 0 } ?: shown.size Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { shown.forEachIndexed { index, _ -> @@ -278,22 +287,30 @@ class PairingGuideActivity : ComponentActivity() { } } - private data class SettingsState(val developerOptions: Boolean, val wirelessDebugging: Boolean) { + private data class SettingsState( + val developerOptions: Boolean, + val wirelessDebugging: Boolean, + val childProcessBlocked: Boolean, + ) { companion object { fun read(context: Context) = SettingsState( GlobalSettings.developerOptionsOn(context), GlobalSettings.wirelessDebuggingOn(context), + PhantomProcessLimit.blocksSteam(PhantomProcessLimit.read(context)), ) } } companion object { private const val EXTRA_DISPLAY = "settingsDisplay" + private const val EXTRA_TOGGLE_ONLY = "toggleOnly" private const val HIGHLIGHT_KEY = ":settings:fragment_args_key" - private fun stepFor(context: Context, stage: Stage) = stepFor(SettingsState.read(context), stage) - - private fun stepFor(settings: SettingsState, stage: Stage): Step = when (stage) { + private fun stepFor(settings: SettingsState, stage: Stage, toggleOnly: Boolean): Step = if (toggleOnly) when { + !settings.developerOptions -> Step.BUILD_NUMBER + settings.childProcessBlocked -> Step.CHILD_PROCESS + else -> Step.DONE + } else when (stage) { is Stage.Working -> Step.WORKING Stage.Done -> Step.DONE is Stage.Failed -> Step.FAILED @@ -317,10 +334,11 @@ class PairingGuideActivity : ComponentActivity() { return mode == AppOpsManager.MODE_ALLOWED } - fun start(context: Context, settingsDisplay: Int?): Boolean { + fun start(context: Context, settingsDisplay: Int?, toggleOnly: Boolean = false): Boolean { if (settingsDisplay == null && !canFloat(context)) return false context.startActivity(Intent(context, PairingGuideActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOGGLE_ONLY, toggleOnly) .apply { if (settingsDisplay != null) putExtra(EXTRA_DISPLAY, settingsDisplay) }) return true } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 786167d1b..067cd7139 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -75,6 +75,8 @@ import com.droiddeck.launcher.input.ControllerPrefs import com.droiddeck.launcher.input.SessionClipboard import com.droiddeck.launcher.input.OnScreenControls import com.droiddeck.launcher.input.PadBridge +import com.droiddeck.launcher.input.PhysicalShortcuts +import com.droiddeck.launcher.input.PhysicalShortcutRouter import com.droiddeck.launcher.input.PadMouse import com.droiddeck.launcher.input.PadState import com.droiddeck.launcher.input.ScreenKeys @@ -90,7 +92,8 @@ import com.droiddeck.launcher.input.TouchscreenGestures import com.droiddeck.launcher.input.ScrollTouchGestures import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.LoadingState -import com.droiddeck.launcher.session.PerfHud +import com.droiddeck.launcher.hud.FusionHudConfig +import com.droiddeck.launcher.hud.FusionHudHost import com.droiddeck.launcher.session.PerfMode import com.droiddeck.launcher.session.SteamRepair import com.droiddeck.launcher.session.GameEnvironmentStore @@ -158,9 +161,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private lateinit var surfaceView: SurfaceView private lateinit var sessionOverlay: ComposeView private lateinit var loading: LoadingState - private lateinit var hud: PerfHud + private lateinit var hud: FusionHudHost private lateinit var memoryHud: com.droiddeck.launcher.session.MemoryHud private var padBridge: PadBridge? = null + private val physicalShortcuts = PhysicalShortcutRouter() private var joyConVr: com.droiddeck.launcher.input.JoyConVr? = null private var handCamera: com.droiddeck.launcher.handtracking.HandCamera? = null private var padMotion: com.droiddeck.launcher.input.PadMotion? = null @@ -188,7 +192,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val steamTouchRetry = Runnable { updateOnScreenControls() } private var steamTouchOptions: com.droiddeck.launcher.input.SteamTouchConfig.Options? = null private var routedTouchMode = SessionPrefs.TOUCH_AUTO + private var touchRoute: com.droiddeck.launcher.input.TouchRouter.Route? = null private var keyboard: KeyboardHost? = null + private var desktopKeyboardButton: com.droiddeck.launcher.input.DesktopKeyboardButton? = null + private var lastUserPressMs = 0L + private var pressDownX = 0f + private var pressDownY = 0f + private var pressTravel = 0f + private var pressMoved = false private var controllerSettings by mutableStateOf(null) private val sessionClipboard by lazy { SessionClipboard(this) { @@ -204,6 +215,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig() private var gestureGame: String? = null private var gestureSettingsOpen by mutableStateOf(false) + private var steamImportAsk by mutableStateOf(null) + private var steamImportAnswered: String? = null + private var directHoldBehavior by mutableStateOf(com.droiddeck.launcher.input.TouchGestureConfig.MOUSE_BEHAVIOR_CLICK) private var screenKeyPickerOpen by mutableStateOf(false) private var screenKeyBind: List? = null private var screenKeyDir: Int? = null @@ -234,6 +248,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val cursorHide = Runnable { cursorVisible = false } private var lastPadInputMs = 0L private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS) + private val cursorPosBuf = IntArray(2) private var cursorSerial = 0 private var clientHidesCursor = false private var pointerLocked = false @@ -243,6 +258,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { gesturePointerReady = false pointerLocked = locked Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released") + if (locked) openEvdevMouse() else closeEvdevMouse() padMouseCheckedAt = 0L if (::padMouse.isInitialized) padMouseMode() // Touchpad deltas are measured from the middle while locked (moveLockedPointer). @@ -257,7 +273,31 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var cursorHotX by mutableStateOf(0) private var cursorHotY by mutableStateOf(0) private var cursorImageScale by mutableStateOf(1f) - private val cursorResync = Runnable { if (syncClientCursor() && clientHidesCursor) cursorVisible = false } + private var cursorResyncIdle = 0 + private val cursorResync = object : Runnable { + override fun run() { + if (syncClientCursor() && clientHidesCursor) { + cursorVisible = false + return + } + if (touchDrivesPointer()) return + cursorResyncIdle = if (alignCursorToGuest()) 0 else cursorResyncIdle + 1 + if (!cursorVisible || pointerLocked) return + if (cursorResyncIdle > CURSOR_RESYNC_IDLE_TICKS) return + uiHandler.postDelayed(this, 60) + } + } + + private fun alignCursorToGuest(): Boolean { + if (pointerLocked || !CompositorHost.isStarted) return false + if (!WaylandCompositor.nativeCursorInputPos(cursorPosBuf)) return false + if (surfaceView.width <= 0 || surfaceView.height <= 0) return false + val x = cursorPosBuf[0] / InputSpace.WIDTH.toFloat() * surfaceView.width + val y = cursorPosBuf[1] / InputSpace.HEIGHT.toFloat() * surfaceView.height + if (kotlin.math.abs(x - cursorPos.x) < 0.75f && kotlin.math.abs(y - cursorPos.y) < 0.75f) return false + cursorPos = androidx.compose.ui.geometry.Offset(x, y) + return true + } private val uiHandler = Handler(Looper.getMainLooper()) private var pendingBackAction: Runnable? = null private val backQamInput = com.droiddeck.launcher.input.BackQamInput( @@ -294,7 +334,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val resumeKeysDown = mutableSetOf>() private val drawerShortcutKeysDown = mutableSetOf>() - private var drawerOpen by mutableStateOf(false) + private var drawerOpenState by mutableStateOf(false) + private var typingReturnMode: SecondScreenMode? = null + private var drawerDocked by mutableStateOf(false) + private var drawerLayerOpen = false + private var drawerBackRequests by mutableIntStateOf(0) + private var drawerOpen: Boolean + get() = drawerOpenState + set(value) { + if (value == drawerOpenState) return + drawerLayerOpen = false + if (value) drawerDocked = dockDrawer() && openDockedMenu() + drawerOpenState = value + if (!value && drawerDocked) closeDockedMenu() + } private var quitFrom by mutableStateOf(null) private var quitFlooded = false private var leaving by mutableStateOf(false) @@ -303,9 +356,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var drawerDefaultProton by mutableStateOf(null) private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) } private var drawerGameProfileId by mutableStateOf(null) + private var fpsLimit by mutableStateOf(SessionState.fpsLimit) private fun noSteamSession(): Boolean = SessionState.mode == SessionService.MODE_RUN && - SessionState.program in setOf(com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN, com.droiddeck.launcher.session.NoSteamLaunch.EPIC) + SessionState.program in setOf( + com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN, com.droiddeck.launcher.session.NoSteamLaunch.EPIC, + com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET, + ) private val drawerGameInventory = GameProfileInventoryCache() private var drawerGameInventoryRevision = 0L private var drawerGameFexPresetSaving by mutableStateOf(false) @@ -329,14 +386,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var backActionsInverted by mutableStateOf(false) private var backDoubleTapMs by mutableStateOf(500) private var lowLatency by mutableStateOf(true) + private var evdevMouseOn by mutableStateOf(false) + private var directCursorOn by mutableStateOf(false) + private var mouseWriter: com.droiddeck.launcher.input.FakeInputWriter? = null + private var evdevMouseActive = false + private var lastMouseX = Float.NaN + private var lastMouseY = Float.NaN private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK) private var pcKeyboardOpen by mutableStateOf(false) 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 hudConfig by mutableStateOf>(emptyMap()) private var hudColor by mutableStateOf(SessionPrefs.HUD_COLOR_MINT) private var hudOpacity by mutableStateOf(SessionPrefs.HUD_OPACITY_DEFAULT) private var upscaler by mutableStateOf(0) @@ -357,6 +418,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var glassesOutputActive by mutableStateOf(false) private var pausedFrame by mutableStateOf(null) private var pausedFrameWanted = false + private var pauseAnimated by mutableStateOf(false) private var trackingRecovery by mutableStateOf(null) private var glasses3dForVr = false private var ended = false @@ -377,6 +439,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val cardboardPrefListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == SessionPrefs.CARDBOARD_KEY) uiHandler.post { applyCardboard(SessionPrefs.cardboard(this)) } } + private val shortcutPrefListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null || key == PhysicalShortcuts.KEY) { + physicalShortcuts.bindings = PhysicalShortcuts.read(this) + } + } private var textureAnisotropy by mutableStateOf(0) private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF) private var drawerTextureAnisotropy by mutableStateOf(null) @@ -386,6 +453,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } } private var frameGen by mutableStateOf(FrameGen.Mode.OFF) + private var frameGenPip = false + private val frameGenRequests = java.util.concurrent.atomic.AtomicInteger() private var fexPreset by mutableStateOf("") private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var steamDownloadsInBackground by mutableStateOf(false) @@ -461,8 +530,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { refreshControls() updatePointerCapture() updateHandCamera() + refreshSecondScreenDisplays() } override fun onInputDeviceRemoved(deviceId: Int) { + physicalShortcuts.onDeviceRemoved(deviceId) Log.i(TAG, "input device $deviceId disconnected") backQamInput.onDeviceRemoved(deviceId) joyConVr?.refresh() @@ -471,10 +542,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { refreshControls() updatePointerCapture() updateHandCamera() + refreshSecondScreenDisplays() } override fun onInputDeviceChanged(deviceId: Int) { joyConVr?.refresh() refreshControls() + refreshSecondScreenDisplays() } } @@ -489,6 +562,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { finish() return } + // A cancelled install keeps its thread until the download stops; a new launch would reuse its stop flag and close itself. + if (savedInstanceState == null && SessionState.installing != null && !SessionState.running) { + runtimeRemovalBlocked = true + android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show() + finish() + return + } if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) { val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() @@ -509,6 +589,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } com.droiddeck.launcher.gpu.SteamVrHealth.begin(this, SessionState.sessionId) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + if (!SessionState.running) { + SessionState.fpsLimit = 0 + fpsLimit = 0 + } applyOutputPacing() if (!SessionState.running) SessionState.followScreen = followChoice() applyFollowOrientation(resources.configuration) @@ -586,11 +670,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { keys.visibility = if (screenKeysShown) View.VISIBLE else View.GONE root.addView(keys) } - keyboard = KeyboardHost(this).also { root.addView(it) } - touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) - directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) { - WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + keyboard = KeyboardHost(this).also { host -> + host.onImeChanged = { updateDesktopKeyboardButton() } + root.addView(host) } + desktopKeyboardButton = com.droiddeck.launcher.input.DesktopKeyboardButton(this) { keyboard?.show() } + .also { root.addView(it) } + touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) + directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener, directTouchSink) threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2) twoFingerGestures = com.droiddeck.launcher.input.TwoFingerGestures(PointerGestures.slop(this) * 2, object : com.droiddeck.launcher.input.TwoFingerGestures.Listener { @@ -609,19 +696,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { surfaceView.postDelayed({ pointerListener.onButton(PointerGestures.BTN_RIGHT, false, x, y) }, 50L) } override fun scrollStart(x: Float, y: Float) = scrollListener.onMove(x, y) - override fun scroll(dx: Float, dy: Float) { - val rect = drawnRect() ?: return - val out = SessionState.outputSize - scrollRestX += dx * out.first / rect.width() * AXIS_UNITS_PER_PIXEL - scrollRestY += dy * out.second / rect.height() * AXIS_UNITS_PER_PIXEL - val h = scrollRestX.toInt() - val v = scrollRestY.toInt() - if (h == 0 && v == 0) return - scrollRestX -= h - scrollRestY -= v - WaylandCompositor.nativeSendSceneInput(12, v, h) - } - override fun scrollEnd() = WaylandCompositor.nativeSendSceneInput(12, 0, 0) + override fun scroll(dx: Float, dy: Float) = fingerScroll(dx, dy) + override fun scrollEnd() = fingerScrollEnd() }) touchscreenGestures = TouchscreenGestures( ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput, @@ -665,7 +741,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val needProton = protonUser && com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this) if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume) } - hud = PerfHud(this) + hud = FusionHudHost(this) + hud.onConfigChanged = { hudConfig = FusionHudConfig.load(this) } + hud.passesTouchAt = ::controlTakesTouchAt + hud.attach(root, root.childCount) memoryHud = com.droiddeck.launcher.session.MemoryHud(this) hud.onPresentingWindowChanged = { if (FrameGen.engine(this) != FrameGen.ENGINE_OFF) CompositorHost.rearmFrameGen { applyFrameGen() } @@ -753,7 +832,31 @@ 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(pipUi) { + hud.setHidden(pipUi) + applyOutputPacing() + if (pipUi != frameGenPip) { + frameGenPip = pipUi + if (FrameGen.engine(this@SessionActivity) != FrameGen.ENGINE_OFF && ::surfaceView.isInitialized) applyFrameGen() + } + } androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() } + androidx.compose.runtime.LaunchedEffect(AgentGuest.desktopTextField) { onDesktopTextField(AgentGuest.desktopTextField) } + androidx.compose.runtime.LaunchedEffect(pipUi, pcKeyboardOpen) { updateDesktopKeyboardButton() } + val keyboardRequestsSeen = androidx.compose.runtime.remember { intArrayOf(AgentGuest.keyboardRequests) } + androidx.compose.runtime.LaunchedEffect(AgentGuest.keyboardRequests) { + if (AgentGuest.keyboardRequests != keyboardRequestsSeen[0]) { + keyboardRequestsSeen[0] = AgentGuest.keyboardRequests + openBottomKeyboard() + } + } + val keyboardDismissalsSeen = androidx.compose.runtime.remember { intArrayOf(AgentGuest.keyboardDismissals) } + androidx.compose.runtime.LaunchedEffect(AgentGuest.keyboardDismissals) { + if (AgentGuest.keyboardDismissals != keyboardDismissalsSeen[0]) { + keyboardDismissalsSeen[0] = AgentGuest.keyboardDismissals + secondScreenPresentation?.closeFieldKeyboard() + } + } androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() } DroidDeckTheme { if (pipUi) { @@ -768,9 +871,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) - val hudLines = listOf(hud.text, memoryHud.text).filter { it.isNotEmpty() } - if (hudLines.isNotEmpty() || hud.graph.size >= 2) - HudText(hudLines.joinToString("\n"), hud.graph, hudColor, hudOpacity) + if (memoryHud.text.isNotEmpty()) HudText(memoryHud.text, 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) { @@ -832,6 +933,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture) + LaunchedEffect(Unit) { + val root = LinuxRuntime.rootDir(this@SessionActivity) + while (true) { + val ask = if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.session.SteamImportPrompt.read(root) } + } else null + steamImportAsk = ask?.takeIf { it.token != steamImportAnswered } + kotlinx.coroutines.delay(1500) + } + } + steamImportAsk?.takeIf { !loading.visible }?.let { ask -> + androidx.compose.runtime.key(ask.token) { + com.droiddeck.launcher.ui.SteamImportDialog(ask.games) { now -> + val root = LinuxRuntime.rootDir(this@SessionActivity) + Thread({ + if (com.droiddeck.launcher.session.SteamImportPrompt.answer(root, ask.token, now)) runOnUiThread { + steamImportAnswered = ask.token + if (steamImportAsk?.token == ask.token) steamImportAsk = null + } + }, "steam-import-answer").start() + } + } + } if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog( game = gestureGame, onDismiss = { gestureSettingsOpen = false }, @@ -920,8 +1044,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { syncLossless() releasePads() androidx.compose.runtime.withFrameNanos { } - val requested = sessionOverlay.requestFocus() - Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}") + val requested = focusDrawer() + Log.i(TAG, "drawer root focus requested=$requested docked=$drawerDocked") } else { releaseDrawerDirection() } @@ -959,241 +1083,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, onClose = { pcKeyboardOpen = false }, ) - SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions( - steam = SessionState.mode == SessionService.MODE_STEAM, - direct = noSteamSession(), - title = if (SessionState.mode == SessionService.MODE_RUN) - com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null, - isHomeApp = isHomeApp, - androidApps = androidApps, - hudOn = hudOn, - hudFps = hudFps, - hudBattery = hudBattery, - hudGraph = hudGraph, - hudColor = hudColor, - hudOpacity = hudOpacity, - upscaler = upscaler, upscaleSharpness = upscaleSharpness, - effects = effects, - cardboard = cardboard, - cardboardSensor = hasRotationSensor, - onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) }, - onCardboardRecenter = { headTracker?.recenter() }, - glassesPresent = glasses.present, - glassesOutput = glassesOutputActive, - onGlassesRecenter = { - headTracker?.recenter() - com.droiddeck.launcher.glasses.GlassesMenuMirror.recenter() - }, - steamVr = steamVrStatus, - immersiveSteamVr = com.droiddeck.launcher.session.QuestExperimental.isSupported(), - onSteamVr = { action -> steamVrAction(action) }, - textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy, - textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias, - frameGen = frameGen, - lossless = lossless, - oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, - steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode, - micAvailable = SessionState.micAvailable && !SessionState.suspended, - micEnabled = SessionState.micEnabled, - onMicEnabled = { SessionService.setMicEnabled(this@SessionActivity, it) }, - touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct), - fexPreset = fexPreset, - secondScreenMode = secondScreenMode, - 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) - WaylandCompositor.nativeSetLookName(ScreenEffectLooks.match(effects, m)?.name) - }, - onUpscaleSharpness = { pct -> - SessionPrefs.setUpscaleSharpness(this@SessionActivity, pct); upscaleSharpness = pct - WaylandCompositor.nativeSetUpscaleSharpness(pct) - }, - onEffects = { e -> - SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e - e.push(upscaler) - }, - onTextureAnisotropy = { value -> - drawerGameProfileId?.let { appId -> - SessionPrefs.setGameTextureAnisotropy(this@SessionActivity, appId.toString(), value) - drawerTextureAnisotropy = value - } ?: value?.let { - SessionPrefs.setTextureAnisotropy(this@SessionActivity, it) - textureAnisotropy = it - } - }, - onTextureLodBias = { value -> - drawerGameProfileId?.let { appId -> - SessionPrefs.setGameTextureLodBias(this@SessionActivity, appId.toString(), value) - drawerTextureLodBias = value - } ?: value?.let { - SessionPrefs.setTextureLodBias(this@SessionActivity, it) - textureLodBias = it - } - }, - onFrameGenPick = { mode -> - FrameGen.set(this@SessionActivity, mode) - readPrefs() - applyFrameGen() - }, - onImportLossless = { - pickLossless.launch(InAppFilePicker.buildIntent( - this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title))) - }, - onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, - onHardwareKeyboard = ::togglePcKeyboard, - controller = controllerSettings, - onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } }, - onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } }, - onQamButton = { on -> updateControllerPrefs { ControllerPrefs.setQamButton(this@SessionActivity, on) } }, - onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } }, - onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, - onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, - onAchievements = achievementsInFront?.let { { drawerOpen = false; achievementsOpen = true } }, - achievementsProgress = achievementsInFront?.progress, - padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null, - onPadType = { id -> - SessionPrefs.setSteamController(this@SessionActivity, id) - com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id)) - com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id)) - com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id)) - padType = id - }, - backActionsInverted = backActionsInverted, - backDoubleTapMs = backDoubleTapMs, - onBackDoubleTapMs = { ms -> - SessionPrefs.setBackDoubleTapMs(this@SessionActivity, ms) - backDoubleTapMs = ms - }, - onBackActionsInverted = { inverted -> - SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) - backActionsInverted = inverted - }, - lowLatency = lowLatency, - onLowLatency = { on -> - SessionPrefs.setLowLatency(this@SessionActivity, on) - lowLatency = on - applyLowLatency() - }, - onOsc = { v -> - SessionPrefs.setOscMode(this@SessionActivity, v) - readPrefs() - updateOnScreenControls() - if (v == SessionPrefs.OSC_STEAM_TOUCH && com.droiddeck.launcher.input.SteamTouchDevice.current == null) - Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show() - }, - onEditSteamTouch = if (steamTouchAvailable && oscMode == SessionPrefs.OSC_STEAM_TOUCH && onScreenButtonsVisible) ({ - drawerOpen = false - steamTouchControls?.startEditing() - }) else null, - onSuspendPolicy = { policy -> - SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy) - suspendPolicy = policy - SessionService.suspendPolicyChanged(this@SessionActivity) - }, - onSteamDownloadsInBackground = { enabled -> - SessionPrefs.setSteamDownloadsInBackground(this@SessionActivity, enabled) - steamDownloadsInBackground = enabled - SessionService.suspendPolicyChanged(this@SessionActivity) - }, - onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, - onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true }, - onScreenKeys = { drawerOpen = false; screenKeys?.startEditing() }, - screenKeysShown = screenKeysShown, - onScreenKeysShown = { shown -> - screenKeysShown = shown - com.droiddeck.launcher.input.ControllerPrefs.setScreenKeysShown(this@SessionActivity, shown) - screenKeys?.stopEditing() - screenKeys?.visibility = if (shown) View.VISIBLE else View.GONE - }, - onScreenLayouts = { - drawerOpen = false - screenLayoutNames = layoutNames() - screenLayoutsOpen = true - }, - onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, - onSecondScreenMode = ::selectSecondScreenMode, - onSecondScreenDisplay = ::selectSecondScreenDisplay, - onLaunchAndroidApp = { app, displayId -> - drawerOpen = false - try { - HomeApp.launch(this@SessionActivity, app, displayId) - } catch (_: Exception) { - val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label - val message = when { - displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label) - label != null -> getString(R.string.android_app_open_failed_on, app.label, label) - else -> getString(R.string.android_app_open_failed_display, app.label, displayId) - } - Toast.makeText(this@SessionActivity, message, Toast.LENGTH_SHORT).show() - } - }, - pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity), - pipAutoEnter = pipAutoEnter, - onPip = { pip.enter() }, - onPipAutoEnter = { on -> - SessionPrefs.setPipAutoEnter(this@SessionActivity, on) - pipAutoEnter = on - pip.refresh(true) - }, - unfoldedControls = unfoldedControls, - 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() }, - onStopFrom = { onScreen -> - drawerOpen = false - if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) { - if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() } - } else { - val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) } - quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat()) - } - }, - onClose = { drawerOpen = false }, - components = drawerComponents, - onComponentsRefresh = { - refreshDrawerComponents() - drawerComponentMenu.refreshComponentCatalog(force = false) - }, - componentCatalog = drawerComponentMenu.compCatalog, - componentCatalogAt = drawerComponentMenu.compCatalogAt, - componentCatalogChecking = drawerComponentMenu.compChecking, - componentDownloads = drawerComponentMenu.compDownloads, - componentDownloadRevision = drawerComponentMenu.compDownloadRevision, - onComponentCatalogRefresh = { drawerComponentMenu.refreshComponentCatalog() }, - onComponentDownload = drawerComponentMenu::downloadComponent, - gameProfiles = drawerGameProfileRefresh.data?.profiles.orEmpty(), - selectedGameProfileId = drawerGameProfileId, - selectedGameProfile = drawerGameProfileRefresh.data - ?.takeIf { it.selectedAppId == drawerGameProfileId }?.profile, - gameProfilesLoaded = drawerGameProfileRefresh.loaded, - gameProfileRefreshFailed = drawerGameProfileRefresh.failed, - onSelectedGameProfile = ::selectDrawerGameProfile, - onGameProfilesPageOpened = { - drawerGameProfileFollowState.resumeFollowingSteam() - drawerGameInventoryRevision++ - }, - onSelectedGameProfileRefresh = ::refreshDrawerGameProfile, - gameFexPresetSaving = drawerGameFexPresetSaving, - onGameFexPreset = ::setDrawerGameFexPreset, - gameNoSteamSaving = drawerGameNoSteamSaving, - onGameNoSteam = ::setDrawerGameNoSteam, - onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) }, - onGameComponent = ::setDrawerGameComponent, - defaultProtonId = drawerDefaultProton, - onDefaultProton = ::chooseDrawerDefaultProton, - onGameProton = ::setDrawerGameProton, - )) + if (!drawerDocked) SessionMenu(docked = false) LaunchedEffect(achievementsOpen) { if (achievementsOpen) { releasePads() @@ -1207,17 +1097,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { com.droiddeck.launcher.ui.AchievementNotificationOverlay() // Auto and Native resume on their own as the session comes back; only a failed resume needs the button. val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE + LaunchedEffect(SessionState.manualPause, SessionState.suspended) { + if (!SessionState.manualPause && !SessionState.suspended) pauseAnimated = false + } SessionPausedOverlay( - suspended = SessionState.suspended && (!autoResume || SessionState.resumeFailed), + suspended = SessionState.suspended && (!autoResume || SessionState.resumeFailed || SessionState.manualPause), engaged = SessionState.resumeRequested || SessionState.resuming, failures = SessionState.resumeFailures, frame = pausedFrame, + animateIn = pauseAnimated, title = pausedTitle(), onResume = { SessionService.resume(this@SessionActivity) }, onFrameNeeded = ::requestPausedFrame, onHidden = { pausedFrame = null pausedFrameWanted = false + pauseAnimated = false }, ) if (leaving) com.droiddeck.launcher.ui.FloodSpread( @@ -1238,6 +1133,271 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + @androidx.compose.runtime.Composable + private fun SessionMenu(docked: Boolean) { + SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, docked = docked, + backRequest = drawerBackRequests, onLayerChange = { drawerLayerOpen = it }, a = DrawerActions( + steam = SessionState.mode == SessionService.MODE_STEAM, + direct = noSteamSession(), + title = if (SessionState.mode == SessionService.MODE_RUN) + com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null, + isHomeApp = isHomeApp, + androidApps = androidApps, + hudOn = hudOn, + hudConfig = hudConfig, + upscaler = upscaler, upscaleSharpness = upscaleSharpness, + effects = effects, + cardboard = cardboard, + cardboardSensor = hasRotationSensor, + onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) }, + onCardboardRecenter = { headTracker?.recenter() }, + glassesPresent = glasses.present, + glassesOutput = glassesOutputActive, + onGlassesRecenter = { + headTracker?.recenter() + com.droiddeck.launcher.glasses.GlassesMenuMirror.recenter() + }, + steamVr = steamVrStatus, + immersiveSteamVr = com.droiddeck.launcher.session.QuestExperimental.isSupported(), + onSteamVr = { action -> steamVrAction(action) }, + textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy, + textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias, + frameGen = frameGen, + lossless = lossless, + oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, + steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode, + micAvailable = SessionState.micAvailable && !SessionState.suspended, + micEnabled = SessionState.micEnabled, + onMicEnabled = { SessionService.setMicEnabled(this@SessionActivity, it) }, + touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct), + fexPreset = fexPreset, + secondScreenMode = secondScreenMode, + secondScreenDisplays = secondScreenDisplays, + selectedSecondScreenDisplay = selectedSecondScreenDisplay, + onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, + onHudSetting = { key, value -> hudConfig = FusionHudConfig.set(this@SessionActivity, key, value); hud.applyConfig() }, + onHudReset = { hudConfig = FusionHudConfig.reset(this@SessionActivity); hud.applyConfig() }, + onHudExport = { exportHudDiagnostics() }, + onUpscaler = { m -> + SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m + WaylandCompositor.nativeSetUpscaler(m) + WaylandCompositor.nativeSetLookName(ScreenEffectLooks.match(effects, m)?.name) + }, + onUpscaleSharpness = { pct -> + SessionPrefs.setUpscaleSharpness(this@SessionActivity, pct); upscaleSharpness = pct + WaylandCompositor.nativeSetUpscaleSharpness(pct) + }, + onEffects = { e -> + SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e + e.push(upscaler) + }, + onTextureAnisotropy = { value -> + drawerGameProfileId?.let { appId -> + SessionPrefs.setGameTextureAnisotropy(this@SessionActivity, appId.toString(), value) + drawerTextureAnisotropy = value + } ?: value?.let { + SessionPrefs.setTextureAnisotropy(this@SessionActivity, it) + textureAnisotropy = it + } + }, + onTextureLodBias = { value -> + drawerGameProfileId?.let { appId -> + SessionPrefs.setGameTextureLodBias(this@SessionActivity, appId.toString(), value) + drawerTextureLodBias = value + } ?: value?.let { + SessionPrefs.setTextureLodBias(this@SessionActivity, it) + textureLodBias = it + } + }, + fpsLimit = fpsLimit, + onFpsLimit = { fps -> + SessionState.fpsLimit = fps + fpsLimit = fps + applyOutputPacing() + }, + onFrameGenPick = { mode -> + FrameGen.set(this@SessionActivity, mode) + readPrefs() + applyFrameGen() + }, + onImportLossless = { + pickLossless.launch(InAppFilePicker.buildIntent( + this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title))) + }, + onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, + onHardwareKeyboard = ::togglePcKeyboard, + controller = controllerSettings, + onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } }, + onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } }, + onQamButton = { on -> updateControllerPrefs { ControllerPrefs.setQamButton(this@SessionActivity, on) } }, + onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } }, + onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, + onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, + onAchievements = achievementsInFront?.let { { drawerOpen = false; achievementsOpen = true } }, + achievementsProgress = achievementsInFront?.progress, + padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null, + onPadType = { id -> + SessionPrefs.setSteamController(this@SessionActivity, id) + com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id)) + com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id)) + com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id)) + padType = id + }, + backActionsInverted = backActionsInverted, + backDoubleTapMs = backDoubleTapMs, + onBackDoubleTapMs = { ms -> + SessionPrefs.setBackDoubleTapMs(this@SessionActivity, ms) + backDoubleTapMs = ms + }, + onBackActionsInverted = { inverted -> + SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) + backActionsInverted = inverted + }, + lowLatency = lowLatency, + onLowLatency = { on -> + SessionPrefs.setLowLatency(this@SessionActivity, on) + lowLatency = on + applyLowLatency() + }, + evdevMouse = evdevMouseOn, + onEvdevMouse = { on -> + SessionPrefs.setEvdevMouse(this@SessionActivity, on) + evdevMouseOn = on + if (on) { if (pointerLocked) openEvdevMouse() } else releaseEvdevMouse() + }, + directCursor = directCursorOn, + onDirectCursor = { on -> + SessionPrefs.setDirectCursor(this@SessionActivity, on) + directCursorOn = on + if (!on && directTouchCursorHidden()) cursorVisible = false + }, + onOsc = { v -> + SessionPrefs.setOscMode(this@SessionActivity, v) + readPrefs() + updateOnScreenControls() + if (v == SessionPrefs.OSC_STEAM_TOUCH && com.droiddeck.launcher.input.SteamTouchDevice.current == null) + Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show() + }, + onEditSteamTouch = if (steamTouchAvailable && oscMode == SessionPrefs.OSC_STEAM_TOUCH && onScreenButtonsVisible) ({ + drawerOpen = false + steamTouchControls?.startEditing() + }) else null, + onSuspendPolicy = { policy -> + SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy) + suspendPolicy = policy + SessionService.suspendPolicyChanged(this@SessionActivity) + }, + onSteamDownloadsInBackground = { enabled -> + SessionPrefs.setSteamDownloadsInBackground(this@SessionActivity, enabled) + steamDownloadsInBackground = enabled + SessionService.suspendPolicyChanged(this@SessionActivity) + }, + onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, + onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true }, + touchActive = drawerTouchMode(), + directHoldBehavior = directHoldBehavior, + onDirectHoldBehavior = { value -> + val base = com.droiddeck.launcher.input.GesturePrefs.read(this@SessionActivity, gestureGame) + com.droiddeck.launcher.input.GesturePrefs.save(this@SessionActivity, gestureGame, base.copy(directHoldBehavior = value)) + refreshGestureConfig() + }, + onScreenKeys = { drawerOpen = false; screenKeys?.startEditing() }, + screenKeysShown = screenKeysShown, + onScreenKeysShown = { shown -> + screenKeysShown = shown + com.droiddeck.launcher.input.ControllerPrefs.setScreenKeysShown(this@SessionActivity, shown) + screenKeys?.stopEditing() + screenKeys?.visibility = if (shown) View.VISIBLE else View.GONE + }, + onScreenLayouts = { + drawerOpen = false + screenLayoutNames = layoutNames() + screenLayoutsOpen = true + }, + onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, + onSecondScreenMode = { mode -> + if (docked) drawerOpen = false + typingReturnMode = null + if (mode != SecondScreenMode.NONE) SessionPrefs.setSecondScreenPanel(this@SessionActivity, mode.id) + selectSecondScreenMode(mode) + }, + onSecondScreenDisplay = ::selectSecondScreenDisplay, + onLaunchAndroidApp = { app, displayId -> + drawerOpen = false + try { + HomeApp.launch(this@SessionActivity, app, displayId) + } catch (_: Exception) { + val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label + val message = when { + displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label) + label != null -> getString(R.string.android_app_open_failed_on, app.label, label) + else -> getString(R.string.android_app_open_failed_display, app.label, displayId) + } + Toast.makeText(this@SessionActivity, message, Toast.LENGTH_SHORT).show() + } + }, + pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity), + pipAutoEnter = pipAutoEnter, + onPip = { pip.enter() }, + onPause = ::pauseNow, + onPipAutoEnter = { on -> + SessionPrefs.setPipAutoEnter(this@SessionActivity, on) + pipAutoEnter = on + pip.refresh(true) + }, + unfoldedControls = unfoldedControls, + 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() }, + onStopFrom = { onScreen -> + drawerOpen = false + if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) { + if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() } + } else { + val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) } + quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat()) + } + }, + onClose = { drawerOpen = false }, + components = drawerComponents, + onComponentsRefresh = { + refreshDrawerComponents() + drawerComponentMenu.refreshComponentCatalog(force = false) + }, + componentCatalog = drawerComponentMenu.compCatalog, + componentCatalogAt = drawerComponentMenu.compCatalogAt, + componentCatalogChecking = drawerComponentMenu.compChecking, + componentDownloads = drawerComponentMenu.compDownloads, + componentDownloadRevision = drawerComponentMenu.compDownloadRevision, + onComponentCatalogRefresh = { drawerComponentMenu.refreshComponentCatalog() }, + onComponentDownload = drawerComponentMenu::downloadComponent, + gameProfiles = drawerGameProfileRefresh.data?.profiles.orEmpty(), + selectedGameProfileId = drawerGameProfileId, + selectedGameProfile = drawerGameProfileRefresh.data + ?.takeIf { it.selectedAppId == drawerGameProfileId }?.profile, + gameProfilesLoaded = drawerGameProfileRefresh.loaded, + gameProfileRefreshFailed = drawerGameProfileRefresh.failed, + onSelectedGameProfile = ::selectDrawerGameProfile, + onGameProfilesPageOpened = { + drawerGameProfileFollowState.resumeFollowingSteam() + drawerGameInventoryRevision++ + }, + onSelectedGameProfileRefresh = ::refreshDrawerGameProfile, + gameFexPresetSaving = drawerGameFexPresetSaving, + onGameFexPreset = ::setDrawerGameFexPreset, + gameNoSteamSaving = drawerGameNoSteamSaving, + onGameNoSteam = ::setDrawerGameNoSteam, + onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) }, + onGameComponent = ::setDrawerGameComponent, + defaultProtonId = drawerDefaultProton, + onDefaultProton = ::chooseDrawerDefaultProton, + onGameProton = ::setDrawerGameProton, + )) + } + private fun refreshHomeApp() { isHomeApp = HomeApp.isDefault(this) androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList() @@ -1622,6 +1782,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { else getString(R.string.load_starting_steam) } + private fun exportHudDiagnostics() { + glassesScope.launch { + val file = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { hud.exportDiagnostics() } + android.widget.Toast.makeText(this@SessionActivity, + if (file != null) getString(R.string.hud_export_done, file.path) else getString(R.string.hud_export_failed), + android.widget.Toast.LENGTH_LONG).show() + } + } + private fun shareCurrentSessionLogs() { val folder = SessionPaths.current() ?: SessionPaths.lastEnded() if (folder == null || !folder.isDirectory) { @@ -1647,9 +1816,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { unfoldedControlsWithController = SessionPrefs.unfoldedControlsWithController(this) picturePosition = SessionPrefs.picturePosition(this) hudOn = SessionPrefs.hudEnabled(this) - hudFps = SessionPrefs.hudShowFps(this) - hudBattery = SessionPrefs.hudShowBattery(this) - hudGraph = SessionPrefs.hudShowGraph(this) + hudConfig = FusionHudConfig.load(this) hudColor = SessionPrefs.hudColor(this) hudOpacity = SessionPrefs.hudOpacity(this) upscaler = SessionPrefs.upscaler(this) @@ -1686,18 +1853,25 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) oscMode = SessionPrefs.oscMode(this) backActionsInverted = SessionPrefs.backActionsInverted(this) + physicalShortcuts.bindings = PhysicalShortcuts.read(this) backDoubleTapMs = SessionPrefs.backDoubleTapMs(this) lowLatency = SessionPrefs.lowLatency(this) + evdevMouseOn = SessionPrefs.evdevMouse(this) + if (!evdevMouseOn) releaseEvdevMouse() + directCursorOn = SessionPrefs.directCursor(this) padType = SessionPrefs.steamController(this) } - @Volatile private var installingRuntime = false + private var localInstalling = false + private val installingRuntime get() = localInstalling || SessionState.installing != null private var repairOffered by mutableStateOf(false) private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) { if (SessionState.stopRequested) return - installingRuntime = true + val worker = Any() + activeInstallWorker = worker + localInstalling = true SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton" SessionEvents.transition( SessionPhase.INSTALLING_RUNTIME, @@ -1715,30 +1889,39 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime" } if (problem == null && resume && !SessionState.stopRequested) { - SessionState.installing = "package" + if (activeInstallWorker === worker) 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) } + val cancellation = com.droiddeck.launcher.core.Downloader.Cancellation { SessionState.stopRequested } + SessionState.installCancellation = cancellation + problem = try { + com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, cancellation, progressAsIs()) + ?.let { getString(R.string.session_package_resume_failed, it) } + } finally { + if (SessionState.installCancellation === cancellation) SessionState.installCancellation = null + } if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package" } if (problem == null && desktop && !SessionState.stopRequested) { - SessionState.installing = "desktop" + if (activeInstallWorker === worker) SessionState.installing = "desktop" SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop")) problem = installDesktop() if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop" } if (problem == null && proton && !SessionState.stopRequested) { - SessionState.installing = "proton" + if (activeInstallWorker === worker) SessionState.installing = "proton" SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "proton.installing", mapOf("component" to "proton")) val protonProblem = installProton() if (protonProblem == null) SessionEvents.record("proton.ready") else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem)) } uiHandler.post { - installingRuntime = false - SessionState.installing = null + localInstalling = false + if (activeInstallWorker === worker) { + activeInstallWorker = null + SessionState.installing = null + } if (SessionState.stopRequested) { - SessionState.stopRequested = false + if (activeInstallWorker == null) SessionState.stopRequested = false SessionEvents.transition(SessionPhase.IDLE, "session.cancelled") collectStartArtifacts("session start cancelled") finish() @@ -1816,17 +1999,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun installDesktop(): String? { uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) } val entry = com.droiddeck.launcher.runtime.DesktopCatalog.desktopEntry(this) - val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry, - progressFor(DESKTOP_LINES, entry.size / 1_000_000)) - return problem?.let { getString(R.string.session_desktop_install_failed, it) } + val cancellation = com.droiddeck.launcher.core.Downloader.Cancellation { SessionState.stopRequested } + SessionState.installCancellation = cancellation + return try { + val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry, cancellation, + progressFor(DESKTOP_LINES, entry.size / 1_000_000)) + problem?.let { getString(R.string.session_desktop_install_failed, it) } + } finally { + if (SessionState.installCancellation === cancellation) SessionState.installCancellation = null + } } private fun installProton(): String? { uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) } val catalog = com.droiddeck.launcher.runtime.DesktopCatalog - val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID } - ?: return "catalog unreachable" - return catalog.install(this, entry, progressFor(PROTON_LINES, entry.size / 1_000_000)) + val cancellation = com.droiddeck.launcher.core.Downloader.Cancellation { SessionState.stopRequested } + SessionState.installCancellation = cancellation + return try { + val entry = catalog.fetch(catalog.STEAM_SEED_URL, cancellation)?.firstOrNull { it.id == catalog.PROTON_SEED_ID } + ?: return if (cancellation.asBoolean) "cancelled" else "catalog unreachable" + catalog.install(this, entry, cancellation, progressFor(PROTON_LINES, entry.size / 1_000_000)) + } finally { + if (SessionState.installCancellation === cancellation) SessionState.installCancellation = null + } } override fun surfaceCreated(holder: SurfaceHolder) { @@ -2039,10 +2234,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun mirrorRefreshLimit(): Int = GlassesRendering.mirrorRefreshLimit(glassesSettings, glasses, glassesScreen != null) - private fun outputFpsLimit(): Int = SessionPrefs.compositorFpsLimit(mirrorRefreshLimit()) + private fun outputFpsLimit(): Int = outputFpsLimit(pipUi) + + private fun outputFpsLimit(pip: Boolean): Int = + SessionPrefs.compositorFpsLimit(mirrorRefreshLimit(), if (noSteamSession()) SessionState.fpsLimit else 0, pip) private fun applyOutputPacing() { - Log.i(TAG, "perf: " + PerfMode.apply(this, outputFpsLimit(), mirrorRefreshLimit())) + Log.i(TAG, "perf: " + PerfMode.apply(this, outputFpsLimit(false), mirrorRefreshLimit())) if (CompositorHost.isStarted) { val hz = refreshHz() val changed = kotlin.math.abs(SessionState.refreshHz - hz) > 0.1f @@ -2054,8 +2252,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun applyFrameGen() { val hz = refreshHz() + val pip = pipUi && mirrorRefreshLimit() <= 0 + val request = frameGenRequests.incrementAndGet() Thread({ - val problem = FrameGen.apply(this, hz) + val problem = synchronized(frameGenRequests) { + if (request != frameGenRequests.get()) return@Thread + if (pip) FrameGen.apply(this, FrameGen.Mode.OFF, hz) else FrameGen.apply(this, hz) + } val state = Lossless.state(this) runOnUiThread { lossless = state @@ -2225,9 +2428,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (line.contains(FEX_MISSING)) fexMissing = true } val steamSetup = com.droiddeck.launcher.session.SteamClientSetupFailure.find(text.lineSequence()) + val launchFailure = com.droiddeck.launcher.session.SessionLaunchFailure.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 (launchFailure == com.droiddeck.launcher.session.SessionLaunchFailure.STEAM_ILLEGAL_INSTRUCTION) { + val message = getString(R.string.session_end_steam_sigill) + val fallback = if (SessionState.steamClientArch == "arm64") getString(R.string.session_end_steam_try_x64) else null + EndHint(listOfNotNull(message, fallback).joinToString("\n")) + } else if (launchFailure == com.droiddeck.launcher.session.SessionLaunchFailure.VULKAN_DEVICE_MISSING) { + val adreno = com.droiddeck.launcher.gpu.GpuInfo.detect().family != com.droiddeck.launcher.gpu.GpuInfo.Family.NOT_ADRENO + EndHint(getString(if (adreno) R.string.session_end_vulkan_device else R.string.session_end_gpu_unsupported)) } 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) @@ -2268,11 +2479,31 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (pipUi) return true if (screenKeyTextFocus) return super.dispatchKeyEvent(event) + if (physicalShortcuts.onKey( + event, + enabled = SessionState.running && !SessionState.suspended && + !(drawerOpen && event.keyCode == KeyEvent.KEYCODE_BUTTON_A) && + !(drawerOpen && PhysicalShortcuts.isDrawerNavigationKey(event)) && + (event.deviceId to event.keyCode) !in resumeKeysDown && + !(::loading.isInitialized && (loading.visible || loading.ended)) && + !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen, + steam = SessionState.mode == SessionService.MODE_STEAM, + ) { action -> + pendingBackAction?.let(uiHandler::removeCallbacks) + pendingBackAction = null + drawerControllerActive = true + Log.i(TAG, "physical shortcut ${action.name} device=${event.deviceId} key=${event.keyCode} scan=${event.scanCode}") + when (action) { + PhysicalShortcuts.Action.STEAM -> sendSteamGuide() + PhysicalShortcuts.Action.QAM -> triggerSteamQam() + PhysicalShortcuts.Action.DROIDDECK -> drawerOpen = !drawerOpen + } + }) return true val pad = com.droiddeck.launcher.input.JoyCon.normalize(event) if (pad !== event) return dispatchKeyEvent(pad) val fromController = PadBridge.isControllerKeyEvent(event) if (fromController && event.action == KeyEvent.ACTION_DOWN) { - if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus() + if (drawerOpen && !drawerControllerActive) focusDrawer() drawerControllerActive = true } if (fromController && event.keyCode == KeyEvent.KEYCODE_BUTTON_B && ::loading.isInitialized && loading.visible && !loading.ended && !drawerOpen) { @@ -2312,12 +2543,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } return true } - val handled = super.dispatchKeyEvent(event) + val handled = dispatchToDrawer(event) if (event.action == KeyEvent.ACTION_DOWN) { Log.i(TAG, "drawer key=${KeyEvent.keyCodeToString(event.keyCode)} handled=$handled viewFocused=${sessionOverlay.hasFocus()}") } if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B || event.keyCode == KeyEvent.KEYCODE_BACK) { - if (!handled && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) drawerOpen = false + if (!handled && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + if (drawerLayerOpen) drawerBackRequests++ else drawerOpen = false + } releaseDrawerDirection() } return true @@ -2368,8 +2601,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { if (pipUi) return true + notePress(event) if (drawerOpen && PadBridge.isControllerMotionEvent(event)) { - if (!drawerControllerActive) sessionOverlay.requestFocus() + if (!drawerControllerActive) focusDrawer() drawerControllerActive = true if (event.actionMasked == MotionEvent.ACTION_MOVE && event.isFromSource(InputDevice.SOURCE_JOYSTICK) @@ -2390,8 +2624,25 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun dispatchTrackballEvent(event: MotionEvent): Boolean = onCapturedPointer(event) || super.dispatchTrackballEvent(event) + /** Whether one of the on-screen control layers takes a finger going down at this screen point. */ + private fun controlTakesTouchAt(rawX: Float, rawY: Float): Boolean { + val at = IntArray(2) + fun takes(view: View, hit: (Float, Float) -> Boolean): Boolean { + if (!view.isShown) return false + view.getLocationOnScreen(at) + val x = rawX - at[0] + val y = rawY - at[1] + return x >= 0f && y >= 0f && x < view.width && y < view.height && hit(x, y) + } + return onScreenControls?.let { takes(it, it::takesTouchAt) } == true || + steamTouchControls?.let { takes(it, it::takesTouchAt) } == true || + screenKeys?.let { takes(it, it::takesTouchAt) } == true || + unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true + } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (pipUi) return true + notePress(event) if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) { drawerControllerActive = false gestureTouchStreamGate.reset() @@ -2467,7 +2718,51 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (action == KeyEvent.ACTION_DOWN && repeatCount == 0) { Log.i(TAG, "drawer synthetic key=${KeyEvent.keyCodeToString(drawerDirectionKey)} viewFocused=${sessionOverlay.hasFocus()}") } - super.dispatchKeyEvent(event) + dispatchToDrawer(event) + } + + private fun dockedMenu(): SecondScreenPresentation? = secondScreenPresentation?.takeIf { drawerDocked && it.menuShown } + + private fun dispatchToDrawer(event: KeyEvent): Boolean { + val bottom = dockedMenu() + if (bottom != null && event.keyCode != KeyEvent.KEYCODE_BACK) return bottom.dispatchMenuKey(event) + return super.dispatchKeyEvent(event) + } + + private fun focusDrawer(): Boolean = dockedMenu()?.focusMenu() ?: sessionOverlay.requestFocus() + + private fun dockDrawer(): Boolean { + if (pipUi || !started || glassesScreen != null || !::displayManager.isInitialized) return false + if (!SessionPrefs.dualScreenLauncher(this)) return false + val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, shownOnDisplayId) ?: return false + if (secondScreenDisplays.none { it.id == target.displayId }) return false + val shown = secondScreenPresentation?.display?.displayId + return shown == null || shown == target.displayId + } + + private fun openDockedMenu(): Boolean { + val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, shownOnDisplayId) ?: return false + return try { + val presentation = secondScreenPresentation ?: secondScreenPresentation(target).also { + selectedSecondScreenDisplay = target.displayId + secondScreenPresentation = it + } + if (!presentation.isShowing) presentation.show() + presentation.showMenu(true) + updateKeyboardRoute() + presentation.menuShown + } catch (e: Exception) { + Log.w(TAG, "could not open the menu on ${target.name}", e) + if (secondScreenMode == SecondScreenMode.NONE) closeSecondScreen(reset = false) + false + } + } + + private fun closeDockedMenu() { + drawerDocked = false + val presentation = secondScreenPresentation ?: return + presentation.showMenu(false) + if (secondScreenMode == SecondScreenMode.NONE) closeSecondScreen(reset = false) else updateKeyboardRoute() } private fun layoutNames(): List = @@ -2475,6 +2770,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun refreshGestureConfig() { gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame) + directHoldBehavior = gestureConfig.directHoldBehavior gestureTouchStreamGate.reset() if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig) if (::directTouchHold.isInitialized) directTouchHold.configure(gestureConfig) @@ -2496,12 +2792,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { 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) + val scale = sceneScale(rect) + sendSceneDelta(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) + sendSceneButton(code, down) } override fun prepareRelativePan() { if (pointerLocked) return @@ -2515,7 +2811,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { 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 wheel(steps: Int) { sendSceneWheel(steps) } override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } } override fun gesture(gesture: TouchscreenGestures.Gesture) { recognizedGesture = gesture @@ -2530,9 +2826,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { if (!pointerLocked) movePointer(x, y) - WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) + sendSceneButton(button, pressed) + } + override fun onWheel(steps: Int) { sendSceneWheel(steps) } + override fun onScroll(dx: Float, dy: Float) = fingerScroll(dx, dy) + override fun onScrollEnd() = fingerScrollEnd() + override fun onZoom(steps: Int) { + WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 1) + sendSceneWheel(steps) + WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 0) } - override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) } override fun onLongPress() { @Suppress("DEPRECATION") (getSystemService(VIBRATOR_SERVICE) as? android.os.Vibrator)?.vibrate(15) @@ -2600,10 +2903,37 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun scrollTouchMode(): Boolean = (routedTouchMode == SessionPrefs.TOUCH_AUTO || routedTouchMode == SessionPrefs.TOUCH_DIRECT) && padMouseMode() + private fun fingerScroll(dx: Float, dy: Float) { + val rect = drawnRect() ?: return + val out = SessionState.outputSize + scrollRestX += dx * out.first / rect.width() * AXIS_UNITS_PER_PIXEL + scrollRestY += dy * out.second / rect.height() * AXIS_UNITS_PER_PIXEL + val h = scrollRestX.toInt() + val v = scrollRestY.toInt() + if (h == 0 && v == 0) return + scrollRestX -= h + scrollRestY -= v + WaylandCompositor.nativeSendSceneInput(12, v, h) + } + + private fun fingerScrollEnd() { + scrollRestX = 0f + scrollRestY = 0f + WaylandCompositor.nativeSendSceneInput(12, 0, 0) + } + + private fun placeTouchpadAtGuest(rect: android.graphics.RectF) { + if (pointerLocked || !CompositorHost.isStarted || surfaceView.width <= 0 || surfaceView.height <= 0) return + if (!WaylandCompositor.nativeCursorInputPos(cursorPosBuf)) return + val x = cursorPosBuf[0] / InputSpace.WIDTH.toFloat() * surfaceView.width + val y = cursorPosBuf[1] / InputSpace.HEIGHT.toFloat() * surfaceView.height + if (rect.contains(x, y)) touchpad.place(x, y) + } + private fun moveLockedPointer(x: Float, y: Float) { val rect = drawnRect() ?: return - val scale = SessionState.outputSize.first / rect.width() - WaylandCompositor.sendPointerDelta((x - rect.centerX()) * scale, (y - rect.centerY()) * scale) + val scale = sceneScale(rect) + sendSceneDelta((x - rect.centerX()) * scale, (y - rect.centerY()) * scale) touchpad.place(rect.centerX(), rect.centerY()) cursorVisible = false } @@ -2620,14 +2950,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { cursorPos = androidx.compose.ui.geometry.Offset(x, y) syncClientCursor() uiHandler.removeCallbacks(cursorResync) - uiHandler.postDelayed(cursorResync, 60) + cursorResyncIdle = 0 + if (!touchDrivesPointer()) uiHandler.postDelayed(cursorResync, 60) if (clientHidesCursor) { cursorVisible = false; return } + if (directTouchCursorHidden()) { cursorVisible = false; return } if (android.os.SystemClock.uptimeMillis() - lastPadInputMs < CURSOR_PAD_HOLD_MS) return cursorVisible = true uiHandler.removeCallbacks(cursorHide) if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") uiHandler.postDelayed(cursorHide, 2500) } + private fun touchDrivesPointer(): Boolean = + routedTouchMode == SessionPrefs.TOUCH_DIRECT || + (routedTouchMode == SessionPrefs.TOUCH_AUTO && !usingTouchpad()) + + private fun directTouchCursorHidden(): Boolean = !directCursorOn && touchDrivesPointer() + private fun syncClientCursor(): Boolean { if (!CompositorHost.isStarted) return false if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false @@ -2672,11 +3010,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val newIds = candidates.map { it.id }.toSet() if (secondScreenMode != SecondScreenMode.NONE && oldIds.isNotEmpty() && selectedSecondScreenDisplay !in newIds) { closeSecondScreen(reset = true) + SessionState.secondScreenAutoOpened = false return } if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1 - if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true) - else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) + if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) { + closeSecondScreen(reset = true) + SessionState.secondScreenAutoOpened = false + } else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) + else if (started && !ended && ::loading.isInitialized && !loading.visible) openDualScreenControls() + updateKeyboardRoute() + } + + private fun openDualScreenControls() { + if (SessionState.secondScreenAutoOpened || secondScreenMode != SecondScreenMode.NONE || pipUi) return + if (!SessionPrefs.dualScreenSessionControls(this)) return + val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, shownOnDisplayId) ?: return + if (secondScreenDisplays.none { it.id == target.displayId }) return + SessionState.secondScreenAutoOpened = true + selectedSecondScreenDisplay = target.displayId + selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad)) } private fun selectSecondScreenDisplay(displayId: Int) { @@ -2710,19 +3063,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } var presentation = secondScreenPresentation if (presentation?.display?.displayId != target.displayId) { + dropDockedDrawer() presentation?.closeControls() - presentation = SecondScreenPresentation( - this, target, - sendPointer = ::moveSecondScreenPointer, - sendButton = { button, pressed -> - WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) - }, - sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) }, - onSteamMenu = ::tapSteamButton, - onQam = ::tapQam, - onClose = { selectSecondScreenMode(SecondScreenMode.NONE) }, - pad = padBridge, - ) + presentation = secondScreenPresentation(target) secondScreenPresentation = presentation } try { @@ -2731,12 +3074,31 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { secondScreenMode = mode SessionState.secondScreenMode = mode SessionState.secondScreenDisplay = selectedSecondScreenDisplay + updateKeyboardRoute() } catch (e: Exception) { Log.w(TAG, "could not show second-screen controls on ${target.name}", e) closeSecondScreen(reset = true) } } + private fun secondScreenPresentation(target: Display) = SecondScreenPresentation( + this, target, + sendPointer = ::moveSecondScreenPointer, + sendButton = { button, pressed -> + WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) + }, + sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) }, + onSteamMenu = ::tapSteamButton, + onQam = ::tapQam, + onClose = { selectSecondScreenMode(SecondScreenMode.NONE) }, + pad = padBridge, + onKeyboardClosed = ::bottomKeyboardClosed, + onMenu = if (dockDrawer()) ({ drawerOpen = true }) else null, + menu = { + DroidDeckTheme { SessionMenu(docked = true) } + }, + ) + private fun tapSteamButton() { if (SessionState.mode == SessionService.MODE_STEAM) { padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) } @@ -2819,9 +3181,49 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { showCursor(px, py) } + private fun openBottomKeyboard() { + if (!started) return + if (secondScreenPresentation?.showsKeyboard != true && dockDrawer()) { + val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, shownOnDisplayId) ?: return + if (drawerOpen) drawerOpen = false + typingReturnMode = secondScreenMode + selectedSecondScreenDisplay = target.displayId + selectSecondScreenMode(SecondScreenMode.KEYBOARD_TRACKPAD) + } + val presentation = secondScreenPresentation ?: return + if (!presentation.showsKeyboard) return + val display = presentation.display?.displayId ?: return + presentation.requestKeyboard(forField = true) + com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, display) + } + + private fun bottomKeyboardClosed() { + com.droiddeck.launcher.ui.SessionTyping.returnFocus(this, taskId) + val mode = typingReturnMode ?: return + typingReturnMode = null + uiHandler.post { if (secondScreenMode == SecondScreenMode.KEYBOARD_TRACKPAD) selectSecondScreenMode(mode) } + } + + private fun updateKeyboardRoute() { + AgentGuest.routeKeyboard(this, SessionState.mode == SessionService.MODE_STEAM && + (secondScreenPresentation?.showsKeyboard == true || dockDrawer())) + } + + private fun dropDockedDrawer() { + if (!drawerDocked) return + drawerDocked = false + drawerLayerOpen = false + drawerOpenState = false + } + private fun closeSecondScreen(reset: Boolean) { + dropDockedDrawer() + val focused = secondScreenPresentation?.window?.decorView?.hasWindowFocus() == true secondScreenPresentation?.closeControls() secondScreenPresentation = null + if (focused && started) com.droiddeck.launcher.ui.SessionTyping.returnFocus(this, taskId) + else com.droiddeck.launcher.ui.SessionTyping.close() + updateKeyboardRoute() if (reset) { secondScreenMode = SecondScreenMode.NONE SessionState.secondScreenMode = SecondScreenMode.NONE @@ -2852,21 +3254,93 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (::directTouchHold.isInitialized) directTouchHold.cancel() if (::twoFingerGestures.isInitialized) twoFingerGestures.cancel() if (::scrollTouch.isInitialized) scrollTouch.cancel() - if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + if (CompositorHost.isStarted && + (deliversTouches(routedTouchMode) || deliversTouches(next))) + WaylandCompositor.nativeSendTouch(3, -1, 0, 0) if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false gestureTouchStreamGate.reset() routedTouchMode = next + if (directTouchCursorHidden()) cursorVisible = false } + private fun deliversTouches(mode: String) = + mode == SessionPrefs.TOUCH_DIRECT || mode == SessionPrefs.TOUCH_AUTO + private fun usingTouchpad(): Boolean = when (routedTouchMode) { SessionPrefs.TOUCH_PAD -> true SessionPrefs.TOUCH_DIRECT -> false else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop" } + private fun drawerTouchMode(): String = when (routedTouchMode) { + SessionPrefs.TOUCH_GESTURES, SessionPrefs.TOUCH_DIRECT, + SessionPrefs.TOUCH_OFF, SessionPrefs.TOUCH_PAD -> routedTouchMode + else -> if (usingTouchpad()) SessionPrefs.TOUCH_PAD else SessionPrefs.TOUCH_DIRECT + } + private val gamePictureBounds = FloatArray(4) private var topPictureMapped = false + private fun sceneScale(rect: android.graphics.RectF): Float = + SessionState.outputSize.first / rect.width() + + private fun openEvdevMouse() { + if (evdevMouseActive || !evdevMouseOn) return + val writer = mouseWriter ?: com.droiddeck.launcher.input.FakeInputWriter( + File(LinuxRuntime.sessionRoot(this), "dev/input").absolutePath, + com.droiddeck.launcher.input.FakeInputWriter.MOUSE_SLOT, + ).also { mouseWriter = it } + if (!writer.open()) return + evdevMouseActive = true + lastMouseX = Float.NaN + lastMouseY = Float.NaN + Log.i(TAG, "raw mouse channel active (pointer locked)") + } + + private fun closeEvdevMouse() { + if (!evdevMouseActive) return + mouseWriter?.softRelease() + evdevMouseActive = false + Log.i(TAG, "raw mouse channel idle") + } + + private fun releaseEvdevMouse() { + evdevMouseActive = false + mouseWriter?.destroy() + mouseWriter = null + Log.i(TAG, "raw mouse channel removed") + } + + private fun sendSceneDelta(dx: Float, dy: Float) { + val writer = if (evdevMouseActive) mouseWriter else null + if (writer != null) writer.writeMouseDelta(dx, dy) + else WaylandCompositor.sendPointerDelta(dx, dy) + } + + private fun sendSceneButton(code: Int, down: Boolean) { + val writer = if (evdevMouseActive) mouseWriter else null + if (writer != null) writer.writeMouseButton(code.toShort(), down) + else WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0) + } + + private fun sendSceneWheel(steps: Int) { + val writer = if (evdevMouseActive) mouseWriter else null + if (writer != null) writer.writeMouseWheel(-steps, 0, 120) + else WaylandCompositor.nativeSendSceneInput(4, steps, 0) + } + + private fun sendMouseDelta(x: Float, y: Float) { + cursorVisible = false + val previousX = lastMouseX + val previousY = lastMouseY + lastMouseX = x + lastMouseY = y + val rect = drawnRect() ?: return + if (previousX.isNaN() || previousY.isNaN()) return + val scale = sceneScale(rect) + sendSceneDelta((x - previousX) * scale, (y - previousY) * scale) + } + private fun drawnRect(): android.graphics.RectF? { val width = surfaceView.width.takeIf { it > 0 } ?: return null val height = surfaceView.height.takeIf { it > 0 } ?: return null @@ -2883,44 +3357,101 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private var ignoreGameTouch = false + private var dismissTouch = false 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 (drawerOpen && drawerDocked && event.actionMasked == MotionEvent.ACTION_DOWN) { + drawerOpen = false + dismissTouch = true } - if (event.actionMasked == MotionEvent.ACTION_DOWN) { + if (dismissTouch) { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) dismissTouch = false + return true + } + val uiBlocking = drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended + if (event.actionMasked == MotionEvent.ACTION_DOWN && !uiBlocking && routedTouchMode != SessionPrefs.TOUCH_GESTURES) { scrollTouchGesture = scrollTouchMode() scrollRestX = 0f scrollRestY = 0f } - if (scrollTouchGesture) return scrollTouch.onTouch(event) - if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true - if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") { - val wasActive = threeFingerTap.active - if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard() - if (threeFingerTap.active && !wasActive) { - if (usingTouchpad()) touchpad.cancel() else WaylandCompositor.nativeSendTouch(3, -1, 0, 0) - } - if (threeFingerTap.active || wasActive) return true + val snapshot = com.droiddeck.launcher.input.TouchRouter.Snapshot( + uiBlocking = uiBlocking, + mode = routedTouchMode, + scrollLayer = scrollTouchGesture, + steamSession = SessionState.mode == SessionService.MODE_STEAM, + steamDesktopUi = SessionState.steamUi == "desktop", + ) + val route = com.droiddeck.launcher.input.TouchRouter.decide(snapshot) + if (route != touchRoute) { + Log.i(TAG, "touch route $touchRoute -> $route (mode=$routedTouchMode steamUi=${SessionState.steamUi} scrollLayer=$scrollTouchGesture)") + touchRoute = route } - if (usingTouchpad()) { - val rect = drawnRect() ?: return false - if (touchpad.bounds != rect) { - val fresh = touchpad.bounds.width() <= 1f - touchpad.bounds = rect - if (fresh) touchpad.place(rect.centerX(), rect.centerY()) + return when (route) { + com.droiddeck.launcher.input.TouchRouter.Route.BLOCKED, + com.droiddeck.launcher.input.TouchRouter.Route.OFF -> true + com.droiddeck.launcher.input.TouchRouter.Route.GESTURES -> { + if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false + touchscreenGestures.onTouch(event) + } + com.droiddeck.launcher.input.TouchRouter.Route.SCROLL -> scrollTouch.onTouch(event) + else -> { + if (com.droiddeck.launcher.input.TouchRouter.steamKeyboardZone(snapshot)) { + val wasActive = threeFingerTap.active + if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard() + if (threeFingerTap.active && !wasActive) { + if (route == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD) touchpad.cancel() + else { + directTouchHold.cancel() + WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + } + } + if (threeFingerTap.active || wasActive) return true + } + if (route == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD) { + val rect = drawnRect() ?: return false + if (touchpad.bounds != rect) { + val fresh = touchpad.bounds.width() <= 1f + touchpad.bounds = rect + if (fresh) touchpad.place(rect.centerX(), rect.centerY()) + } + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + touchpad.desktop = SessionState.mode == SessionService.MODE_DESKTOP + placeTouchpadAtGuest(rect) + } + touchpad.onTouch(event) + } else { + dispatchDirectTouch(event) + } } - return touchpad.onTouch(event) } + } + + private val directTouchSink = object : DirectTouchHold.Touch { + override fun down(pointerId: Int, x: Float, y: Float) = sendDirectTouch(0, pointerId, x, y) + override fun move(pointerId: Int, x: Float, y: Float) = sendDirectTouch(1, pointerId, x, y) + override fun up(pointerId: Int, x: Float, y: Float) = sendDirectTouch(2, pointerId, x, y) + override fun cancel() = WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + } + + private fun sendDirectTouch(action: Int, pointerId: Int, x: Float, y: Float) { + val rect = drawnRect() ?: return + val cx = x.coerceIn(rect.left, rect.right) + val cy = y.coerceIn(rect.top, rect.bottom) + WaylandCompositor.nativeSendTouch(action, pointerId, + InputSpace.x(cx, surfaceView.width), InputSpace.y(cy, surfaceView.height)) + showCursor(cx, cy) + } + + private fun dispatchDirectTouch(event: MotionEvent): Boolean { val rect = drawnRect() ?: return false if (event.actionMasked == MotionEvent.ACTION_DOWN) ignoreGameTouch = topPictureMapped && !rect.contains(event.x, event.y) if (ignoreGameTouch) { - if (event.actionMasked == MotionEvent.ACTION_DOWN) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + directTouchHold.cancel() WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + } if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false return true } @@ -2929,46 +3460,34 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun forwardTouch(event: MotionEvent, rect: android.graphics.RectF): Boolean { - 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) - WaylandCompositor.nativeSendTouch(action, event.getPointerId(index), - InputSpace.x(x, surfaceView.width), InputSpace.y(y, surfaceView.height)) - } - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + showCursor(event.x.coerceIn(rect.left, rect.right), event.y.coerceIn(rect.top, rect.bottom)) + if (WaylandCompositor.nativeFocusTakesTouch()) { listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach { WaylandCompositor.nativeSendSceneInput(3, it, 0) } - sendTouch(0, event.actionIndex) } - MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex) - MotionEvent.ACTION_MOVE -> for (index in 0 until event.pointerCount) sendTouch(1, index) - MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> sendTouch(2, event.actionIndex) - MotionEvent.ACTION_CANCEL -> WaylandCompositor.nativeSendTouch(3, -1, 0, 0) - else -> return false } + directTouchHold.onTouch(event) return true } private fun onMouse(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_HOVER_MOVE, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_HOVER_ENTER -> - movePointer(event.x, event.y) + if (evdevMouseActive) sendMouseDelta(event.x, event.y) else movePointer(event.x, event.y) MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> { - movePointer(event.x, event.y) + if (evdevMouseActive) sendMouseDelta(event.x, event.y) else movePointer(event.x, event.y) val button = when (event.actionButton) { MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE else -> PointerGestures.BTN_LEFT } - WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0) + sendSceneButton(button, event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) } MotionEvent.ACTION_SCROLL -> { val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL) - if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0) + if (v != 0f) sendSceneWheel(-Math.round(v)) } MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} else -> return false @@ -2991,6 +3510,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun onCapturedPointer(event: MotionEvent): Boolean { if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false + notePress(event) when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { var dx = event.x @@ -2999,7 +3519,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { dx += event.getHistoricalX(i) dy += event.getHistoricalY(i) } - WaylandCompositor.sendPointerDelta(dx, dy) + sendSceneDelta(dx, dy) } MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> { val button = when (event.actionButton) { @@ -3007,11 +3527,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE else -> PointerGestures.BTN_LEFT } - WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0) + sendSceneButton(button, event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) } MotionEvent.ACTION_SCROLL -> { val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL) - if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0) + if (v != 0f) sendSceneWheel(-Math.round(v)) } MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} else -> return false @@ -3135,6 +3655,60 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }) } + private fun desktopTextInput() = + SessionState.mode == SessionService.MODE_DESKTOP && SessionState.running && !pipUi + + private fun notePress(event: MotionEvent) { + val touch = event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_BUTTON_PRESS -> { + pressDownX = event.x + pressDownY = event.y + pressTravel = 0f + pressMoved = false + if (!touch) lastUserPressMs = android.os.SystemClock.uptimeMillis() + } + MotionEvent.ACTION_POINTER_DOWN -> pressMoved = true + MotionEvent.ACTION_MOVE -> if (touch || event.buttonState != 0) { + val moved = if (event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) { + pressTravel += kotlin.math.hypot(event.x, event.y) + pressTravel + } else { + kotlin.math.hypot(event.x - pressDownX, event.y - pressDownY) + } + if (moved >= android.view.ViewConfiguration.get(this).scaledTouchSlop * 2f) { + pressMoved = true + if (!touch) lastUserPressMs = 0L + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_BUTTON_RELEASE -> { + val quick = !touch || event.eventTime - event.downTime < android.view.ViewConfiguration.getLongPressTimeout() + if (!pressMoved && quick) lastUserPressMs = android.os.SystemClock.uptimeMillis() + } + } + } + + private fun onDesktopTextField(focused: Boolean) { + val host = keyboard + if (host != null && desktopTextInput()) { + val tapped = android.os.SystemClock.uptimeMillis() - lastUserPressMs < TEXT_FIELD_TAP_MS + if (focused && tapped && resumed && !pcKeyboardOpen && + resources.configuration.hardKeyboardHidden != android.content.res.Configuration.HARDKEYBOARDHIDDEN_NO) host.show() + else if (!focused && host.shown) host.hide() + } + updateDesktopKeyboardButton() + } + + private fun updateDesktopKeyboardButton() { + val button = desktopKeyboardButton ?: return + val show = desktopTextInput() && AgentGuest.desktopTextField && !pcKeyboardOpen && + !(keyboard?.let { it.shown && (Build.VERSION.SDK_INT < 30 || it.imeShowing) } ?: false) + val visibility = if (show) View.VISIBLE else View.GONE + if (button.visibility == visibility) return + if (show) button.applyColors() + button.visibility = visibility + } + private fun keyboardCoversPad() = SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard @@ -3145,10 +3719,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } // The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame. - private fun capturePausedFrame() { + private fun capturePausedFrame(manual: Boolean = false) { if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return if (SessionState.suspended && pausedFrame != null) return - if (suspendPolicy == SessionPrefs.SUSPEND_NEVER || !::surfaceView.isInitialized) return + if ((suspendPolicy == SessionPrefs.SUSPEND_NEVER && !manual && !SessionState.manualPause) || !::surfaceView.isInitialized) return if (surfaceView.holder.surface?.isValid != true) return val width = surfaceView.width val height = surfaceView.height @@ -3180,6 +3754,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + private fun pauseNow() { + if (!SessionState.running || SessionState.suspended) return + pauseAnimated = true + drawerOpen = false + pausedFrame = null + capturePausedFrame(manual = true) + SessionService.pause(this) + } + // Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the // compositor shows the frozen frame again once its output is back, so copy it from there. private fun requestPausedFrame() { @@ -3265,6 +3848,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.onStart() if (runtimeRemovalBlocked) return started = true + if (::hud.isInitialized) hud.setBackgrounded(false) refreshActivityDisplayId() displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) for (name in arrayOf("peak_refresh_rate", "min_refresh_rate")) { @@ -3272,6 +3856,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } refreshSecondScreenDisplays() if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) + else if (::loading.isInitialized && !loading.visible) openDualScreenControls() startedScreens++ SessionService.setActivityVisible(this, true, sessionActivityDisplayId) Glasses.rescan() @@ -3282,6 +3867,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onStop() { if (runtimeRemovalBlocked) { super.onStop(); return } started = false + if (::hud.isInitialized) hud.setBackgrounded(true) updateHandCamera() displayManager.unregisterDisplayListener(displayListener) contentResolver.unregisterContentObserver(refreshSettingObserver) @@ -3335,11 +3921,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } (getSystemService(INPUT_SERVICE) as? InputManager) ?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper())) + refreshSecondScreenDisplays() if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers() joyConVr?.refresh() onScreenControls?.reload() controllerSettings = ControllerPrefs.read(this) resumed = true + updateDesktopKeyboardButton() com.droiddeck.launcher.input.HomeButtonShortcuts.attach(this, enabled = { resumed && window.decorView.hasWindowFocus() && !ended && !drawerOpen && !pcKeyboardOpen && @@ -3352,6 +3940,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { updatePointerCapture() if (!pipUi) sessionClipboard.start() updatePadMotion() + ControllerPrefs.prefs(this).registerOnSharedPreferenceChangeListener(shortcutPrefListener) readPrefs() refreshControls() if (::pip.isInitialized) pip.resumed() @@ -3362,6 +3951,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onPause() { com.droiddeck.launcher.input.HomeButtonShortcuts.detach(this) if (runtimeRemovalBlocked) { super.onPause(); return } + physicalShortcuts.clear() backQamInput.cancel() capturePausedFrame() sessionClipboard.stop() @@ -3370,6 +3960,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (::directTouchHold.isInitialized) directTouchHold.cancel() if (::twoFingerGestures.isInitialized) twoFingerGestures.cancel() uiHandler.removeCallbacks(clearRecognizedGesture) + uiHandler.removeCallbacks(cursorResync) gestureSettingsOpen = false drawerOpen = false pcKeyboardOpen = false @@ -3384,6 +3975,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { updatePadMotion() keyboard?.takeIf { it.shown }?.hide() SessionPrefs.sessionPrefs(this).unregisterOnSharedPreferenceChangeListener(cardboardPrefListener) + ControllerPrefs.prefs(this).unregisterOnSharedPreferenceChangeListener(shortcutPrefListener) updateHeadTracker() super.onPause() } @@ -3792,6 +4384,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { loading.visible = false hud.start() memoryHud.start() + if (started) openDualScreenControls() } } @@ -3800,6 +4393,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { backQamInput.cancel() steamVrRecovery.close() uiHandler.removeCallbacks(steamTouchRetry) + uiHandler.removeCallbacks(cursorResync) + releaseEvdevMouse() if (runtimeRemovalBlocked) { super.onDestroy(); return } watching = false releaseDrawerDirection() @@ -3889,17 +4484,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { const val EXTRA_STEAMVR = "steamVr" const val EXTRA_REPAIR_RUNTIME = "repairRuntime" private const val LEAVE_TIMEOUT_MS = 1_500L + private const val TEXT_FIELD_TAP_MS = 1_000L private const val PAUSED_FRAME_MAX_EDGE = 1920f private const val PAUSED_FRAME_REDRAW_MS = 150L private const val TAG = "SessionActivity" private var startedScreens = 0 + @Volatile private var activeInstallWorker: Any? = null private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need" private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION private const val CURSOR_PAD_HOLD_MS = 1200L + private const val CURSOR_RESYNC_IDLE_TICKS = 33 private const val PAD_MOUSE_CHECK_MS = 500L private const val PAD_SCROLL_POINTER_X = 0.985f private const val PAD_SCROLL_HEIGHTS_PER_S = 3f private const val AXIS_UNITS_PER_PIXEL = 3f + private const val KEY_LEFTCTRL = 29 private const val DRAWER_HAT_THRESHOLD = 0.5f private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f private const val DRAWER_STICK_EXIT_THRESHOLD = 0.35f 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 82572ab19..a89c14c66 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt @@ -22,6 +22,14 @@ object AgentGuest { private set var steamKeyboard by mutableStateOf(false) private set + var desktopTextField by mutableStateOf(false) + private set + var keyboardRequests by mutableIntStateOf(0) + private set + var keyboardDismissals by mutableIntStateOf(0) + private set + @Volatile private var keyboardRoute = false + @Volatile private var routeDir: File? = null private const val TAG = "AgentGuest" private const val POLL_MS = 50L private val lock = Any() @@ -35,10 +43,12 @@ object AgentGuest { observer?.stopWatching() observer = null lastFocusKey = null - main.post { steamKeyboard = false } + main.post { steamKeyboard = false; desktopTextField = false } val dir = dir(context) dir.deleteRecursively() dir.mkdirs() + routeDir = dir + writeKeyboardRoute(dir, keyboardRoute) @Suppress("DEPRECATION") observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) { override fun onEvent(event: Int, path: String?) { @@ -48,6 +58,9 @@ object AgentGuest { } "focus.json" -> readJson(File(dir, path))?.let(::recordFocus) "keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) } + "ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) } + "keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) } + "keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) } } } }.also { it.startWatching() } @@ -58,10 +71,46 @@ object AgentGuest { synchronized(lock) { observer?.stopWatching() observer = null - main.post { steamKeyboard = false } + main.post { steamKeyboard = false; desktopTextField = false } } } + fun routeKeyboard(context: Context, bottom: Boolean) { + synchronized(lock) { + if (keyboardRoute == bottom) return + keyboardRoute = bottom + writeKeyboardRoute(routeDir ?: dir(context), bottom) + } + } + + private fun writeKeyboardRoute(dir: File, bottom: Boolean) { + val file = File(dir, "keyboard-route") + runCatching { + if (!bottom) file.delete() + else if (dir.isDirectory) { + val temp = File(dir, "keyboard-route.tmp") + temp.writeText("bottom") + if (!temp.renameTo(file)) temp.delete() + } + }.onFailure { Log.w(TAG, "keyboard route not written", it) } + } + + private fun recordKeyboardRequest(source: FileObserver) { + synchronized(lock) { + if (source !== observer) return + main.post { keyboardRequests++ } + } + SessionEvents.record("keyboard.requested", emptyMap()) + } + + private fun recordKeyboardDismiss(source: FileObserver) { + synchronized(lock) { + if (source !== observer) return + main.post { keyboardDismissals++ } + } + SessionEvents.record("keyboard.dismissed", emptyMap()) + } + private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) { val showing = snapshot.optBoolean("steamKeyboard") synchronized(lock) { @@ -71,6 +120,15 @@ object AgentGuest { SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq"))) } + private fun recordTextField(source: FileObserver, snapshot: JSONObject) { + val focused = snapshot.optBoolean("textField") + synchronized(lock) { + if (source !== observer) return + main.post { desktopTextField = focused } + } + SessionEvents.record("ime.changed", mapOf("textField" to focused, "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/agent/AgentStartActivity.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentStartActivity.kt index f052df93c..ce8cca924 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentStartActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentStartActivity.kt @@ -23,6 +23,9 @@ class AgentStartActivity : Activity() { Thread({ try { AgentBridgeProvider.awaitRecovery() + val until = android.os.SystemClock.elapsedRealtime() + RUNTIME_WAIT_MS + while (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isBusy() && !isFinishing && + android.os.SystemClock.elapsedRealtime() < until) Thread.sleep(RUNTIME_POLL_MS) runOnUiThread { if (!isFinishing) startSession(request) } @@ -51,6 +54,11 @@ class AgentStartActivity : Activity() { } private fun startSession(request: JSONObject) { + if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isBusy()) { + Log.w(TAG, "the Linux runtime is being installed or changed") + finish() + return + } try { AgentAccess.requireStart(this, request) } catch (e: AgentException) { @@ -107,5 +115,7 @@ class AgentStartActivity : Activity() { companion object { const val EXTRA_REQUEST = "request" private const val TAG = "AgentStartActivity" + private const val RUNTIME_WAIT_MS = 15 * 60_000L + private const val RUNTIME_POLL_MS = 500L } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt index d54a5fff5..8f2da0d68 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt @@ -146,5 +146,7 @@ internal class AddonPack( listOf("version", "xodus-cli", "xodus-service")) val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE, listOf("version", "comet")) + val BUTLER = AddonPack("butler", BuildConfig.PACK_BUTLER_SHA256, BuildConfig.PACK_BUTLER_SIZE, + listOf("version", "butler", "7z.so", "libc7zip.so")) } } 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 d587ecb7c..4163eb4a3 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -29,6 +29,11 @@ import java.util.Locale object DeviceReport { private const val TAG = "DeviceReport" + internal fun cpuFeaturesSummary(cpuInfo: String): String = cpuInfo.lineSequence() + .filter { it.substringBefore(':').trim() == "Features" } + .map { it.substringAfter(':').trim().split(Regex("\\s+")).filter(String::isNotEmpty).joinToString(" ") } + .filter(String::isNotEmpty).distinct().joinToString(" | ").ifEmpty { "not reported" } + fun write(context: Context, target: File, mode: String) { try { target.writeText(build(context, mode)) @@ -141,6 +146,7 @@ object DeviceReport { h("CPU and memory") k("Cores", CpuCores.all.size) + k("CPU features", cpuFeaturesSummary(FileUtils.readString(File("/proc/cpuinfo")).orEmpty())) b.append("Core ceilings ") b.append(CpuCores.all.joinToString(", ") { c -> "cpu$c " + (CpuCores.maxGhz(c)?.let { String.format(Locale.US, "%.2f GHz", it) } ?: "?") 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 98e41ab6f..31e8f45f5 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -16,20 +16,83 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; public final class Downloader { private static final String TAG = "Downloader"; private static final int CONNECT_TIMEOUT_MS = 10_000; private static final int STALL_TIMEOUT_MS = 15_000; private static final int HTTP_RANGE_NOT_SATISFIABLE = 416; + private static final java.util.concurrent.ExecutorService CANCEL_EXECUTOR = + java.util.concurrent.Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r, "download-cancel"); + thread.setDaemon(true); + return thread; + }); private Downloader() {} + public static final class Cancellation implements java.util.function.BooleanSupplier { + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final AtomicReference connection = new AtomicReference<>(); + private final AtomicReference input = new AtomicReference<>(); + private final java.util.function.BooleanSupplier external; + + public Cancellation() { this(null); } + public Cancellation(java.util.function.BooleanSupplier external) { this.external = external; } + + public void cancel() { + cancelled.set(true); + HttpURLConnection active = connection.get(); + if (active != null) disconnectAsync(active); + InputStream activeInput = input.get(); + if (activeInput != null) closeAsync(activeInput); + } + + private static void disconnectAsync(HttpURLConnection active) { + CANCEL_EXECUTOR.execute(() -> { + try { active.disconnect(); } catch (RuntimeException ignored) { } + }); + } + + private static void closeAsync(InputStream active) { + CANCEL_EXECUTOR.execute(() -> { + try { active.close(); } catch (IOException ignored) { } + }); + } + + @Override public boolean getAsBoolean() { + return cancelled.get() || (external != null && external.getAsBoolean()); + } + + private void attach(HttpURLConnection active) { + connection.set(active); + if (getAsBoolean()) disconnectAsync(active); + } + + private void detach(HttpURLConnection active) { connection.compareAndSet(active, null); } + + private void attach(InputStream active) { + input.set(active); + if (getAsBoolean()) closeAsync(active); + } + + private void detach(InputStream active) { input.compareAndSet(active, null); } + } + public static String downloadString(String url) { + return downloadString(url, null); + } + + public static String downloadString(String url, java.util.function.BooleanSupplier cancelled) { List sources = DownloadSource.INSTANCE.candidates(url); for (int i = 0; i < sources.size(); i++) { + if (isCancelled(cancelled)) return null; if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i)); - Fetch fetch = fetch(sources.get(i)); + Fetch fetch = fetch(sources.get(i), cancelled); + // A cancelled fetch says nothing about the source, so it must not count as a failure. + if (isCancelled(cancelled)) return null; if (fetch.body != null) { DownloadSource.INSTANCE.succeeded(sources.get(i)); return fetch.body; @@ -53,30 +116,52 @@ public final class Downloader { /** Verify each candidate before accepting it, so a bad mirror can fall back. */ public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256, java.util.function.Consumer progress) { + return downloadFile(url, destination, resume, expectedSha256, null, progress); + } + + /** {@code cancelled} is polled while bytes arrive; a cancel stops at once and leaves the partial file for the caller. */ + public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256, + java.util.function.BooleanSupplier cancelled, + 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, sources(url, expectedSha256), destination, resume, expectedSha256, progress); + return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, cancelled, 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. static boolean downloadCandidates(String original, List sources, File destination, boolean resume, String expectedSha256, java.util.function.Consumer progress) { + return downloadCandidates(original, sources, destination, resume, expectedSha256, null, progress); + } + + static boolean downloadCandidates(String original, List sources, File destination, boolean resume, + String expectedSha256, java.util.function.BooleanSupplier cancelled, + java.util.function.Consumer progress) { Identity identity = loadIdentity(destination, resume, expectedSha256); for (int i = 0; i < sources.size(); i++) { String source = sources.get(i); + if (isCancelled(cancelled)) return false; if (i > 0) Log.i(TAG, "retrying " + original + " via " + source); - Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, progress); + Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress); + // A cancel is not a source failure: no other mirror, and no DownloadSource.failed(). + if (outcome == Outcome.CANCELLED) return false; if (outcome == Outcome.DONE) { - if (expectedSha256 != null && !expectedSha256.equalsIgnoreCase(Hashes.sha256(destination))) { - Log.w(TAG, "checksum mismatch from " + source + "; trying another source"); - if (!discardPartial(destination, identity)) return false; - continue; + if (expectedSha256 != null) { + String actualSha256 = Hashes.sha256(destination); + if (isCancelled(cancelled)) return false; + if (!expectedSha256.equalsIgnoreCase(actualSha256)) { + Log.w(TAG, "checksum mismatch from " + source + "; trying another source"); + if (!discardPartial(destination, identity)) return false; + continue; + } } + if (isCancelled(cancelled)) return false; resumeFile(destination).delete(); DownloadSource.INSTANCE.succeeded(source); if (progress != null) progress.accept(1f); @@ -88,7 +173,11 @@ public final class Downloader { return false; } - private enum Outcome { DONE, RETRY, FATAL } + private enum Outcome { DONE, RETRY, FATAL, CANCELLED } + + private static boolean isCancelled(java.util.function.BooleanSupplier cancelled) { + return cancelled != null && cancelled.getAsBoolean(); + } private static final class Identity { private String source; @@ -156,7 +245,9 @@ public final class Downloader { } private static Outcome attempt(String url, File destination, boolean resume, Identity identity, - String expectedSha256, java.util.function.Consumer progress) { + String expectedSha256, java.util.function.BooleanSupplier cancelled, + java.util.function.Consumer progress) { + if (isCancelled(cancelled)) return Outcome.CANCELLED; HttpURLConnection connection = null; long have = resume && destination.isFile() ? destination.length() : 0; try { @@ -166,6 +257,7 @@ public final class Downloader { have = 0; } connection = open(url, STALL_TIMEOUT_MS); + if (cancelled instanceof Cancellation) ((Cancellation) cancelled).attach(connection); if (have > 0) { connection.setRequestProperty("Range", "bytes=" + have + "-"); connection.setRequestProperty("If-Range", identity.etag); @@ -174,7 +266,7 @@ public final class Downloader { if (have > 0 && code == HTTP_RANGE_NOT_SATISFIABLE) { connection.disconnect(); if (!discardPartial(destination, identity)) return Outcome.RETRY; - return attempt(url, destination, false, identity, expectedSha256, progress); + return attempt(url, destination, false, identity, expectedSha256, cancelled, progress); } if (code / 100 != 2) { Log.w(TAG, url + " -> HTTP " + code); @@ -185,13 +277,13 @@ public final class Downloader { if (appending && !rangeStartsAt(connection.getHeaderField("Content-Range"), have)) { connection.disconnect(); if (!discardPartial(destination, identity)) return Outcome.RETRY; - return attempt(url, destination, false, identity, expectedSha256, progress); + return attempt(url, destination, false, identity, expectedSha256, cancelled, progress); } String etag = connection.getHeaderField("ETag"); if (have > 0 && appending && (!strongETag(etag) || !etag.equals(identity.etag))) { connection.disconnect(); if (!discardPartial(destination, identity)) return Outcome.RETRY; - return attempt(url, destination, false, identity, expectedSha256, progress); + return attempt(url, destination, false, identity, expectedSha256, cancelled, progress); } if (!appending && have > 0) { // If-Range may legitimately return full 200 when representation changed. @@ -208,26 +300,36 @@ public final class Downloader { identity.etag = strongETag(etag) ? etag : null; saveIdentity(destination, identity, expectedSha256); long written = have; - try (InputStream in = new BufferedInputStream(connection.getInputStream(), 1 << 16); + InputStream input = connection.getInputStream(); + if (cancelled instanceof Cancellation) ((Cancellation) cancelled).attach(input); + try (InputStream in = new BufferedInputStream(input, 1 << 16); OutputStream out = new FileOutputStream(destination, appending)) { byte[] buffer = new byte[1 << 16]; long lastReport = 0; for (int read = in.read(buffer); read > 0; read = in.read(buffer)) { out.write(buffer, 0, read); written += read; + if (isCancelled(cancelled)) return Outcome.CANCELLED; if (progress != null && written - lastReport > (1 << 20)) { lastReport = written; progress.accept(total > 0 ? written / (float) total : -1f); } } + } finally { + if (cancelled instanceof Cancellation) ((Cancellation) cancelled).detach(input); } + if (isCancelled(cancelled)) return Outcome.CANCELLED; if (total > 0 && written < total) return Outcome.RETRY; return Outcome.DONE; } catch (Exception e) { + if (isCancelled(cancelled)) return Outcome.CANCELLED; Log.w(TAG, "download " + url, e); return Outcome.RETRY; } finally { - if (connection != null) connection.disconnect(); + if (connection != null) { + if (cancelled instanceof Cancellation) ((Cancellation) cancelled).detach(connection); + connection.disconnect(); + } } } @@ -256,9 +358,15 @@ public final class Downloader { } private static Fetch fetch(String url) { + return fetch(url, null); + } + + private static Fetch fetch(String url, java.util.function.BooleanSupplier cancelled) { HttpURLConnection connection = null; + Cancellation token = cancelled instanceof Cancellation ? (Cancellation) cancelled : null; try { connection = open(url, STALL_TIMEOUT_MS); + if (token != null) token.attach(connection); int code = connection.getResponseCode(); if (code / 100 != 2) { Log.w(TAG, url + " -> HTTP " + code); @@ -270,10 +378,14 @@ public final class Downloader { return new Fetch(new String(out.toByteArray(), StandardCharsets.UTF_8), false); } } catch (Exception e) { + if (isCancelled(cancelled)) return new Fetch(null, false); Log.w(TAG, "GET " + url, e); return new Fetch(null, false); } finally { - if (connection != null) connection.disconnect(); + if (connection != null) { + if (token != null) token.detach(connection); + connection.disconnect(); + } } } 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 20073c4ca..f579adff0 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -1,21 +1,59 @@ 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("") + internal const val MAX_LINE_LENGTH = 64 * 1024 + internal const val OVERSIZED_LINE = "" + + private data class MatcherKey(val pattern: String, val options: Set) + + private class MatcherCache : LinkedHashMap(32, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > 32 } - 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 + // Android matchers own native state, so reuse them instead of creating one for every log line. + private val matchers = object : ThreadLocal() { + override fun initialValue(): MatcherCache = MatcherCache() + } + + private fun withMatcher( + regex: Regex, + input: CharSequence, + action: (java.util.regex.Matcher) -> T, + ): T { + val key = MatcherKey(regex.pattern, regex.options) + val matcher = matchers.get()!!.getOrPut(key) { regex.toPattern().matcher("") } + return try { + action(matcher.reset(input)) + } finally { + matcher.reset("") + } + } + + private fun replaceMatches(input: String, regex: Regex, replacement: String): String = + replaceMatches(input, regex) { replacement } + + private fun replaceMatches( + input: String, + regex: Regex, + replacement: (java.util.regex.Matcher) -> String, + ): String = withMatcher(regex, input) { matcher -> + if (!matcher.find()) return@withMatcher input + val out = StringBuffer(input.length) + do { + matcher.appendReplacement(out, java.util.regex.Matcher.quoteReplacement(replacement(matcher))) + } while (matcher.find()) + matcher.appendTail(out) + out.toString() + } + + private fun matchesEntire(input: String, regex: Regex): Boolean = + withMatcher(regex, input) { it.matches() } + + private fun isHidlLoaderDiagnostic(input: String, match: java.util.regex.Matcher): Boolean { + if (!matchesEntire(match.group(), HIDL_SHARED_LIBRARY)) return false + val start = match.start() + val end = match.end() val prefix = "library \"" val suffix = "\" not found" val earliestQuote = (start - 4096).coerceAtLeast(0) @@ -39,6 +77,12 @@ object LogRedactor { (suffixEnd == input.length || !(input[suffixEnd].isLetterOrDigit() || input[suffixEnd] == '_')) } + 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 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") @@ -111,6 +155,7 @@ object LogRedactor { java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"), java.io.File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf"), ) + matchers.remove() } @Volatile @@ -178,8 +223,8 @@ object LogRedactor { } fun describeAddresses(text: String): String { - var out = IPV6.replace(text) { m -> kind(m.value)?.let { "<$it>" } ?: m.value } - out = IPV4.replace(out) { m -> kind(m.value)?.let { "<$it>" } ?: m.value } + var out = replaceMatches(text, IPV6) { m -> kind(m.group())?.let { "<$it>" } ?: m.group() } + out = replaceMatches(out, IPV4) { m -> kind(m.group())?.let { "<$it>" } ?: m.group() } return out } @@ -204,23 +249,28 @@ object LogRedactor { "(? out.write(redact(line)); out.write("\n") } - } + fun scrubTo( + src: java.io.File, + out: java.io.Writer, + shouldStop: () -> Boolean = { false }, + ): Boolean = scrubLines(src, out, ::redact, shouldStop) fun redactForShare(line: String): String { if (line.isEmpty()) return line + if (line.length > MAX_LINE_LENGTH) return OVERSIZED_LINE return try { val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "") - LAN_IPV4.replace(out, "") + replaceMatches(out, LAN_IPV4, "") } catch (t: Throwable) { "" } } - private const val SHARE_LINE_MAX = 64 * 1024 - - fun scrubForShare(src: java.io.File, out: java.io.Writer) { + fun scrubForShare( + src: java.io.File, + out: java.io.Writer, + shouldStop: () -> Boolean = { false }, + ): Boolean { src.inputStream().buffered().use { input -> input.mark(2) val first = input.read() @@ -228,27 +278,30 @@ object LogRedactor { input.reset() if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) { out.write("\n") - return + return true } 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 + val line = StringBuilder(4096) + var withheld: String? = null var afterCr = false fun emit() { - out.write(if (withheld) "" else redactForShare(line.toString())) + out.write(withheld ?: redactForShare(line.toString())) out.write("\n") line.setLength(0) - withheld = false + withheld = null } try { while (true) { + if (shouldStop()) return false val size = reader.read(buffer) if (size < 0) break + if (shouldStop()) return false for (i in 0 until size) { + if (shouldStop()) return false val ch = buffer[i] if (afterCr) { afterCr = false @@ -257,23 +310,79 @@ object LogRedactor { 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 + } else if (withheld == null) { + if (ch == '\u0000' || line.length == MAX_LINE_LENGTH) { + withheld = if (ch == '\u0000') "" else OVERSIZED_LINE line.setLength(0) - } else { - line.append(ch) - } + } else line.append(ch) } } } - if (withheld || line.isNotEmpty()) emit() + if (withheld != null || line.isNotEmpty()) emit() } catch (_: java.nio.charset.CharacterCodingException) { out.write("\n") } } } + return true + } + + + private fun scrubLines( + src: java.io.File, + out: java.io.Writer, + scrub: (String) -> String, + shouldStop: () -> Boolean, + ): Boolean { + src.bufferedReader().use { input -> + val chunk = CharArray(8192) + val line = StringBuilder(4096) + var oversized = false + var pendingContent = false + var pendingCarriageReturn = false + + fun emit() { + out.write(if (oversized) OVERSIZED_LINE else scrub(line.toString())) + out.write("\n") + line.setLength(0) + oversized = false + pendingContent = false + } + + while (true) { + if (shouldStop()) return false + val count = input.read(chunk) + if (count < 0) break + if (shouldStop()) return false + for (i in 0 until count) { + val char = chunk[i] + if (pendingCarriageReturn) { + emit() + pendingCarriageReturn = false + if (char == '\n') continue + } + when (char) { + '\r' -> pendingCarriageReturn = true + '\n' -> emit() + else -> { + pendingContent = true + if (!oversized) { + if (line.length < MAX_LINE_LENGTH) line.append(char) + else { + line.setLength(0) + oversized = true + } + } + } + } + } + } + if (pendingCarriageReturn || pendingContent) { + if (shouldStop()) return false + emit() + } + } + return true } private fun quoted(value: String): String { @@ -315,45 +424,51 @@ object LogRedactor { fun redact(line: String): String { if (line.isEmpty()) return line + if (line.length > MAX_LINE_LENGTH) return OVERSIZED_LINE return try { val out = Line(line) - if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}" }) - if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "")) - if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) { - val value = it.groupValues[2] + if (out.has("mac")) out.set(replaceMatches(out.text, MAC_FIELD) { "${it.group(1)}" }) + if (out.holds(':', 5) || out.holds('-', 5)) out.set(replaceMatches(out.text, MAC, "")) + if (out.has("serial", "android")) out.set(replaceMatches(out.text, DEVICE_IDENTIFIER) { + val value = it.group(2) val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty() - "${it.groupValues[1]}${quote}${quote}" + "${it.group(1)}${quote}${quote}" }) - if (out.holds('-', 4)) out.set(GUID.replace(out.text, "")) - 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.holds('-', 4)) out.set(replaceMatches(out.text, GUID, "")) + if (out.has("jwt")) out.set(replaceMatches(out.text, JWT_LABELLED) { "${it.group(1)}" }) + if ("ey" in out.text) out.set(replaceMatches(out.text, JWT_BASE64, "")) 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]}${it.groupValues[2]}" }) + out.set(replaceMatches(out.text, SECRET_KV) { + val quote = it.group(2).orEmpty() + "${it.group(1)}${quote}${quote}" + }) + } - 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, "")) - if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" }) - if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m -> - val id = m.groupValues[1] + if (out.has("guard", "factor", "2fa")) out.set(replaceMatches(out.text, GUARD_CODE) { "${it.group(1)}" }) + if (out.hasRun(32, ::isHex)) out.set(replaceMatches(out.text, WEBAPI_KEY, "")) + if ("76561" in out.text) out.set(replaceMatches(out.text, STEAMID64) { "${it.group(1)}********${it.group(3)}" }) + if ("[U:1:" in out.text) out.set(replaceMatches(out.text, STEAMID3) { m -> + val id = m.group(1) "[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]" }) - if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}" }) - for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "")) - if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}" }) - if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}" }) + if (out.has("external")) out.set(replaceMatches(out.text, EXTERNAL_ADDR) { "${it.group(1)}" }) + for (r in own) if (r.needs == null || out.has(r.needs)) out.set(replaceMatches(out.text, r.regex, "")) + if (out.has("onloginstatechange")) out.set(replaceMatches(out.text, LOGIN_STATE) { "${it.group(1)}" }) + if ("OnLoginUsersChanged " in out.text) out.set(replaceMatches(out.text, LOGIN_USERS) { "${it.group(1)}" }) if (out.has("account", "user")) { - out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }) + out.set(replaceMatches(out.text, ACCOUNT_KV) { "${it.group(1)}${it.group(2)}${quoted(it.group(3))}" }) } - 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) { m -> - m.value.takeIf { isHidlLoaderDiagnostic(out.text, m) } ?: "" + if (out.has("login")) out.set(replaceMatches(out.text, LOGIN_KV) { "${it.group(1)}${it.group(2)}${quoted(it.group(3))}" }) + for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(replaceMatches(out.text, r.regex, "")) + if ('@' in out.text) out.set(replaceMatches(out.text, EMAIL) { m -> + m.group().takeIf { isHidlLoaderDiagnostic(out.text, m) } ?: "" + }) if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) { - out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=" }) + out.set(replaceMatches(out.text, RESIDUAL) { "${it.group(1)}=" }) } if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) { - out.set(LONG_TOKEN.replace(out.text, "")) + out.set(replaceMatches(out.text, LONG_TOKEN, "")) } out.text } catch (t: Throwable) { 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 aa1c6eef4..94b5bba6e 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -161,6 +161,25 @@ object AddedGames { "appxmanifest.xml" to Regex("]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE), ) + private val GAME_PARTS = setOf( + "bin", "bin32", "bin64", "binaries", "x64", "x86", "win64", "win32", "windows", + "launcher", "game", "engine", "system", "data", "app", + ) + + internal fun groupGames(folder: File): List? { + val files = folder.listFiles() ?: return null + if (files.any { it.isFile && it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) }) return null + if (declaredExes(folder).isNotEmpty()) return null + val dirs = files.filter { it.isDirectory && !it.name.startsWith(".") }.sortedBy { it.name.lowercase() } + if (dirs.size < 2 || dirs.any { it.name.lowercase() in GAME_PARTS }) return null + return dirs.takeIf { all -> all.all { rank(it).exes.isNotEmpty() } } + } + + private fun expand(folder: File, entries: Set): List = + if (folder.path in entries) listOf(folder) else groupGames(folder) ?: listOf(folder) + + private fun entryFolders(context: Context): Set = AddedExes.list(context).map { it.folder }.toSet() + /** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */ internal fun isSteamLibraryFolder(name: String): Boolean = name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR @@ -173,6 +192,7 @@ object AddedGames { GameStorage.effective(context)?.let { File(it.path, "steamapps/common") }, ) val removed = SessionPrefs.removedAddedGames(context).toSet() + val entries = entryFolders(context) for (dir in folders.distinctBy { it.absolutePath }) { if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue } val steamInstalls = steamInstallDirs(dir) @@ -180,7 +200,7 @@ object AddedGames { if (folder.name.lowercase() in steamInstalls || isSteamLibraryFolder(folder.name)) continue if (folder.path in removed) continue if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue - scanGame(context, folder, out) + for (game in expand(folder, entries)) if (game.path !in removed) scanGame(context, game, out) } } for (entry in AddedExes.list(context)) { @@ -232,8 +252,9 @@ object AddedGames { 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 } + SessionPrefs.removedAddedGames(context).map { File(it) }.filter { owns(it) && groupGames(it) == null }.maxByOrNull { canonical(it).length }?.let { return it } StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it } + val removed = SessionPrefs.removedAddedGames(context) val dirs = listOfNotNull( GameStorage.effective(context)?.let { File(it.path, "steamapps/common") }, GameStorage.effective(context)?.let { File(it.path) }, @@ -242,7 +263,10 @@ object AddedGames { val base = canonical(dir) if (!inside(path, base)) continue val first = path.removePrefix("$base/").substringBefore('/') - File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) }?.let { return it } + val top = File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) && it.path !in removed } ?: continue + val group = groupGames(top) ?: return top + if (top.path in entries.map { it.folder }) return null + return group.firstOrNull(::holds) } return null } @@ -295,10 +319,11 @@ object AddedGames { if (guestPath(app, dir) == null) bindFolder(app, dir) val removed = SessionPrefs.removedAddedGames(app).toSet() val listed = listedFolders(app, removed) + val entries = entryFolders(app) var already = 0 val fresh = ArrayList() val added = ArrayList() - for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty()) { + for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty().flatMap { expand(it, entries) }) { val path = canonical(sub) if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue } onChecking(sub.name) @@ -332,6 +357,7 @@ object AddedGames { private fun listedFolders(context: Context, removed: Set): List { val out = ArrayList() + val entries = entryFolders(context) GamesFolderMigration.run(context) val dirs = listOfNotNull( GameStorage.effective(context)?.let { File(it.path) }, @@ -341,7 +367,7 @@ object AddedGames { for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) { if (folder.path in removed || isSteamLibraryFolder(folder.name)) continue if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue - out.add(canonical(folder)) + expand(folder, entries).filter { it.path !in removed }.forEach { out.add(canonical(it)) } } } AddedExes.list(context).filter { it.folder !in removed }.forEach { out.add(canonical(File(it.folder))) } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt index 650c717d8..69ccb08c9 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt @@ -89,7 +89,13 @@ object AutoComponents { private fun enqueue(context: Context, job: Job) { if (!queuedKeys.add(job.key)) { - if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job) + synchronized(jobs) { + val old = jobs.firstOrNull { it.key == job.key } + if (old != null && ((job.asked && !old.asked) || (job.inSession && !old.inSession)) && jobs.remove(old)) { + jobs.add(Job(old.game ?: job.game, old.component, old.asked || job.asked, + if (job.asked && !old.asked) job.seq else old.seq, old.pics ?: job.pics, old.inSession || job.inSession)) + } + } if (runNow && !draining) drain(context) return } @@ -185,18 +191,19 @@ object AutoComponents { val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return val wanted = autoPicks(findings, catalog) val user = WinComponents.selection(context, key).user + val before = WinComponents.selection(context, key).auto val installed = installedOf(context) val kept = LinkedHashMap() val missing = ArrayList() for ((id, reason) in wanted) { if (user[id] == false) { kept[id] = reason; continue } if (id in installed) { kept[id] = reason; continue } + if (id in before) kept[id] = reason synchronized(waiting) { waiting.getOrPut(id) { ArrayList() }.let { if (it.none { g -> appKey(g) == key }) it.add(game) } } queueDownload(context, id, asked, inSession) missing.add(id) } - if (inSession) Pending.set(context, key, missing) - val before = WinComponents.selection(context, key).auto + if (inSession) Pending.set(context, key, (Pending.ids(context, key).filter { it !in installed && it !in wanted } + missing).distinct()) if (before.keys != kept.keys) { WinComponents.setAuto(context, key, kept) Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}") @@ -235,6 +242,16 @@ object AutoComponents { write(context, games) } + @Synchronized fun ids(context: Context, key: String): List = + read(context).optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() + + @Synchronized fun add(context: Context, key: String, ids: List) { + val games = read(context) + val have = games.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() + games.put(key, JSONArray((have + ids).distinct())) + write(context, games) + } + @Synchronized fun drop(context: Context, id: String) { val games = read(context) for (k in games.keys().asSequence().toList()) { @@ -317,6 +334,32 @@ object AutoComponents { fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false) + fun restoreMissing(context: Context): List { + val selections = WinComponents.selections(context) + if (selections.isEmpty()) return emptyList() + val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return emptyList() + val installed = installedOf(context) + val lostFor = selections.mapValues { (_, selection) -> + withParts(selection.effective.filter { WinComponents.validId(it) }, catalog).filter { it !in installed && it in catalog } + }.filterValues { it.isNotEmpty() } + if (lostFor.isEmpty()) return emptyList() + val lost = lostFor.values.flatten().distinct() + Log.i(TAG, "wincomp: reinstalling ${lost.joinToString()}, picked for games but missing from the runtime") + lostFor.forEach { (key, ids) -> Pending.add(context, key, ids) } + lost.forEach { queueDownload(context, it, asked = true, inSession = true) } + return lost + } + + private fun withParts(ids: List, catalog: Map): List { + val out = LinkedHashSet() + fun visit(id: String, depth: Int) { + if (depth > 8 || !out.add(id)) return + catalog[id]?.dependencies?.forEach { visit(it, depth + 1) } + } + ids.forEach { visit(it, 0) } + return out.toList() + } + fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true) fun sweep(context: Context, games: List) { 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 9fb776447..958e1c5ae 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -274,7 +274,7 @@ object Library { Spec("duckstation", "DuckStation", "PS1", "/opt/appimages/duckstation.AppImage", listOf("ps1", "psx"), setOf("cue", "chd", "pbp", "iso", "bin", "img", "ecm", "m3u")), Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi"), atPanel = true), Spec("cemu", "Cemu", "Wii U", "/opt/appimages/cemu.AppImage", listOf("wiiu", "wii u"), setOf("wua", "wud", "wux", "rpx")), - Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")), + Spec("ppsspp", "PPSSPP", "PSP", "/usr/local/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")), Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()), ) private fun displayTitle(name: String): String = 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 652d33fbe..c137e666f 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt @@ -16,6 +16,7 @@ data class GpuInfo( val modelSource: String = "", val kgslChipId: String = "", val platformGpuModel: Int = 0, + val verifiedDevice: Boolean = false, ) { enum class Family(val label: String) { A8XX("Adreno 8xx"), @@ -36,6 +37,7 @@ data class GpuInfo( get() = when { family == Family.NOT_ADRENO -> Support.UNSUPPORTED isA850 -> Support.UNTESTED + verifiedDevice -> Support.TESTED family == Family.A6XX && model == 650 -> Support.TESTED family == Family.A8XX -> Support.TESTED family == Family.A7XX && model >= 725 -> Support.TESTED @@ -85,9 +87,13 @@ data class GpuInfo( oneUi8Gen2 = samsung && model == 740, kgslName = raw.orEmpty(), modelSource = source, kgslChipId = chipId, platformGpuModel = platformGpuModel, + verifiedDevice = adreno && verifiedOn(model, Build.MODEL), ) } + internal fun verifiedOn(model: Int, device: String): Boolean = + model == 722 && device.equals("Retroid Pocket Duo", ignoreCase = true) + 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( 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 9978409c5..9bae92e42 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -47,10 +48,16 @@ public final class TurnipDriver { private final Context context; private final File contentDir; + private final Supplier gpuDetector; public TurnipDriver(Context context) { + this(context, GpuInfo.Companion::detect); + } + + TurnipDriver(Context context, Supplier gpuDetector) { this.context = context; this.contentDir = new File(context.getFilesDir(), "graphics_driver"); + this.gpuDetector = gpuDetector; } public String driverPath(String driverId) { @@ -284,7 +291,12 @@ public final class TurnipDriver { } String quest = QuestExperimental.INSTANCE.defaultAndroidDriver(); if (quest != null) return quest; - int model = GpuInfo.Companion.detect().getModel(); + GpuInfo gpu = gpuDetector.get(); + if (gpu.getFamily() == GpuInfo.Family.NOT_ADRENO) { + Log.i(TAG, "not an Adreno GPU; leaving the display driver to the system"); + return null; + } + int model = gpu.getModel(); Log.i(TAG, "gpu model: " + (model == 0 ? "unknown" : model)); return chooseByModel(model, android.os.Build.VERSION.SDK_INT); } diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt new file mode 100644 index 000000000..dd21683f0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.hud + +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path + +/** artwork/droiddeck-mark.svg: the split D with its circular cut-out and the blue orb. */ +internal object DroidDeckMark { + private const val VIEW_X = -55.98f + const val VIEW_W = 239.92f + const val VIEW_H = 223.72f + + private val body: Path by lazy { + val d = Path().apply { + moveTo(-31.98f, 0f) + lineTo(63.97f, 0f) + cubicTo(125.81f, 0f, 175.93f, 50.08f, 175.93f, 111.86f) + cubicTo(175.93f, 173.64f, 125.81f, 223.72f, 63.97f, 223.72f) + lineTo(-31.98f, 223.72f) + close() + } + val cut = Path().apply { + addCircle(63.98f, 111.86f, 71.81f, Path.Direction.CW) + addRect(56.055f, 0f, 71.885f, 45f, Path.Direction.CW) + addRect(56.055f, 178.72f, 71.885f, 223.72f, Path.Direction.CW) + } + d.op(cut, Path.Op.DIFFERENCE) + d.offset(-VIEW_X, 0f) + d + } + + private val scaled = Path() + private val matrix = Matrix() + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + + fun widthFor(height: Float): Float = height * VIEW_W / VIEW_H + + /** Where the drawn mark starts and ends inside [widthFor]: the artwork has empty space on both sides. */ + fun inkLeftFor(height: Float): Float = (-31.98f - VIEW_X) * height / VIEW_H + fun inkRightFor(height: Float): Float = (175.93f - VIEW_X) * height / VIEW_H + + fun draw(canvas: Canvas, left: Float, top: Float, height: Float, bodyColor: Int, orbColor: Int) { + val s = height / VIEW_H + matrix.setScale(s, s) + matrix.postTranslate(left, top) + body.transform(matrix, scaled) + paint.color = bodyColor + canvas.drawPath(scaled, paint) + paint.color = orbColor + canvas.drawCircle(left + (63.98f - VIEW_X) * s, top + 111.86f * s, 55.98f * s, paint) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/FusionHudConfig.kt b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudConfig.kt new file mode 100644 index 000000000..ca95538a6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudConfig.kt @@ -0,0 +1,56 @@ +package com.droiddeck.launcher.hud + +import android.content.Context +import com.droiddeck.launcher.session.SessionPrefs + +/** FusionHUD's settings as one "key=value,…" string (FusionHudView.applyConfig), stored in SessionPrefs. */ +internal object FusionHudConfig { + /** + * Bannerlator's Fusion defaults (DeckOverlaySettings), with the FPS graph from the old DroidDeck HUD. The HUD starts + * locked, so like the old HUD it lets touches through until it is unlocked to move it. + */ + val DEFAULTS: Map = linkedMapOf( + "hudSize" to "minimal", "hudLocked" to "1", "hudScale" to "100", "hudOpacity" to "80", "hudColor" to "mid", + "hudOutline" to "40", "hudOutlineAccent" to "1", + "showFPS" to "1", "showFPSGraph" to "1", "showCPUUsage" to "1", "showCPULoad" to "1", "showGPULoad" to "1", + "showVram" to "1", "showRAM" to "1", "showPower" to "1", "showTemp" to "1", "showBatteryTemp" to "1", + "showGpuTemp" to "0", "showBattery" to "0", "showLow001" to "1", "fpsDecimal" to "1", "showPerCore" to "1", + "showSwap" to "1", "showNet" to "1", "showResolution" to "1", "showProton" to "1", "showSession" to "1", + "showClock" to "1", "showEngine" to "1", "showRenderer" to "1", "showGpuModel" to "1", + "tempUnit" to "c", "tempBands" to "1", "tempAuto" to "1", "tempRedCpu" to "90", "tempRedGpu" to "90", "tempRedBat" to "48", + ) + + /** Switches Bannerlator sets together (DeckOverlaySettings twins). */ + private val TWINS = mapOf("showCPUUsage" to "showCPULoad", "showTemp" to "showBatteryTemp", "showEngine" to "showRenderer") + + fun load(context: Context): MutableMap { + val values = LinkedHashMap(DEFAULTS) + val stored = SessionPrefs.fusionHudConfig(context) + if (stored == null) { + values["showFPS"] = flag(SessionPrefs.hudShowFps(context)) + values["showFPSGraph"] = flag(SessionPrefs.hudShowGraph(context)) + values["hudOpacity"] = SessionPrefs.hudOpacity(context).toString() + } else { + for (entry in KeyValueSet(stored)) if (entry.size == 2 && entry[0] in DEFAULTS) values[entry[0]] = entry[1] + } + return values + } + + fun encode(values: Map): String = values.entries.joinToString(",") { "${it.key}=${it.value}" } + + fun set(context: Context, key: String, value: String): Map { + val values = load(context) + if (key !in DEFAULTS) return values + values[key] = value + TWINS[key]?.let { values[it] = value } + SessionPrefs.setFusionHudConfig(context, encode(values)) + return values + } + + fun reset(context: Context): Map { + SessionPrefs.setFusionHudConfig(context, encode(DEFAULTS)) + return DEFAULTS + } + + private fun flag(on: Boolean) = if (on) "1" else "0" +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/FusionHudHost.kt b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudHost.kt new file mode 100644 index 000000000..3e3b5db63 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudHost.kt @@ -0,0 +1,236 @@ +package com.droiddeck.launcher.hud + +import android.content.Context +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.view.Choreographer +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.GpuInfo +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.io.File +import kotlin.math.roundToInt + +/** + * FusionHUD on the session screen, in place of the old text HUD: the view, the frame feed from gamescope + * (MangoHud's frame times) and MangoHud's readings, started with the session's first frame. + */ +class FusionHudHost(context: Context) { + private val context = context.applicationContext + private val handler = Handler(Looper.getMainLooper()) + private val feed = GameFrameFeed(File(LinuxRuntime.rootDir(this.context), HudFrames.PATH)) + private val readings = MangoReadings(File(this.context.filesDir, "session"), LinuxRuntime.rootDir(this.context)) + private var view: FusionHudView? = null + private var parent: ViewGroup? = null + private var running = false + private var hidden = false + private var backgrounded = false + private var polling = false + private var wantX = Float.NaN + private var wantY = Float.NaN + private var engine = "" + private var gpuName = "" + private var protonPid = 0 + private var proton = "" + @Volatile private var lastWindow: String? = null + + var onPresentingWindowChanged: (() -> Unit)? = null + + /** The HUD itself changed a setting: a tap cycles the size, a long press locks it. */ + var onConfigChanged: (() -> Unit)? = null + + /** Screen point → whether an on-screen control takes a touch there; a locked HUD lets those through. */ + var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null + + private val listener = object : WaylandCompositor.GameListener { + override fun onGameSurface(window: String?, gpuName: String?) { + if (!gpuName.isNullOrBlank()) handler.post { this@FusionHudHost.gpuName = cleanGpuName(gpuName) } + if (window != null && window != lastWindow) { + lastWindow = window + handler.post { onPresentingWindowChanged?.invoke() } + } + } + + override fun onGameFrame() { + feed.onCompositorFrame(System.nanoTime()) + } + } + + private val frameCallback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!running || !polling) return + if (feed.poll(System.nanoTime())) view?.onFrames() + Choreographer.getInstance().postFrameCallback(this) + } + } + + private val enabled: Boolean + get() = SessionPrefs.hudEnabled(context) && + !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists() + + /** Adds the HUD to [root] at [index] (above the game surface, under the session overlay). */ + fun attach(root: FrameLayout, index: Int) { + if (view != null) return + val hud = FusionHudView(root.context) + hud.setFrameStats(feed.stats) + hud.setMangoReadings(readings) + hud.setGpuModel(cleanGpuName(GpuInfo.detect().displayName(context))) + hud.onRefresh = Runnable { refreshLabels(hud) } + hud.setOnMovedListener { x, y -> + wantX = x; wantY = y + keepOnScreen(hud) + wantX = hud.x; wantY = hud.y + SessionPrefs.setFusionHudPosition(context, hud.x, hud.y) + } + hud.setOnSizeCycledListener { size -> FusionHudConfig.set(context, "hudSize", size); onConfigChanged?.invoke() } + hud.setOnLockChangedListener { locked -> + FusionHudConfig.set(context, "hudLocked", if (locked) "1" else "0") + onConfigChanged?.invoke() + } + hud.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> if (!hud.isDragging) keepOnScreen(hud) } + hud.passesTouchAt = { x, y -> passesTouchAt?.invoke(x, y) == true } + hud.visibility = View.GONE + val margin = (16 * root.resources.displayMetrics.density).roundToInt() + root.addView(hud, index, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + leftMargin = margin + topMargin = margin + }) + SessionPrefs.fusionHudPosition(context)?.let { (x, y) -> wantX = x; wantY = y; hud.post { keepOnScreen(hud) } } + view = hud + parent = root + applyConfig() + } + + fun applyConfig() { + view?.applyConfig(FusionHudConfig.encode(FusionHudConfig.load(context))) + } + + fun refresh() { + applyConfig() + if (!enabled) stop() else if (!running) start() + } + + fun start() { + if (running || !enabled || view == null) return + running = true + WaylandCompositor.setGameListener(listener) + applyShown() + } + + fun stop() { + running = false + showing = false + polling = false + Choreographer.getInstance().removeFrameCallback(frameCallback) + WaylandCompositor.clearGameListener(listener) + feed.close() + view?.visibility = View.GONE + } + + /** Picture-in-picture and similar: keep running, draw nothing. */ + fun setHidden(hide: Boolean) { + hidden = hide + applyShown() + } + + /** The session screen is stopped (another app in front): draw nothing and stop reading sensors. */ + fun setBackgrounded(stopped: Boolean) { + backgrounded = stopped + applyShown() + } + + private fun applyShown() { + if (!running) return + val show = !hidden && !backgrounded + view?.visibility = if (show) View.VISIBLE else View.GONE + showing = show + // Frames are read once per display frame only while the HUD is drawn. + if (show != polling) { + polling = show + if (show) Choreographer.getInstance().postFrameCallback(frameCallback) + else Choreographer.getInstance().removeFrameCallback(frameCallback) + } + } + + /** A sensor report (FusionHUD's diagnostics) in Downloads/DroidDeck; null when it can't be written. */ + fun exportDiagnostics(): File? = runCatching { + val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck") + dir.mkdirs() + val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US).format(java.util.Date()) + val file = File(dir, "HUD-diagnostics-$stamp.txt") + // Its own reader: the HUD's CPU load is the change since its previous sample, which an export must + // not move. Two samples half a second apart, MangoHud's sampling period. + val metrics = HudMetrics(context) + metrics.setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) + metrics.snapshot() + Thread.sleep(500) + val mango = metrics.snapshot() + file.writeText(metrics.also { it.setMangoReadings(null) }.buildDiagnosticsReport(context) + "\n== As the HUD shows them (MangoHud's sources) ==\n" + + "CPU ${mango.cpuPercent}% ${mango.cpuTempC}°C, GPU ${mango.gpuPercent}% ${mango.gpuClockMhz}MHz ${mango.gpuTempC}°C, " + + "RAM ${mango.ramUsedText()}/${mango.ramTotalText()}, VRAM ${mango.vramText()}, battery ${mango.battery.percent}% " + + "${mango.battery.watts}W charging=${mango.battery.charging}\n" + + "CPU temperature zones: ${LinuxRuntime.cpuTempZones().joinToString { it.name }}\n" + + "Frames: ${if (feed.engine.isNotEmpty() || feed.pid > 0) "gamescope (pid ${feed.pid}, ${feed.engine})" else "compositor"}, " + + "fps ${feed.stats.fps}\n") + file + }.getOrNull() + + private fun refreshLabels(hud: FusionHudView) { + if (feed.engine.isNotBlank() && !feed.steamFocused) engine = feed.engine + hud.setEngineLabel(engine) + if (gpuName.isNotBlank()) hud.setGpuModel(gpuName) + hud.setResolution(if (feed.outputWidth > 0 && feed.outputHeight > 0) buildString { + append(feed.outputWidth).append('x').append(feed.outputHeight) + if (feed.refreshMilliHz > 0) append(" @ ").append((feed.refreshMilliHz + 499) / 1000).append("Hz") + } else "") + val pid = feed.pid + if (pid > 0 && pid != protonPid) { + protonPid = pid + proton = protonName(pid) ?: proton + } + hud.setWineVersion(proton) + hud.setPresentedFps(presentedFps()) + } + + private fun presentedFps(): Float { + if (FrameGen.mode(context).engine == FrameGen.ENGINE_OFF) return 0f + val stats = runCatching { WaylandCompositor.nativeFrameGenStats() }.getOrNull() ?: return 0f + return if (stats.size > 3 && stats[3] > 1f) stats[3] else 0f + } + + // The HUD is clamped from where the user left it (wantX/wantY, set on a drop or restore), not from where + // it is drawn: a window that shrinks and grows back (picture-in-picture, a fold) must not leave it in the + // corner the small window pushed it to. + private fun keepOnScreen(v: View) { + val p = v.parent as? View ?: return + if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return + if (wantX.isNaN()) { wantX = v.x; wantY = v.y } + val maxX = (p.width - v.width).toFloat().coerceAtLeast(0f) + val maxY = (p.height - v.height).toFloat().coerceAtLeast(0f) + val x = wantX.coerceIn(0f, maxX) + val y = wantY.coerceIn(0f, maxY) + if (v.x != x) v.x = x + if (v.y != y) v.y = y + } + + companion object { + /** True while the HUD is on screen; DroidDeck keeps sampling VRAM for it (GpuMemComponent). */ + @Volatile @JvmStatic var showing = false + private set + + /** "Adreno (TM) 750" as the driver names it, "Adreno 750" as DroidDeck does elsewhere. */ + internal fun cleanGpuName(name: String): String = name.replace("(TM)", "").replace(Regex("\\s+"), " ").trim() + + /** The Proton build of a game process: the first STEAM_COMPAT_TOOL_PATHS entry's folder. */ + internal fun protonName(pid: Int): String? = runCatching { + val environ = File("/proc/$pid/environ").readBytes().toString(Charsets.UTF_8).split('\u0000') + val paths = environ.firstOrNull { it.startsWith("STEAM_COMPAT_TOOL_PATHS=") }?.substringAfter('=') ?: return null + paths.split(':').firstOrNull { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/') + }.getOrNull()?.takeIf { it.isNotBlank() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/FusionHudModels.kt b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudModels.kt new file mode 100644 index 000000000..ea4c336af --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudModels.kt @@ -0,0 +1,86 @@ +/* + * FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays. + * Copyright (C) The412Banner + * + * Licensed under the GNU General Public License v3.0 (see LICENSE), with an + * ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md): + * any use, fork, or distribution must preserve credit to "The412Banner" and a + * link to https://github.com/The412Banner/FusionHUD in the project documentation + * AND in the in-app credits/about screen. + * + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + */ + +package com.droiddeck.launcher.hud + +/** + * Size modes for the Fusion HUD (the 4th selectable in-game overlay style). One shared color-coded + * visual language, four amounts of detail — see the approved mockup. The config value is the lower-case + * token stored under the `hudSize` key; the label is the user-facing string. + */ +enum class FusionSize(val token: String, val label: String) { + FULL("full", "Full"), + TILES("tiles", "Tiles"), + PILL("pill", "Pill"), + MINIMAL("minimal", "Minimal"), + MEGA("mega", "Mega"); + + /** Next size in the tap-cycle: Full → Tiles → Pill → Minimal → Mega → Full. */ + fun next(): FusionSize { + val all = values() + return all[(ordinal + 1) % all.size] + } + + /** + * The metric toggle chips this size actually renders — the SINGLE source of truth queried by BOTH + * config surfaces ({@code FpsCounterConfigDialog} + in-game {@code XServerDrawer} HudContent) so the + * chip list can't drift from what the view draws. Chip ids are the chip LABELS used in the UI. + * (Hiding a chip is purely UI; buildConfig() still emits every key — strip-invariant.) + */ + fun supportedChips(): Set = when (this) { + MINIMAL -> MINIMAL_CHIPS + PILL -> PILL_CHIPS + FULL, TILES -> STANDARD_CHIPS + MEGA -> MEGA_CHIPS + } + + companion object { + fun from(token: String?): FusionSize = + values().firstOrNull { it.token.equals(token, ignoreCase = true) } ?: FULL + + // Chip-label ids (must match the labels the config UIs build). "Clock" is a small subtle + // corner readout available in EVERY size, so it's in all sets. + // The API/engine label ("Engine" chip) shows on the FPS row in EVERY size, so it's in all sets. + // "Wrapper" and "DX ver" are Mega-only: the wrapper appears solely below Mega's frametime graph, + // and "DX ver" gates the DX-version suffix on Mega's FPS-row engine label. + val MINIMAL_CHIPS = setOf("FPS", "FPS graph", "0.01% low", "FPS .1", "Engine", "Clock") + val PILL_CHIPS = setOf("FPS", "FPS graph", "GPU", "CPU", "VRAM", "RAM", "Battery", "GPU model", "FPS .1", "Engine", "Clock") + val STANDARD_CHIPS = setOf( + "FPS", "FPS graph", "GPU", "GPU temp", "CPU", "VRAM", "RAM", "Power", "Temp", + "Battery", "GPU model", "Engine", "0.01% low", "FPS .1", "Clock", + ) + val MEGA_ONLY = setOf( + "Per-core", "Swap", "Network", "Resolution", "Proton", "Wrapper", "DX ver", "Session", + ) + val MEGA_CHIPS = STANDARD_CHIPS + MEGA_ONLY + } +} + +/** + * The Wayland HDR state the Fusion HUD shows on its own line, directly under the latency · display-server + * line (the host sets it with [FusionHudView.setHdrState]). [NONE] = no line at all: every session whose + * HDR gate is closed (X11, SDR screens, HDR off) keeps the HUD exactly as it was. + */ +object FusionHdr { + const val NONE = 0 + const val ON = 1 // HDR frames on screen with HDR headroom + const val NO_HEADROOM = 2 // HDR frames on screen, but the display gives them no headroom (5 s+) + const val OFF = 3 // the drawer's HDR output switch is off (tone-mapped to SDR) + const val READY = 4 // HDR open for the session, no HDR frames on screen right now + const val TONEMAPPED = 5 // HDR output on, but the frames are shown tone-mapped (frame generation + // on a screen that offers no HDR10 swapchain) + const val NOT_ON_THIS_SCREEN = 6 // HDR open for the session, but the screen the game is on NOW + // reports no HDR10 (the TV was unplugged mid-game). The offer to the + // game cannot be withdrawn once made, so the gate stays open for the + // session — but "ready" would be a lie on a panel that cannot show it. +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/FusionHudView.kt b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudView.kt new file mode 100644 index 000000000..2fadde009 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/FusionHudView.kt @@ -0,0 +1,1379 @@ +/* + * FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays. + * Copyright (C) The412Banner + * + * Licensed under the GNU General Public License v3.0 (see LICENSE), with an + * ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md): + * any use, fork, or distribution must preserve credit to "The412Banner" and a + * link to https://github.com/The412Banner/FusionHUD in the project documentation + * AND in the in-app credits/about screen. + * + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + */ + +package com.droiddeck.launcher.hud + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BlurMaskFilter +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Typeface +import android.text.format.DateFormat +import android.view.MotionEvent +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.compose.ui.graphics.toArgb +import com.droiddeck.launcher.ui.Themes +import java.util.Date +import java.util.Locale +import java.util.function.BiConsumer +import java.util.function.Consumer +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Fusion HUD — the 4th selectable in-game overlay. One color-coded visual language rendered at four + * SIZE modes (Full / Tiles / Pill / Minimal / Mega), all driven by [FrameStats] (gamescope's frame times + * read by MangoHud's rules) + a single cached [HudMetrics.Snapshot]. Pure Canvas drawing. + * + * DroidDeck: the readouts refresh every 500 ms (MangoHud's sampling period) on a Main-dispatcher + * coroutine, collecting the snapshot on Dispatchers.IO; the frame graph is redrawn whenever new frames + * arrive ([onFrames]), as MangoHud draws it. + * + * Gestures go through [HudLockController]: long-press toggles the position lock (with a lock/unlock + * badge fade), a tap cycles the size (Full→Tiles→Pill→Minimal→Full), and a drag repositions — tap + + * drag are frozen while locked. A locked HUD lets a touch through where an on-screen control under it + * takes it ([passesTouchAt]); everywhere else it keeps the touch, so a long press there unlocks it. + */ +class FusionHudView(context: Context) : View(context) { + + private var frames: FrameStats? = null + internal fun setFrameStats(stats: FrameStats?) { frames = stats } + internal fun setMangoReadings(readings: MangoReadings?) { metrics.setMangoReadings(readings) } + + // Frames per second actually reaching the panel while DroidDeck's frame generation adds frames the + // game never made; 0 = nothing is adding frames, and the readout is the game's rate alone. + private var presentedFps = 0f + fun setPresentedFps(fps: Float) { presentedFps = fps } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var updateJob: Job? = null + private val metrics = HudMetrics(context) + + private val density = resources.displayMetrics.density + + // ---- Config ----------------------------------------------------------- + private var size = FusionSize.FULL + private var scale = 1f + private var bgOpacity = 0.8f + private var outlineIntensity = 0.4f + private var outlineFollowAccent = true + private var colorIntensity = 1f + private var tempDisplay = HudMetrics.TempDisplay.from(null) + + private var showFPS = true + private var showEngine = true + private var showGpuModel = true + private var showCPU = true + private var showGPU = true + private var showCpuTemp = true + private var showGpuTemp = false + private var showVram = true + private var showRAM = true + private var showBattery = true + private var showPower = true + private var showBatteryTemp = false + private var showGraph = false + private var showLow001 = true + private var fpsDecimal = true + private var showClockTime = true // subtle corner/footer clock, available in EVERY size + // Mega-only rows + private var showPerCore = true + private var showSwap = true + private var showNet = true + private var showResolution = true + private var showProton = true + private var showWrapper = true + private var showDxVer = true + private var showSession = true + + private var engineLabel = "" + private var displayServer = "" // "X11" / "Wayland": which display server the game runs on + private var hdrState = FusionHdr.NONE // Wayland HDR, on its own line under latency · display server + private var gpuModel = "" + // The GPU name a Wayland session reports to games instead of the real adapter ("" = none). It has + // to travel separately from gpuModel: Wayland hands the spoof to DXVK and leaves the Vulkan device + // alone, so everything the host reads back is still the real chip. On X11 the driver wrapper renames + // the device itself, so there gpuModel already IS the spoofed name and this stays empty. + private var gpuSpoofName = "" + // Stack-layer version strings (Mega bottom band + DX version on the engine row); fed by the host. + private var wineVersion = "" // "Proton 10.0-4" + private var graphicsWrapper = "" // graphics-driver wrapper package, e.g. "GameNative", "bcn_layer 20260719" + private var dxvkVersion = "" // DXVK version, appended to a DXVK engine row + private var vkd3dVersion = "" // VKD3D version, appended to a VKD3D engine row + + // ---- Live data (updated on the refresh coroutine, read on draw) -------- + private var snap: HudMetrics.Snapshot? = null + private var fpsNow = 0f + private var fpsAvg = 0f + private var lows: FrameStats.Lows = FrameStats.Lows.EMPTY + private var graphMinMs = 0f + private var graphMaxMs = 0f + private val graphBuf = FloatArray(FrameStats.GRAPH_FRAMES) + private val graphPath = Path() + private var resolution = "" + + // DroidDeck look: the theme's main colour for the FPS label, graph and accent outline, the theme's + // surface behind the readouts, a DroidDeck header (wordmark) on Full/Tiles/Minimal/Mega and the logo + // chip on the Pill. + private var accent = 0xFF1A9FFF.toInt() + private var surfaceRgb = 0x121417 + private var headerMark: RectF? = null + private var chipRect: RectF? = null + + // Every size sits in the DroidDeck mark's D: a straight left side split by the orb, round on the + // right. Its fixed layers (fill, header band, watermark, glowing outline) go into a bitmap, because + // Android before 9 skips glow on shapes drawn with hardware acceleration. + private var dPanel: RectF? = null + private var dRadius = 0f + private var dDividerTop = -1f + private var dFrame: Bitmap? = null + private var dFrameKey = "" + private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG) + + // ---- Colors (mockup) -------------------------------------------------- + private val colGpu = 0xFF5EE08A.toInt() + private val colCpu = 0xFF58A6FF.toInt() + private val colVram = 0xFFC98BFF.toInt() + private val colRam = 0xFFFF7BC0.toInt() + private val colBat = 0xFFFFAB5E.toInt() + private var colFps = 0xFF1A9FFF.toInt() + private var colGraph = 0xFF5CB8FF.toInt() + private val colValue = 0xFFF2F5F9.toInt() + private val colDim = 0xFF9AA4B2.toInt() + private val colLo = 0xFFE4E8EE.toInt() + private val colDisp = 0xFF4DD0E1.toInt() // display server (X11 / Wayland) + private val colHdr = 0xFFFFD54F.toInt() // HDR on screen + + // ---- Paints ----------------------------------------------------------- + private val measurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + } + private val drawPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + } + private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val tilePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val graphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND + color = colGraph + } + private val headerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val wordmarkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + + // ---- Built geometry --------------------------------------------------- + private class Glyph(val x: Float, val baseline: Float, val text: String, val color: Int, val sizePx: Float, val sans: Boolean = false, val glow: Boolean = false) { + fun moved(dx: Float, dy: Float) = Glyph(x + dx, baseline + dy, text, color, sizePx, sans, glow) + } + private class Span(val text: String, val color: Int, val sizePx: Float, val glow: Boolean = false) + /** + * A "label + value-run" row for the aligned grid sizes (Full / Mega). When [inline] is true the row + * opts OUT of the shared label column — its value run is placed directly after the label — so a long + * label (the graphics-wrapper on the FPS row) can't widen the column and shove every value right. + */ + private class HudRow(val label: Span, val vals: List, val inline: Boolean = false) + private val glyphs = ArrayList() + private val tileRects = ArrayList() + private var graphRect: RectF? = null + private var contentW = 0f + private var contentH = 0f + + // ---- Listeners -------------------------------------------------------- + private var onMovedListener: BiConsumer? = null + private var onSizeCycledListener: Consumer? = null + private var onLockChangedListener: Consumer? = null + fun setOnMovedListener(l: BiConsumer?) { onMovedListener = l } + fun setOnSizeCycledListener(l: Consumer?) { onSizeCycledListener = l } + fun setOnLockChangedListener(l: Consumer?) { onLockChangedListener = l } + + private val lockController = HudLockController(context, this, object : HudLockController.Callbacks { + override fun onTap() { cycleSize() } + override fun onMoved(x: Float, y: Float) { onMovedListener?.accept(x, y) } + override fun onLockChanged(locked: Boolean) { onLockChangedListener?.accept(locked) } + }) + + // ---- Public surface (symmetric with the other overlays) --------------- + fun setEngineLabel(s: String?) { if ((s ?: "") != engineLabel) { engineLabel = s ?: ""; post { rebuildAndInvalidate() } } } + fun setGpuModel(s: String?) { if ((s ?: "") != gpuModel) { gpuModel = s ?: ""; post { rebuildAndInvalidate() } } } + /** This session's Wayland GPU-name spoof (null / empty = none, the default): the GPU row names it + * in place of the real model, marked so it can't be read as the chip that is really rendering. */ + fun setGpuSpoofName(s: String?) { gpuSpoofName = s ?: ""; post { rebuildAndInvalidate() } } + fun setDisplayServer(s: String?) { displayServer = s ?: ""; post { rebuildAndInvalidate() } } + /** [FusionHdr] code; [FusionHdr.NONE] (the default) draws no HDR line anywhere. Any thread. */ + fun setHdrState(state: Int) { + val v = if (state in FusionHdr.ON..FusionHdr.NOT_ON_THIS_SCREEN) state else FusionHdr.NONE + post { if (v != hdrState) { hdrState = v; rebuildAndInvalidate() } } + } + fun setWineVersion(s: String?) { if ((s ?: "") != wineVersion) { wineVersion = s ?: ""; post { rebuildAndInvalidate() } } } + fun setGraphicsWrapper(s: String?) { graphicsWrapper = s ?: ""; post { rebuildAndInvalidate() } } + fun setDxWrapper(dxvk: String?, vkd3d: String?) { + dxvkVersion = dxvk ?: ""; vkd3dVersion = vkd3d ?: ""; post { rebuildAndInvalidate() } + } + + /** The game's output from gamescope ("1280x720 @ 144Hz"), as MangoHud shows it; "" = this display. */ + fun setResolution(s: String?) { resolution = s ?: "" } + + private val periodDone = Channel(Channel.CONFLATED) + private var seenFpsUpdates = 0L + + /** New frames arrived: redraw the graph (MangoHud redraws it every frame), and the readouts when a + * sampling period completed, at the moment MangoHud redraws its own. */ + fun onFrames() { + val updates = frames?.fpsUpdates ?: 0L + if (updates != seenFpsUpdates) { + seenFpsUpdates = updates + periodDone.trySend(Unit) + } + if (graphRect != null) invalidate() + } + + /** Fusion has no orientation (tap cycles size instead); kept for a symmetric host surface. */ + fun setVertical(vertical: Boolean) { /* no-op */ } + fun isVertical(): Boolean = false + + private fun cycleSize() { + size = size.next() + onSizeCycledListener?.accept(size.token) + rebuildAndInvalidate() + } + + fun applyConfig(configString: String?) { + if (configString.isNullOrEmpty()) return + val cfg = KeyValueSet(configString) + + size = FusionSize.from(cfg.get("hudSize", "minimal")) + showFPS = cfg.get("showFPS", "1") == "1" + showEngine = cfg.get("showEngine", "1") == "1" + showGpuModel = cfg.get("showGpuModel", "1") == "1" + showCPU = cfg.get("showCPUUsage", cfg.get("showCPULoad", "1")) == "1" + showGPU = cfg.get("showGPULoad", "1") == "1" + showCpuTemp = cfg.get("showTemp", "1") == "1" + showGpuTemp = cfg.get("showGpuTemp", "0") == "1" + showVram = cfg.get("showVram", "1") == "1" + showRAM = cfg.get("showRAM", "1") == "1" + showBattery = cfg.get("showBattery", "1") == "1" + showPower = cfg.get("showPower", "1") == "1" + showBatteryTemp = cfg.get("showBatteryTemp", "0") == "1" + showGraph = cfg.get("showFPSGraph", "0") == "1" + showLow001 = cfg.get("showLow001", "1") == "1" + fpsDecimal = cfg.get("fpsDecimal", "1") == "1" + showClockTime = cfg.get("showClock", "1") == "1" // Fusion clock defaults ON + showPerCore = cfg.get("showPerCore", "1") == "1" + showSwap = cfg.get("showSwap", "1") == "1" + showNet = cfg.get("showNet", "1") == "1" + showResolution = cfg.get("showResolution", "1") == "1" + showProton = cfg.get("showProton", "1") == "1" + showWrapper = cfg.get("showWrapper", "1") == "1" + showDxVer = cfg.get("showDxVer", "1") == "1" + showSession = cfg.get("showSession", "1") == "1" + tempDisplay = HudMetrics.TempDisplay.from(cfg) + lockController.setLocked(cfg.get("hudLocked", "1") == "1") + + colorIntensity = when (cfg.get("hudColor", "vivid")) { + "soft" -> 0.72f; "mid" -> 0.88f; else -> 1.0f + } + outlineIntensity = (parseOutline(cfg.get("hudOutline", "40")) / 100f).coerceIn(0f, 1f) + outlineFollowAccent = cfg.get("hudOutlineAccent", "1") == "1" + scale = (parseIntOr(cfg.get("hudScale", "100"), 100).coerceIn(50, 200)) / 100f + bgOpacity = (parseIntOr(cfg.get("hudOpacity", "80"), 80).coerceIn(0, 100)) / 100f + applyTheme() + + rebuildAndInvalidate() + } + + // ---- Lifecycle: self-refresh ------------------------------------------ + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (isShown) startUpdates() + } + + override fun onDetachedFromWindow() { + stopUpdates() + super.onDetachedFromWindow() + } + + // The host hides the HUD with GONE (HUD off, picture-in-picture) and keeps it attached: read the + // sensors only while it shows. + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + if (isAttachedToWindow && isShown) startUpdates() else stopUpdates() + } + + private fun stopUpdates() { + updateJob?.cancel(); updateJob = null + } + + private fun startUpdates() { + if (updateJob?.isActive == true) return + updateJob = scope.launch { + while (isActive) { + val stats = frames + val now = System.nanoTime() + val collected = withContext(Dispatchers.IO) { + Pair(metrics.snapshot(), stats?.lows(now) ?: FrameStats.Lows.EMPTY) + } + snap = collected.first + lows = collected.second + fpsNow = stats?.fps ?: 0f + fpsAvg = stats?.averageFps() ?: 0f + graphMinMs = stats?.graphMinMs() ?: 0f + graphMaxMs = stats?.graphMaxMs() ?: 0f + onRefresh?.run() + rebuildAndInvalidate() + // Without frames (a still screen) MangoHud redraws nothing; the HUD keeps refreshing. + withTimeoutOrNull(REFRESH_MS) { periodDone.receive() } + } + } + } + + /** Runs on the main thread just before each readout refresh, for the host's labels. */ + var onRefresh: Runnable? = null + + private fun applyTheme() { + val palette = Themes.current(context) + accent = palette.primary.toArgb() + surfaceRgb = palette.surface.toArgb() and 0xFFFFFF + colFps = accent + colGraph = lighten(accent, 0.3f) + } + + private fun lighten(color: Int, amount: Float): Int = Color.rgb( + (Color.red(color) + (255 - Color.red(color)) * amount).roundToInt(), + (Color.green(color) + (255 - Color.green(color)) * amount).roundToInt(), + (Color.blue(color) + (255 - Color.blue(color)) * amount).roundToInt(), + ) + + private fun rebuildAndInvalidate() { + rebuild() + requestLayout() + invalidate() + } + + // ---- Text helpers ----------------------------------------------------- + private fun blend(color: Int): Int { + val i = colorIntensity.coerceIn(0f, 1f) + if (i >= 0.999f) return color + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[1] *= i + return Color.HSVToColor(Color.alpha(color), hsv) + } + + private fun sp(v: Float) = v * density * scale + private fun measure(text: String, sizePx: Float): Float { measurePaint.textSize = sizePx; return measurePaint.measureText(text) } + private fun lineH(sizePx: Float): Float { measurePaint.textSize = sizePx; val fm = measurePaint.fontMetrics; return fm.descent - fm.ascent } + private fun ascent(sizePx: Float): Float { measurePaint.textSize = sizePx; return measurePaint.fontMetrics.ascent } + + private fun runWidth(spans: List): Float { + var w = 0f + for (s in spans) w += measure(s.text, s.sizePx) + return w + } + + private fun placeRun(startX: Float, baseline: Float, spans: List, glow: Boolean = false): Float { + var x = startX + for (s in spans) { + glyphs.add(Glyph(x, baseline, s.text, blend(s.color), s.sizePx, glow = glow || s.glow)) + x += measure(s.text, s.sizePx) + } + return x + } + + private fun one(v: Float): String = + if (fpsDecimal) String.format(Locale.US, "%.1f", v) else v.roundToInt().toString() + + // Shows "30->118" while something downstream of the game is adding frames. + // The game's own rate stays visible next to the panel rate, because the gap + // between the two is precisely what frame generation is doing. + private fun fpsText(v: Float): String = + if (differs(v)) "${one(v)}\u2192${one(presentedFps)}" else one(v) + + // Either direction: up = frames added (LSFG Native), down = frames lost + // between the guest and the panel, which is the failure win-fg can hit + // and which a single number hides completely. + private fun differs(v: Float): Boolean = + presentedFps > 0f && kotlin.math.abs(presentedFps - v) > maxOf(1f, v * 0.10f) + + private fun fmt1(v: Float): String = String.format(Locale.US, "%.1f", v) + private fun lowText(v: Float): String? = if (v <= 0f) null else fmt1(v) + + // number (white, or "—" muted when null) + unit (muted, smaller) + private fun numUnit(num: String?, unit: String, numPx: Float, unitPx: Float): List = + if (num == null) listOf(Span("—", colDim, numPx), Span(unit, colDim, unitPx)) + else listOf(Span(num, colValue, numPx), Span(unit, colDim, unitPx)) + + // "0.2GiB"/"75%"/"1938MHz" → white number + muted suffix + private fun valueUnit(text: String, numPx: Float, unitPx: Float): List { + val i = text.indexOfFirst { !(it.isDigit() || it == '.' || it == '-') } + return if (i <= 0) listOf(Span(text, colValue, numPx)) + else listOf(Span(text.substring(0, i), colValue, numPx), Span(text.substring(i), colDim, unitPx)) + } + + private fun tempSpans(c: Int?, sensor: HudMetrics.TempSensor, numPx: Float, unitPx: Float): List { + if (c == null) return emptyList() + val txt = HudMetrics.formatTemp(c.toFloat(), tempDisplay, false) // "81°C" + val col = HudMetrics.tempColor( + c.toFloat(), metrics.resolveThresholds(sensor, tempDisplay), tempDisplay, colValue) + val i = txt.indexOf('°') + return if (i <= 0) listOf(Span(txt, col, numPx)) + else listOf(Span(txt.substring(0, i), col, numPx), Span(txt.substring(i), colDim, unitPx)) + } + + private fun gap(unitPx: Float) = Span(" ", colDim, unitPx) + + /** The frame-rate number in neon, as on Minimal. */ + private fun glowing(spans: List): List = + spans.mapIndexed { i, s -> if (i == 0) Span(s.text, s.color, s.sizePx, glow = true) else s } + + // ---- Wayland HDR state (one line of its own; nothing at all for FusionHdr.NONE) ---- + /** The state as a full line: "HDR", "HDR (no headroom)", "HDR off", "HDR ready", "HDR tone-mapped", + * "HDR not on this screen". */ + private fun hdrLine(px: Float): List = when (hdrState) { + FusionHdr.ON -> listOf(Span("HDR", colHdr, px)) + FusionHdr.NO_HEADROOM -> listOf(Span("HDR", colHdr, px), Span(" (no headroom)", colBat, px)) + FusionHdr.OFF -> listOf(Span("HDR off", colDim, px)) + FusionHdr.READY -> listOf(Span("HDR ready", colDim, px)) + FusionHdr.TONEMAPPED -> listOf(Span("HDR", colDim, px), Span(" tone-mapped", colBat, px)) + FusionHdr.NOT_ON_THIS_SCREEN -> listOf(Span("HDR", colDim, px), Span(" not on this screen", colBat, px)) + else -> emptyList() + } + /** The value after an "HDR" label (Full / Mega): "on", "no headroom", "off", "ready", "tone-mapped", + * "not on this screen". */ + private fun hdrValue(px: Float): List = when (hdrState) { + FusionHdr.ON -> listOf(Span("on", colHdr, px)) + FusionHdr.NO_HEADROOM -> listOf(Span("no headroom", colBat, px)) + FusionHdr.OFF -> listOf(Span("off", colDim, px)) + FusionHdr.READY -> listOf(Span("ready", colDim, px)) + FusionHdr.TONEMAPPED -> listOf(Span("tone-mapped", colBat, px)) + FusionHdr.NOT_ON_THIS_SCREEN -> listOf(Span("not on this screen", colBat, px)) + else -> emptyList() + } + private fun hdrText(): String = hdrLine(1f).joinToString("") { it.text } + + // ---- GPU model (with the Wayland GPU-name spoof) ---- + /** What the GPU row names: the spoofed GPU when this session reports one, else the real model. */ + private fun gpuNameText(): String = gpuSpoofName.ifBlank { gpuModel } + /** The GPU row's value run. A spoofed name carries a marker in the same warning colour the HDR line + * qualifies its state with, so the row can't be read as the chip that is really rendering: + * "GeForce GTX 1080 (spoof)", and where the line is tightest (the pill) "GeForce GTX 1080 spoof". */ + private fun gpuModelSpans(px: Float, color: Int, compact: Boolean = false): List { + val name = Span(gpuNameText(), color, px) + if (gpuSpoofName.isBlank()) return listOf(name) + return listOf(name, Span(if (compact) " spoof" else " (spoof)", colBat, px)) + } + + private val inkRect = android.graphics.Rect() + + // ---- Layout builders -------------------------------------------------- + private fun rebuild() { + glyphs.clear(); tileRects.clear(); graphRect = null + headerMark = null; chipRect = null; dPanel = null; dDividerTop = -1f + val s = snap + if (s == null) { contentW = 0f; contentH = 0f; return } + when (size) { + FusionSize.FULL -> { buildFull(s); intoPanel(sp(10f), header = true) } + FusionSize.TILES -> { buildTiles(s); intoPanel(sp(9f), header = true) } + FusionSize.PILL -> { buildPill(s); intoPanel(sp(10f), header = false) } + FusionSize.MINIMAL -> buildMinimal(s) + FusionSize.MEGA -> { buildMega(s); intoPanel(sp(10f), header = true) } + } + } + + // ---- DroidDeck panel, header and chip ----------------------------------- + /** The clock sits in the header on every size but the Pill, which keeps it under the FPS. */ + private fun footerClock(): Boolean = showClockTime && size == FusionSize.PILL + + private fun measureSans(text: String, sizePx: Float): Float { wordmarkPaint.textSize = sizePx; return wordmarkPaint.measureText(text) } + + private fun panelInset() = sp(8f) // room for the glow around the panel + private fun panelLeft() = sp(13f) // the orb and its glow stick out of the left side + private fun contentLeft() = panelLeft() + sp(16f) + + /** The DroidDeck mark, "DroidDeck" and the clock across the top, starting on [left], with the same + * space above and below the mark. Returns the header's bottom edge and its right end. */ + private fun addPanelHeader(left: Float, inner: Float): Pair { + val wordPx = sp(10f); val clockPx = sp(9.5f); val gapH = sp(7f) + wordmarkPaint.textSize = wordPx + val markH = wordmarkPaint.fontMetrics.let { it.descent - it.ascent } * 0.95f + val markTop = inner + gapH + val markLeft = left - DroidDeckMark.inkLeftFor(markH) + headerMark = RectF(markLeft, markTop, markLeft + DroidDeckMark.widthFor(markH), markTop + markH) + val midY = markTop + markH / 2f + var x = markLeft + DroidDeckMark.inkRightFor(markH) + sp(6f) + wordmarkPaint.getTextBounds("DroidDeck", 0, 9, inkRect) + glyphs.add(Glyph(x, midY - inkRect.exactCenterY(), "DroidDeck", blend(colValue), wordPx, sans = true)) + x += measureSans("DroidDeck", wordPx) + if (showClockTime) { + measurePaint.textSize = clockPx + measurePaint.getTextBounds("0", 0, 1, inkRect) + val base = midY - inkRect.exactCenterY() + x += sp(6f) + glyphs.add(Glyph(x, base, "·", blend(colDim), clockPx)) + x += measure("·", clockPx) + sp(6f) + val t = clockTimeString() + glyphs.add(Glyph(x, base, t, blend(colDim), clockPx)) + x += measure(t, clockPx) + } + dDividerTop = markTop + markH + gapH + return Pair(dDividerTop + max(1f, density), x) + } + + /** + * Moves a size's content, laid out from [pad] at the origin, into the panel: under the header, or after + * the logo chip on the Pill. Lines that start on the content's left edge are lined up by their ink, so + * the logo, labels and numbers share one edge. + */ + private fun intoPanel(pad: Float, header: Boolean) { + if (contentW <= 0f || contentH <= 0f) return + for (i in glyphs.indices) { + val g = glyphs[i] + if (kotlin.math.abs(g.x - pad) < 0.5f) glyphs[i] = g.moved(-inkLeft(g.text, g.sizePx), 0f) + } + val stroke = outlineWidth(); val inner = panelInset() + stroke + var left = contentLeft() + val content = glyphs.size + var top = inner + sp(10f) + var headerRight = 0f + if (header) { + val (bottom, right) = addPanelHeader(left, inner) + top = bottom + sp(8f); headerRight = right + } else { + val chip = sp(22f) + chipRect = RectF(left, 0f, left + chip, chip) + left += chip + sp(8f) + } + val dx = left - pad; val dy = top - pad + for (i in 0 until content) glyphs[i] = glyphs[i].moved(dx, dy) + for (r in tileRects) r.offset(dx, dy) + graphRect?.offset(dx, dy) + val panel = RectF(panelLeft(), panelInset(), max(contentW - pad + dx, headerRight) + sp(18f), + contentH - pad + dy + sp(10f) + stroke) + chipRect?.let { it.offsetTo(it.left, panel.centerY() - it.height() / 2f) } + finishPanel(panel, halfRound = !header) + } + + /** Closes the panel around everything placed so far, widened until no line, tile or graph reaches the + * round side: a half circle on the short sizes (Pill, Minimal), large round corners on the tall ones. */ + private fun finishPanel(panel: RectF, halfRound: Boolean) { + val r = if (halfRound) panel.height() / 2f else min(panel.height() / 2f, sp(56f)) + val margin = outlineWidth() + sp(4f) + fun curveIn(top: Float, bottom: Float): Float { + val d = max(max(panel.top + r - top, bottom - (panel.bottom - r)), 0f) + return if (d <= 0f) 0f else if (d >= r) r else r - sqrt(r * r - d * d) + } + var right = panel.right + for (g in glyphs) { + val paint = if (g.sans) wordmarkPaint else measurePaint + paint.textSize = g.sizePx + paint.getTextBounds(g.text, 0, g.text.length, inkRect) + if (inkRect.isEmpty) continue + right = max(right, g.x + inkRect.right + curveIn(g.baseline + inkRect.top, g.baseline + inkRect.bottom) + margin) + } + graphRect?.let { right = max(right, it.right + curveIn(it.top, it.bottom) + margin) } + for (t in tileRects) right = max(right, t.right + curveIn(t.top, t.bottom) + margin) + panel.right = max(right, panel.left + sp(6f) + r) + dPanel = panel; dRadius = r + contentW = panel.right + panelInset() + contentH = panel.bottom + panelInset() + } + + // ---- Shared small-info helpers ---------------------------------------- + private fun clockTimeString(): String = DateFormat.getTimeFormat(context).format(Date()) + + private fun elapsedString(): String { + val sec = (frames?.sessionSeconds(System.nanoTime()) ?: 0f).toInt().coerceAtLeast(0) + val h = sec / 3600; val m = (sec % 3600) / 60; val s = sec % 60 + return if (h > 0) String.format(Locale.US, "%d:%02d:%02d", h, m, s) + else String.format(Locale.US, "%02d:%02d", m, s) + } + + /** + * The FPS row's API/engine label — the DX→Vulkan translator ([engineLabel]: "DXVK"/"VKD3D"/"Zink"), + * gated by the "Engine" chip. This is short and shows in EVERY size. Falls back to a bare "FPS" when + * the toggle is off or no engine label is set. (The graphics WRAPPER is separate — Mega-only, drawn + * below the frametime graph — not on the FPS row.) + */ + private fun apiLabel(): String = + if (showEngine && engineLabel.isNotBlank()) engineLabel else "FPS" + + /** The DX-wrapper version WITH its engine name (e.g. "DXVK 2.4.1", "VKD3D 2.14"), or "" if none. + * Used for the Mega stack line below the graph. */ + private fun dxVersionLabeled(): String = when { + engineLabel.contains("VKD3D", true) && vkd3dVersion.isNotBlank() -> "VKD3D $vkd3dVersion" + engineLabel.contains("DXVK", true) && dxvkVersion.isNotBlank() -> "DXVK $dxvkVersion" + else -> "" + } + + /** The DX-wrapper version for the current engine, or "" (used for the Tiles "API" tile sub-line). */ + private fun dxVersion(): String = when { + engineLabel.contains("VKD3D", true) -> vkd3dVersion + engineLabel.contains("DXVK", true) -> dxvkVersion + else -> "" + } + + /** Resolution + refresh, e.g. "1280x720 @ 120Hz". Read from the current display (Main thread). */ + private fun resolutionString(): String { + if (resolution.isNotBlank()) return resolution + val dm = resources.displayMetrics + val hz = (display?.refreshRate ?: 0f) + val base = "${dm.widthPixels}x${dm.heightPixels}" + return if (hz >= 1f) "$base @ ${hz.roundToInt()}Hz" else base + } + + /** A subtle, low-emphasis time-of-day tucked into the bottom-right of any size. Grows contentH slightly. */ + private fun addSubtleClock(pad: Float, leftAligned: Boolean = false) { + if (!footerClock()) return + val px = sp(9.5f) + val t = clockTimeString() + val w = measure(t, px) + val footerTop = contentH - pad // reuse the bottom padding band as the footer line + val baseline = footerTop - ascent(px) + // Right-aligned footer; on the Pill it sits on the left edge, under the FPS. + val x = if (leftAligned) pad else (contentW - pad - w).coerceAtLeast(pad) + glyphs.add(Glyph(x, baseline, t, blend(colDim), px)) + contentH = footerTop + lineH(px) + pad * 0.5f + contentW = max(contentW, pad + w + pad) + } + + private fun buildFull(s: HudMetrics.Snapshot) { + val rowPx = sp(12f); val unitPx = rowPx * 0.62f + val pad = sp(10f); val lineGap = sp(4f); val lvGap = sp(8f) + val rows = ArrayList() + + if (showGpuModel && gpuNameText().isNotBlank()) + rows.add(HudRow(Span("GPU", colGpu, rowPx), gpuModelSpans(rowPx, colValue))) + if (displayServer.isNotBlank()) + rows.add(HudRow(Span("DISP", colDisp, rowPx), listOf(Span(displayServer, colValue, rowPx)))) + if (hdrState != FusionHdr.NONE) + rows.add(HudRow(Span("HDR", colHdr, rowPx), hdrValue(rowPx))) + if (showGPU) { + val v = ArrayList() + v += numUnit(s.gpuPercent?.toString(), "%", rowPx, unitPx) + if (showGpuTemp) tempSpans(s.gpuTempC, HudMetrics.TempSensor.GPU, rowPx, unitPx) + .let { if (it.isNotEmpty()) { v += gap(unitPx); v += it } } + v += gap(unitPx); v += numUnit(s.gpuClockMhz?.toString(), "MHz", rowPx, unitPx) + rows.add(HudRow(Span("GPU", colGpu, rowPx), v)) + } + if (showCPU) { + val v = ArrayList() + v += numUnit(s.cpuPercent?.toString(), "%", rowPx, unitPx) + if (showCpuTemp) tempSpans(s.cpuTempC, HudMetrics.TempSensor.CPU, rowPx, unitPx) + .let { if (it.isNotEmpty()) { v += gap(unitPx); v += it } } + v += gap(unitPx); v += numUnit(s.cpuClockMhz?.toString(), "MHz", rowPx, unitPx) + rows.add(HudRow(Span("CPU", colCpu, rowPx), v)) + } + if (showVram && s.vramText() != null) + rows.add(HudRow(Span("VRAM", colVram, rowPx), valueUnit(s.vramText()!!, rowPx, unitPx))) + if (showRAM) { + val v = ArrayList() + v += valueUnit(s.ramUsedText(), rowPx, unitPx) + v += gap(unitPx); v += numUnit(s.ramPercent.roundToInt().toString(), "%", rowPx, unitPx) + rows.add(HudRow(Span("RAM", colRam, rowPx), v)) + } + if (showBattery || showPower || showBatteryTemp) { + val v = ArrayList(); var any = false + if (showBattery && s.battery.percent != null) { v += numUnit(s.battery.percent.toString(), "%", rowPx, unitPx); any = true } + if (showBatteryTemp) tempSpans(s.battery.tempC, HudMetrics.TempSensor.BATTERY, rowPx, unitPx) + .let { if (it.isNotEmpty()) { if (any) v += gap(unitPx); v += it; any = true } } + if (showPower && s.battery.watts > 0f) { if (any) v += gap(unitPx); v += numUnit(fmt1(s.battery.watts), "W", rowPx, unitPx); any = true } + if (any) rows.add(HudRow(Span("BAT", colBat, rowPx), v)) + } + if (showFPS) { + val v = ArrayList() + v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx)) + v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx) + // Short API/engine label ("DXVK"/"VKD3D"/"Zink", else "FPS") — a normal aligned row. + rows.add(HudRow(Span(apiLabel(), colFps, rowPx), v)) + rows.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx))) + rows.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx))) + rows.add(HudRow(Span("0.1%", colLo, rowPx), numUnit(lowText(lows.low01Fps), "FPS", rowPx, unitPx))) + if (showLow001) + rows.add(HudRow(Span("0.01%", colLo, rowPx), numUnit(lowText(lows.low001Fps), "FPS", rowPx, unitPx))) + } + + var labelCol = 0f + for (r in rows) if (!r.inline) labelCol = max(labelCol, measure(r.label.text, rowPx)) + val h = lineH(rowPx); val asc = ascent(rowPx) + var y = pad; var maxRight = pad + for (r in rows) { + val baseline = y - asc + placeRun(pad, baseline, listOf(r.label)) + val valX = if (r.inline) pad + measure(r.label.text, rowPx) + lvGap + else pad + labelCol + lvGap + val end = placeRun(valX, baseline, r.vals) + maxRight = max(maxRight, end) + y += h + lineGap + } + + if (showFPS && showGraph) { + // Frametime min/max line + green graph. "Frametime" is far wider than the short-label grid + // column, so place the stat AFTER the ACTUAL label width (not labelCol) — otherwise the two + // overlap and render as "Frametimemin:.. max:..". + val ftPx = unitPx * 1.15f + val baseline = y - asc + placeRun(pad, baseline, listOf(Span("Frametime", colFps, ftPx))) + val stat = "min:${fmt1(graphMinMs)} max:${fmt1(graphMaxMs)}" + val statX = pad + max(labelCol, measure("Frametime", ftPx)) + lvGap + val end = placeRun(statX, baseline, listOf(Span(stat, colDim, unitPx))) + maxRight = max(maxRight, end) + y += h + lineGap + val gh = sp(22f) + val right = max(maxRight, pad + sp(160f)) + graphRect = RectF(pad, y, right, y + gh) + maxRight = max(maxRight, right) + y += gh + } + + contentW = maxRight + pad + contentH = y + pad + addSubtleClock(pad) + } + + private fun buildTiles(s: HudMetrics.Snapshot) { + val keyPx = sp(10f); val valPx = sp(18f); val subPx = sp(10f); val unitPx = sp(11f) + val pad = sp(9f); val innerPad = sp(8f); val tileGap = sp(6f); val lineGap = sp(4f) + + class Tile(val key: String, val keyColor: Int, val value: List, val sub: String?, val wide: Boolean) + val tiles = ArrayList() + + if (showFPS) tiles.add(Tile("FPS", colFps, listOf(Span(fpsText(fpsNow), colValue, valPx, glow = true)), + "${fmt1(fpsAvg)} avg · ${lowText(lows.low1Fps) ?: "—"} 1%", false)) + if (showFPS) tiles.add(Tile("FRAME", colDim, numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", valPx, unitPx), + "${fmt1(graphMinMs)} – ${fmt1(graphMaxMs)}", false)) + if (showGPU) tiles.add(Tile("GPU", colGpu, numUnit(s.gpuPercent?.toString(), "%", valPx, unitPx), + if (showGpuTemp && s.gpuTempC != null) HudMetrics.formatTemp(s.gpuTempC.toFloat(), tempDisplay, false) + else s.gpuClockMhz?.let { "${it}MHz" }, false)) + if (showCPU) tiles.add(Tile("CPU", colCpu, numUnit(s.cpuPercent?.toString(), "%", valPx, unitPx), + listOfNotNull(if (showCpuTemp) s.cpuTempC?.let { HudMetrics.formatTemp(it.toFloat(), tempDisplay, false) } else null, + s.cpuClockMhz?.toString()).joinToString(" · ").ifBlank { null }, false)) + if (showVram && s.vramText() != null) tiles.add(Tile("VRAM", colVram, valueUnit(s.vramText()!!, valPx, unitPx), null, false)) + if (showRAM) tiles.add(Tile("RAM", colRam, numUnit(s.ramPercent.roundToInt().toString(), "%", valPx, unitPx), + "${s.ramUsedText()} / ${s.ramTotalText()}", false)) + // API/engine tile — short translator name (DXVK/VKD3D/Zink) with the DX version on the sub-line; + // a normal half-width tile that naturally fills the empty slot beside RAM. Gated by "Engine". + if (showEngine && engineLabel.isNotBlank()) + tiles.add(Tile("API", colFps, listOf(Span(engineLabel, colValue, valPx)), + dxVersion().ifBlank { null }, false)) + if (displayServer.isNotBlank()) + tiles.add(Tile("DISPLAY", colDisp, listOf(Span(displayServer, colValue, valPx)), + if (hdrState != FusionHdr.NONE) hdrText() else null, false)) + if (showGpuModel && gpuNameText().isNotBlank()) + tiles.add(Tile("GPU", colGpu, gpuModelSpans(valPx, colValue), null, true)) + if (showBattery || showPower || showBatteryTemp) { + val parts = ArrayList(); var any = false + if (showBattery && s.battery.percent != null) { parts += numUnit(s.battery.percent.toString(), "%", valPx, unitPx); any = true } + if (showBatteryTemp && s.battery.tempC != null) { if (any) parts += Span(" · ", colDim, subPx); parts += tempSpans(s.battery.tempC, HudMetrics.TempSensor.BATTERY, valPx, unitPx); any = true } + if (showPower && s.battery.watts > 0f) { if (any) parts += Span(" · ", colDim, subPx); parts += numUnit(fmt1(s.battery.watts), "W", valPx, unitPx); any = true } + if (any) tiles.add(Tile("BAT", colBat, parts, null, true)) + } + if (tiles.isEmpty()) { contentW = 0f; contentH = 0f; return } + + val keyH = lineH(keyPx); val valH = lineH(valPx); val subH = lineH(subPx) + fun tileW(t: Tile) = max(max(measure(t.key, keyPx), runWidth(t.value)), + t.sub?.let { measure(it, subPx) } ?: 0f) + innerPad * 2 + var normalW = 0f + for (t in tiles) if (!t.wide) normalW = max(normalW, tileW(t)) + if (normalW == 0f) for (t in tiles) normalW = max(normalW, tileW(t) / 2f) + val tileH = innerPad * 2 + keyH + lineGap + valH + lineGap + subH // uniform (room for a sub line) + val fullW = normalW * 2 + tileGap + + var x = pad; var y = pad; var col = 0 + fun place(t: Tile, tx: Float, ty: Float, tw: Float) { + tileRects.add(RectF(tx, ty, tx + tw, ty + tileH)) + var by = ty + innerPad - ascent(keyPx) + placeRun(tx + innerPad, by, listOf(Span(t.key, t.keyColor, keyPx))) + by = ty + innerPad + keyH + lineGap - ascent(valPx) + placeRun(tx + innerPad, by, t.value) + if (t.sub != null) { + by = ty + innerPad + keyH + lineGap + valH + lineGap - ascent(subPx) + placeRun(tx + innerPad, by, listOf(Span(t.sub, colDim, subPx))) + } + } + for (t in tiles) { + if (t.wide) { + if (col != 0) { y += tileH + tileGap; col = 0; x = pad } + place(t, pad, y, fullW) + y += tileH + tileGap; col = 0; x = pad + } else { + place(t, x, y, normalW) + col++; x += normalW + tileGap + if (col == 2) { col = 0; x = pad; y += tileH + tileGap } + } + } + if (col != 0) y += tileH + tileGap + if (showFPS && showGraph) { + graphRect = RectF(pad, y, pad + fullW, y + sp(22f)) + y += sp(22f) + tileGap + } + contentW = pad + fullW + pad + contentH = y + (pad - tileGap) + addSubtleClock(pad) + } + + private fun buildPill(s: HudMetrics.Snapshot) { + val bigPx = sp(30f); val bigUnitPx = bigPx * 0.36f; val stkPx = sp(11.5f) + val pad = sp(10f); val midGap = sp(12f); val stkLineGap = sp(3f) + + val left = ArrayList() + // "30->118" is roughly three times the width of "30", and the pill's + // number is set at 30sp precisely because it is the one big thing on a + // small overlay. Shrink it while it carries both figures so the pill + // does not balloon across the screen; it is still the largest element. + val generating = differs(fpsNow) + val fpsPx = if (generating) bigPx * 0.55f else bigPx + left += Span(fpsText(fpsNow), colValue, fpsPx, glow = true) + left += Span("fps", colDim, if (generating) bigUnitPx * 0.8f else bigUnitPx) + + val stack = ArrayList>() + // The GPU name normally heads the stack. A name wider than every stat under it (a Wayland spoof + // such as "Radeon RX 6800/6800 XT / 6900 XT spoof") would set the stack's width and stretch the + // whole capsule around empty space, so that name gets its own line across the top of the pill + // instead, starting over the API caption (see nameOnTop below). + val gpuName = if (showGpuModel && gpuNameText().isNotBlank()) gpuModelSpans(stkPx, colDim, compact = true) else null + run { + val l = ArrayList() + if (showGPU) { l += Span("GPU ${s.gpuPercent ?: "—"}%", colGpu, stkPx) } + if (showCPU) { if (l.isNotEmpty()) l += Span(" · ", colDim, stkPx); l += Span("CPU ${s.cpuPercent ?: "—"}%", colCpu, stkPx) } + if (l.isNotEmpty()) stack.add(l) + } + // RAM % (system-wide) takes the slot the latency row used to occupy, matching the GPU/CPU idiom + // on the row above it. colRam keeps RAM's identity colour consistent with the Full/Tiles sizes. + if (showRAM) stack.add(listOf(Span("RAM ${s.ramPercent.roundToInt()}%", colRam, stkPx))) + // One battery row, as on the other sizes: each part shows on its own switch, so the battery + // temperature no longer goes with the RAM switch and watts show without the battery level. + if (showBattery || showPower || showBatteryTemp) { + val l = ArrayList() + fun part(spans: List) { + l += if (l.isEmpty()) Span("BAT ", colBat, stkPx) else Span(" · ", colDim, stkPx) + l += spans + } + if (showBattery && s.battery.percent != null) part(listOf(Span("${s.battery.percent}%", colBat, stkPx))) + if (showBatteryTemp && s.battery.tempC != null) + part(tempSpans(s.battery.tempC, HudMetrics.TempSensor.BATTERY, stkPx, stkPx * 0.62f)) + if (showPower && s.battery.watts > 0f) part(listOf(Span("${fmt1(s.battery.watts)}W", colDim, stkPx))) + if (l.isNotEmpty()) stack.add(l) + } + // Latency (+ the display server, + optional VRAM) drops to the bottom of the stack, under the + // battery row. The display server sits here, not on the GPU-model line: that line is the + // pill's widest and ran into the capsule's rounded edge. + run { + val l = ArrayList() + l += Span("${fmt1(1000f / max(fpsNow, 1f))}ms", colDim, stkPx) + if (displayServer.isNotBlank()) { l += Span(" · ", colDim, stkPx); l += Span(displayServer, colDisp, stkPx) } + if (showVram && s.vramText() != null) { l += Span(" · ", colDim, stkPx); l += Span("${s.vramText()} vram", colDim, stkPx) } + stack.add(l) + } + // Wayland HDR state on its OWN line directly under the latency · display-server line (it ran off + // the capsule's right edge as "40.3ms · Wayland · HDR (no headroom)"). Only in HDR sessions. + if (hdrState != FusionHdr.NONE) stack.add(hdrLine(stkPx)) + + var statsW = 0f + for (l in stack) statsW = max(statsW, runWidth(l)) + val nameOnTop = gpuName != null && runWidth(gpuName) > statsW + if (gpuName != null && !nameOnTop) stack.add(0, gpuName) + + // Left column: a small API/engine caption (DXVK/VKD3D/Zink) above the big FPS and the clock below + // it, all on the left edge, so the left reads API · FPS · clock top-to-bottom. + val apiStr = apiLabel() + val hasApi = apiStr != "FPS" + val apiH = if (hasApi) lineH(stkPx) else 0f + val apiGap = if (hasApi) stkLineGap else 0f + + val leftW = runWidth(left); val leftH = lineH(bigPx) + val apiW = if (hasApi) measure(apiStr, stkPx) else 0f + val leftBlockW = max(leftW, apiW) + val leftColH = apiH + apiGap + leftH + val stkH = lineH(stkPx) + var stackW = 0f + for (l in stack) stackW = max(stackW, runWidth(l)) + val stackTotalH = stack.size * stkH + (stack.size - 1).coerceAtLeast(0) * stkLineGap + val innerH = max(leftColH, stackTotalH) + val topLineH = if (nameOnTop) stkH + stkLineGap else 0f + contentW = pad + leftBlockW + midGap + stackW + pad + contentH = pad + topLineH + innerH + pad + + // left column (API caption + big FPS), vertically centered as a block + var ly = pad + topLineH + (innerH - leftColH) / 2f + if (hasApi) { + placeRun(pad, ly - ascent(stkPx), listOf(Span(apiStr, colFps, stkPx))) + ly += apiH + apiGap + } + placeRun(pad, ly - ascent(bigPx), left) + // stack, vertically centered + var sy = pad + topLineH + (innerH - stackTotalH) / 2f + for (l in stack) { + placeRun(pad + leftBlockW + midGap, sy - ascent(stkPx), l) + sy += stkH + stkLineGap + } + addSubtleClock(pad, leftAligned = true) + if (nameOnTop && gpuName != null) { + // Left-aligned over the API caption; the pill only widens if the name is wider than the + // API/FPS column + stats. + val end = placeRun(pad, pad - ascent(stkPx), gpuName) + contentW = max(contentW, end + pad) + } + } + + // One column as wide as the widest row: the logo, FPS, lows, graph and engine line start on its left + // edge (ink, not advance), and the header keeps the same space above and below the logo. + private fun buildMinimal(s: HudMetrics.Snapshot) { + val bigPx = sp(34f); val bigUnitPx = bigPx * 0.32f; val subPx = sp(11.5f); val footPx = sp(9.5f) + val stroke = outlineWidth() + val left = contentLeft() + val inner = panelInset() + stroke + + // Same reasoning as the pill: 34sp is sized for one number, not two. + val minGenerating = differs(fpsNow) + val big = listOf( + Span(fpsText(fpsNow), colValue, if (minGenerating) bigPx * 0.55f else bigPx), + Span("fps", colDim, if (minGenerating) bigUnitPx * 0.8f else bigUnitPx)) + val sub = ArrayList() + sub += Span("1% ", colDim, subPx); sub += Span(lowText(lows.low1Fps) ?: "—", colFps, subPx) + sub += Span(" · 0.1% ", colDim, subPx); sub += Span(lowText(lows.low01Fps) ?: "—", colFps, subPx) + if (showLow001) { + sub += Span(" · 0.01% ", colDim, subPx); sub += Span(lowText(lows.low001Fps) ?: "—", colFps, subPx) + } + val footerLabel = listOf(if (showEngine) engineLabel else "", displayServer) + .filter { it.isNotBlank() }.joinToString(" · ") + val hdr = if (hdrState != FusionHdr.NONE) hdrLine(footPx) else emptyList() + + val (headerBottom, headerRight) = addPanelHeader(left, inner) + val x = headerRight + + val colW = maxOf(runWidth(big), runWidth(sub), x - left, + max(measure(footerLabel, footPx), runWidth(hdr)), if (showGraph) sp(120f) else 0f) + val bigBase = headerBottom + sp(8f) + capHeight(big[0].sizePx) + placeRun(left - inkLeft(big[0].text, big[0].sizePx), bigBase, big, glow = true) + val subBase = bigBase + sp(10f) + capHeight(subPx) + placeRun(left - inkLeft(sub[0].text, subPx), subBase, sub) + var last = subBase + if (showGraph) { + val top = subBase + sp(9f) + graphRect = RectF(left, top, left + colW, top + sp(24f)) + last = top + sp(24f) + } + if (footerLabel.isNotBlank()) { + val base = last + sp(8f) + capHeight(footPx) + glyphs.add(Glyph(left - inkLeft(footerLabel, footPx), base, footerLabel, blend(colDim), footPx)) + last = base + } + if (hdr.isNotEmpty()) { + val base = last + sp(6f) + capHeight(footPx) + placeRun(left - inkLeft(hdr[0].text, footPx), base, hdr) + last = base + } + measurePaint.textSize = footPx + val panel = RectF(panelLeft(), panelInset(), left + colW + sp(34f), last + measurePaint.fontMetrics.descent + sp(9f) + stroke) + + finishPanel(panel, halfRound = true) + } + + private fun capHeight(px: Float): Float { + measurePaint.textSize = px + measurePaint.getTextBounds("0", 0, 1, inkRect) + return -inkRect.top.toFloat() + } + + private fun inkLeft(text: String, px: Float): Float { + if (text.isEmpty()) return 0f + measurePaint.textSize = px + measurePaint.getTextBounds(text, 0, text.length, inkRect) + return inkRect.left.toFloat() + } + + /** Places a column of "label + value" rows; returns (bottomY, rightX). Appends glyphs. Rows flagged + * [HudRow.inline] opt out of the shared label column (value follows the label directly). */ + private fun layoutColumn(rows: List, x: Float, y0: Float, + rowPx: Float, lineGap: Float, lvGap: Float): Pair { + if (rows.isEmpty()) return Pair(y0, x) + var labelCol = 0f + for (r in rows) if (!r.inline) labelCol = max(labelCol, measure(r.label.text, rowPx)) + val h = lineH(rowPx); val asc = ascent(rowPx) + var y = y0; var right = x + for (r in rows) { + val baseline = y - asc + placeRun(x, baseline, listOf(r.label)) + val valX = if (r.inline) x + measure(r.label.text, rowPx) + lvGap + else x + labelCol + lvGap + val end = placeRun(valX, baseline, r.vals) + right = max(right, end) + y += h + lineGap + } + return Pair(y - lineGap, right) + } + + /** Places wrapping " · "-separated info fragments within maxWidth; returns (bottomY, rightX). */ + private fun layoutWrap(fragments: List>, x: Float, y0: Float, maxWidth: Float, + px: Float, lineGap: Float): Pair { + if (fragments.isEmpty()) return Pair(y0, x) + val h = lineH(px); val asc = ascent(px) + val sepW = measure(" · ", px) + var cx = x; var y = y0; var right = x; var firstOnLine = true + for (frag in fragments) { + val w = runWidth(frag) + if (!firstOnLine && (cx + sepW + w - x) > maxWidth) { y += h + lineGap; cx = x; firstOnLine = true } + if (!firstOnLine) { placeRun(cx, y - asc, listOf(Span(" · ", colDim, px))); cx += sepW } + val end = placeRun(cx, y - asc, frag) + cx = end; right = max(right, end); firstOnLine = false + } + return Pair(y + h, right) + } + + private fun buildMega(s: HudMetrics.Snapshot) { + val rowPx = sp(11.5f); val unitPx = rowPx * 0.62f; val bandPx = sp(9.5f) + val pad = sp(10f); val lineGap = sp(3.5f); val lvGap = sp(7f); val gutter = sp(16f) + + // ---- LEFT column: GPU, aggregate CPU, then per-core rows ---- + val left = ArrayList() + if (showGpuModel && gpuNameText().isNotBlank()) + left.add(HudRow(Span("GPU", colGpu, rowPx), gpuModelSpans(rowPx, colValue))) + if (showGPU) { + val v = ArrayList() + v += numUnit(s.gpuPercent?.toString(), "%", rowPx, unitPx) + if (showGpuTemp) tempSpans(s.gpuTempC, HudMetrics.TempSensor.GPU, rowPx, unitPx) + .let { if (it.isNotEmpty()) { v += gap(unitPx); v += it } } + v += gap(unitPx); v += numUnit(s.gpuClockMhz?.toString(), "MHz", rowPx, unitPx) + left.add(HudRow(Span("GPU", colGpu, rowPx), v)) + } + if (showCPU) { + val v = ArrayList() + v += numUnit(s.cpuPercent?.toString(), "%", rowPx, unitPx) + if (showCpuTemp) tempSpans(s.cpuTempC, HudMetrics.TempSensor.CPU, rowPx, unitPx) + .let { if (it.isNotEmpty()) { v += gap(unitPx); v += it } } + v += gap(unitPx); v += numUnit(s.cpuClockMhz?.toString(), "MHz", rowPx, unitPx) + left.add(HudRow(Span("CPU", colCpu, rowPx), v)) + } + if (showPerCore) { + val pct = s.perCorePercent; val clk = s.perCoreClockMhz + val n = max(pct.size, clk.size) + for (i in 0 until n) { + val v = ArrayList() + v += numUnit(if (i < pct.size && pct[i] >= 0) pct[i].toString() else null, "%", rowPx, unitPx) + v += gap(unitPx) + v += numUnit(if (i < clk.size && clk[i] > 0) clk[i].toString() else null, "MHz", rowPx, unitPx) + left.add(HudRow(Span("C$i", colCpu, rowPx), v)) + } + } + + // ---- RIGHT column: VRAM, RAM, SWP, NET, BAT, engine, lows ---- + val right = ArrayList() + if (showVram && s.vramText() != null) + right.add(HudRow(Span("VRAM", colVram, rowPx), valueUnit(s.vramText()!!, rowPx, unitPx))) + if (showRAM) { + val v = ArrayList() + v += valueUnit(s.ramUsedText(), rowPx, unitPx) + v += Span("/", colDim, unitPx); v += valueUnit(s.ramTotalText(), rowPx, unitPx) + v += gap(unitPx); v += numUnit(s.ramPercent.roundToInt().toString(), "%", rowPx, unitPx) + right.add(HudRow(Span("RAM", colRam, rowPx), v)) + } + if (showSwap && s.swapUsedText() != null) { + val v = ArrayList() + v += valueUnit(s.swapUsedText()!!, rowPx, unitPx) + s.swapTotalText()?.let { v += Span("/", colDim, unitPx); v += valueUnit(it, rowPx, unitPx) } + right.add(HudRow(Span("SWP", colRam, rowPx), v)) + } + if (showNet && s.netDownBps != null) { + val d = (s.netDownBps ?: 0L) / 1024L + val u = (s.netUpBps ?: 0L) / 1024L + val v = ArrayList() + v += Span("↓", colDim, unitPx); v += Span(d.toString(), colValue, rowPx) + v += Span(" ↑", colDim, unitPx); v += Span(u.toString(), colValue, rowPx) + v += Span("KB/s", colDim, unitPx) + right.add(HudRow(Span("NET", colFps, rowPx), v)) + } + if (showBattery || showPower || showBatteryTemp) { + val v = ArrayList(); var any = false + if (showBattery && s.battery.percent != null) { v += numUnit(s.battery.percent.toString(), "%", rowPx, unitPx); any = true } + if (showBatteryTemp) tempSpans(s.battery.tempC, HudMetrics.TempSensor.BATTERY, rowPx, unitPx) + .let { if (it.isNotEmpty()) { if (any) v += gap(unitPx); v += it; any = true } } + if (showPower && s.battery.watts > 0f) { if (any) v += gap(unitPx); v += numUnit(fmt1(s.battery.watts), "W", rowPx, unitPx); any = true } + if (any) right.add(HudRow(Span("BAT", colBat, rowPx), v)) + } + if (showFPS) { + val v = ArrayList() + v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx)) + v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx) + // FPS/engine row: just the short API label. The DX version moved to the stack lines + // below the frametime graph (see below), so this row stays compact. + right.add(HudRow(Span(apiLabel(), colFps, rowPx), v)) + right.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx))) + right.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx))) + right.add(HudRow(Span("0.1%", colLo, rowPx), numUnit(lowText(lows.low01Fps), "FPS", rowPx, unitPx))) + if (showLow001) + right.add(HudRow(Span("0.01%", colLo, rowPx), numUnit(lowText(lows.low001Fps), "FPS", rowPx, unitPx))) + } + + // ---- Two columns side by side (each reflows independently) ---- + val (leftBottom, leftRight) = layoutColumn(left, pad, pad, rowPx, lineGap, lvGap) + val rightX = (if (left.isEmpty()) pad else leftRight + gutter) + val (rightBottom, rightRight) = layoutColumn(right, rightX, pad, rowPx, lineGap, lvGap) + var y = max(leftBottom, rightBottom) + var maxRight = max(leftRight, rightRight) + + // ---- Bottom band spanning both columns: res · Proton · elapsed ---- + // (The graphics wrapper moved to the FPS-row label above, so it's no longer in this band.) + val band = ArrayList>() + if (showResolution) band.add(listOf(Span("RES ", colDim, bandPx), Span(resolutionString(), colValue, bandPx))) + if (displayServer.isNotBlank()) band.add(listOf(Span("DISP ", colDim, bandPx), Span(displayServer, colDisp, bandPx))) + if (hdrState != FusionHdr.NONE) band.add(listOf(Span("HDR ", colDim, bandPx)) + hdrValue(bandPx)) + if (showProton && wineVersion.isNotBlank()) band.add(listOf(Span(wineVersion, colVram, bandPx))) + if (showSession) band.add(listOf(Span("elapsed ", colDim, bandPx), Span(elapsedString(), colValue, bandPx))) + if (band.isNotEmpty()) { + y += sp(4f) + val (bBottom, bRight) = layoutWrap(band, pad, y, max(maxRight - pad, sp(180f)), bandPx, lineGap) + y = bBottom; maxRight = max(maxRight, bRight) + } + + // ---- Full-width frametime graph ---- + if (showGraph) { + y += sp(3f) + val gh = sp(22f) + val gr = max(maxRight, pad + sp(220f)) + graphRect = RectF(pad, y, gr, y + gh) + maxRight = max(maxRight, gr) + y += gh + } + + // ---- Stack-layer lines BELOW the frametime graph, subtle (same size/style as the bottom + // band): the DX-wrapper version first (e.g. "DXVK 2.4.1-…", gated by "DX ver"), then the + // graphics wrapper beneath it (e.g. "Original", gated by "Wrapper"). ---- + val dxLine = dxVersionLabeled() + val hasDx = showDxVer && dxLine.isNotBlank() + if (hasDx) { + y += sp(4f) + maxRight = max(maxRight, placeRun(pad, y - ascent(bandPx), listOf(Span(dxLine, colDim, bandPx)))) + y += lineH(bandPx) + } + if (showWrapper && graphicsWrapper.isNotBlank()) { + y += if (hasDx) sp(1f) else sp(4f) + maxRight = max(maxRight, placeRun(pad, y - ascent(bandPx), listOf(Span(graphicsWrapper, colDim, bandPx)))) + y += lineH(bandPx) + } + + contentW = maxRight + pad + contentH = y + pad + addSubtleClock(pad) // subtle time-of-day, consistent with the other sizes + } + + // ---- Measure / draw --------------------------------------------------- + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + setMeasuredDimension( + resolveSize(contentW.roundToInt(), widthMeasureSpec), + resolveSize(contentH.roundToInt(), heightMeasureSpec), + ) + } + + override fun onDraw(canvas: Canvas) { + if (contentW <= 0f || contentH <= 0f) { lockController.drawBadge(canvas); return } + + dPanel?.let { panel -> panelFrame(panel)?.let { canvas.drawBitmap(it, 0f, 0f, null) } } + + // Tile backings + if (tileRects.isNotEmpty()) { + tilePaint.color = Color.argb((14 * bgOpacity).roundToInt().coerceIn(8, 40), 255, 255, 255) + val tr = sp(6f) + for (rect in tileRects) canvas.drawRoundRect(rect, tr, tr, tilePaint) + } + + chipRect?.let { drawChip(canvas, it) } + + // Text + for (g in glyphs) { + val paint = if (g.sans) wordmarkPaint else drawPaint + paint.textSize = g.sizePx + paint.color = g.color + if (g.glow) paint.setShadowLayer(sp(6f), 0f, 0f, withAlpha(colGraph, 217)) + canvas.drawText(g.text, g.x, g.baseline, paint) + if (g.glow) paint.clearShadowLayer() + } + headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) } + + // Frametime graph + graphRect?.let { drawGraph(canvas, it) } + + lockController.drawBadge(canvas) + } + + // MangoHud's frametime graph: the last 200 frames on a fixed 0-50 ms scale, longer frames clipped + // to 49.9 ms (hud_elements.cpp TransformForward_Custom). + private fun drawGraph(canvas: Canvas, rect: RectF) { + val stats = frames ?: return + stats.graph(graphBuf) + val step = rect.width() / (graphBuf.size - 1) + graphPath.rewind() + for (i in graphBuf.indices) { + val px = rect.left + i * step + val py = rect.bottom - min(graphBuf[i], GRAPH_CLIP_MS) / FrameStats.GRAPH_MAX_MS * rect.height() + if (i == 0) graphPath.moveTo(px, py) else graphPath.lineTo(px, py) + } + if (dPanel != null) { + // Neon without a blur: a blur would be redrawn with every frame. + graphPaint.strokeWidth = sp(4.5f) + graphPaint.color = withAlpha(blend(colGraph), 56) + canvas.drawPath(graphPath, graphPaint) + } + graphPaint.strokeWidth = sp(1.6f) + graphPaint.color = blend(colGraph) + canvas.drawPath(graphPath, graphPaint) + } + + private fun outlineWidth(): Float = if (outlineIntensity > 0f) outlineIntensity * sp(3.5f) else 0f + private fun outlineColor(): Int = if (outlineFollowAccent) accent else Color.rgb(200, 200, 200) + private fun panelColor(): Int = Color.argb((bgOpacity.coerceIn(0f, 1f) * 255f).roundToInt(), + Color.red(surfaceRgb), Color.green(surfaceRgb), Color.blue(surfaceRgb)) + private fun withAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + + /** The D: straight left side with small corners, round on the right with radius [rad] (a half circle + * when it is half the height). [split] > 0 leaves that much open above and below the middle of the + * left side, where the orb sits. */ + private fun dShape(l: Float, t: Float, r: Float, b: Float, rad: Float, corner: Float, split: Float): Path { + val cy = (t + b) / 2f + return Path().apply { + if (split > 0f) moveTo(l, cy + split) else moveTo(l, t + corner) + lineTo(l, b - corner); quadTo(l, b, l + corner, b) + lineTo(r - rad, b) + arcTo(r - 2f * rad, b - 2f * rad, r, b, 90f, -90f, false) + lineTo(r, t + rad) + arcTo(r - 2f * rad, t, r, t + 2f * rad, 0f, -90f, false) + lineTo(l + corner, t); quadTo(l, t, l, t + corner) + if (split > 0f) lineTo(l, cy - split) else close() + } + } + + /** Where the inside of the outline's round side is at height [y]. */ + private fun roundEdgeX(p: RectF, y: Float, stroke: Float): Float { + val r = dRadius - stroke + val cx = p.right - dRadius + val dy = p.top + dRadius - y + return if (dy <= 0f) p.right - stroke else if (dy >= r) cx else cx + sqrt(r * r - dy * dy) + } + + private fun panelFrame(panel: RectF): Bitmap? { + if (width <= 0 || height <= 0) return null + val stroke = outlineWidth() + val key = "$width,$height,$panel,$dRadius,$dDividerTop,$stroke,$accent,$surfaceRgb,$bgOpacity,${outlineColor()}" + if (key == dFrameKey) dFrame?.let { return it } + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val c = Canvas(bmp) + val corner = sp(6f) + val body = dShape(panel.left, panel.top, panel.right, panel.bottom, dRadius, corner, 0f) + bgPaint.color = panelColor() + c.drawPath(body, bgPaint) + c.save() + c.clipPath(body) + if (dDividerTop >= 0f) { + val dividerBottom = dDividerTop + max(1f, density) + headerPaint.shader = android.graphics.LinearGradient(panel.left, 0f, panel.right, 0f, + withAlpha(accent, 46), withAlpha(accent, 0), android.graphics.Shader.TileMode.CLAMP) + c.drawRect(panel.left, panel.top, panel.right, dividerBottom, headerPaint) + headerPaint.shader = null + headerPaint.color = withAlpha(accent, 90) + c.drawRect(panel.left, dDividerTop, roundEdgeX(panel, dDividerTop, stroke), dividerBottom, headerPaint) + } + // The watermark fills the half circle on the short sizes and sits on the right of the tall ones. + val halfRound = dRadius >= panel.height() / 2f - 0.5f + val wmH = if (halfRound) panel.height() * 0.92f else min(panel.height(), panel.width()) * 0.55f + DroidDeckMark.draw(c, panel.right - DroidDeckMark.widthFor(wmH) - (if (halfRound) panel.height() * 0.02f else sp(10f)), + panel.centerY() - wmH / 2f, wmH, Color.argb(20, 255, 255, 255), withAlpha(accent, 20)) + c.restore() + if (stroke > 0f) { + val h = stroke / 2f + val outline = dShape(panel.left + h, panel.top + h, panel.right - h, panel.bottom - h, dRadius - h, corner, sp(9f)) + val blur = BlurMaskFilter((sp(3.2f) - 0.5f).coerceAtLeast(0.5f) / 0.57735f, BlurMaskFilter.Blur.NORMAL) + glowPaint.strokeWidth = stroke + // Two blurred passes under the sharp one, as the 4-in-1 preview's neon. + for (pass in 0..2) { + glowPaint.maskFilter = if (pass < 2) blur else null + glowPaint.style = Paint.Style.STROKE; glowPaint.color = outlineColor() + c.drawPath(outline, glowPaint) + glowPaint.style = Paint.Style.FILL; glowPaint.color = accent + c.drawCircle(panel.left + h, panel.centerY(), sp(5.5f), glowPaint) + } + glowPaint.maskFilter = null + } + dFrame = bmp; dFrameKey = key + return bmp + } + + @VisibleForTesting + internal fun showForTest(snapshot: HudMetrics.Snapshot, engine: String, nowNs: Long) { + engineLabel = engine + snap = snapshot + lows = frames?.lows(nowNs) ?: FrameStats.Lows.EMPTY + fpsNow = frames?.fps ?: 0f + rebuild() + } + + @VisibleForTesting internal fun panelForTest(): RectF? = dPanel + @VisibleForTesting internal fun panelRadiusForTest(): Float = dRadius + @VisibleForTesting internal fun graphForTest(): RectF? = graphRect + @VisibleForTesting internal fun linesForTest(): List = + glyphs.groupBy { it.baseline.roundToInt() }.toSortedMap().values.map { line -> line.sortedBy { it.x }.joinToString("") { it.text } } + + private fun drawChip(canvas: Canvas, rect: RectF) { + val r = sp(6f) + headerPaint.shader = null + headerPaint.color = Color.argb(235, Color.red(surfaceRgb), Color.green(surfaceRgb), Color.blue(surfaceRgb)) + canvas.drawRoundRect(rect, r, r, headerPaint) + strokePaint.strokeWidth = sp(1f) + strokePaint.color = accent + val h = strokePaint.strokeWidth / 2f + canvas.drawRoundRect(rect.left + h, rect.top + h, rect.right - h, rect.bottom - h, r, r, strokePaint) + val markH = rect.height() * 0.6f + val markW = DroidDeckMark.widthFor(markH) + DroidDeckMark.draw(canvas, rect.centerX() - markW / 2f, rect.centerY() - markH / 2f, markH, blend(colValue), accent) + } + + /** Screen point → whether an on-screen control under the HUD takes a touch there. */ + internal var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null + + // Declining the down event hands the whole gesture to the views under the HUD. + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN && lockController.isLocked() && + passesTouchAt?.invoke(event.rawX, event.rawY) == true) return false + return lockController.onTouchEvent(event) + } + + internal val isDragging: Boolean get() = lockController.isDragging() + + private companion object { + const val REFRESH_MS = 500L + const val GRAPH_CLIP_MS = 49.9f + + fun parseIntOr(s: String?, d: Int): Int = try { s?.trim()?.toInt() ?: d } catch (e: Exception) { d } + + fun parseOutline(v: String?): Int = when (v?.trim()?.lowercase(Locale.US)) { + null -> 40; "off" -> 0; "soft" -> 40; "strong" -> 70 + else -> v.trim().toIntOrNull()?.coerceIn(0, 100) ?: 40 + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt new file mode 100644 index 000000000..fcca0aaa2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt @@ -0,0 +1,262 @@ +package com.droiddeck.launcher.hud + +import java.io.File +import kotlin.math.roundToInt + +/** + * Frame statistics by MangoHud's rules, so the HUD and Steam's performance overlay agree: MangoHud + * overlay.cpp update_hud_info_with_frametime and hud_elements.cpp fps() / frame_timing(). The FPS is the + * frames counted over a sampling period of at least 500 ms, the frame time next to it is 1000 / FPS, and + * the graph holds the last 200 frame times (zeros until filled) on a fixed 0-50 ms scale, min and max + * taken over those 200. Average and lows are FusionHUD's own readouts. + */ +internal class FrameStats { + private val graph = FloatArray(GRAPH_FRAMES) + private var graphHead = 0 + private var lastPresentNs = 0L + private var lastFpsUpdateNs = 0L + private var framesSinceUpdate = 0 + + private val lowsRing = LongArray(LOWS_FRAMES) + private var lowsHead = 0 + private var lowsSize = 0 + private var cachedLows = Lows.EMPTY + private var cachedLowsNs = 0L + + private var sessionStartNs = 0L + private var sessionFrames = 0L + + @Volatile var fps = 0f + private set + + /** Completed sampling periods; MangoHud redraws its readouts when one completes. */ + @Volatile var fpsUpdates = 0L + private set + + /** Frames added so far; the HUD redraws its graph when this moves. */ + @Volatile var count = 0L + private set + + @Synchronized + fun add(frametimeNs: Long, timeNs: Long) { + if (frametimeNs <= 0L) return + if (lastPresentNs != 0L) { + graph[graphHead] = frametimeNs / 1e6f + graphHead = (graphHead + 1) % GRAPH_FRAMES + } + val elapsed = timeNs - lastFpsUpdateNs + if (elapsed >= FPS_PERIOD_NS) { + fps = (1e9 * framesSinceUpdate / elapsed).toFloat() + framesSinceUpdate = 0 + lastFpsUpdateNs = timeNs + fpsUpdates++ + } + lastPresentNs = timeNs + framesSinceUpdate++ + + if (frametimeNs < PAUSE_NS) { + lowsRing[lowsHead] = frametimeNs + lowsHead = (lowsHead + 1) % LOWS_FRAMES + if (lowsSize < LOWS_FRAMES) lowsSize++ + if (sessionStartNs == 0L) sessionStartNs = timeNs - frametimeNs + sessionFrames++ + } + count++ + } + + @Synchronized + fun reset() { + graph.fill(0f) + graphHead = 0 + lastPresentNs = 0L + lastFpsUpdateNs = 0L + framesSinceUpdate = 0 + lowsHead = 0 + lowsSize = 0 + cachedLows = Lows.EMPTY + cachedLowsNs = 0L + sessionStartNs = 0L + sessionFrames = 0L + fps = 0f + count++ + } + + /** MangoHud's frame time readout: 1000 / FPS. */ + fun frametimeMs(): Float = 1000f / maxOf(fps, 1f) + + /** The graph, oldest first, into [out] (GRAPH_FRAMES long). */ + @Synchronized + fun graph(out: FloatArray) { + for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES] + } + + @Synchronized + fun graphMinMs(): Float = graph.min() + + @Synchronized + fun graphMaxMs(): Float = graph.max() + + /** Average over the frames this session, from the first frame to [nowNs]'s last frame. */ + @Synchronized + fun averageFps(): Float { + if (sessionFrames < 2 || lastPresentNs <= sessionStartNs) return 0f + return (1e9 * sessionFrames / (lastPresentNs - sessionStartNs)).toFloat() + } + + @Synchronized + fun sessionSeconds(nowNs: Long): Float = if (sessionStartNs == 0L) 0f else (nowNs - sessionStartNs) / 1e9f + + class Lows(val low1Fps: Float, val low01Fps: Float, val low001Fps: Float) { + companion object { + val EMPTY = Lows(0f, 0f, 0f) + } + } + + /** FPS at the 99th, 99.9th and 99.99th percentile frame time of the last LOWS_FRAMES frames. */ + fun lows(nowNs: Long): Lows { + val copy: LongArray + synchronized(this) { + if (cachedLows !== Lows.EMPTY && nowNs - cachedLowsNs < LOWS_RECOMPUTE_NS) return cachedLows + if (lowsSize < 2) return Lows.EMPTY + copy = LongArray(lowsSize) + val start = (lowsHead - lowsSize + LOWS_FRAMES) % LOWS_FRAMES + for (i in 0 until lowsSize) copy[i] = lowsRing[(start + i) % LOWS_FRAMES] + } + copy.sort() + fun at(p: Double): Float { + val ft = copy[(p * (copy.size - 1)).roundToInt().coerceIn(0, copy.size - 1)] + return if (ft > 0) (1e9 / ft).toFloat() else 0f + } + val result = Lows(at(0.99), at(0.999), at(0.9999)) + synchronized(this) { + cachedLows = result + cachedLowsNs = nowNs + } + return result + } + + companion object { + const val GRAPH_FRAMES = 200 + const val GRAPH_MAX_MS = 50f + const val FPS_PERIOD_NS = 500_000_000L + const val LOWS_FRAMES = 8192 + private const val PAUSE_NS = 1_000_000_000L + private const val LOWS_RECOMPUTE_NS = 250_000_000L + } +} + +/** + * Feeds [stats] with the frame times gamescope shares (the ones MangoHud shows). Sessions without the + * shared file, or where gamescope stopped sending, fall back to the frames DroidDeck's compositor shows, + * which stop at the screen's refresh rate. + */ +internal class GameFrameFeed(private val file: File, val stats: FrameStats = FrameStats()) { + private var handle = 0L + private var nextOpenNs = 0L + private var lastRingFrameNs = 0L + private var lastInfoNs = 0L + private val frametimes = LongArray(HudFrames.CAPACITY) + private val times = LongArray(HudFrames.CAPACITY) + private val info = IntArray(5) + @Volatile private var fromGamescope = false + private var lastCompositorNs = 0L + @Volatile private var compositorFrames = 0L // written by the compositor thread only + private var seenCompositorFrames = 0L + + @Volatile var engine = "" + private set + @Volatile var steamFocused = false + private set + @Volatile var pid = 0 + private set + @Volatile var outputWidth = 0 + private set + @Volatile var outputHeight = 0 + private set + @Volatile var refreshMilliHz = 0 + private set + + /** Main thread, once per display frame. True when new frames arrived. */ + fun poll(nowNs: Long): Boolean { + val compositorAdded = compositorFrames != seenCompositorFrames + seenCompositorFrames = compositorFrames + if (handle == 0L) { + if (nowNs < nextOpenNs) return compositorAdded + nextOpenNs = nowNs + REOPEN_NS + handle = runCatching { HudFrames.nativeOpen(file.path) }.getOrDefault(0L) + if (handle == 0L) return compositorAdded + // Frames already in the file were written before this reader started. + HudFrames.nativePoll(handle, frametimes, times) + } + val n = HudFrames.nativePoll(handle, frametimes, times) + if (n > 0) { + if (!fromGamescope) { + stats.reset() + fromGamescope = true + } + for (i in 0 until n) stats.add(frametimes[i], times[i]) + lastRingFrameNs = times[n - 1] + } else if (lastRingFrameNs != 0L && nowNs - lastRingFrameNs > LIVE_NS) { + fromGamescope = false + } + // A new session writes the file again from the start; reopen when gamescope has gone quiet. + if (n <= 0 && nowNs - maxOf(lastRingFrameNs, nextOpenNs - REOPEN_NS) > STALE_NS) close() + if (nowNs - lastInfoNs >= INFO_NS) { + lastInfoNs = nowNs + readInfo() + } + return n > 0 || compositorAdded + } + + /** Compositor thread: one frame shown on the panel. */ + fun onCompositorFrame(nowNs: Long) { + if (fromGamescope) { + lastCompositorNs = 0L + return + } + val last = lastCompositorNs + lastCompositorNs = nowNs + if (last != 0L) { + stats.add(nowNs - last, nowNs) + compositorFrames++ + } + } + + fun close() { + if (handle != 0L) HudFrames.nativeClose(handle) + handle = 0L + } + + private fun readInfo() { + val name = if (handle != 0L && fromGamescope) HudFrames.nativeInfo(handle, info) else null + if (name == null) { + engine = "" + return + } + pid = info[0] + outputWidth = info[1] + outputHeight = info[2] + refreshMilliHz = info[3] + steamFocused = info[4] != 0 + engine = engineLabel(name) + } + + companion object { + private const val REOPEN_NS = 2_000_000_000L + private const val LIVE_NS = 2_000_000_000L + private const val STALE_NS = 5_000_000_000L + private const val INFO_NS = 500_000_000L + + /** DroidDeck's mangoapp's names for gamescope's engine field (Valve's 20260902.1 build, app/main.cpp + * engine_type_from_str and overlay.h engines); gamescope sends "gamescope" when it knows none. */ + fun engineLabel(name: String): String = when (name) { + "DXVK" -> "DXVK" + "vkd3d" -> "VKD3D" + "mesa zink" -> "ZINK" + "Damavand" -> "DAMAVAND" + "Feral3D" -> "Feral3D" + "gamescope", "" -> "GAMESCOPE" + else -> "VULKAN" + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt new file mode 100644 index 000000000..63a9a7945 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt @@ -0,0 +1,17 @@ +package com.droiddeck.launcher.hud + +/** Reader for the frame-times file gamescope patch 0125 writes (hudframes.c). */ +internal object HudFrames { + init { + System.loadLibrary("droiddeckwayland") + } + + /** Under the Linux root: the session exports /run/droiddeck-hud/frames to gamescope. */ + const val PATH = "run/droiddeck-hud/frames" + const val CAPACITY = 1024 + + @JvmStatic external fun nativeOpen(path: String): Long + @JvmStatic external fun nativeClose(handle: Long) + @JvmStatic external fun nativePoll(handle: Long, frametimes: LongArray, times: LongArray): Int + @JvmStatic external fun nativeInfo(handle: Long, info: IntArray): String? +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java new file mode 100644 index 000000000..3266e85fe --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java @@ -0,0 +1,265 @@ +/* + * FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays. + * Copyright (C) The412Banner + * + * Licensed under the GNU General Public License v3.0 (see LICENSE), with an + * ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md): + * any use, fork, or distribution must preserve credit to "The412Banner" and a + * link to https://github.com/The412Banner/FusionHUD in the project documentation + * AND in the in-app credits/about screen. + * + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + */ + +package com.droiddeck.launcher.hud; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.os.SystemClock; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Shared touch + lock behaviour for every in-game performance HUD (classic FrameRating(+Horizontal), + * GameHub PerfHudView, GameNative PerformanceHudView, and the Fusion HUD). One place so the four + * overlays can't drift: + * + *
    + *
  • tap → {@link Callbacks#onTap()} (orientation-toggle for the first three; size-cycle for + * Fusion) — a no-op that only re-flashes the lock badge while locked.
  • + *
  • drag → moves the host view and, on release, {@link Callbacks#onMoved} to persist the + * position — disabled (frozen) while locked.
  • + *
  • long-press → toggles {@code hudLocked}, persisted via {@link Callbacks#onLockChanged} + * and kept in sync with the "Lock in place" menu chip. Works locked or unlocked (so a long-press + * always unlocks); a locked {@code FusionHudView} passes touches on only over on-screen controls.
  • + *
+ * + *

On every lock/unlock (and on a blocked gesture while locked) a small padlock badge fades in → + * holds → fades out in a corner of the panel. The fade is driven by a self-reposting Runnable that + * invalidates only while visible — nothing runs on the present path, nothing animates at rest. + * + *

All methods are Main-thread only (touch + draw), matching the epoll-thread rule: the views feed + * this from {@code onTouchEvent}/{@code dispatchDraw}, never from a present tick. + */ +public class HudLockController { + + public interface Callbacks { + /** Unlocked single tap: orientation-toggle (classic/gamehub/gamenative) or size-cycle (fusion). */ + void onTap(); + /** Drag released (unlocked): persist the new (x, y). */ + void onMoved(float x, float y); + /** Lock toggled by long-press: persist {@code hudLocked} + sync the menu chip. */ + void onLockChanged(boolean locked); + } + + // Fade timing (ms): in → hold → out. Lock/unlock hold ~1.5 s; a blocked-gesture re-flash is shorter. + private static final long FADE_IN_MS = 160; + private static final long HOLD_TOGGLE_MS = 1500; + private static final long HOLD_REFLASH_MS = 750; + private static final long FADE_OUT_MS = 420; + private static final long FRAME_MS = 32; + + private final View view; + private final Callbacks cb; + private final GestureDetector detector; + + private boolean locked = false; + + // Drag state + private float downRawX, downRawY, startX, startY; + private boolean dragging = false; + + // Fade state + private long fadeStart = 0; + private long holdMs = HOLD_TOGGLE_MS; + private boolean fadeShowLocked = false; + + private final Paint iconFill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint iconStroke = new Paint(Paint.ANTI_ALIAS_FLAG); + + public HudLockController(Context context, View view, Callbacks cb) { + this.view = view; + this.cb = cb; + this.detector = new GestureDetector(context, new Listener()); + this.detector.setIsLongpressEnabled(true); + iconFill.setStyle(Paint.Style.FILL); + iconStroke.setStyle(Paint.Style.STROKE); + iconStroke.setStrokeCap(Paint.Cap.ROUND); + iconStroke.setStrokeJoin(Paint.Join.ROUND); + } + + /** Set lock state from config (applyConfig) WITHOUT firing the persist callback. */ + public void setLocked(boolean locked) { this.locked = locked; } + + public boolean isLocked() { return locked; } + + public boolean isDragging() { return dragging; } + + /** Feed from the host view's onTouchEvent. Always consumes (mirrors the legacy manual handlers). */ + public boolean onTouchEvent(MotionEvent event) { + detector.onTouchEvent(event); + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + if (dragging && !locked) cb.onMoved(view.getX(), view.getY()); + dragging = false; + } + return true; + } + + private final class Listener extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onDown(MotionEvent e) { + downRawX = e.getRawX(); + downRawY = e.getRawY(); + startX = view.getX(); + startY = view.getY(); + dragging = false; + return true; + } + + @Override + public void onLongPress(MotionEvent e) { + // Long-press always toggles — this is the only gesture that still works while locked. + locked = !locked; + cb.onLockChanged(locked); + flash(locked, HOLD_TOGGLE_MS); + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (locked) { + flash(true, HOLD_REFLASH_MS); // intentionally locked — remind the user + } else { + cb.onTap(); + } + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + if (locked) { + if (!dragging) flash(true, HOLD_REFLASH_MS); + dragging = true; // mark so the blocked drag doesn't fall through to a tap + return true; + } + view.setX(startX + (e2.getRawX() - downRawX)); + view.setY(startY + (e2.getRawY() - downRawY)); + dragging = true; + return true; + } + } + + // ---- Fade badge ------------------------------------------------------- + private void flash(boolean showLocked, long hold) { + fadeShowLocked = showLocked; + holdMs = hold; + fadeStart = SystemClock.elapsedRealtime(); + view.removeCallbacks(fadeTick); + view.post(fadeTick); + } + + private final Runnable fadeTick = new Runnable() { + @Override + public void run() { + view.invalidate(); + if (currentAlpha() > 0f) view.postDelayed(this, FRAME_MS); + } + }; + + private float currentAlpha() { + if (fadeStart == 0) return 0f; + long e = SystemClock.elapsedRealtime() - fadeStart; + if (e < 0) return 0f; + if (e < FADE_IN_MS) return e / (float) FADE_IN_MS; + long holdEnd = FADE_IN_MS + holdMs; + if (e < holdEnd) return 1f; + long outEnd = holdEnd + FADE_OUT_MS; + if (e < outEnd) return 1f - (e - holdEnd) / (float) FADE_OUT_MS; + return 0f; + } + + private static int withAlpha(int color, float a) { + return Color.argb(Math.round(255 * a), Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Draw the padlock badge in the top-right of the panel if a fade is in progress. Call from the + * host's onDraw (plain View) or dispatchDraw (ViewGroup), after the content. + * + *

No background box: the glyph is drawn twice — a slightly expanded, thicker semi-transparent + * black "halo" pass for legibility on any game frame, then the white glyph on top — so it reads as + * a floating icon, not an opaque tile. + */ + public void drawBadge(Canvas canvas) { + float a = currentAlpha(); + if (a <= 0f) return; + float d = view.getResources().getDisplayMetrics().density; + float size = 15f * d; + float margin = 4f * d; + float right = view.getWidth() - margin; + float left = right - size; + float top = margin; + + float cx = (left + right) / 2f; + float bottom = top + size; + float bodyTop = top + size * 0.46f; + float bodyLeft = left + size * 0.14f; + float bodyRight = right - size * 0.14f; + float r = size * 0.2f; + float shackleTop = top + size * 0.08f; + + // Pass 1: dark halo (expanded body, thicker shackle) for contrast; Pass 2: white glyph. + drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d, + Color.argb(Math.round(150 * a), 0, 0, 0), 3.0f * d, 0.9f * d, false); + drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d, + withAlpha(0xFFFFFFFF, a), 1.7f * d, 0f, true); + } + + /** One padlock pass. {@code halo} inflates the body/shackle in dark; the white pass punches the keyhole. */ + private void drawLock(Canvas canvas, float cx, float bodyTop, float bodyLeft, float bodyRight, + float bottom, float r, float shackleTop, float d, + int color, float strokeW, float expand, boolean punchKeyhole) { + iconFill.setColor(color); + iconStroke.setColor(color); + iconStroke.setStrokeWidth(strokeW); + + // Body + canvas.drawRoundRect(bodyLeft - expand, bodyTop - expand, bodyRight + expand, bottom + expand, + 2.4f * d, 2.4f * d, iconFill); + if (punchKeyhole) { + int prev = iconFill.getColor(); + iconFill.setColor(Color.argb(Color.alpha(color), 0, 0, 0)); + canvas.drawCircle(cx, (bodyTop + bottom) / 2f, 1.6f * d, iconFill); + iconFill.setColor(prev); + } + + RectF arc = new RectF(cx - r, shackleTop, cx + r, shackleTop + 2f * r); + if (fadeShowLocked) { + // Closed: both legs run straight down into the body. + Path p = new Path(); + p.addArc(arc, 180f, 180f); + p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); + p.moveTo(cx + r, shackleTop + r); p.lineTo(cx + r, bodyTop); + canvas.drawPath(p, iconStroke); + } else { + // Open, clearly to the LEFT: the RIGHT leg stays seated in the body; the arc + LEFT leg + // hinge up-and-left off the right attachment so the left leg lifts well clear of the body. + canvas.drawLine(cx + r, shackleTop + r, cx + r, bodyTop, iconStroke); // fixed right leg + Path p = new Path(); + p.addArc(arc, 180f, 180f); + p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); // full-length left leg + canvas.save(); + canvas.rotate(-42f, cx + r, shackleTop + r); + canvas.drawPath(p, iconStroke); + canvas.restore(); + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java b/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java new file mode 100644 index 000000000..88cbc8707 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java @@ -0,0 +1,1628 @@ +/* + * FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays. + * Copyright (C) The412Banner + * + * Licensed under the GNU General Public License v3.0 (see LICENSE), with an + * ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md): + * any use, fork, or distribution must preserve credit to "The412Banner" and a + * link to https://github.com/The412Banner/FusionHUD in the project documentation + * AND in the in-app credits/about screen. + * + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + */ + +package com.droiddeck.launcher.hud; + +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; +import android.os.Build; +import android.os.SystemClock; + + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +/** + * Shared live-metric collector for every performance HUD overlay. This is the single, device-complete + * implementation of the sysfs / Android readers — no overlay should re-read sysfs itself, so the GPU + * discovery / thermal / battery logic never diverges. + * + *

Ported and hardened from GameNative's {@code PerformanceHudView} readers, which are far more + * device-complete than the two-fixed-path versions previously inlined in the classic overlays: + *

    + *
  • GPU load — ~10 static candidates plus a dynamic {@code /sys/class/devfreq} + + * {@code /sys/devices/virtual/devfreq} walk for gpu/mali/g3d/kgsl nodes; handles gpubusy + * (busy/total), Mali gpuinfo (delta-ms/wall-ms, needs state between calls) and generic percent + * nodes. Discovery cached.
  • + *
  • CPU usage — {@code /proc/stat} aggregate delta with a scaling-frequency fallback for devices + * where {@code /proc/stat} is restricted.
  • + *
  • Thermal — prioritized CPU zone discovery (cpu-silicon > cpu-0 > cpu > soc > + * s5p-tmu > cputop > tsens > cluster > big/little) and a separate prioritized GPU zone + * discovery plus kgsl/mali direct paths. milli-°C normalization + 1..150 sanity clamp.
  • + *
  • Battery — % capacity, power W, runtime-left estimate (charge_counter / current_now, smoothed), + * battery temperature, plus the dual-battery current-sum for the classic overlays.
  • + *
+ * + *

Every getter is null / absent-safe: nullable getters return {@code null} when a metric cannot be + * read (the overlay hides that row, matching GameNative behavior); the legacy non-null getters return + * 0 in that case. Not thread-safe; call from a single refresh thread per instance. + */ +public class HudMetrics { + private final Context context; + + public HudMetrics(Context context) { this.context = context; } + + // DroidDeck: the readings Steam's performance overlay shows come from MangoReadings, so both agree. + private MangoReadings mango; + public void setMangoReadings(MangoReadings readings) { mango = readings; } + + // ======================================================================= + // CPU usage + // ======================================================================= + private Long lastCpuTotal = null; + private Long lastCpuIdle = null; + + /** + * Overall device CPU usage 0..100 computed from the /proc/stat delta since the last call, with a + * scaling-frequency fallback. Returns null only when neither source is readable. + */ + public Integer getCpuUsagePercent() { + String line = readFirstLine("/proc/stat"); + if (line != null) { + String[] parts = line.trim().split("\\s+"); + if (parts.length >= 5 && "cpu".equals(parts[0])) { + long total = 0, idle = 0, iowait = 0; + boolean ok = true; + for (int i = 1; i < parts.length; i++) { + try { + long v = Long.parseLong(parts[i]); + total += v; + if (i == 4) idle = v; + else if (i == 5) iowait = v; + } catch (NumberFormatException e) { ok = false; break; } + } + if (ok) { + long idleTotal = idle + iowait; + Long prevTotal = lastCpuTotal, prevIdle = lastCpuIdle; + lastCpuTotal = total; + lastCpuIdle = idleTotal; + if (prevTotal != null && prevIdle != null) { + long dTotal = total - prevTotal; + long dIdle = idleTotal - prevIdle; + if (dTotal > 0) { + long usage = (Math.max(0, dTotal - dIdle) * 100L) / dTotal; + return clampPercent((int) usage); + } + } + // First sample seeds the delta — fall through to the frequency estimate so the + // very first read still returns something. + } + } + } + return readCpuUsagePercentFromFrequency(); + } + + private Integer readCpuUsagePercentFromFrequency() { + long currentTotal = 0, maxTotal = 0; + int cores = Runtime.getRuntime().availableProcessors(); + for (int i = 0; i < cores; i++) { + Long cur = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/scaling_cur_freq"); + Long max = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/cpuinfo_max_freq"); + if (cur != null && max != null && max > 0L) { + currentTotal += Math.max(0, Math.min(cur, max)); + maxTotal += max; + } + } + if (maxTotal <= 0L) return null; + return clampPercent((int) ((currentTotal * 100L) / maxTotal)); + } + + /** Legacy non-null accessor (0 when unavailable). Kept for {@code PerfHudView}. */ + public float getCPUUsage() { + Integer p = getCpuUsagePercent(); + return p == null ? 0f : p; + } + + // ======================================================================= + // GPU load + // ======================================================================= + private List gpuUsagePathsCache = null; + private Long lastMaliGpuInfoMs = null; + private long lastMaliGpuInfoWallMs = 0L; + + /** GPU utilisation 0..100, or null when no readable source is found on this device. */ + public Integer getGpuUsagePercent() { + for (String path : discoverGpuUsagePaths()) { + Integer p = readGpuUsageSample(path); + if (p != null) return p; + } + return null; + } + + /** Legacy non-null accessor (0 when unavailable). Kept for {@code PerfHudView}. */ + public int getGPULoad() { + Integer p = getGpuUsagePercent(); + return p == null ? 0 : p; + } + + private List discoverGpuUsagePaths() { + if (gpuUsagePathsCache != null) return gpuUsagePathsCache; + LinkedHashSet candidates = new LinkedHashSet<>(); + + for (String p : GPU_USAGE_STATIC_PATHS) { + if (new File(p).canRead()) candidates.add(p); + } + + // Vendor platform nodes with hashed/addressed names we can't hard-code (e.g. + // /sys/devices/platform/13000000.mali/utilisation, .../gpusysfs/gpu_busy, panfrost, sgpu). + File platformDir = new File("/sys/devices/platform"); + File[] platformNodes = platformDir.listFiles(File::isDirectory); + if (platformNodes != null) { + for (File node : platformNodes) { + String name = node.getName().toLowerCase(Locale.US); + boolean looksLikeGpu = false; + for (String t : GPU_NODE_TOKENS) { + if (name.contains(t)) { looksLikeGpu = true; break; } + } + if (!looksLikeGpu) continue; + for (String fileName : GPU_USAGE_FILES) { + File f = new File(node, fileName); + if (f.canRead()) candidates.add(f.getPath()); + } + } + } + + File[] devfreqRoots = { + new File("/sys/class/devfreq"), + new File("/sys/devices/virtual/devfreq"), + }; + for (File root : devfreqRoots) { + if (!root.isDirectory()) continue; + File[] nodeDirs = root.listFiles(File::isDirectory); + if (nodeDirs == null) continue; + for (File node : nodeDirs) { + String nodePath = node.getPath().toLowerCase(Locale.US); + boolean looksLikeGpu = false; + for (String t : GPU_NODE_TOKENS) { + if (nodePath.contains(t)) { looksLikeGpu = true; break; } + } + for (String fileName : GPU_USAGE_FILES) { + File f = new File(node, fileName); + if (!f.canRead()) continue; + if (looksLikeGpu || fileName.equals("gpu_busy_percentage") + || fileName.equals("gpu_busy_percent") || fileName.equals("gpuinfo")) { + candidates.add(f.getPath()); + } + } + } + } + + gpuUsagePathsCache = new ArrayList<>(candidates); + return gpuUsagePathsCache; + } + + // Ordered, world-readable-when-present GPU utilisation nodes across the vendors we've seen: + // Adreno KGSL, Mali (misc + Exynos vendor), PowerVR, and amdgpu-style sysfs (Samsung Xclipse / + // "sgpu", AMD-on-Exynos). readGpuUsageSample() knows how to parse each filename shape. + private static final String[] GPU_USAGE_STATIC_PATHS = { + "/sys/class/kgsl/kgsl-3d0/gpubusy", + "/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage", + "/sys/class/kgsl/kgsl-3d0/devfreq/gpu_load", + "/sys/class/misc/mali0/device/utilisation", + "/sys/class/misc/mali0/device/utilization", + "/sys/class/misc/mali0/device/gpuinfo", + "/sys/devices/platform/mali/utilization", + "/sys/kernel/gpu/gpu_busy", + "/sys/devices/platform/gpusysfs/gpu_busy", // Exynos Mali vendor node + "/sys/class/misc/pvrsrvkm/device/utilisation", + "/sys/class/pvr/utilisation", // PowerVR (Rogue) sysfs + "/sys/class/pvr/gpu_utilisation", + "/sys/class/drm/card0/device/gpu_busy_percent", // amdgpu / Xclipse (sgpu), 0..100 + "/sys/class/devfreq/gpu/load", + // ── Additive cross-vendor fallbacks (appended LAST: tried only after every path above + // misses, so a device that already resolves — e.g. Adreno kgsl — never reaches these). ── + "/sys/kernel/ged/hal/gpu_utilization", // MediaTek GED: " " → loading% + "/sys/module/ged/parameters/gpu_loading", // MediaTek GED module param, plain percent + }; + // Percent-bearing filenames scanned under each devfreq / platform GPU node. + private static final String[] GPU_USAGE_FILES = { + "gpu_busy_percentage", "gpu_busy_percent", "gpu_load", "utilisation", "utilization", + "load", "gpu_busy", "gpuinfo", + }; + // Substrings that mark a devfreq / platform node as the GPU, across vendors. + private static final String[] GPU_NODE_TOKENS = { + "gpu", "mali", "g3d", "kgsl", "panfrost", "pvr", "powervr", "xclipse", "sgpu", + }; + + private Integer readGpuUsageSample(String path) { + String fileName = path.substring(path.lastIndexOf('/') + 1); + switch (fileName) { + case "gpubusy": { + String raw = readFirstLine(path); + if (raw == null) return null; + String[] parts = raw.trim().split("\\s+"); + if (parts.length < 2) return null; + Long busy = parseLong(parts[0]); + Long total = parseLong(parts[1]); + if (busy == null || total == null || total <= 0L) return null; + return clampPercent((int) ((busy * 100L) / total)); + } + case "gpuinfo": { + // Mali multi-line node: the GPU busy time (ms) is the last token of line 1. Utilisation + // is the delta over wall-clock delta between two reads, so it needs state. + String line = readNthLine(path, 1); + if (line == null) return null; + String[] toks = line.trim().split("\\s+"); + Long gpuMs = parseLong(toks[toks.length - 1]); + if (gpuMs == null) return null; + long now = SystemClock.elapsedRealtime(); + Long prevMs = lastMaliGpuInfoMs; + long prevWall = lastMaliGpuInfoWallMs; + lastMaliGpuInfoMs = gpuMs; + lastMaliGpuInfoWallMs = now; + if (prevMs == null || prevWall <= 0L) return null; + long wallDelta = now - prevWall; + if (wallDelta <= 0L) return null; + long gpuDelta = Math.max(0L, gpuMs - prevMs); + return clampPercent((int) ((gpuDelta * 100L) / wallDelta)); + } + default: + return readPercentFromLine(path); + } + } + + private Integer readPercentFromLine(String path) { + String raw = readFirstLine(path); + if (raw == null) return null; + for (String tok : raw.trim().split("\\s+")) { + String digits = tok.replaceAll("[^0-9]", ""); + if (!digits.isEmpty()) { + Integer v = parseInt(digits); + return v == null ? null : clampPercent(v); + } + } + return null; + } + + // ======================================================================= + // RAM + // ======================================================================= + public float getRAMPercent() { + ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo(); + am.getMemoryInfo(mi); + if (mi.totalMem <= 0) return 0; + return (mi.totalMem - mi.availMem) * 100f / mi.totalMem; + } + + /** Used RAM as "x.xGB" (or "yMB" below 1 GB), matching GameNative's HUD text. */ + public String getUsedRamText() { + ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (am == null) return "—"; + ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo(); + am.getMemoryInfo(mi); + long usedBytes = Math.max(0L, mi.totalMem - mi.availMem); + double usedGb = usedBytes / (1024.0 * 1024.0 * 1024.0); + if (usedGb >= 1.0) return String.format(Locale.US, "%.1fGB", usedGb); + return (usedBytes / (1024L * 1024L)) + "MB"; + } + + // ======================================================================= + // Temperature + // ======================================================================= + private List thermalZonesCache = null; // each entry: {type, tempPath} + + /** Legacy non-null CPU-temperature accessor in °C (0 when unavailable). Kept for {@code PerfHudView}. */ + public float getTemperature() { + Integer t = getCpuTempC(); + return t == null ? 0f : t; + } + + /** Hottest representative CPU temperature in °C, or null when no CPU zone is readable. */ + public Integer getCpuTempC() { + return readTemperatureC(discoverPrioritizedCpuTempPaths()); + } + + /** GPU temperature in °C, or null when no GPU sensor is readable. */ + public Integer getGpuTempC() { + ArrayList paths = new ArrayList<>(); + paths.add("/sys/class/kgsl/kgsl-3d0/temp"); + paths.add("/sys/class/kgsl/kgsl-3d0/devfreq/temp"); + paths.add("/sys/class/misc/mali0/device/temp"); + paths.add("/sys/kernel/gpu/temp"); + paths.addAll(discoverPrioritizedGpuTempPaths()); + return readTemperatureC(paths); + } + + private List discoverPrioritizedCpuTempPaths() { + return prioritizePaths(type -> { + if (type.contains("gpu")) return null; // never mistake a GPU zone for CPU + if (type.contains("cpu-silicon")) return 0; + if (type.contains("cpu-0")) return 1; + if (type.contains("cpuss")) return 2; // Qualcomm cpuss composite + if (type.contains("mtktscpu")) return 2; // MediaTek CPU thermal + if (type.contains("cpu")) return 2; // cpu, cpu-*-usr, cpu_thermal, cpu_center + if (type.contains("s5p-tmu")) return 3; // Exynos TMU + if (type.contains("soc")) return 4; + if (type.contains("cputop")) return 5; + if (type.contains("tsens")) return 6; // Qualcomm tsens aggregate + if (type.contains("cluster")) return 7; + if (type.contains("big") || type.contains("little")) return 8; + return null; + }); + } + + private List discoverPrioritizedGpuTempPaths() { + return prioritizePaths(type -> { + if (type.contains("gpu-silicon")) return 0; + if (type.contains("gpuss")) return 1; // Qualcomm gpuss + if (type.contains("mtktsgpu")) return 1; // MediaTek GPU thermal + if (type.contains("gpu-virt")) return 2; + if (type.contains("gpu")) return 2; + if (type.contains("g3d")) return 3; // Exynos Mali (g3d) + if (type.contains("kgsl")) return 4; + if (type.contains("mali")) return 5; + if (type.contains("xclipse") || type.contains("sgpu")) return 6; + return null; + }); + } + + private interface Ranker { Integer rank(String type); } + + private List prioritizePaths(Ranker ranker) { + List zones = discoverAllThermalZones(); + ArrayList order = new ArrayList<>(); // {index, rank} + for (int i = 0; i < zones.size(); i++) { + Integer r = ranker.rank(zones.get(i)[0]); + if (r != null) order.add(new int[]{i, r}); + } + // Sort by rank, then by path for determinism. + order.sort((a, b) -> { + if (a[1] != b[1]) return Integer.compare(a[1], b[1]); + return zones.get(a[0])[1].compareTo(zones.get(b[0])[1]); + }); + ArrayList result = new ArrayList<>(); + for (int[] e : order) result.add(zones.get(e[0])[1]); + return result; + } + + private List discoverAllThermalZones() { + if (thermalZonesCache != null) return thermalZonesCache; + ArrayList zones = new ArrayList<>(); + LinkedHashSet seenPaths = new LinkedHashSet<>(); + File[] thermalDirs = { + new File("/sys/class/thermal"), + new File("/sys/devices/virtual/thermal"), + }; + for (File dir : thermalDirs) { + File[] zoneDirs = dir.listFiles((d, name) -> name.startsWith("thermal_zone")); + if (zoneDirs == null) continue; + for (File zone : zoneDirs) { + if (!zone.isDirectory()) continue; + String type = readFirstLine(new File(zone, "type").getPath()); + if (type == null) continue; + type = type.trim().toLowerCase(Locale.US); + String tempPath = new File(zone, "temp").getPath(); + if (seenPaths.add(tempPath)) zones.add(new String[]{type, tempPath}); + } + } + thermalZonesCache = zones; + return zones; + } + + private Integer readTemperatureC(List paths) { + LinkedHashSet seen = new LinkedHashSet<>(); + for (String path : paths) { + if (!seen.add(path)) continue; + Integer raw = readIntFromLine(path); + if (raw == null) continue; + // Round to nearest degree for milli-°C sources; sanity-clamp to reject offline sensors. + int celsius = raw > 1000 ? (raw + 500) / 1000 : raw; + if (celsius >= 1 && celsius <= 150) return celsius; + } + return null; + } + + /** The first path in {@code paths} that yields a plausible reading, or null. */ + private String resolveTempPath(List paths) { + LinkedHashSet seen = new LinkedHashSet<>(); + for (String path : paths) { + if (!seen.add(path)) continue; + Integer raw = readIntFromLine(path); + if (raw == null) continue; + int celsius = raw > 1000 ? (raw + 500) / 1000 : raw; + if (celsius >= 1 && celsius <= 150) return path; + } + return null; + } + + // ======================================================================= + // Temperature danger bands (green / amber / red) + // ======================================================================= + + public enum TempSensor { CPU, GPU, BATTERY } + + /** Amber and red onset in °C. Always °C internally — conversion happens at format time only. */ + public static final class Thresholds { + public final int amberC; + public final int redC; + Thresholds(int amberC, int redC) { this.amberC = amberC; this.redC = redC; } + } + + public static final int TEMP_COLOR_OK = 0xFF66BB6A; + public static final int TEMP_COLOR_AMBER = 0xFFFFB74D; + public static final int TEMP_COLOR_RED = 0xFFEF5350; + + // Tier-2 fallbacks, used when the device declares no usable trip points. Battery is the only one + // with a well-grounded number (Li-ion); CPU/GPU vary enough between parts that these are a + // deliberately conservative guess — which is exactly why trip points are preferred. + private static final int DEFAULT_RED_CPU = 90; + private static final int DEFAULT_RED_GPU = 90; + private static final int DEFAULT_RED_BAT = 48; + + /** Amber sits a proportional step below red, which scales sensibly across both CPU/GPU and battery ranges. */ + public static int amberForRed(int redC) { + return Math.round(redC * 0.88f); + } + + /** Parsed temperature display options, shared by every overlay so they can't disagree. */ + public static final class TempDisplay { + public final boolean fahrenheit; + public final boolean colorBands; + public final boolean auto; + public final int redCpu, redGpu, redBat; + + public TempDisplay(boolean fahrenheit, boolean colorBands, boolean auto, + int redCpu, int redGpu, int redBat) { + this.fahrenheit = fahrenheit; + this.colorBands = colorBands; + this.auto = auto; + this.redCpu = redCpu; + this.redGpu = redGpu; + this.redBat = redBat; + } + + public static TempDisplay from(KeyValueSet cfg) { + if (cfg == null) return new TempDisplay(false, true, true, + DEFAULT_RED_CPU, DEFAULT_RED_GPU, DEFAULT_RED_BAT); + return new TempDisplay( + cfg.get("tempUnit", "c").equalsIgnoreCase("f"), + !cfg.get("tempBands", "1").equals("0"), + !cfg.get("tempAuto", "1").equals("0"), + parseInt(cfg.get("tempRedCpu", ""), DEFAULT_RED_CPU), + parseInt(cfg.get("tempRedGpu", ""), DEFAULT_RED_GPU), + parseInt(cfg.get("tempRedBat", ""), DEFAULT_RED_BAT)); + } + + int manualRedFor(TempSensor s) { + switch (s) { + case GPU: return redGpu; + case BATTERY: return redBat; + default: return redCpu; + } + } + + private static int parseInt(String s, int fallback) { + try { return s == null || s.isEmpty() ? fallback : Integer.parseInt(s.trim()); } + catch (NumberFormatException e) { return fallback; } + } + } + + // Trip points are a device constant — resolve once per sensor and keep it. + private final java.util.EnumMap autoThresholdCache = + new java.util.EnumMap<>(TempSensor.class); + private final java.util.EnumSet autoThresholdResolved = + java.util.EnumSet.noneOf(TempSensor.class); + + /** + * Bands for a sensor. Manual mode uses the user's red point; Auto prefers the device's OWN + * declared thermal trip points (per-device truth — a handheld with active cooling and a phone on + * the same SoC throttle at very different points) and falls back to {@link #DEFAULT_RED_CPU} etc. + */ + public Thresholds resolveThresholds(TempSensor sensor, TempDisplay display) { + if (display != null && !display.auto) { + int red = display.manualRedFor(sensor); + return new Thresholds(amberForRed(red), red); + } + if (!autoThresholdResolved.contains(sensor)) { + autoThresholdResolved.add(sensor); + Thresholds fromDevice = readTripPointThresholds(sensor); + if (fromDevice != null) autoThresholdCache.put(sensor, fromDevice); + } + Thresholds cached = autoThresholdCache.get(sensor); + if (cached != null) return cached; + int red = sensor == TempSensor.GPU ? DEFAULT_RED_GPU + : sensor == TempSensor.BATTERY ? DEFAULT_RED_BAT + : DEFAULT_RED_CPU; + return new Thresholds(amberForRed(red), red); + } + + /** + * Bands from the kernel's declared trip points for the zone we actually read this sensor from: + * amber ← the lowest sane {@code passive} trip, red ← the lowest sane {@code hot}/{@code critical}. + * + *

Returns null unless a usable red point is found. Two real traps this guards against: some + * zones aren't temperature sensors at all (a Snapdragon's {@code pm8550-bcl-lvl0} battery-current + * limiter reports {@code trip_point_0_temp = 1} with type {@code passive}), and the GPU is often + * read from a non-thermal_zone node ({@code /sys/class/kgsl/...}) that has no trip points. + */ + private Thresholds readTripPointThresholds(TempSensor sensor) { + String tempPath; + switch (sensor) { + case GPU: + tempPath = resolveTempPath(discoverPrioritizedGpuTempPaths()); + break; + case BATTERY: + return null; // battery temp comes from BatteryManager, not a thermal zone + default: + tempPath = resolveTempPath(discoverPrioritizedCpuTempPaths()); + } + if (tempPath == null) return null; + File zoneDir = new File(tempPath).getParentFile(); + if (zoneDir == null) return null; + + File[] tripTemps = zoneDir.listFiles( + (d, name) -> name.startsWith("trip_point_") && name.endsWith("_temp")); + if (tripTemps == null || tripTemps.length == 0) return null; + + Integer amber = null, red = null; + for (File tripTemp : tripTemps) { + Integer raw = readIntFromLine(tripTemp.getPath()); + if (raw == null) continue; + int celsius = raw > 1000 ? (raw + 500) / 1000 : raw; + // Reject anything that isn't plausibly a thermal trip — this is what filters out the + // battery-current-limiter zones whose "trip point" is a level index, not a temperature. + if (celsius < 20 || celsius > 120) continue; + + String typePath = tripTemp.getPath().replaceAll("_temp$", "_type"); + String type = readFirstLine(typePath); + if (type == null) continue; + type = type.trim().toLowerCase(Locale.US); + + if (type.contains("critical") || type.contains("hot")) { + if (red == null || celsius < red) red = celsius; + } else if (type.contains("passive")) { + if (amber == null || celsius < amber) amber = celsius; + } + } + if (red == null) return null; + if (amber == null || amber >= red) amber = amberForRed(red); + return new Thresholds(amber, red); + } + + /** Band colour for a reading, or {@code fallbackColor} when banding is off or the value is unusable. */ + public static int tempColor(Float celsius, Thresholds t, TempDisplay display, int fallbackColor) { + if (celsius == null || t == null || display == null || !display.colorBands) return fallbackColor; + if (celsius >= t.redC) return TEMP_COLOR_RED; + if (celsius >= t.amberC) return TEMP_COLOR_AMBER; + return TEMP_COLOR_OK; + } + + /** Formats a °C reading in the user's unit. Thresholds stay °C; only display converts. */ + public static String formatTemp(float celsius, TempDisplay display, boolean oneDecimal) { + boolean f = display != null && display.fahrenheit; + float value = f ? celsius * 9f / 5f + 32f : celsius; + String unit = f ? "°F" : "°C"; + return oneDecimal + ? String.format(Locale.ENGLISH, "%.1f%s", value, unit) + : String.format(Locale.ENGLISH, "%d%s", Math.round(value), unit); + } + + // ======================================================================= + // Battery + // ======================================================================= + public static final class Battery { + public final float watts; + public final boolean charging; + public final Integer percent; // 0..100 or null + public final Integer tempC; // rounded °C or null + public final String runtimeText; // "LEFT 2h 5m" / "LEFT CHG" / null + + Battery(float watts, boolean charging) { + this(watts, charging, null, null, null); + } + Battery(float watts, boolean charging, Integer percent, Integer tempC, String runtimeText) { + this.watts = watts; + this.charging = charging; + this.percent = percent; + this.tempC = tempC; + this.runtimeText = runtimeText; + } + } + + /** power_supply current_now channels (µA) for the dual-battery sum. */ + private static final String[] CURRENT_CHANNELS = { + "/sys/class/power_supply/battery/current_now", + "/sys/class/power_supply/bms/current_now", + "/sys/class/power_supply/main/current_now", + }; + + /** power_supply voltage_now channels (µV) — fallback when {@code EXTRA_VOLTAGE} reads 0 on some + * OEM firmwares (e.g. HONOR Magic), which otherwise leaves wattage stuck at 0.0W. */ + private static final String[] VOLTAGE_CHANNELS = { + "/sys/class/power_supply/battery/voltage_now", + "/sys/class/power_supply/bms/voltage_now", + "/sys/class/power_supply/main/voltage_now", + }; + /** power_supply power_now channels (µW) — last-resort direct power reading when neither the + * BatteryManager voltage nor {@code voltage_now} is available. */ + private static final String[] POWER_CHANNELS = { + "/sys/class/power_supply/battery/power_now", + "/sys/class/power_supply/bms/power_now", + "/sys/class/power_supply/main/power_now", + }; + + /** First readable long across a channel list, or null. */ + private static Long readLongFromChannels(String[] paths) { + for (String p : paths) { + Long v = readLongFromLine(p); + if (v != null) return v; + } + return null; + } + + /** + * ADDITIVE charging fallback from {@code /sys/class/power_supply/*}, consulted ONLY when the + * framework gave no usable charge state ({@code EXTRA_STATUS == UNKNOWN} or a null battery intent). + * A charger/input supply reporting {@code online == 1} — or the battery's own {@code status} reading + * "Charging"/"Full" — means charging. Returns TRUE when charging is positively detected, FALSE when a + * status/online node is readable but not charging, or null when nothing usable exists (so the caller + * keeps its own default). Never reached on devices whose BatteryManager reports a real status + * (e.g. Adreno phones/handhelds), so it cannot change their reading. + */ + private static Boolean readChargingFromPowerSupply() { + File[] supplies = new File("/sys/class/power_supply").listFiles(); + if (supplies == null || supplies.length == 0) return null; + Boolean sawSignal = null; + for (File supply : supplies) { + String name = supply.getName().toLowerCase(Locale.US); + String type = readFirstLine(new File(supply, "type").getPath()); + type = type == null ? "" : type.trim().toLowerCase(Locale.US); + boolean isBattery = type.equals("battery") || name.equals("battery") || name.equals("bms"); + if (isBattery) { + // The battery's own status string is the most direct charge-state signal. + String st = readFirstLine(new File(supply, "status").getPath()); + if (st == null) continue; + st = st.trim().toLowerCase(Locale.US); + sawSignal = Boolean.FALSE; // "discharging"/"not charging" both land here + if (st.equals("charging") || st.equals("full")) return Boolean.TRUE; + } else { + // A charger / power-input supply (ac/usb/wireless/mains/dc): online==1 → external power in. + Long online = readLongFromLine(new File(supply, "online").getPath()); + if (online == null) continue; + sawSignal = Boolean.FALSE; + if (online == 1L) return Boolean.TRUE; + } + } + return sawSignal; + } + + private Double smoothedBatteryRuntimeHours = null; + private static final double MAX_RUNTIME_HOURS = 72.0; + private static final double RUNTIME_SMOOTHING_OLD_WEIGHT = 0.65; + private static final double RUNTIME_SMOOTHING_NEW_WEIGHT = 0.35; + + /** + * Legacy discharge-only power reading used by the classic overlays + {@code PerfHudView}: + * watts are reported only while discharging (0 when charging). When {@code dualBattery} is set the + * per-cell current channels are summed to correct devices that report only one cell's current. + */ + public Battery getBattery(boolean dualBattery) { + Intent status = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + boolean charging = false; + int voltageMv = 0; + if (status != null) { + charging = status.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0; + voltageMv = status.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0); + } else { + // ADDITIVE: no battery intent → best-effort power_supply charge probe (default stays false). + // Never reached on devices that deliver ACTION_BATTERY_CHANGED (e.g. Adreno). + Boolean sysCharging = readChargingFromPowerSupply(); + if (sysCharging != null) charging = sysCharging; + } + long microAmps; + if (dualBattery) { + long sum = 0; int n = 0; + for (String path : CURRENT_CHANNELS) { + Long v = readLongFromLine(path); + if (v != null) { sum += Math.abs(v); n++; } + } + microAmps = n > 0 ? -sum : readCurrentNowFallback(); + } else { + microAmps = readCurrentNowFallback(); + } + // current_now's SIGN convention is device-dependent — many OEMs (Xiaomi/Poco especially) + // report discharge as POSITIVE, the opposite of the AOSP convention. Gating wattage on the + // sign therefore reads 0W on battery on those devices (and only shows a value while charging). + // Use the magnitude for the power figure; the reliable charge DIRECTION is EXTRA_PLUGGED (the + // `charging` flag), which the HUD already uses for the PWR/CHG label. + // EXTRA_VOLTAGE reads 0 on some OEM firmwares even though current is fine — fall back to the + // power_supply voltage_now (µV → mV) so wattage isn't stuck at 0. + if (voltageMv <= 0) { + Long uv = readLongFromChannels(VOLTAGE_CHANNELS); + if (uv != null && uv > 0) voltageMv = (int) (Math.abs(uv) / 1000L); + } + // current unit auto-detected (mA vs µA) — see currentRawToAmps. + float watts = (voltageMv / 1000f) * currentRawToAmps(microAmps); + return new Battery(watts, charging); + } + + /** + * Full GameNative-parity battery snapshot for the GameNative-style HUD: %, power W (magnitude, + * regardless of charge/discharge), smoothed runtime-left estimate, and temperature. + */ + public Battery collectBattery() { + BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + if (bm == null) return new Battery(0f, false); + + Integer percent = null; + int cap = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); + if (cap >= 0 && cap <= 100) percent = cap; + + Intent status = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + if (status == null) { + // No battery intent at all → additive power_supply charge probe (default stays false). + Boolean sysCharging = readChargingFromPowerSupply(); + return new Battery(0f, sysCharging != null && sysCharging, percent, null, null); + } + + int st = status.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN); + boolean charging = st == BatteryManager.BATTERY_STATUS_CHARGING || st == BatteryManager.BATTERY_STATUS_FULL; + // ADDITIVE: only when the framework reports no usable charge state do we consult power_supply. + // A device that reports CHARGING/DISCHARGING/FULL/NOT_CHARGING (e.g. Adreno) never enters this. + if (st == BatteryManager.BATTERY_STATUS_UNKNOWN) { + Boolean sysCharging = readChargingFromPowerSupply(); + if (sysCharging != null) charging = sysCharging; + } + long currentMicroAmps = Math.abs(bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)); + long chargeCounter = bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER); + int voltageMv = status.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0); + + Integer tempC = null; + int rawTemp = status.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0); + if (rawTemp > 0) tempC = Math.round(rawTemp / 10f); + + // EXTRA_VOLTAGE reads 0 on some OEM firmwares (e.g. HONOR Magic) even though capacity, temp + // and current all work — which left wattage stuck at 0.0W. Fall back to the power_supply + // voltage_now (µV → mV); if voltage is still unavailable, use power_now (µW) directly. + if (voltageMv <= 0) { + Long uv = readLongFromChannels(VOLTAGE_CHANNELS); + if (uv != null && uv > 0) voltageMv = (int) (Math.abs(uv) / 1000L); + } + // Wattage = current × voltage, with current UNIT auto-detected (CURRENT_NOW is nominally µA but + // many OEMs — e.g. this HONOR — report mA; see batteryCurrentAmps). currentMicroAmps above stays + // as-is for the runtime estimate (a ratio, so unit-independent). If neither current nor voltage + // resolves, fall back to power_now (µW) directly. + float amps = batteryCurrentAmps(bm); + float watts = 0f; + if (amps > 0f && voltageMv > 0) { + watts = (voltageMv / 1000f) * amps; + } else { + Long uw = readLongFromChannels(POWER_CHANNELS); // µW + if (uw != null && uw != 0L) watts = (float) (Math.abs(uw) / 1_000_000.0); + } + + String runtimeText; + if (charging) { + smoothedBatteryRuntimeHours = null; + runtimeText = "LEFT CHG"; + } else if (currentMicroAmps <= 0L || chargeCounter <= 0L) { + runtimeText = null; + } else { + double rawHours = (double) chargeCounter / (double) currentMicroAmps; + if (!Double.isFinite(rawHours) || rawHours <= 0.0 || rawHours > MAX_RUNTIME_HOURS) { + runtimeText = null; + } else { + double smoothed = smoothedBatteryRuntimeHours == null ? rawHours + : (smoothedBatteryRuntimeHours * RUNTIME_SMOOTHING_OLD_WEIGHT) + + (rawHours * RUNTIME_SMOOTHING_NEW_WEIGHT); + smoothedBatteryRuntimeHours = smoothed; + runtimeText = "LEFT " + formatRuntimeHours(smoothed); + } + } + return new Battery(watts, charging, percent, tempC, runtimeText); + } + + private static String formatRuntimeHours(double hours) { + int totalMinutes = Math.max(1, (int) Math.round(hours * 60.0)); + int wholeHours = totalMinutes / 60; + int minutes = totalMinutes % 60; + if (wholeHours > 0 && minutes > 0) return wholeHours + "h " + minutes + "m"; + if (wholeHours > 0) return wholeHours + "h"; + return minutes + "m"; + } + + private long readCurrentNowFallback() { + BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + return bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW); + } + + /** Raw battery current reading → AMPS, with unit AUTO-DETECT. {@code BATTERY_PROPERTY_CURRENT_NOW} + * (and {@code current_now}) are nominally µA, but many OEMs report mA — a small number, whereas a + * real µA reading is always huge. So a magnitude < 20000 means the device gave mA. Without this, + * wattage on mA-reporting devices came out ~1000× too small → 0.0W even though current & voltage are + * fine. Sentinel/zero → 0. (Heuristic mirrors Ludashi-plus WinlatorHUD.getBatteryCurrentAmps().) */ + private static float currentRawToAmps(long raw) { + if (raw == 0L || raw == Long.MIN_VALUE) return 0f; + long a = Math.abs(raw); + return a < 20000L ? a / 1000f : a / 1_000_000f; // mA → A, else µA → A + } + + /** Battery current in AMPS for the wattage calc: framework property first, then power_supply + * {@code current_now} sysfs (battery/bms/main) when it's unsupported ({@code Long.MIN_VALUE}) or 0. */ + private float batteryCurrentAmps(BatteryManager bm) { + long raw = (bm != null) ? bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) : 0L; + if (raw == 0L || raw == Long.MIN_VALUE) { + Long sys = readLongFromChannels(CURRENT_CHANNELS); + raw = (sys != null) ? sys : 0L; + } + return currentRawToAmps(raw); + } + + // ======================================================================= + // Clock frequencies (best-effort, nullable) + // ======================================================================= + + /** Peak CPU core clock in MHz (max scaling_cur_freq across cores), or null when unreadable. */ + public Integer getCpuClockMhz() { + int cores = Runtime.getRuntime().availableProcessors(); + long maxKhz = 0; + for (int i = 0; i < cores; i++) { + Long cur = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/scaling_cur_freq"); + if (cur != null && cur > maxKhz) maxKhz = cur; + } + if (maxKhz <= 0) return null; + return (int) (maxKhz / 1000L); // kHz → MHz + } + + private List gpuClockPathsCache = null; + private static final String[] GPU_CLOCK_PATHS = { + "/sys/class/kgsl/kgsl-3d0/gpuclk", // Adreno, Hz + "/sys/class/kgsl/kgsl-3d0/devfreq/cur_freq", // Adreno devfreq, Hz + "/sys/class/kgsl/kgsl-3d0/clock_mhz", // some kernels, already MHz + "/sys/kernel/gpu/gpu_clock", // Mali vendor node, MHz + "/sys/devices/platform/gpusysfs/gpu_clock", // Exynos Mali vendor node, MHz + "/sys/class/misc/mali0/device/clock", // Mali, kHz/MHz + "/sys/class/devfreq/gpu/cur_freq", // generic devfreq gpu, Hz + // ── Additive cross-vendor fallbacks (appended LAST: only reached when every node above + // is unreadable, so Adreno/Mali devices that already resolve are unaffected). ── + "/sys/kernel/ged/hal/current_freqency", // MediaTek GED (vendor's spelling), kHz + "/sys/kernel/ged/hal/gpu_clock", // MediaTek GED alt, MHz + }; + + private List discoverGpuClockPaths() { + if (gpuClockPathsCache != null) return gpuClockPathsCache; + LinkedHashSet found = new LinkedHashSet<>(); + for (String p : GPU_CLOCK_PATHS) if (new File(p).canRead()) found.add(p); + // Vendor-agnostic devfreq walk: any gpu/mali/panfrost/xclipse/sgpu node's cur_freq (Hz). + File[] devfreqRoots = { + new File("/sys/class/devfreq"), + new File("/sys/devices/virtual/devfreq"), + }; + for (File root : devfreqRoots) { + if (!root.isDirectory()) continue; + File[] nodeDirs = root.listFiles(File::isDirectory); + if (nodeDirs == null) continue; + for (File node : nodeDirs) { + String nodePath = node.getPath().toLowerCase(Locale.US); + boolean looksLikeGpu = false; + for (String t : GPU_NODE_TOKENS) { + if (nodePath.contains(t)) { looksLikeGpu = true; break; } + } + if (!looksLikeGpu) continue; + File f = new File(node, "cur_freq"); + if (f.canRead()) found.add(f.getPath()); + } + } + gpuClockPathsCache = new ArrayList<>(found); + return gpuClockPathsCache; + } + + /** GPU clock in MHz (Adreno KGSL / devfreq / Mali nodes), or null when no source reports it. */ + public Integer getGpuClockMhz() { + for (String p : discoverGpuClockPaths()) { + Long raw = readLongFromLine(p); + if (raw == null || raw <= 0) continue; + long mhz; + if (raw >= 1_000_000L) mhz = raw / 1_000_000L; // Hz + else if (raw >= 10_000L) mhz = raw / 1_000L; // kHz (defensive) + else mhz = raw; // already MHz + if (mhz >= 1 && mhz <= 3000) return (int) mhz; + } + return null; + } + + // ======================================================================= + // VRAM (best-effort, Java-only sysfs — no native rebuild) + // ======================================================================= + // Covered here in pure Java: Adreno KGSL (bytes mapped/allocated) and amdgpu-style drm nodes + // (Samsung Xclipse / "sgpu", AMD-on-Exynos), which expose mem_info_vram_used/_total directly. + // TODO(native): VK_EXT_memory_budget as a vendor-agnostic fallback for Mali/PowerVR, which expose + // no world-readable VRAM node. FEASIBILITY (checked 2026-07): the compositor's Vulkan layer + // (app/src/main/cpp/winlator/vulkan.c) creates an EPHEMERAL VkInstance/VkPhysicalDevice inside + // getRenderer() and destroys it every call — it holds no reusable device and loads only the + // properties/extension entrypoints, not vkGetPhysicalDeviceMemoryProperties2. Wiring the budget + // query therefore means a new JNI method + enabling VK_KHR_get_physical_device_properties2 / + // VK_EXT_memory_budget at instance create + an NDK rebuild — out of scope for a source-only pass. + // CAVEAT to document if/when added: this HUD is an app-side overlay, NOT in the game's Vulkan + // device, so an app-created instance reports the DEVICE-WIDE heap budget (a GPU-memory-pressure + // proxy), not the game's per-process VRAM. Devices with no readable node return null → HUD hides VRAM. + private List vramUsedPathsCache = null; + private static final String[] VRAM_USED_PATHS = { + "/sys/class/kgsl/kgsl-3d0/gpumem_mapped", // bytes currently mapped to the GPU + "/sys/class/kgsl/kgsl-3d0/page_alloc", // bytes allocated to the KGSL page pool + "/sys/class/kgsl/kgsl-3d0/mapped", // alt name on some kernels + "/sys/class/drm/card0/device/mem_info_vram_used",// amdgpu / Xclipse (sgpu), bytes + "/sys/class/kgsl/kgsl-3d0/page_alloc_max", // high-water fallback + }; + // Total-VRAM companions (amdgpu exposes one; used only by the diagnostics report). + private static final String[] VRAM_TOTAL_PATHS = { + "/sys/class/drm/card0/device/mem_info_vram_total", + }; + + /** Used GPU memory in bytes from Adreno KGSL sysfs, or null when this device exposes nothing readable. */ + public Long getVramUsedBytes() { + if (vramUsedPathsCache == null) { + ArrayList found = new ArrayList<>(); + for (String p : VRAM_USED_PATHS) if (new File(p).canRead()) found.add(p); + vramUsedPathsCache = found; + } + for (String p : vramUsedPathsCache) { + Long b = sanitizeVramBytes(readLongFromLine(p)); + if (b != null) return b; + } + return null; + } + + private static Long sanitizeVramBytes(Long v) { + if (v == null) return null; + if (v < (1L << 20)) return null; // < 1 MiB → idle-zero / not a byte count + if (v > 64L * (1L << 30)) return null; // > 64 GiB → not bytes + return v; + } + + /** Used GPU memory as "x.xGiB" (or "yMiB"), or null when no VRAM source is readable. */ + public String getVramUsedText() { + Long b = getVramUsedBytes(); + return b == null ? null : formatGiB(b); + } + + private static String formatGiB(long bytes) { + return String.format(Locale.US, "%.1fGiB", bytes / (1024.0 * 1024.0 * 1024.0)); + } + + // ======================================================================= + // Single cached snapshot — read EVERYTHING once per HUD refresh + // ======================================================================= + + /** + * Immutable point-in-time reading of every metric the Fusion HUD draws. Collected in one pass by + * {@link #snapshot()} so the view never touches sysfs / BatteryManager during draw (the user's + * explicit ask: one collection pass, cached, so gameplay isn't strained). All numeric fields are + * raw (nullable when unreadable); the view formats + colours them. + */ + public static final class Snapshot { + public final Integer cpuPercent; // 0..100 or null + public final Integer gpuPercent; // 0..100 or null + public final Integer cpuClockMhz; // MHz or null + public final Integer gpuClockMhz; // MHz or null + public final Integer cpuTempC; // °C or null + public final Integer gpuTempC; // °C or null + public final long ramUsedBytes; + public final long ramTotalBytes; + public final float ramPercent; // 0..100 + public final Long vramUsedBytes; // bytes or null + public final Battery battery; // never null + // ---- Mega-only extras ---- + public final int[] perCorePercent; // per-core 0..100, -1 unknown (never null; may be empty) + public final int[] perCoreClockMhz; // per-core MHz, 0 unknown (never null; may be empty) + public final Long swapUsedBytes; // bytes or null (no swap) + public final Long swapTotalBytes; // bytes or null + public final Long netDownBps; // bytes/sec down, or null (first sample / unreadable) + public final Long netUpBps; // bytes/sec up, or null + + Snapshot(Integer cpuPercent, Integer gpuPercent, Integer cpuClockMhz, Integer gpuClockMhz, + Integer cpuTempC, Integer gpuTempC, long ramUsedBytes, long ramTotalBytes, + float ramPercent, Long vramUsedBytes, Battery battery, + int[] perCorePercent, int[] perCoreClockMhz, Long swapUsedBytes, Long swapTotalBytes, + Long netDownBps, Long netUpBps) { + this.cpuPercent = cpuPercent; + this.gpuPercent = gpuPercent; + this.cpuClockMhz = cpuClockMhz; + this.gpuClockMhz = gpuClockMhz; + this.cpuTempC = cpuTempC; + this.gpuTempC = gpuTempC; + this.ramUsedBytes = ramUsedBytes; + this.ramTotalBytes = ramTotalBytes; + this.ramPercent = ramPercent; + this.vramUsedBytes = vramUsedBytes; + this.battery = battery; + this.perCorePercent = perCorePercent != null ? perCorePercent : new int[0]; + this.perCoreClockMhz = perCoreClockMhz != null ? perCoreClockMhz : new int[0]; + this.swapUsedBytes = swapUsedBytes; + this.swapTotalBytes = swapTotalBytes; + this.netDownBps = netDownBps; + this.netUpBps = netUpBps; + } + + public String ramUsedText() { return formatBytesGb(ramUsedBytes); } + public String ramTotalText() { return formatBytesGb(ramTotalBytes); } + public String vramText() { return vramUsedBytes == null ? null : formatGiB(vramUsedBytes); } + public String swapUsedText() { return swapUsedBytes == null ? null : formatBytesGb(swapUsedBytes); } + public String swapTotalText() { return swapTotalBytes == null ? null : formatBytesGb(swapTotalBytes); } + + private static String formatBytesGb(long bytes) { + return String.format(Locale.US, "%.1fGiB", bytes / (1024.0 * 1024.0 * 1024.0)); + } + } + + /** One collection pass over every metric. Call from the HUD's own ~1 s refresh thread, never draw. */ + public Snapshot snapshot() { + if (mango != null) return mangoSnapshot(mango); + Integer cpu = getCpuUsagePercent(); + Integer gpu = getGpuUsagePercent(); + Integer cpuClk = getCpuClockMhz(); + Integer gpuClk = getGpuClockMhz(); + Integer cpuTemp = getCpuTempC(); + Integer gpuTemp = getGpuTempC(); + + long usedBytes = 0, totalBytes = 0; + float ramPct = 0f; + ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (am != null) { + ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo(); + am.getMemoryInfo(mi); + totalBytes = Math.max(0L, mi.totalMem); + usedBytes = Math.max(0L, mi.totalMem - mi.availMem); + ramPct = totalBytes > 0 ? usedBytes * 100f / totalBytes : 0f; + } + + Long vram = getVramUsedBytes(); + Battery battery = collectBattery(); + + int[] perCorePct = readPerCorePercent(); + int[] perCoreClk = getPerCoreClockMhz(); + long[] swap = getSwapBytes(); + long[] net = readNetRateBps(); + + return new Snapshot(cpu, gpu, cpuClk, gpuClk, cpuTemp, gpuTemp, + usedBytes, totalBytes, ramPct, vram, battery, + perCorePct, perCoreClk, + swap == null ? null : swap[0], swap == null ? null : swap[1], + net == null ? null : net[0], net == null ? null : net[1]); + } + + // DroidDeck: the same snapshot with MangoHud's sources and rounding (see MangoReadings). + private Snapshot mangoSnapshot(MangoReadings m) { + MangoReadings.Cpu cpu = m.cpu(); + MangoReadings.Memory mem = m.memory(); + long used = mem != null ? mem.getUsedBytes() : 0L; + long total = mem != null ? mem.getTotalBytes() : 0L; + Battery android = collectBattery(); + MangoReadings.Battery bat = m.battery(); + Battery battery = bat == null ? android : new Battery(bat.getWatts(), bat.getCharging(), + bat.getPercent() != null ? bat.getPercent() : android.percent, android.tempC, android.runtimeText); + long[] net = readNetRateBps(); + return new Snapshot(cpu != null ? cpu.getPercent() : null, m.gpuPercent(), getCpuClockMhz(), m.gpuClockMhz(), + m.cpuTempC(), m.gpuTempC(), used, total, total > 0 ? used * 100f / total : 0f, m.vramBytes(), battery, + cpu != null ? cpu.getPerCore() : new int[0], getPerCoreClockMhz(), + mem != null && mem.getSwapTotalBytes() > 0 ? mem.getSwapUsedBytes() : null, + mem != null && mem.getSwapTotalBytes() > 0 ? mem.getSwapTotalBytes() : null, + net == null ? null : net[0], net == null ? null : net[1]); + } + + // ======================================================================= + // Mega-view extras: per-core CPU, swap, network (best-effort, nullable) + // ======================================================================= + + /** Per-core clock in MHz (each core's {@code scaling_cur_freq}); 0 for a core that can't be read. */ + public int[] getPerCoreClockMhz() { + int cores = Runtime.getRuntime().availableProcessors(); + int[] out = new int[Math.max(0, cores)]; + for (int i = 0; i < out.length; i++) { + Long cur = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/scaling_cur_freq"); + out[i] = (cur != null && cur > 0) ? (int) (cur / 1000L) : 0; + } + return out; + } + + // Per-core /proc/stat deltas — one prev sample per core, seeded on the first read. + private long[] lastCoreTotal = null; + private long[] lastCoreIdle = null; + + /** Per-core CPU usage 0..100 from the {@code cpuN} /proc/stat deltas; -1 for a core not yet sampled. */ + public int[] readPerCorePercent() { + int cores = Runtime.getRuntime().availableProcessors(); + int[] out = new int[Math.max(0, cores)]; + java.util.Arrays.fill(out, -1); + if (lastCoreTotal == null || lastCoreTotal.length != cores) { + lastCoreTotal = new long[cores]; + lastCoreIdle = new long[cores]; + java.util.Arrays.fill(lastCoreTotal, -1L); + } + try (BufferedReader r = new BufferedReader(new FileReader("/proc/stat"))) { + String line; + while ((line = r.readLine()) != null) { + if (!line.startsWith("cpu")) break; // cpu lines are first; stop at the block end + if (line.length() < 4 || !Character.isDigit(line.charAt(3))) continue; // skip aggregate "cpu " + String[] parts = line.trim().split("\\s+"); + int core; + try { core = Integer.parseInt(parts[0].substring(3)); } catch (Exception e) { continue; } + if (core < 0 || core >= cores) continue; + long total = 0, idle = 0; + boolean ok = true; + for (int i = 1; i < parts.length; i++) { + try { + long v = Long.parseLong(parts[i]); + total += v; + if (i == 4) idle = v; // idle + else if (i == 5) idle += v; // + iowait + } catch (NumberFormatException e) { ok = false; break; } + } + if (!ok) continue; + long prevTotal = lastCoreTotal[core], prevIdle = lastCoreIdle[core]; + lastCoreTotal[core] = total; + lastCoreIdle[core] = idle; + if (prevTotal >= 0) { + long dTotal = total - prevTotal, dIdle = idle - prevIdle; + if (dTotal > 0) out[core] = clampPercent((int) ((Math.max(0, dTotal - dIdle) * 100L) / dTotal)); + } + } + } catch (Exception ignored) {} + // Frequency fallback for any core /proc/stat couldn't give us — restricted on some firmwares + // (e.g. HONOR / Android 16), which is also why the aggregate getCpuUsagePercent() falls back. + // scaling_cur_freq / cpuinfo_max_freq is a load proxy (per-core clocks ARE readable there), and + // it also seeds the very first sample. Cores with a real /proc/stat delta keep it (no regression). + for (int i = 0; i < out.length; i++) { + if (out[i] >= 0) continue; + Long cur = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/scaling_cur_freq"); + Long max = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/cpuinfo_max_freq"); + if (cur != null && max != null && max > 0L) + out[i] = clampPercent((int) ((Math.max(0L, Math.min(cur, max)) * 100L) / max)); + } + return out; + } + + /** Swap {used, total} in bytes from /proc/meminfo, or null when there is no swap. */ + public long[] getSwapBytes() { + long totalKb = -1, freeKb = -1; + try (BufferedReader r = new BufferedReader(new FileReader("/proc/meminfo"))) { + String line; + while ((line = r.readLine()) != null && (totalKb < 0 || freeKb < 0)) { + if (line.startsWith("SwapTotal:")) totalKb = parseMeminfoKb(line); + else if (line.startsWith("SwapFree:")) freeKb = parseMeminfoKb(line); + } + } catch (Exception e) { return null; } + if (totalKb <= 0) return null; + long used = Math.max(0L, (totalKb - Math.max(0L, freeKb))) * 1024L; + return new long[]{used, totalKb * 1024L}; + } + + private static long parseMeminfoKb(String line) { + String[] parts = line.trim().split("\\s+"); + if (parts.length < 2) return -1; + Long v = parseLong(parts[1]); + return v == null ? -1 : v; + } + + // Net rate: cumulative rx/tx bytes + the wall-clock of the previous read → bytes/sec. + private long lastNetRx = -1, lastNetTx = -1, lastNetWallMs = 0; + + /** Network {down, up} in bytes/sec (all interfaces except loopback), or null on the first/failed read. */ + public long[] readNetRateBps() { + long rx = 0, tx = 0; + boolean any = false; + try (BufferedReader r = new BufferedReader(new FileReader("/proc/net/dev"))) { + String line; + while ((line = r.readLine()) != null) { + int colon = line.indexOf(':'); + if (colon < 0) continue; + String iface = line.substring(0, colon).trim(); + if (iface.equals("lo") || iface.isEmpty()) continue; + String[] f = line.substring(colon + 1).trim().split("\\s+"); + if (f.length < 9) continue; + Long rxB = parseLong(f[0]); + Long txB = parseLong(f[8]); + if (rxB == null || txB == null) continue; + rx += rxB; tx += txB; any = true; + } + } catch (Exception e) { return null; } + if (!any) return null; + long now = SystemClock.elapsedRealtime(); + long prevRx = lastNetRx, prevTx = lastNetTx, prevWall = lastNetWallMs; + lastNetRx = rx; lastNetTx = tx; lastNetWallMs = now; + if (prevRx < 0 || prevWall <= 0) return null; // seed + long dt = now - prevWall; + if (dt <= 0) return null; + long down = Math.max(0L, (rx - prevRx) * 1000L / dt); + long up = Math.max(0L, (tx - prevTx) * 1000L / dt); + return new long[]{down, up}; + } + + // ======================================================================= + // Diagnostics report (invoked ONLY on user tap — never on the HUD refresh path) + // ======================================================================= + + /** + * One-tap plain-text device report for the "Export HUD diagnostics" action: for every metric it + * lists the resolved source path/API + current value, or MISS + the exact candidates it tried, + * followed by an "exists on this device" dump (thermal zone types, devfreq/kgsl/mali nodes) so a + * device owner can send back the real path for anything that missed. Does a short prime+sample so + * delta-based readers (CPU%/GPU%/net) report a live value. Run OFF the main thread. + */ + public String buildDiagnosticsReport(Context ctxIn) { + final Context ctx = ctxIn != null ? ctxIn : this.context; + // Prime the delta-based readers, wait, then take a live sample. + snapshot(); + try { Thread.sleep(350L); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } + Snapshot s = snapshot(); + + StringBuilder sb = new StringBuilder(); + sb.append("DroidDeck FusionHUD diagnostics\n"); + sb.append("Generated: ") + .append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(new Date())).append('\n'); + sb.append("Device: ").append(Build.MANUFACTURER).append(' ').append(Build.MODEL).append('\n'); + sb.append("Board/HW: ").append(Build.BOARD).append(" / ").append(Build.HARDWARE).append('\n'); + sb.append("SoC: ").append(socString()).append('\n'); + sb.append("Android: ").append(Build.VERSION.RELEASE) + .append(" (API ").append(Build.VERSION.SDK_INT).append(")\n"); + sb.append("CPU cores: ").append(Runtime.getRuntime().availableProcessors()).append('\n'); + sb.append("GPU renderer: ").append(safeGpu(() -> com.droiddeck.launcher.gpu.GpuInfo.Companion.detect().displayName(ctx))).append('\n'); + + sb.append("\n== Metrics ==\n"); + + // ---- CPU ---- + boolean procStat = new File("/proc/stat").canRead(); + if (s.cpuPercent != null) { + metricHit(sb, "CPU %", s.cpuPercent + "%", + procStat ? "/proc/stat (cpu aggregate delta)" + : "/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq (freq estimate)"); + } else { + metricMiss(sb, "CPU %", "/proc/stat", + "/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq"); + } + + // Per-core % + StringBuilder cores = new StringBuilder(); + for (int i = 0; i < s.perCorePercent.length; i++) { + if (i > 0) cores.append(' '); + int v = s.perCorePercent[i]; + cores.append('c').append(i).append('=').append(v < 0 ? "?" : (v + "%")); + } + if (s.perCorePercent.length > 0) + metricHit(sb, "Per-core %", cores.toString(), "/proc/stat (per-cpu lines)"); + else + metricMiss(sb, "Per-core %", "/proc/stat"); + + // Per-core clock + StringBuilder coreClk = new StringBuilder(); + for (int i = 0; i < s.perCoreClockMhz.length; i++) { + if (i > 0) coreClk.append(' '); + int v = s.perCoreClockMhz[i]; + coreClk.append('c').append(i).append('=').append(v <= 0 ? "?" : (v + "MHz")); + } + if (s.cpuClockMhz != null) + metricHit(sb, "CPU clock", s.cpuClockMhz + "MHz (peak); " + coreClk, + "/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq"); + else + metricMiss(sb, "CPU clock", "/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq"); + + // CPU temp + String cpuTempPath = resolveTempPath(discoverPrioritizedCpuTempPaths()); + if (s.cpuTempC != null && cpuTempPath != null) + metricHit(sb, "CPU temp", s.cpuTempC + "°C", cpuTempPath); + else + metricMiss(sb, "CPU temp", + "/sys/class/thermal/thermal_zone*/temp (types: cpu*/cpuss/tsens/mtktscpu/s5p-tmu/soc)"); + + // ---- GPU ---- + String gpuUsagePath = firstReadableGpuUsagePath(); + if (s.gpuPercent != null && gpuUsagePath != null) + metricHit(sb, "GPU %", s.gpuPercent + "%", gpuUsagePath); + else + metricMissList(sb, "GPU %", discoverGpuUsagePaths(), GPU_USAGE_STATIC_PATHS); + + String gpuClockPath = firstReadableGpuClockPath(); + if (s.gpuClockMhz != null && gpuClockPath != null) + metricHit(sb, "GPU clock", s.gpuClockMhz + "MHz", gpuClockPath); + else + metricMissList(sb, "GPU clock", discoverGpuClockPaths(), GPU_CLOCK_PATHS); + + String gpuTempPath = resolveTempPath(discoverPrioritizedGpuTempPaths()); + if (s.gpuTempC != null) + metricHit(sb, "GPU temp", s.gpuTempC + "°C", + gpuTempPath != null ? gpuTempPath : "kgsl/mali direct temp node"); + else + metricMiss(sb, "GPU temp", + "/sys/class/kgsl/kgsl-3d0/temp", "/sys/class/misc/mali0/device/temp", + "/sys/class/thermal/thermal_zone*/temp (types: gpu*/gpuss/g3d/kgsl/mali/mtktsgpu)"); + + // ---- Memory ---- + metricHit(sb, "RAM", s.ramUsedText() + " / " + s.ramTotalText() + + " (" + Math.round(s.ramPercent) + "%)", "ActivityManager.MemoryInfo"); + if (s.swapUsedBytes != null) + metricHit(sb, "SWAP", s.swapUsedText() + " / " + s.swapTotalText(), "/proc/meminfo"); + else + metricMiss(sb, "SWAP", "/proc/meminfo (SwapTotal=0 or unreadable)"); + + // VRAM + String vramPath = firstReadableVramUsedPath(); + if (s.vramUsedBytes != null && vramPath != null) { + Long total = readFirstReadableLong(VRAM_TOTAL_PATHS); + String v = s.vramText() + (total != null ? " / " + formatGiB(total) : ""); + metricHit(sb, "VRAM", v, vramPath); + } else { + metricMissList(sb, "VRAM (Java sysfs; Vulkan budget = native TODO)", + new ArrayList<>(), VRAM_USED_PATHS); + } + + // ---- Net ---- + if (s.netDownBps != null) + metricHit(sb, "NET", formatRate(s.netDownBps) + " down / " + formatRate(s.netUpBps) + " up", + "/proc/net/dev"); + else + metricMiss(sb, "NET", "/proc/net/dev (first sample or unreadable)"); + + // ---- Battery ---- + Battery b = s.battery; + StringBuilder bat = new StringBuilder(); + bat.append(b.percent != null ? b.percent + "%" : "?%") + .append(", ").append(String.format(Locale.US, "%.1fW", b.watts)) + .append(b.charging ? " (charging)" : "") + .append(", temp ").append(b.tempC != null ? b.tempC + "°C" : "?") + .append(b.runtimeText != null ? ", " + b.runtimeText : ""); + metricHit(sb, "Battery", bat.toString(), "BatteryManager + /sys/class/power_supply"); + + // ---- Exists-on-device dump ---- + appendExistsDump(sb); + return sb.toString(); + } + + private static String socString() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return Build.SOC_MANUFACTURER + " " + Build.SOC_MODEL; + } + return "(SoC id needs Android 12+; HW=" + Build.HARDWARE + ")"; + } + + private interface GpuStr { String get(); } + private static String safeGpu(GpuStr fn) { + try { String v = fn.get(); return v == null ? "(null)" : v; } + catch (Throwable t) { return "(unavailable: " + t.getClass().getSimpleName() + ")"; } + } + + private String firstReadableGpuUsagePath() { + for (String p : discoverGpuUsagePaths()) if (readGpuUsageSample(p) != null) return p; + return null; + } + + private String firstReadableGpuClockPath() { + for (String p : discoverGpuClockPaths()) { + Long raw = readLongFromLine(p); + if (raw != null && raw > 0) return p; + } + return null; + } + + private String firstReadableVramUsedPath() { + if (vramUsedPathsCache == null) getVramUsedBytes(); // builds the cache + if (vramUsedPathsCache == null) return null; + for (String p : vramUsedPathsCache) if (sanitizeVramBytes(readLongFromLine(p)) != null) return p; + return null; + } + + private static Long readFirstReadableLong(String[] paths) { + for (String p : paths) { + Long v = readLongFromLine(p); + if (v != null && v > 0) return v; + } + return null; + } + + private static String formatRate(Long bps) { + if (bps == null) return "?"; + double kb = bps / 1024.0; + if (kb >= 1024.0) return String.format(Locale.US, "%.1fMB/s", kb / 1024.0); + return String.format(Locale.US, "%.0fKB/s", kb); + } + + private static void metricHit(StringBuilder sb, String name, String value, String source) { + sb.append(name).append(": ").append(value).append("\n source: ").append(source).append('\n'); + } + + private static void metricMiss(StringBuilder sb, String name, String... candidates) { + sb.append(name).append(": MISS\n tried: "); + for (int i = 0; i < candidates.length; i++) { + if (i > 0) sb.append("\n "); + sb.append(candidates[i]); + } + sb.append('\n'); + } + + private static void metricMissList(StringBuilder sb, String name, List discovered, String[] statics) { + LinkedHashSet all = new LinkedHashSet<>(); + if (discovered != null) all.addAll(discovered); + for (String p : statics) all.add(p); + sb.append(name).append(": MISS\n tried: "); + boolean first = true; + for (String p : all) { + if (!first) sb.append("\n "); + sb.append(p); + first = false; + } + if (first) sb.append("(no candidates)"); + sb.append('\n'); + } + + private void appendExistsDump(StringBuilder sb) { + sb.append("\n== Exists on this device (send these back for any MISS above) ==\n"); + sb.append("-- /sys/class/thermal/thermal_zone*/type --\n"); + List zones = discoverAllThermalZones(); + if (zones.isEmpty()) sb.append(" (none readable)\n"); + for (String[] z : zones) sb.append(" ").append(z[1]).append(" type=").append(z[0]).append('\n'); + appendDevfreqListing(sb, "/sys/class/devfreq"); + appendDevfreqListing(sb, "/sys/devices/virtual/devfreq"); + appendReadableFiles(sb, "/sys/class/kgsl/kgsl-3d0"); + appendReadableFiles(sb, "/sys/class/misc/mali0/device"); + appendReadableFiles(sb, "/sys/class/drm/card0/device"); + appendPowerSupplyDump(sb); + appendGpuNoRootProbes(sb); + } + + /** Probes for a ROOT-FREE GPU-load path on locked-sysfs Adreno devices (SM8750/HONOR etc.): + * (1) can the app open the KGSL device node? The sysfs mirror (/sys/class/kgsl) is SELinux-blocked + * (vendor_sysfs_kgsl), but /dev/kgsl-3d0 is labeled gpu_device and is normally app-openable — + * that's the door for reading GPU busy via ioctl perf-counters instead of sysfs. + * (2) is there an app-readable devfreq node under /sys/devices/platform that bypasses the blocked + * /sys/class/kgsl symlink (a free win for at least the clock). */ + private void appendGpuNoRootProbes(StringBuilder sb) { + sb.append("-- GPU no-root reachability probes --\n"); + sb.append(" /dev/kgsl-3d0 open O_RDWR: ") + .append(probeOpen("/dev/kgsl-3d0", android.system.OsConstants.O_RDWR)).append('\n'); + sb.append(" /dev/kgsl-3d0 open O_RDONLY: ") + .append(probeOpen("/dev/kgsl-3d0", android.system.OsConstants.O_RDONLY)).append('\n'); + sb.append(" /dev/dri/renderD128 O_RDWR: ") + .append(probeOpen("/dev/dri/renderD128", android.system.OsConstants.O_RDWR)).append('\n'); + boolean found = false; + File[] roots = { new File("/sys/devices/platform/soc"), new File("/sys/devices/platform") }; + for (File root : roots) { + File[] nodes = root.listFiles(); + if (nodes == null) continue; + for (File soc : nodes) { + if (!soc.getName().contains("kgsl-3d0")) continue; + File[] inner = new File(soc, "devfreq").listFiles(); + if (inner == null) continue; + for (File node : inner) { + found = true; + Long cur = readLongFromLine(node + "/cur_freq"); + Long load = readLongFromLine(node + "/gpu_load"); + if (load == null) load = readLongFromLine(node + "/load"); + sb.append(" devfreq ").append(node.getAbsolutePath()) + .append(": cur_freq=").append(cur == null ? "—" : cur.toString()) + .append(" load=").append(load == null ? "—" : load.toString()).append('\n'); + } + } + } + if (!found) sb.append(" devfreq (platform kgsl): (no app-readable node found)\n"); + } + + /** open()+close() a path with the given flags, reporting OK or the errno class — used to test + * whether the app's sandbox can reach a device node without root. */ + private static String probeOpen(String path, int flags) { + java.io.FileDescriptor fd = null; + try { + fd = android.system.Os.open(path, flags, 0); + return "OK"; + } catch (Throwable t) { + return t.getClass().getSimpleName() + ": " + t.getMessage(); + } finally { + if (fd != null) try { android.system.Os.close(fd); } catch (Throwable ignored) {} + } + } + + /** Battery-wattage inputs: which power_supply nodes this app can actually READ, and their raw + * values. Confirms the voltage_now / power_now fallback for devices where EXTRA_VOLTAGE is 0. */ + private void appendPowerSupplyDump(StringBuilder sb) { + sb.append("-- /sys/class/power_supply (battery watts inputs) --\n"); + File[] nodes = new File("/sys/class/power_supply").listFiles(); + if (nodes == null || nodes.length == 0) { sb.append(" (absent / unreadable)\n"); return; } + boolean any = false; + for (File node : nodes) { + Long v = readLongFromLine(node + "/voltage_now"); + Long c = readLongFromLine(node + "/current_now"); + Long p = readLongFromLine(node + "/power_now"); + if (v == null && c == null && p == null) continue; + any = true; + sb.append(" ").append(node.getName()) + .append(": voltage_now=").append(v == null ? "—" : v + "µV") + .append(" current_now=").append(c == null ? "—" : c + "µA") + .append(" power_now=").append(p == null ? "—" : p + "µW") + .append('\n'); + } + if (!any) sb.append(" (nodes present but voltage_now/current_now/power_now unreadable)\n"); + } + + private static void appendDevfreqListing(StringBuilder sb, String rootPath) { + File root = new File(rootPath); + File[] nodes = root.listFiles(File::isDirectory); + sb.append("-- ").append(rootPath).append(" --\n"); + if (nodes == null || nodes.length == 0) { sb.append(" (absent / empty)\n"); return; } + for (File node : nodes) { + sb.append(" ").append(node.getName()).append("/: ").append(readableFileNames(node)).append('\n'); + } + } + + private static void appendReadableFiles(StringBuilder sb, String dirPath) { + File dir = new File(dirPath); + sb.append("-- ").append(dirPath).append(" --\n"); + if (!dir.isDirectory()) { sb.append(" (absent / unreadable)\n"); return; } + sb.append(" ").append(readableFileNames(dir)).append('\n'); + } + + private static String readableFileNames(File dir) { + File[] files = dir.listFiles(); + if (files == null) return "(unreadable)"; + StringBuilder names = new StringBuilder(); + for (File f : files) { + if (f.isFile() && f.canRead()) { + if (names.length() > 0) names.append(", "); + names.append(f.getName()); + } + } + return names.length() == 0 ? "(no readable files)" : names.toString(); + } + + // ======================================================================= + // Low-level helpers + // ======================================================================= + private static int clampPercent(int v) { return v < 0 ? 0 : (v > 100 ? 100 : v); } + + private static String readFirstLine(String path) { + try (BufferedReader r = new BufferedReader(new FileReader(path))) { + return r.readLine(); + } catch (Exception e) { + return null; + } + } + + private static String readNthLine(String path, int lineIndex) { + try (BufferedReader r = new BufferedReader(new FileReader(path))) { + String line = null; + for (int i = 0; i <= lineIndex; i++) { + line = r.readLine(); + if (line == null) return null; + } + return line; + } catch (Exception e) { + return null; + } + } + + private static Long readLongFromLine(String path) { + String line = readFirstLine(path); + return line == null ? null : parseLong(line.trim()); + } + + private static Integer readIntFromLine(String path) { + String line = readFirstLine(path); + return line == null ? null : parseInt(line.trim()); + } + + private static Long parseLong(String s) { + try { return Long.parseLong(s); } catch (Exception e) { return null; } + } + + private static Integer parseInt(String s) { + try { return Integer.parseInt(s); } catch (Exception e) { return null; } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java b/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java new file mode 100644 index 000000000..1c3a1ba9b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java @@ -0,0 +1,132 @@ +/* + * FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays. + * Copyright (C) The412Banner + * + * Licensed under the GNU General Public License v3.0 (see LICENSE), with an + * ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md): + * any use, fork, or distribution must preserve credit to "The412Banner" and a + * link to https://github.com/The412Banner/FusionHUD in the project documentation + * AND in the in-app credits/about screen. + * + * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code. + */ + +package com.droiddeck.launcher.hud; + +import androidx.annotation.NonNull; + +import java.util.Iterator; + +public class KeyValueSet implements Iterable { + private String data = ""; + + // Default constructor for an empty KeyValueSet + public KeyValueSet() { + this.data = ""; + } + + // Constructor to initialize with a string representation of key-value pairs + public KeyValueSet(String data) { + this.data = data != null && !data.isEmpty() ? data : ""; + } + + // Helper method to find the start and end indices of a key in the data string + private int[] indexOfKey(String key) { + int start = 0; + int end = data.indexOf(","); + if (end == -1) end = data.length(); + + while (start < end) { + int index = data.indexOf("=", start); + String currKey = data.substring(start, index); + if (currKey.equals(key)) return new int[]{start, end}; + start = end + 1; + end = data.indexOf(",", start); + if (end == -1) end = data.length(); + } + + return null; + } + + // Method to get the value associated with a key, or an empty string if the key is not present + public String get(String key) { + for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1]; + return ""; + } + + // Method to get the value associated with a key, or a fallback value if the key is not present + public String get(String key, String fallback) { + for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1]; + return fallback; + } + + // Method to get a boolean value associated with a key, or a fallback value if the key is not present + public boolean getBoolean(String key, boolean fallback) { + String value = get(key); + if (value.isEmpty()) return fallback; + return value.equals("1") || value.equals("t") || value.equalsIgnoreCase("true"); + } + + // Method to get a float value associated with a key, or a fallback value if the key is not present + public float getFloat(String key, float fallback) { + String value = get(key); + try { + if (!value.isEmpty()) return Float.parseFloat(value); + } catch (NumberFormatException e) { + // Ignore exception and return fallback + } + return fallback; + } + + // Method to set a value for a key in the key-value data + public void put(String key, Object value) { + int[] range = indexOfKey(key); + if (range != null) { + data = data.substring(0, range[0]) + key + "=" + value + data.substring(range[1]); + } else { + data = (!data.isEmpty() ? data + "," : "") + key + "=" + value; + } + } + + // Method to set a float value for a key in the key-value data + public void put(String key, float value) { + put(key, String.valueOf(value)); + } + + // Iterator implementation for key-value pairs in the format [key, value] + @NonNull + @Override + public Iterator iterator() { + final int[] start = {0}; + final int[] end = {data.indexOf(",")}; + // A single-item config (no comma) gives indexOf == -1, making hasNext() (start < end) + // immediately false so the sole pair was never iterated (get() then returned the + // fallback). Guard it exactly as next() already does. An empty string still yields + // end == 0 → no iteration, which is correct. + if (end[0] == -1) end[0] = data.length(); + final String[] item = new String[2]; + return new Iterator() { + @Override + public boolean hasNext() { + return start[0] < end[0]; + } + + @Override + public String[] next() { + int index = data.indexOf("=", start[0]); + item[0] = data.substring(start[0], index); + item[1] = data.substring(index + 1, end[0]); + start[0] = end[0] + 1; + end[0] = data.indexOf(",", start[0]); + if (end[0] == -1) end[0] = data.length(); + return item; + } + }; + } + + @NonNull + @Override + public String toString() { + return data; + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt b/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt new file mode 100644 index 000000000..828bcc0f5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt @@ -0,0 +1,157 @@ +package com.droiddeck.launcher.hud + +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File + +/** + * The readings Steam's performance overlay shows in a DroidDeck session (Valve's mangohud 20260902.1, + * tools/mangoapp), taken from the files the session hands it and computed the same way, so the two + * overlays agree. Sources and formulas follow that build: cpu.cpp calculateCPUData, deckard_temp.cpp, + * msm.cpp getQcomGpuInfo, memory.cpp update_meminfo and battery.cpp getPercent / getPower. + */ +internal class MangoReadings(private val sessionDir: File, private val rootDir: File) { + class Cpu(val percent: Int, val perCore: IntArray) + class Memory(val usedBytes: Long, val totalBytes: Long, val swapUsedBytes: Long, val swapTotalBytes: Long) + class Battery(val percent: Int?, val watts: Float, val charging: Boolean) + + private val cpuTimes = HashMap() + private val cpuPercents = HashMap() + + /** Total and per-core load from the session's /proc/stat (DroidDeck's stand-in when the app can't read it). */ + @JvmOverloads + fun cpu(stat: File = statFile()): Cpu? { + val lines = runCatching { stat.readLines() }.getOrNull() ?: return null + var total: Int? = null + val cores = ArrayList() + for (line in lines) { + if (!line.startsWith("cpu")) break + val parts = line.split(WHITESPACE) + if (parts.size < 11) continue + val values = LongArray(10) { parts[it + 1].toLongOrNull() ?: 0L } + val percent = cpuPercent(parts[0], values) + if (parts[0] == "cpu") total = percent else cores.add(percent) + } + return total?.let { Cpu(it, cores.toIntArray()) } + } + + private fun statFile(): File = + File(sessionDir, "proc-stat").takeIf { !File("/proc/stat").canRead() && it.isFile } ?: File("/proc/stat") + + private fun cpuPercent(key: String, v: LongArray): Int { + // user nice system idle iowait irq softirq steal guest guest_nice; guest time is already in user. + val user = v[0] - v[8] + val nice = v[1] - v[9] + val systemAll = v[2] + v[5] + v[6] + val idleAll = v[3] + v[4] + val virt = v[8] + v[9] + val totalTime = user + nice + systemAll + idleAll + v[7] + virt + val now = longArrayOf(user, nice, systemAll, v[7], virt, totalTime) + val last = cpuTimes.put(key, now) ?: LongArray(6) + fun period(i: Int) = if (now[i] > last[i]) now[i] - last[i] else 0L + val totalPeriod = period(5).toFloat() + if (totalPeriod == 0f) return cpuPercents[key] ?: 0 + val busy = period(1) * 100f / totalPeriod + period(0) * 100f / totalPeriod + + period(2) * 100f / totalPeriod + (period(3) + period(4)) * 100f / totalPeriod + return busy.coerceIn(0f, 100f).toInt().also { cpuPercents[key] = it } + } + + /** Average of the zones the session types cpuN-thermal, truncated. */ + fun cpuTempC(): Int? { + var sum = 0f + var count = 0 + for (zone in LinuxRuntime.cpuTempZones()) { + val milli = readLong(File(zone, "temp")) ?: continue + sum += milli / 1000f + count++ + } + return if (count == 0) null else (sum / count).toInt() + } + + fun gpuPercent(): Int? { + val feed = fresh(File(sessionDir, "sys/kgsl-3d0/perf_now")) + val source = feed ?: LinuxRuntime.gpuLoadSource()?.let(::File) ?: return null + return leadingNumber(source)?.toInt()?.coerceIn(0, 100) + } + + fun gpuClockMhz(): Int? { + val feed = fresh(File(sessionDir, "sys/kgsl-3d0/cur_freq")) + val source = feed ?: listOf("$KGSL/devfreq/cur_freq", "$KGSL/gpuclk").map(::File).firstOrNull { it.canRead() } ?: return null + val hz = leadingNumber(source) ?: return null + return (hz / 1_000_000.0).toInt().takeIf { it > 0 } + } + + fun gpuTempC(): Int? { + val milli = LinuxRuntime.gpuTempSource()?.let { readLong(File(it)) } ?: return null + return (milli / 1000.0).toInt() + } + + /** MemTotal - MemAvailable and SwapTotal - SwapFree from /proc/meminfo. */ + fun memory(): Memory? { + val kb = HashMap() + runCatching { + File("/proc/meminfo").forEachLine { line -> + val colon = line.indexOf(':') + if (colon > 0) line.substring(colon + 1).trim().split(WHITESPACE).firstOrNull()?.toLongOrNull() + ?.let { kb[line.substring(0, colon)] = it } + } + } + val total = kb["MemTotal"] ?: return null + val available = kb["MemAvailable"] ?: return null + val swapTotal = kb["SwapTotal"] ?: 0L + val swapFree = kb["SwapFree"] ?: 0L + return Memory((total - available) * 1024, total * 1024, (swapTotal - swapFree) * 1024, swapTotal * 1024) + } + + /** The KGSL total DroidDeck shares with the session (GpuMemComponent). */ + fun vramBytes(): Long? = fresh(File(rootDir, VRAM_FILE), VRAM_FRESH_MS)?.let(::readLong) + + /** BAT* under the session's power_supply (BatteryComponent): no watts while charging, full or unknown. */ + fun battery(): Battery? { + val dirs = File(sessionDir, "sys/power_supply").listFiles { f -> f.name.contains("BAT") }?.sortedBy { it.name } + if (dirs.isNullOrEmpty()) return null + var chargeNow = 0f + var chargeFull = 0f + var watts = 0f + var charging = false + for (dir in dirs) { + val status = runCatching { File(dir, "status").readLines().firstOrNull() }.getOrNull() + if (status == "Charging" || status == "Unknown" || status == "Full") charging = true + val now = File(dir, "charge_now").takeIf { it.exists() } ?: File(dir, "energy_now") + val full = File(dir, "charge_full").takeIf { it.exists() } ?: File(dir, "energy_full") + if (now.exists()) { + chargeNow += (readFloat(now) ?: 0f) / 1_000_000f + chargeFull += (readFloat(full) ?: 0f) / 1_000_000f + } else { + chargeNow += (readFloat(File(dir, "capacity")) ?: 0f) / 100f + chargeFull = dirs.size.toFloat() + } + if (!charging) { + val power = File(dir, "power_now") + watts += if (power.exists()) kotlin.math.abs(readFloat(power) ?: 0f) / 1_000_000f + else kotlin.math.abs((readFloat(File(dir, "current_now")) ?: 0f) * (readFloat(File(dir, "voltage_now")) ?: 0f)) / 1e12f + } + } + val percent = if (chargeFull > 0f) Math.round(chargeNow / chargeFull * 100f) else null + return Battery(percent, if (charging) 0f else watts, charging) + } + + private fun fresh(file: File, maxAgeMs: Long = FEED_FRESH_MS): File? = + file.takeIf { it.isFile && System.currentTimeMillis() - it.lastModified() < maxAgeMs } + + + companion object { + private const val KGSL = "/sys/class/kgsl/kgsl-3d0" + const val VRAM_FILE = "run/droiddeck-hud/gpu-mem" + private const val FEED_FRESH_MS = 10_000L + private const val VRAM_FRESH_MS = 15_000L + private val WHITESPACE = Regex("\\s+") + + private fun readLong(file: File): Long? = runCatching { file.readText().trim().toLong() }.getOrNull() + private fun readFloat(file: File): Float? = runCatching { file.readLines().first().trim().toFloat() }.getOrNull() + + /** std::stod on the first line: the leading number, "6 %" -> 6. */ + fun leadingNumber(file: File): Double? = runCatching { + Regex("^\\s*-?\\d+(\\.\\d+)?").find(file.readLines().first())?.value?.trim()?.toDouble() + }.getOrNull() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/DesktopKeyboardButton.kt b/app/src/main/java/com/droiddeck/launcher/input/DesktopKeyboardButton.kt new file mode 100644 index 000000000..633d5a54f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/DesktopKeyboardButton.kt @@ -0,0 +1,118 @@ +package com.droiddeck.launcher.input + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Build +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.WindowInsets +import android.widget.FrameLayout +import com.droiddeck.launcher.R + +@SuppressLint("ViewConstructor") +class DesktopKeyboardButton(context: Context, private val onTap: () -> Unit) : View(context) { + + private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = dp(1.5f) } + private val box = RectF() + private var idleFill = 0 + private var idleStroke = 0 + private var heldStroke = 0 + private var pressed = false + + init { + contentDescription = context.getString(R.string.second_show_keyboard) + isClickable = true + visibility = GONE + layoutParams = FrameLayout.LayoutParams(dp(SIZE).toInt(), dp(SIZE).toInt(), Gravity.BOTTOM or Gravity.START) + place(0, 0) + applyColors() + } + + private fun dp(value: Float) = value * resources.displayMetrics.density + + fun applyColors() { + val settings = ControllerPrefs.read(context) + val tint = settings.tint + val alpha = settings.opacity / 100f + fun light(a: Int, f: Float) = Color.argb( + (a * alpha).toInt(), + (Color.red(tint) + (255 - Color.red(tint)) * f).toInt(), + (Color.green(tint) + (255 - Color.green(tint)) * f).toInt(), + (Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(), + ) + idleFill = Color.argb(190, (Color.red(tint) * 0.12f).toInt(), (Color.green(tint) * 0.12f).toInt(), (Color.blue(tint) * 0.12f).toInt()) + idleStroke = light(150, 0.25f) + heldStroke = light(230, 0.7f) + invalidate() + } + + private fun place(left: Int, bottom: Int) { + val params = layoutParams as FrameLayout.LayoutParams + val marginStart = left + (dp(CENTER_X) - dp(SIZE) / 2f).toInt() + val marginBottom = bottom + (dp(CENTER_Y) - dp(SIZE) / 2f).toInt() + if (params.leftMargin == marginStart && params.bottomMargin == marginBottom) return + params.leftMargin = marginStart + params.bottomMargin = marginBottom + layoutParams = params + } + + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null + place(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetBottom ?: 0) + return super.onApplyWindowInsets(insets) + } + + override fun onDraw(canvas: Canvas) { + val x = width / 2f + val y = height / 2f + fill.color = idleFill + canvas.drawCircle(x, y, dp(18f), fill) + stroke.color = if (pressed) heldStroke else idleStroke + canvas.drawCircle(x, y, dp(18f), stroke) + box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f)) + canvas.drawRoundRect(box, dp(2f), dp(2f), stroke) + for (row in 0..1) for (col in 0..2) { + val kx = x + dp(-6f + col * 5f) + val ky = y + dp(-3f + row * 4f) + canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke) + } + canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { pressed = true; invalidate() } + MotionEvent.ACTION_UP -> { + val inside = event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat() + pressed = false + invalidate() + if (inside) { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + performClick() + } + } + MotionEvent.ACTION_CANCEL -> { pressed = false; invalidate() } + } + return true + } + + override fun performClick(): Boolean { + super.performClick() + onTap() + return true + } + + private companion object { + const val SIZE = 52f + const val CENTER_X = 90f + const val CENTER_Y = 34f + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt index 8bf852fdb..d38eb9758 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt @@ -8,52 +8,125 @@ import kotlin.math.abs class DirectTouchHold( private val slopPx: Float, private val listener: PointerGestures.Listener, - private val cancelTouch: () -> Unit, + private val touch: Touch, ) { + interface Touch { + fun down(pointerId: Int, x: Float, y: Float) + fun move(pointerId: Int, x: Float, y: Float) + fun up(pointerId: Int, x: Float, y: Float) + fun cancel() + } + private val handler = Handler(Looper.getMainLooper()) - private var x = 0f - private var y = 0f - private var consumed = false - private var pressed = false + private var downX = 0f + private var downY = 0f + private var downId = 0 private var delayMs = 0L + private var holdBehavior = TouchGestureConfig.MOUSE_BEHAVIOR_CLICK + private var forwarded = false + private var held = false + private var pressing = false + private var upX = 0f + private var upY = 0f + private var liftId = 0 + private var liftPending = false + private val lift = Runnable { + liftPending = false + if (forwarded) { + touch.up(liftId, upX, upY) + forwarded = false + } + } private val release = Runnable { - if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y) - pressed = false + if (pressing) listener.onButton(PointerGestures.BTN_RIGHT, false, downX, downY) + pressing = false } private val hold = Runnable { - cancelTouch() - consumed = true + held = true listener.onLongPress() - listener.onButton(PointerGestures.BTN_RIGHT, true, x, y) - pressed = true - handler.postDelayed(release, 50L) + listener.onButton(PointerGestures.BTN_RIGHT, true, downX, downY) + pressing = true + if (holdBehavior != TouchGestureConfig.MOUSE_BEHAVIOR_HOLD) handler.postDelayed(release, 50L) } fun configure(config: TouchGestureConfig) { cancel() - delayMs = if (config.longPressEnabled) config.longPressDelay.toLong().coerceAtLeast(1L) else 0L + delayMs = config.longPressDelay.toLong().coerceAtLeast(1L) + holdBehavior = config.directHoldBehavior } fun cancel() { handler.removeCallbacks(hold) handler.removeCallbacks(release) + handler.removeCallbacks(lift) + liftPending = false release.run() - consumed = false + if (forwarded) touch.cancel() + forwarded = false + held = false } fun onTouch(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + if (liftPending) { + handler.removeCallbacks(lift) + lift.run() + } + cancel() + downX = event.x + downY = event.y + downId = event.getPointerId(0) + if (holdBehavior == TouchGestureConfig.MOUSE_BEHAVIOR_NONE) forwardDown() + else handler.postDelayed(hold, delayMs) + return true + } + if (held) { + when (event.actionMasked) { + MotionEvent.ACTION_UP -> { release.run(); held = false } + MotionEvent.ACTION_CANCEL -> cancel() + } + return true + } when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - cancel() - x = event.x; y = event.y - if (delayMs > 0L) handler.postDelayed(hold, delayMs) + MotionEvent.ACTION_POINTER_DOWN -> { + handler.removeCallbacks(hold) + if (!forwarded) forwardDown() + val index = event.actionIndex + touch.down(event.getPointerId(index), event.getX(index), event.getY(index)) + } + MotionEvent.ACTION_MOVE -> { + if (!forwarded && (event.pointerCount > 1 || + abs(event.x - downX) > slopPx || abs(event.y - downY) > slopPx)) { + handler.removeCallbacks(hold) + forwardDown() + } + if (forwarded) for (index in 0 until event.pointerCount) + touch.move(event.getPointerId(index), event.getX(index), event.getY(index)) + } + MotionEvent.ACTION_POINTER_UP -> { + val index = event.actionIndex + touch.up(event.getPointerId(index), event.getX(index), event.getY(index)) + } + MotionEvent.ACTION_UP -> { + handler.removeCallbacks(hold) + if (!forwarded) forwardDown() + upX = event.x + upY = event.y + liftId = event.getPointerId(event.actionIndex) + liftPending = true + handler.postDelayed(lift, LIFT_MS) } - 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 + return true + } + + private fun forwardDown() { + touch.down(downId, downX, downY) + forwarded = true + } + + private companion object { + const val LIFT_MS = 50L } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/DualScreen.kt b/app/src/main/java/com/droiddeck/launcher/input/DualScreen.kt new file mode 100644 index 000000000..e8d7fd71e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/DualScreen.kt @@ -0,0 +1,81 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Build +import android.view.Display +import android.view.InputDevice +import android.view.MotionEvent +import com.droiddeck.launcher.session.SessionPrefs +import kotlin.math.abs +import kotlin.math.roundToInt + +object DualScreen { + const val MODE_AUTO = "auto" + const val MODE_ON = "on" + const val MODE_OFF = "off" + val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF) + private const val TYPE_INTERNAL = 1 + private val knownModels = setOf("retroid pocket duo", "ayn thor") + private val touchedDisplays = mutableSetOf() + + fun builtInDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? { + val primary = displayManager.getDisplay(primaryDisplayId) + val awake = primary?.state != Display.STATE_OFF + val panels = if (awake) touchPanels() else emptyList() + return internalDisplay(displayManager, primaryDisplayId) { + if (awake && it.state == Display.STATE_OFF) return@internalDisplay false + synchronized(touchedDisplays) { + if (awake && hasOwnTouchPanel(size(it), primary?.let(::size), panels)) touchedDisplays += it.displayId + it.displayId in touchedDisplays + } + } + } + + private fun internalDisplay(displayManager: DisplayManager, primaryDisplayId: Int, accept: (Display) -> Boolean = { true }): Display? = + displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull { + it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0 && + isBuiltIn(displayType(it), Build.MODEL) && accept(it) + } + + internal fun hasOwnTouchPanel(display: Pair, primary: Pair?, panels: List>): Boolean { + val matching = panels.count { covers(it, display) } + return if (primary != null && covers(primary, display)) matching >= 2 else matching >= 1 + } + + private fun covers(panel: Pair, display: Pair): Boolean = + (near(panel.first, display.first) && near(panel.second, display.second)) || + (near(panel.first, display.second) && near(panel.second, display.first)) + + private fun near(a: Int, b: Int) = abs(a - b) <= 2 + + private fun size(display: Display): Pair = + display.mode.let { it.physicalWidth to it.physicalHeight } + + private fun touchPanels(): List> = + InputDevice.getDeviceIds().toList().mapNotNull { id -> + val device = InputDevice.getDevice(id) ?: return@mapNotNull null + if (device.isVirtual || !device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN)) return@mapNotNull null + val x = device.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHSCREEN) ?: return@mapNotNull null + val y = device.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHSCREEN) ?: return@mapNotNull null + (x.max - x.min + 1f).roundToInt() to (y.max - y.min + 1f).roundToInt() + } + + fun display(context: Context, displayManager: DisplayManager, primaryDisplayId: Int): Display? = + when (SessionPrefs.dualScreenMode(context)) { + MODE_OFF -> null + MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId) + else -> builtInDisplay(displayManager, primaryDisplayId) + } + + fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? = + displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull { + it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0 + } + + internal fun isBuiltIn(type: Int?, model: String): Boolean = + if (type != null) type == TYPE_INTERNAL else model.trim().lowercase() in knownModels + + private fun displayType(display: Display): Int? = + runCatching { Display::class.java.getMethod("getType").invoke(display) as Int }.getOrNull() +} 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 f140314dc..46ce99798 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -14,6 +14,7 @@ public class FakeInputWriter { public static final short EV_SYN = 0x00; public static final short EV_KEY = 0x01; + public static final short EV_REL = 0x02; public static final short EV_ABS = 0x03; public static final short EV_MSC = 0x04; @@ -52,7 +53,8 @@ public class FakeInputWriter { private static final int EVENT_SIZE = 24; static final int MAX_EVENTS_PER_UPDATE = 32; private static final int BUFFER_SIZE = EVENT_SIZE * MAX_EVENTS_PER_UPDATE; - private static final int MAX_FAKE_INPUT_SLOTS = 4; + private static final int MAX_FAKE_INPUT_SLOTS = 5; + public static final int MOUSE_SLOT = 4; private static final int RING_CAPACITY_EVENTS = 4096; private static final int RING_HEADER_SIZE = 64; @@ -88,6 +90,17 @@ public class FakeInputWriter { public static final int PAD_TYPE_XBOX = 2; public static final int PAD_TYPE_DS4 = 3; public static final int PAD_TYPE_DUALSENSE = 4; + public static final int PAD_TYPE_MOUSE = 5; + + public static final short REL_X = 0x00; + public static final short REL_Y = 0x01; + public static final short REL_HWHEEL = 0x06; + public static final short REL_WHEEL = 0x08; + public static final short REL_WHEEL_HI_RES = 0x0b; + public static final short REL_HWHEEL_HI_RES = 0x0c; + public static final short MOUSE_BTN_LEFT = 0x110; + public static final short MOUSE_BTN_RIGHT = 0x111; + public static final short MOUSE_BTN_MIDDLE = 0x112; private static volatile int padType = -1; private static volatile boolean tritonConnected = false; private static final int TRITON_OFFSET = RING_SIZE + 64; @@ -136,6 +149,8 @@ public class FakeInputWriter { private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY; private int prevTriggerL, prevTriggerR; private int prevHatX, prevHatY; + private final boolean[] prevMouseButtons = new boolean[3]; + private float pendingMouseX, pendingMouseY; private boolean hasChanges = false; private boolean pendingFullResend = false; private boolean forceResend = false; @@ -201,7 +216,7 @@ public class FakeInputWriter { return FakeInputRingPaths.ringFile(fakeInputDir, slot); } - private static void initializeRingHeader(ByteBuffer data) { + private static void initializeRingHeader(int slot, ByteBuffer data) { data.order(ByteOrder.LITTLE_ENDIAN); data.putInt(RING_MAGIC_OFFSET, RING_MAGIC); data.putInt(RING_VERSION_OFFSET, RING_VERSION); @@ -221,7 +236,7 @@ public class FakeInputWriter { data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0); } data.putInt(DECK_CONTROLS_OFFSET, 0); - int type = padType; + int type = slot == MOUSE_SLOT ? PAD_TYPE_MOUSE : padType; data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0); data.putInt(LATENCY_OFFSET, latency); data.putInt(TRITON_OFFSET, TRITON_MAGIC); @@ -312,7 +327,7 @@ public class FakeInputWriter { raf.setLength(RING_FILE_SIZE); channel = raf.getChannel(); ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE); - initializeRingHeader(data); + initializeRingHeader(slot, data); RingSlot ringSlot = new RingSlot(); ringSlot.data = data; @@ -745,6 +760,14 @@ public class FakeInputWriter { prevHatY = 0; writeEvent(EV_ABS, ABS_HAT0Y, 0); } + for (int i = 0; i < prevMouseButtons.length; i++) { + if (prevMouseButtons[i]) { + prevMouseButtons[i] = false; + writeEvent(EV_KEY, (short) (MOUSE_BTN_LEFT + i), 0); + } + } + pendingMouseX = 0; + pendingMouseY = 0; if (hasChanges) { writeEvent(EV_SYN, SYN_REPORT, 0); @@ -874,4 +897,64 @@ public class FakeInputWriter { public boolean isOpen() { return isOpen; } + + public synchronized void writeMouseDelta(float dx, float dy) { + if (!isOpen && !open()) + return; + pendingMouseX += dx; + pendingMouseY += dy; + int x = (int) Math.floor(pendingMouseX); + int y = (int) Math.floor(pendingMouseY); + if (x == 0 && y == 0) + return; + pendingMouseX -= x; + pendingMouseY -= y; + buffer.clear(); + hasChanges = false; + if (x != 0) writeEvent(EV_REL, REL_X, x); + if (y != 0) writeEvent(EV_REL, REL_Y, y); + writeEvent(EV_SYN, SYN_REPORT, 0); + buffer.flip(); + if (!flushBuffer()) + Log.e(TAG, "Mouse delta write error: fake input mmap ring unavailable"); + } + + public synchronized void writeMouseButton(short code, boolean pressed) { + if (code < MOUSE_BTN_LEFT || code > MOUSE_BTN_MIDDLE) + return; + int idx = code - MOUSE_BTN_LEFT; + if (!isOpen && !open()) + return; + if (prevMouseButtons[idx] == pressed) + return; + prevMouseButtons[idx] = pressed; + buffer.clear(); + hasChanges = false; + writeEvent(EV_KEY, code, pressed ? 1 : 0); + writeEvent(EV_SYN, SYN_REPORT, 0); + buffer.flip(); + if (!flushBuffer()) + Log.e(TAG, "Mouse button write error: fake input mmap ring unavailable"); + } + + public synchronized void writeMouseWheel(int verticalClicks, int horizontalClicks, int value120) { + if (verticalClicks == 0 && horizontalClicks == 0) + return; + if (!isOpen && !open()) + return; + buffer.clear(); + hasChanges = false; + if (verticalClicks != 0) { + writeEvent(EV_REL, REL_WHEEL, verticalClicks); + writeEvent(EV_REL, REL_WHEEL_HI_RES, verticalClicks * value120); + } + if (horizontalClicks != 0) { + writeEvent(EV_REL, REL_HWHEEL, horizontalClicks); + writeEvent(EV_REL, REL_HWHEEL_HI_RES, horizontalClicks * value120); + } + writeEvent(EV_SYN, SYN_REPORT, 0); + buffer.flip(); + if (!flushBuffer()) + Log.e(TAG, "Mouse wheel write error: fake input mmap ring unavailable"); + } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt b/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt index 785f7c1a1..fd289f734 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.input +import android.os.Build import android.view.KeyEvent class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCancel: () -> Unit = {}) { @@ -8,6 +9,10 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa fun onKeyEvent(event: KeyEvent, enabled: Boolean): Boolean { if (!isHome(event.keyCode)) return false + if (isQuickMenuKey(event)) { + if (event.action == KeyEvent.ACTION_DOWN) cancel() + return false + } if (!enabled) cancel() val device = event.deviceId return when (event.action) { @@ -43,5 +48,10 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa companion object { // The Retroid Nova pad falls back to Generic.kl, which maps its Home scan code to MOVE_HOME. fun isHome(keyCode: Int) = keyCode == KeyEvent.KEYCODE_HOME || keyCode == KeyEvent.KEYCODE_MOVE_HOME + + private const val DUO_QUICK_MENU_SCAN_CODE = 194 + + fun isQuickMenuKey(event: KeyEvent) = + event.scanCode == DUO_QUICK_MENU_SCAN_CODE && "Retroid Pocket Duo".equals(Build.MODEL, ignoreCase = true) } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/KeyboardHost.kt b/app/src/main/java/com/droiddeck/launcher/input/KeyboardHost.kt index 494533f43..ab2550a4a 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/KeyboardHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/KeyboardHost.kt @@ -23,6 +23,9 @@ class KeyboardHost( private val map: KeyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD) var shown = false private set + var imeShowing = false + private set + var onImeChanged: ((Boolean) -> Unit)? = null init { isFocusable = false @@ -42,6 +45,28 @@ class KeyboardHost( return KeyConnection() } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val visible = insets.isVisible(WindowInsets.Type.ime()) + if (visible != imeShowing) { + imeShowing = visible + onImeChanged?.invoke(visible) + } + } + return super.onApplyWindowInsets(insets) + } + + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || keyCode != KeyEvent.KEYCODE_BACK || !shown) { + return super.onKeyPreIme(keyCode, event) + } + if (event.action == KeyEvent.ACTION_UP && !event.isCanceled) { + hide() + onImeChanged?.invoke(false) + } + return true + } + fun toggle() = if (shown && imeVisible()) hide() else show() private fun imeVisible() = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index e0a0b14b9..b7fced710 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -672,6 +672,11 @@ class OnScreenControls( private fun closestSnap(position: Float, targets: List): Float? = targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) } + /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */ + fun takesTouchAt(x: Float, y: Float): Boolean = + editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) || + (!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null) + private fun controlAt(x: Float, y: Float): Control? = controls.firstOrNull { !quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius) diff --git a/app/src/main/java/com/droiddeck/launcher/input/PhysicalShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/input/PhysicalShortcuts.kt new file mode 100644 index 000000000..a79fa8c70 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/PhysicalShortcuts.kt @@ -0,0 +1,145 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent +import androidx.annotation.StringRes +import com.droiddeck.launcher.R +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +object PhysicalShortcuts { + internal const val KEY = "physicalShortcuts" + private const val TAG = "PhysicalShortcuts" + + enum class Action(@StringRes val label: Int) { + STEAM(R.string.drawer_steam_menu), + QAM(R.string.drawer_open_qam), + DROIDDECK(R.string.shortcuts_dd_menu), + } + + data class Button( + val device: String, + val name: String, + val keyCode: Int, + val scanCode: Int, + ) { + fun matches(event: KeyEvent): Boolean = + event.device?.let { device == identity(it) } == true && + keyCode == event.keyCode && scanCode == event.scanCode + + fun sameInput(other: Button): Boolean = + device == other.device && keyCode == other.keyCode && scanCode == other.scanCode + + val label: String + get() = KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_").removePrefix("BUTTON_") + + } + + data class Binding(val button: Button, val action: Action) + + private fun identity(device: InputDevice): String = + "${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}" + + fun isVolumeKey(event: KeyEvent): Boolean = when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> true + else -> false + } + + fun isDrawerNavigationKey(event: KeyEvent): Boolean = when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> true + else -> false + } + + fun button(event: KeyEvent): Button? { + val device = event.device ?: return null + if (device.isVirtual || isVolumeKey(event)) return null + when (event.keyCode) { + KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP, + KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null + } + if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null + return Button(identity(device), device.name, event.keyCode, event.scanCode) + } + + fun read(context: Context): List { + val raw = ControllerPrefs.prefs(context).getString(KEY, null) ?: return emptyList() + return try { + val array = JSONArray(raw) + buildList { + for (i in 0 until array.length()) { + try { + val entry = array.getJSONObject(i) + val action = Action.entries.firstOrNull { it.name == entry.getString("action") } + val code = entry.getInt("keyCode") + val scan = entry.getInt("scanCode") + val device = entry.getString("device") + if (action == null || device.isBlank() || code < 0 || scan < 0) { + Log.w(TAG, "Ignoring invalid shortcut at index $i") + continue + } + val binding = Binding(Button(device, entry.getString("name"), code, scan), action) + if (none { it.button.sameInput(binding.button) }) add(binding) + } catch (e: JSONException) { + Log.w(TAG, "Ignoring malformed shortcut at index $i", e) + } + } + } + } catch (e: JSONException) { + Log.w(TAG, "Could not read physical shortcuts; reset shortcuts to restore defaults", e) + emptyList() + } + } + + fun assign(context: Context, button: Button, action: Action) { + val bindings = read(context).filterNot { + it.button.sameInput(button) || (it.button.device == button.device && it.action == action) + } + Binding(button, action) + write(context, bindings) + } + + fun remove(context: Context, binding: Binding) { + write(context, read(context).filterNot { it == binding }) + } + + private fun write(context: Context, bindings: List) { + val array = JSONArray() + bindings.forEach { + array.put(JSONObject().put("device", it.button.device).put("name", it.button.name) + .put("keyCode", it.button.keyCode).put("scanCode", it.button.scanCode).put("action", it.action.name)) + } + ControllerPrefs.prefs(context).edit().putString(KEY, array.toString()).apply() + } + +} + +class PhysicalShortcutRouter { + var bindings: List = emptyList() + private data class Press(val device: Int, val keyCode: Int, val scanCode: Int) + private val held = mutableSetOf() + + fun onKey(event: KeyEvent, enabled: Boolean, steam: Boolean, perform: (PhysicalShortcuts.Action) -> Unit): Boolean { + val press = Press(event.deviceId, event.keyCode, event.scanCode) + if (event.action == KeyEvent.ACTION_UP) return held.remove(press) + if (event.action != KeyEvent.ACTION_DOWN) return false + if (press in held) return true + if (!enabled || bindings.isEmpty()) return false + val button = PhysicalShortcuts.button(event) ?: return false + val binding = bindings.firstOrNull { it.button.sameInput(button) } ?: return false + if (!steam && binding.action != PhysicalShortcuts.Action.DROIDDECK) return false + held.add(press) + if (event.repeatCount == 0) perform(binding.action) + return true + } + + fun onDeviceRemoved(deviceId: Int) { + held.removeAll { it.device == deviceId } + } + + fun clear() { + held.clear() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/PointerGestures.kt b/app/src/main/java/com/droiddeck/launcher/input/PointerGestures.kt index dab9ff652..74b50a452 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PointerGestures.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/PointerGestures.kt @@ -1,104 +1,22 @@ package com.droiddeck.launcher.input -import android.os.Handler -import android.os.Looper -import android.view.MotionEvent import android.view.ViewConfiguration -import kotlin.math.abs -import kotlin.math.roundToInt -class PointerGestures( - private val slopPx: Float, - private val listener: Listener, -) { +class PointerGestures { interface Listener { fun onMove(x: Float, y: Float) fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) fun onWheel(steps: Int) fun onLongPress() - } - - private val handler = Handler(Looper.getMainLooper()) - private var downX = 0f - private var downY = 0f - private var downAt = 0L - private var moved = false - private var held = false - private var dragging = false - private var scrolling = false - private var scrollAnchorY = 0f - private val longPress = Runnable { - held = true - listener.onLongPress() - } - - fun onTouch(event: MotionEvent): Boolean { - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - downX = event.x; downY = event.y; downAt = event.eventTime - moved = false; held = false; dragging = false; scrolling = false - listener.onMove(event.x, event.y) - handler.postDelayed(longPress, HOLD_MS) - return true - } - MotionEvent.ACTION_POINTER_DOWN -> { - handler.removeCallbacks(longPress) - if (dragging) { listener.onButton(BTN_LEFT, false, event.x, event.y); dragging = false } - scrolling = true - scrollAnchorY = event.getY(0) - return true - } - MotionEvent.ACTION_MOVE -> { - if (scrolling) { - val dy = event.getY(0) - scrollAnchorY - val steps = (dy / SCROLL_STEP_PX).toInt() - if (steps != 0) { - listener.onWheel(-steps) - scrollAnchorY += steps * SCROLL_STEP_PX - } - return true - } - if (!moved && (abs(event.x - downX) > slopPx || abs(event.y - downY) > slopPx)) { - moved = true - handler.removeCallbacks(longPress) - if (held) { - dragging = true - listener.onButton(BTN_LEFT, true, downX, downY) - } - } - if (moved) listener.onMove(event.x, event.y) - return true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - handler.removeCallbacks(longPress) - val cancelled = event.actionMasked == MotionEvent.ACTION_CANCEL - when { - scrolling -> {} - dragging -> listener.onButton(BTN_LEFT, false, event.x, event.y) - held && !moved && !cancelled -> { - listener.onButton(BTN_RIGHT, true, downX, downY) - listener.onButton(BTN_RIGHT, false, downX, downY) - } - !moved && !cancelled && event.eventTime - downAt < TAP_MS -> { - listener.onButton(BTN_LEFT, true, downX, downY) - listener.onButton(BTN_LEFT, false, downX, downY) - } - } - scrolling = false; dragging = false; held = false - return true - } - MotionEvent.ACTION_POINTER_UP -> return true - } - return false + fun onScroll(dx: Float, dy: Float) {} + fun onScrollEnd() {} + fun onZoom(steps: Int) {} } companion object { const val BTN_LEFT = 0x110 const val BTN_RIGHT = 0x111 const val BTN_MIDDLE = 0x112 - private const val HOLD_MS = 450L - private const val TAP_MS = 350L - private const val SCROLL_STEP_PX = 40f fun slop(context: android.content.Context) = ViewConfiguration.get(context).scaledTouchSlop.toFloat() } diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt index c7a9291e9..df129888c 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt @@ -609,6 +609,9 @@ class ScreenKeys(context: Context, private val sendKey: (Int, Boolean) -> Unit) else -> entry.halfH } + /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */ + fun takesTouchAt(x: Float, y: Float): Boolean = editing || entryAt(x, y) != null + private fun entryAt(x: Float, y: Float): Entry? = entries.lastOrNull { entry -> x >= entry.cx - entry.halfW && x <= entry.cx + entry.halfW && y >= entry.cy - entry.halfH && y <= entry.cy + entry.halfH } diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt b/app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt index 3bb1c9c1e..4f1ebc9bd 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt @@ -86,27 +86,14 @@ class ScrollTouchGestures( lastY = startY tracker = VelocityTracker.obtain().also { it.addMovement(event) } listener.onMove(startX, startY) - handler.postDelayed(longPress, HOLD_MS) + handler.postDelayed(longPress, TouchTiming.HOLD_MS) } MotionEvent.ACTION_MOVE -> { val index = event.findPointerIndex(pointerId).takeIf { it >= 0 } ?: return true tracker?.addMovement(event) - val x = event.getX(index) - val y = event.getY(index) - if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) { - handler.removeCallbacks(longPress) - if (held) { - dragging = true - listener.onButton(PointerGestures.BTN_LEFT, true) - } else { - scrolling = true - vertical = abs(y - startY) >= abs(x - startX) - lastX = startX - lastY = startY - } - } - if (dragging) listener.onMove(x, y) - follow(event, x, y) + for (h in 0 until event.historySize) + sample(event.getHistoricalX(index, h), event.getHistoricalY(index, h), event.getHistoricalEventTime(h)) + sample(event.getX(index), event.getY(index), event.eventTime) } MotionEvent.ACTION_POINTER_UP -> { if (event.getPointerId(event.actionIndex) == pointerId) cancel() @@ -115,7 +102,7 @@ class ScrollTouchGestures( handler.removeCallbacks(longPress) if (pointerId == -1 || event.getPointerId(0) != pointerId) return true tracker?.addMovement(event) - follow(event, event.x, event.y) + follow(event.x, event.y, event.eventTime) when { scrolling -> tracker?.takeIf { event.eventTime - movedAt <= FLING_STILL_MS }?.let { t -> t.computeCurrentVelocity(1000) @@ -139,9 +126,26 @@ class ScrollTouchGestures( return true } - private fun follow(event: MotionEvent, x: Float, y: Float) { + private fun sample(x: Float, y: Float, t: Long) { + if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) { + handler.removeCallbacks(longPress) + if (held) { + dragging = true + listener.onButton(PointerGestures.BTN_LEFT, true) + } else { + scrolling = true + vertical = abs(y - startY) >= abs(x - startX) + lastX = startX + lastY = startY + } + } + if (dragging) listener.onMove(x, y) + follow(x, y, t) + } + + private fun follow(x: Float, y: Float, t: Long) { if (!scrolling || (x == lastX && y == lastY)) return - movedAt = event.eventTime + movedAt = t if (vertical) listener.onScroll(0f, lastY - y) else listener.onScroll(lastX - x, 0f) lastX = x lastY = y @@ -151,7 +155,7 @@ class ScrollTouchGestures( releaseClick() listener.onButton(button, true) clicked = button - handler.postDelayed(clickUp, CLICK_MS) + handler.postDelayed(clickUp, TouchTiming.CLICK_MS) } private fun releaseClick() { @@ -181,8 +185,6 @@ class ScrollTouchGestures( return if (pixels < 0) -units else units } - private const val HOLD_MS = 500L - private const val CLICK_MS = 50L private const val FLING_TIME_S = 0.45f private const val FLING_STOP_PX = 40f private const val FLING_STILL_MS = 60L diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index 6789654f7..019ac686c 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -9,7 +9,12 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) { NONE("none", R.string.second_mode_none), KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad), TERMINAL("terminal", R.string.second_mode_terminal), - DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls), + DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls); + + companion object { + fun opening(remembered: String?, deckPad: Boolean): SecondScreenMode = + entries.firstOrNull { it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) } ?: if (deckPad) DECK_CONTROLS else KEYBOARD_TRACKPAD + } } data class SecondScreenDisplay(val id: Int, val label: String) 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 2138f9c92..68eed5ede 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt @@ -244,6 +244,9 @@ class SteamTouchControls( private fun hit(list: List, x: Float, y: Float): Element? = list.filter { reach(it, x, y) <= 1.15f }.minByOrNull { reach(it, x, y) } + /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */ + fun takesTouchAt(x: Float, y: Float): Boolean = editing || menuOpen || hit(live(), x, y) != null + private fun live(): List = if (shakenAway) emptyList() else if (options.inputMode == SteamTouchConfig.INPUT_MOUSE) elements.filter { it.type == SteamTouchConfig.THUMB } else elements diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt index 8021536a9..f386ebb17 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt @@ -15,6 +15,7 @@ data class TouchGestureConfig( val longPressAction: String = ACTION_RIGHT_CLICK, val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD, val longPressDelay: Int = DEFAULT_DELAY_MS, + val directHoldBehavior: String = MOUSE_BEHAVIOR_CLICK, val doubleTapEnabled: Boolean = true, val doubleTapDelay: Int = DEFAULT_DELAY_MS, val twoFingerDragEnabled: Boolean = true, @@ -50,6 +51,7 @@ data class TouchGestureConfig( put(KEY_LONG_PRESS_ACTION, longPressAction) put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior) put(KEY_LONG_PRESS_DELAY, longPressDelay) + put(KEY_DIRECT_HOLD_BEHAVIOR, directHoldBehavior) put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled) put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay) put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled) @@ -85,6 +87,7 @@ data class TouchGestureConfig( const val MOUSE_BEHAVIOR_HOLD = "hold" const val MOUSE_BEHAVIOR_CLICK = "click" + const val MOUSE_BEHAVIOR_NONE = "none" const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct" const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative" @@ -110,6 +113,7 @@ data class TouchGestureConfig( 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_DIRECT_HOLD_BEHAVIOR = "directHoldBehavior" 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" @@ -155,6 +159,9 @@ data class TouchGestureConfig( obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD) ), longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS), + directHoldBehavior = normalizeDirectHoldBehavior( + obj.optString(KEY_DIRECT_HOLD_BEHAVIOR, MOUSE_BEHAVIOR_CLICK) + ), 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), @@ -190,6 +197,10 @@ data class TouchGestureConfig( } } + private fun normalizeDirectHoldBehavior(value: String): String { + return if (value == MOUSE_BEHAVIOR_CLICK || value == MOUSE_BEHAVIOR_NONE) value else MOUSE_BEHAVIOR_HOLD + } + private fun normalizeMouseBehavior(value: String): String { return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD } diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchRouter.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchRouter.kt new file mode 100644 index 000000000..27cb87659 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchRouter.kt @@ -0,0 +1,31 @@ +package com.droiddeck.launcher.input + +import com.droiddeck.launcher.session.SessionPrefs + +object TouchRouter { + enum class Route { BLOCKED, GESTURES, SCROLL, OFF, TOUCHPAD, DIRECT } + + data class Snapshot( + val uiBlocking: Boolean, + val mode: String, + val scrollLayer: Boolean, + val steamSession: Boolean, + val steamDesktopUi: Boolean, + ) + + fun decide(s: Snapshot): Route { + if (s.uiBlocking) return Route.BLOCKED + if (s.mode == SessionPrefs.TOUCH_GESTURES) return Route.GESTURES + if (s.scrollLayer) return Route.SCROLL + if (s.mode == SessionPrefs.TOUCH_OFF) return Route.OFF + return if (touchpad(s)) Route.TOUCHPAD else Route.DIRECT + } + + fun steamKeyboardZone(s: Snapshot): Boolean = s.steamSession && !s.steamDesktopUi + + private fun touchpad(s: Snapshot): Boolean = when (s.mode) { + SessionPrefs.TOUCH_PAD -> true + SessionPrefs.TOUCH_DIRECT -> false + else -> !s.steamSession || s.steamDesktopUi + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchTiming.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchTiming.kt new file mode 100644 index 000000000..8db3c95c9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchTiming.kt @@ -0,0 +1,9 @@ +package com.droiddeck.launcher.input + +object TouchTiming { + const val HOLD_MS = 500L + const val TAP_MS = 300L + const val CLICK_MS = 30L + const val DOUBLE_TAP_MS = 300L + const val SCROLL_STEP_PX = 36f +} 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 47f0e4b64..b03cd03b6 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt @@ -4,6 +4,7 @@ import android.os.Handler import android.os.Looper import android.view.MotionEvent import kotlin.math.abs +import kotlin.math.hypot class TouchpadGestures( private val slopPx: Float, @@ -15,6 +16,7 @@ class TouchpadGestures( private set var bounds: android.graphics.RectF = android.graphics.RectF(0f, 0f, 1f, 1f) var sensitivity = 1.15f + var desktop = false private val handler = Handler(Looper.getMainLooper()) private var lastX = 0f @@ -27,7 +29,11 @@ class TouchpadGestures( private var secondDownAt = 0L private var secondWasTap = false private var twoFingers = false + private var scrollAnchorX = 0f private var scrollAnchorY = 0f + private var fingers = 0 + private var pinching = false + private var pinchSpan = 0f private var scrolled = false private var anchorId = -1 private var anchorStartX = 0f @@ -52,7 +58,7 @@ class TouchpadGestures( releaseClick() listener.onButton(button, true, x, y) clicked = button - handler.postDelayed(clickUp, CLICK_MS) + handler.postDelayed(clickUp, TouchTiming.CLICK_MS) } private fun releaseClick() { @@ -79,6 +85,20 @@ class TouchpadGestures( y = py.coerceIn(bounds.top, bounds.bottom) } + private fun trackMove(px: Float, py: Float) { + val dx = px - lastX + val dy = py - lastY + lastX = px; lastY = py + travelled += abs(dx) + abs(dy) + if (travelled > slopPx) { + handler.removeCallbacks(longPress) + val speed = abs(dx) + abs(dy) + val gain = sensitivity * (1f + minOf(speed / 40f, 1.5f)) + place(x + dx * gain, y + dy * gain) + listener.onMove(x, y) + } + } + fun onTouch(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { @@ -86,18 +106,20 @@ class TouchpadGestures( lastX = event.x; lastY = event.y downAt = event.eventTime travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false + fingers = 1; pinching = false anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false - if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) { + if (event.eventTime - lastTapAt < TouchTiming.DOUBLE_TAP_MS) { dragging = true listener.onButton(PointerGestures.BTN_LEFT, true, x, y) } else { dragging = false - handler.postDelayed(longPress, HOLD_MS) + handler.postDelayed(longPress, TouchTiming.HOLD_MS) } return true } MotionEvent.ACTION_POINTER_DOWN -> { handler.removeCallbacks(longPress) + fingers = maxOf(fingers, event.pointerCount) val i = event.actionIndex if (moverId == -1 && event.getPointerId(i) != anchorId) { moverId = event.getPointerId(i) @@ -111,7 +133,9 @@ class TouchpadGestures( twoFingers = true secondDownAt = event.eventTime secondWasTap = true + scrollAnchorX = (event.getX(0) + event.getX(1)) / 2f scrollAnchorY = (event.getY(0) + event.getY(1)) / 2f + pinchSpan = span(event) return true } MotionEvent.ACTION_MOVE -> { @@ -146,29 +170,49 @@ class TouchpadGestures( } else if (holdDrag) { return true } - if (twoFingers && event.pointerCount >= 2) { - val mid = (event.getY(0) + event.getY(1)) / 2f - val dy = mid - scrollAnchorY - val steps = (dy / SCROLL_STEP_PX).toInt() - if (steps != 0) { - listener.onWheel(-steps) - scrollAnchorY += steps * SCROLL_STEP_PX + if (twoFingers && event.pointerCount >= 2 && desktop) { + if (fingers >= 3) return true + val span = span(event) + if (!scrolled && !pinching && abs(span - pinchSpan) > slopPx * PINCH_SLOPS) { + pinching = true + secondWasTap = false + pinchSpan = span + } + if (pinching) { + val steps = ((span - pinchSpan) / PINCH_STEP_PX).toInt() + if (steps != 0) { + listener.onZoom(-steps) + pinchSpan += steps * PINCH_STEP_PX + } + return true + } + val midX = (event.getX(0) + event.getX(1)) / 2f + val midY = (event.getY(0) + event.getY(1)) / 2f + val dx = scrollAnchorX - midX + val dy = scrollAnchorY - midY + if (scrolled || abs(dx) + abs(dy) > slopPx) { + listener.onScroll(dx, dy) + scrollAnchorX = midX; scrollAnchorY = midY scrolled = true secondWasTap = false } return true } - val dx = event.x - lastX - val dy = event.y - lastY - lastX = event.x; lastY = event.y - travelled += abs(dx) + abs(dy) - if (travelled > slopPx) { - handler.removeCallbacks(longPress) - val speed = (abs(dx) + abs(dy)) - val gain = sensitivity * (1f + minOf(speed / 40f, 1.5f)) - place(x + dx * gain, y + dy * gain) - listener.onMove(x, y) + if (twoFingers && event.pointerCount >= 2) { + val mid = (event.getY(0) + event.getY(1)) / 2f + val dy = mid - scrollAnchorY + val steps = (dy / TouchTiming.SCROLL_STEP_PX).toInt() + if (steps != 0) { + listener.onWheel(-steps) + scrollAnchorY += steps * TouchTiming.SCROLL_STEP_PX + scrolled = true + secondWasTap = false + } + return true } + for (h in 0 until event.historySize) + trackMove(event.getHistoricalX(0, h), event.getHistoricalY(0, h)) + trackMove(event.x, event.y) return true } MotionEvent.ACTION_POINTER_UP -> { @@ -194,7 +238,12 @@ class TouchpadGestures( 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) { + if (desktop && scrolled && event.pointerCount == 2) listener.onScrollEnd() + if (fingers >= 3) { + if (event.pointerCount == 2 && secondWasTap && !scrolled && event.eventTime - downAt < TouchTiming.TAP_MS * 2) + click(PointerGestures.BTN_MIDDLE) + if (event.pointerCount == 2) secondWasTap = false + } else if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TouchTiming.TAP_MS && !scrolled) { click(PointerGestures.BTN_RIGHT) secondWasTap = false } @@ -206,29 +255,30 @@ class TouchpadGestures( when { dragging -> listener.onButton(PointerGestures.BTN_LEFT, false, x, y) twoFingers -> { - if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TAP_MS * 2) { + if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TouchTiming.TAP_MS * 2) { click(PointerGestures.BTN_RIGHT) } } - !cancelled && travelled <= slopPx && event.eventTime - downAt < TAP_MS -> { + !cancelled && travelled <= slopPx && event.eventTime - downAt < TouchTiming.TAP_MS -> { click(PointerGestures.BTN_LEFT) lastTapAt = event.eventTime } } dragging = false; held = false; twoFingers = false holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1 + pinching = false; fingers = 0 return true } } return false } + private fun span(event: MotionEvent): Float = + if (event.pointerCount < 2) 0f else hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1)) + companion object { - private const val HOLD_MS = 500L - private const val TAP_MS = 300L - private const val CLICK_MS = 50L - private const val DOUBLE_TAP_MS = 300L - private const val SCROLL_STEP_PX = 36f + private const val PINCH_SLOPS = 3f + private const val PINCH_STEP_PX = 60f 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 index e71a14cd3..140b4ace4 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java @@ -36,7 +36,7 @@ public final class TouchscreenGestures { private final Output output; private final float doubleTapSlop; private TouchGestureConfig gestureConfig = new TouchGestureConfig(); - private static final int CLICK_DELAYED_TIME = 50; + private static final int CLICK_DELAYED_TIME = 30; private Runnable delayedPress, pendingHoldClickRelease; private Runnable pendingTapPress; private final ArrayDeque pendingTapClicks = new ArrayDeque<>(); 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 81fabd7f7..d3ab2b546 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -180,7 +180,8 @@ object BwrapSpawner { val env = LinkedHashMap() 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) } + val libc = listOf("/usr/lib/aarch64-linux-gnu/libc.so.6", "/lib/aarch64-linux-gnu/libc.so.6").map { hostPath(binds, root.path, it) } + if (preloadFits(rootfs, libc.firstOrNull { File(it).isFile } ?: libc.first())) 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) @@ -188,16 +189,20 @@ object BwrapSpawner { private const val GPU_DIR = "/run/droiddeck-gpu" + private val LIB_DIRS = listOf("usr/lib/aarch64-linux-gnu", "usr/lib") + private val GPU_LIBS = listOf("libdisplay-info.so.3", "libdisplay-info.so.2", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so") + + private fun runtimeLib(rootfs: String, name: String): File? = + LIB_DIRS.map { File(rootfs, "$it/$name") }.firstOrNull { it.exists() } + private fun gpu(rootfs: String, root: File, env: MutableMap): List? { - val lib = File(rootfs, "usr/lib") - val driver = File(lib, "libvulkan_freedreno.so") - if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null + val driver = runtimeLib(rootfs, "libvulkan_freedreno.so") + if (driver == null || !File("/dev/kgsl-3d0").exists()) return null if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null if (env["BL_DESKTOP"] == "1" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null - val binds = ArrayList() - for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) { - val f = File(lib, name) - if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null } + val binds = arrayListOf(Bind(driver.canonicalPath, "$GPU_DIR/libvulkan_freedreno.so")) + for (name in GPU_LIBS) { + val f = runtimeLib(rootfs, name) ?: continue binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name")) } val icd = File(root.parentFile, "freedreno_icd.json") @@ -227,6 +232,27 @@ object BwrapSpawner { return binds } + private val GLIBC_RELEASE = Regex("release version 2\\.(\\d+)") + private val GLIBC_NEED = Regex("GLIBC_2\\.(\\d+)") + private val glibcMinors = java.util.concurrent.ConcurrentHashMap() + + private fun glibcMinor(file: File, pattern: Regex, pick: (Sequence) -> Int?): Int? { + val key = "${file.path}:${file.length()}:${file.lastModified()}:${pattern.pattern}" + glibcMinors[key]?.let { return it } + val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: return null + return pick(pattern.findAll(text).map { it.groupValues[1].toInt() })?.also { glibcMinors[key] = it } + } + + internal fun preloadFits(rootfs: String, sandboxLibc: String): Boolean { + val libc = File(sandboxLibc) + if (!libc.isFile) return true + val have = glibcMinor(libc, GLIBC_RELEASE) { it.firstOrNull() } ?: return true + val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines() + ?.map { it.trim() }?.filter { it.startsWith("/") } ?: return true + val need = libs.mapNotNull { File(rootfs + it).takeIf { f -> f.isFile }?.let { f -> glibcMinor(f, GLIBC_NEED) { m -> m.maxOrNull() } } }.maxOrNull() ?: return true + return have >= need + } + private const val PRELOAD_DIR = "/run/droiddeck-preload" private const val NET_LINK = "etc/droiddeck-net" @@ -321,7 +347,15 @@ object BwrapSpawner { catch (e: IOException) { } if (process.isAlive) { Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it") - process.destroy() + if (pid > 0) { + runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) } + } else { + process.destroy() + } + try { + if (!process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly() + } catch (_: InterruptedException) { + } } }, "bwrap-$id-watch").apply { isDaemon = true; start() } val buffer = ByteArray(16384) 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 20714b552..4a4857634 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -22,8 +22,8 @@ object DesktopCatalog { val icon: String, val category: String, ) - fun fetch(url: String = CATALOG_URL): List? { - val body = Downloader.downloadString(url) ?: return null + fun fetch(url: String = CATALOG_URL, cancelled: java.util.function.BooleanSupplier? = null): List? { + val body = Downloader.downloadString(url, cancelled) ?: return null return try { val json = JSONObject(body) val arr = json.getJSONArray("packages") @@ -71,9 +71,22 @@ object DesktopCatalog { } fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) { + resumeInterrupted(context, null, listener) + } + + fun resumeInterrupted( + context: Context, + cancelled: Downloader.Cancellation?, + 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 } + val problem = if (cancelled == null) { + install(context, entry, listener) + } else { + install(context, entry, cancelled, listener) + } + if (problem != null) return problem } null } @@ -102,19 +115,27 @@ object DesktopCatalog { !File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile const val DESKTOP_ID = "desktop-kde" + const val EMULATORS_ID = "emulators" + private const val EMULATORS_SCRIPT = "/usr/local/bin/droiddeck-emulators" - fun desktopEntry(context: Context) = Entry( - DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, BuildConfig.DESKTOP_KDE_TAG, "tar", - BuildConfig.DESKTOP_KDE_URL, BuildConfig.DESKTOP_KDE_SHA256, BuildConfig.DESKTOP_KDE_SIZE, "", - "", "", - ) + fun desktopEntry(context: Context): Entry { + val pack = DesktopPack.info(LinuxRuntime.rootDir(context)) + return Entry( + DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, pack?.tag ?: BuildConfig.DESKTOP_KDE_TAG, "tar", + pack?.url ?: BuildConfig.DESKTOP_KDE_URL, pack?.sha256 ?: BuildConfig.DESKTOP_KDE_SHA256, + pack?.size ?: BuildConfig.DESKTOP_KDE_SIZE, "", "", "", + ) + } fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context)) + fun desktopFromPack(root: File): Boolean = DesktopPack.info(root) != null + fun desktopInstalled(root: File): Boolean = !File(root, DesktopRemoval.PENDING).exists() && File(root, "usr/bin/kwin_wayland").isFile && - FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() == BuildConfig.DESKTOP_KDE_TAG + FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() == + (DesktopPack.info(root)?.tag ?: BuildConfig.DESKTOP_KDE_TAG) fun desktopPresent(context: Context): Boolean { val root = LinuxRuntime.rootDir(context) @@ -144,12 +165,19 @@ object DesktopCatalog { } fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) { - installLocked(context, entry, listener) + installLocked(context, entry, null, listener) } - private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? { + fun install(context: Context, entry: Entry, cancelled: Downloader.Cancellation, + listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) { + installLocked(context, entry, cancelled, listener) + } + + private fun installLocked(context: Context, entry: Entry, cancelled: Downloader.Cancellation?, + listener: LinuxRuntimeInstaller.ProgressListener?): String? { val root = LinuxRuntime.rootDir(context) if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing) + if (entry.id == EMULATORS_ID && desktopFromPack(root)) return installEmulators(context, entry, cancelled, listener) if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name) val download = downloadFile(context, entry) val journal = journal(root, entry.id) @@ -166,14 +194,20 @@ object DesktopCatalog { 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 -> + val progress = java.util.function.Consumer { 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) + val ok = if (cancelled == null) { + Downloader.downloadFile(entry.url, download, true, entry.sha256, progress) + } else { + Downloader.downloadFile(entry.url, download, true, entry.sha256, cancelled, progress) + } + if (!ok || cancelled?.getAsBoolean() == true) 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) } + if (cancelled?.getAsBoolean() == true) return context.getString(R.string.user_apps_download_failed) report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1) when (entry.kind) { "appimage" -> { @@ -192,11 +226,12 @@ object DesktopCatalog { check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" } keepDownload = true marker(context, entry.id).delete() + if (entry.id == DESKTOP_ID && desktopFromPack(root)) DesktopPack.prepareInstall(root) if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed) } } if (entry.id == DESKTOP_ID) { - cleanLegacy(context, report) + if (desktopFromPack(root)) DesktopPack.finishInstall(root) else cleanLegacy(context, report) val pending = File(root, DesktopRemoval.PENDING) check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" } } @@ -214,6 +249,47 @@ object DesktopCatalog { } } + private fun installEmulators(context: Context, entry: Entry, cancelled: Downloader.Cancellation?, + listener: LinuxRuntimeInstaller.ProgressListener?): String? { + val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name)) + val stop = java.util.concurrent.atomic.AtomicBoolean(false) + val finished = java.util.concurrent.atomic.AtomicBoolean(false) + cancelled?.let { token -> + Thread({ + try { + while (!finished.get() && !stop.get()) { + if (token.getAsBoolean()) stop.set(true) else Thread.sleep(200) + } + } catch (_: InterruptedException) { + } + }, "emulators-cancel").apply { isDaemon = true; start() } + } + try { + var failure: String? = null + val status = GuestCommand.run(context, listOf("/bin/bash", EMULATORS_SCRIPT, "install"), logName = "emulators", + cancelled = stop) { line -> + when { + line.startsWith("== STEP ") -> { + val stage = context.getString(R.string.deskpkg_installing, line.removePrefix("== STEP ")) + RuntimeInstallService.packageProgress(stage, -1) + listener?.onProgress(stage, -1) + } + line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ") + } + } + if (stop.get()) return context.getString(R.string.user_apps_download_failed) + if (failure != null || status != 0) return context.getString(R.string.deskpkg_install_failed) + check(FileUtils.writeString(marker(context, entry.id), entry.version)) { "Could not finish package installation" } + return null + } catch (e: Exception) { + Log.e(TAG, "install ${entry.id}", e) + return e.message ?: context.getString(R.string.deskpkg_install_failed) + } finally { + finished.set(true) + release() + } + } + private fun unzipAppImage(zip: File, target: File): Boolean { try { java.util.zip.ZipInputStream(java.io.BufferedInputStream(java.io.FileInputStream(zip))).use { zin -> diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopPack.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopPack.kt new file mode 100644 index 000000000..3b791c579 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopPack.kt @@ -0,0 +1,178 @@ +package com.droiddeck.launcher.runtime + +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.Hashes +import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.util.zip.GZIPInputStream +import org.json.JSONObject + +internal object DesktopPack { + const val INFO = "usr/share/droiddeck/desktop-pack.json" + const val META = "usr/share/droiddeck/desktop" + private const val MANIFEST = "$META/files.tsv.gz" + private const val ORIGINALS = "$META/originals" + private const val STATUS = "var/lib/dpkg/status" + private const val STATUS_BEFORE = "var/lib/dpkg/status.droiddeck" + private const val PACK_STATUS = "$META/dpkg-status" + + data class Info(val tag: String, val url: String, val sha256: String, val size: Long) + + data class Entry(val kind: Char, val type: Char, val size: Long, val value: String, val path: String) { + companion object { + fun parse(line: String): Entry? { + val parts = line.split('\t', limit = 5) + if (parts.size != 5 || parts[0].length != 1 || parts[1].length != 1) return null + return Entry(parts[0][0], parts[1][0], parts[2].toLongOrNull() ?: return null, parts[3], parts[4]) + } + } + } + + data class Result(val removed: Int, val restored: Int, val kept: Int) + + fun info(root: File): Info? = runCatching { + val json = JSONObject(FileUtils.readString(File(root, INFO)) ?: return null) + Info(json.getString("tag"), json.getString("url"), json.getString("sha256"), json.getLong("size")) + }.getOrNull() + + fun installed(root: File): Boolean = File(root, MANIFEST).isFile + + fun entries(root: File): List = + GZIPInputStream(File(root, MANIFEST).inputStream()).bufferedReader().use { reader -> + reader.lineSequence().mapNotNull(Entry::parse).toList() + } + + fun installedSize(root: File): Long = + if (installed(root)) entries(root).filter { it.kind == 'N' && it.type == 'F' }.sumOf { it.size } else 0L + + fun prepareInstall(root: File) { + val live = File(root, STATUS) + val before = File(root, STATUS_BEFORE) + if (!before.exists() && live.isFile) Files.copy(live.toPath(), before.toPath(), StandardCopyOption.COPY_ATTRIBUTES) + } + + fun finishInstall(root: File) { + for (entry in entries(root).filter { it.kind == 'R' }.sortedByDescending { it.path.length }) { + val file = inside(root, entry.path) ?: continue + if (entry.type == 'D') file.delete() else Files.deleteIfExists(file.toPath()) + } + val live = File(root, STATUS) + val packStatus = File(root, PACK_STATUS) + if (live.isFile && !packStatus.exists()) Files.copy(live.toPath(), packStatus.toPath()) + val before = File(root, STATUS_BEFORE) + val runtime = File(File(root, ORIGINALS), STATUS) + if (before.isFile && runtime.isFile && live.isFile) { + writeStatus(live, mergeStatus(runtime.readText(), packStatus.readText(), before.readText())) + } + Files.deleteIfExists(before.toPath()) + } + + internal fun mergeStatus(base: String, target: String, mine: String): String { + val from = stanzas(base) + val into = LinkedHashMap(stanzas(target)) + val kept = stanzas(mine) + for ((key, stanza) in kept) { + if (from[key] != stanza && into[key] == from[key]) into[key] = stanza + } + for (key in from.keys) { + if (key !in kept && into[key] == from[key]) into.remove(key) + } + return into.values.joinToString("") { it + "\n\n" } + } + + private fun stanzas(text: String): Map { + val out = LinkedHashMap() + for (stanza in text.split(Regex("\n[ \t]*\n")).map { it.trim('\n') }.filter { it.isNotBlank() }) { + val fields = stanza.lines() + fun field(name: String) = fields.firstOrNull { it.startsWith("$name:") }?.substringAfter(':')?.trim().orEmpty() + out["${field("Package")}:${field("Architecture")}"] = stanza + } + return out + } + + private fun writeStatus(file: File, text: String) { + val part = LinuxRuntimeInstaller.partFile(file).toPath() + Files.deleteIfExists(part) + Files.copy(file.toPath(), part, StandardCopyOption.COPY_ATTRIBUTES) + Files.write(part, text.toByteArray(), java.nio.file.StandardOpenOption.TRUNCATE_EXISTING) + Files.move(part, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } + + fun remove(root: File, progress: (Int) -> Unit = {}): Result { + val entries = entries(root) + var removed = 0 + var restored = 0 + var kept = 0 + val originals = File(root, ORIGINALS) + val live = File(root, STATUS) + val userStatus = if (live.isFile) live.readText() else null + for (entry in entries.filter { it.kind == 'C' || it.kind == 'R' }) { + val file = inside(root, entry.path) ?: continue + if (entry.type == 'D') { + file.mkdirs() + continue + } + if (entry.kind == 'C' && !entry.path.startsWith("var/lib/dpkg/") && !matchesOrMissing(file, entry)) { + kept++ + continue + } + val source = inside(originals, entry.path) ?: continue + val sourcePath = source.toPath() + if (!Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) continue + file.parentFile?.mkdirs() + val part = LinuxRuntimeInstaller.partFile(file).toPath() + Files.deleteIfExists(part) + if (Files.isSymbolicLink(sourcePath)) Files.createSymbolicLink(part, Files.readSymbolicLink(sourcePath)) + else Files.copy(sourcePath, part, StandardCopyOption.COPY_ATTRIBUTES) + if (Files.isDirectory(file.toPath(), LinkOption.NOFOLLOW_LINKS)) FileUtils.delete(file) + Files.move(part, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + restored++ + } + val added = entries.filter { it.kind == 'N' } + val directories = ArrayList() + added.forEachIndexed { index, entry -> + val file = inside(root, entry.path) + when { + file == null -> kept++ + entry.type == 'D' -> directories += file + matches(file, entry) -> { Files.deleteIfExists(file.toPath()); removed++ } + Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS) -> kept++ + } + if (index % 2000 == 0) progress(index * 100 / added.size.coerceAtLeast(1)) + } + for (dir in directories.sortedByDescending { it.path.length }) { + if (Files.isDirectory(dir.toPath(), LinkOption.NOFOLLOW_LINKS) && dir.list()?.isEmpty() == true) dir.delete() + } + val packStatus = File(root, PACK_STATUS) + if (userStatus != null && packStatus.isFile && live.isFile) { + writeStatus(live, mergeStatus(packStatus.readText(), live.readText(), userStatus)) + } + FileUtils.delete(File(root, META)) + return Result(removed, restored, kept) + } + + private fun matchesOrMissing(file: File, entry: Entry) = + !Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS) || matches(file, entry) + + private fun matches(file: File, entry: Entry): Boolean { + val path = file.toPath() + return when (entry.type) { + 'L' -> Files.isSymbolicLink(path) && Files.readSymbolicLink(path).toString() == entry.value + 'F' -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && file.length() == entry.size && + Hashes.sha256(file).equals(entry.value, ignoreCase = true) + else -> false + } + } + + internal fun inside(root: File, path: String): File? { + if (path.isEmpty() || path.startsWith("/")) return null + val normal = Paths.get(path).normalize() + if (normal.toString().isEmpty() || normal.startsWith("..")) return null + val parent = normal.parent + val dir = if (parent == null) root else com.droiddeck.launcher.core.ArchivePaths.inside(root, parent.toString()) ?: return null + return File(dir, normal.fileName.toString()) + } +} 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 b789ab7cb..b050c5f7c 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt @@ -18,8 +18,11 @@ internal object DesktopRemoval { private fun protected(context: Context, root: File) = DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip") - fun installedSize(context: Context): Long = - lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L } + fun installedSize(context: Context): Long { + val root = LinuxRuntime.rootDir(context) + if (DesktopPack.info(root) != null) return DesktopPack.installedSize(root) + return lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L } + } fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) { val root = LinuxRuntime.rootDir(context) @@ -40,8 +43,12 @@ internal object DesktopRemoval { Log.i("DesktopRemoval", "LXQt: $result") } - fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = try { + fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? { val root = LinuxRuntime.rootDir(context) + return if (DesktopPack.info(root) != null) removePack(context, root, listener) else removeLegacy(context, root, listener) + } + + private fun removeLegacy(context: Context, root: File, listener: LinuxRuntimeInstaller.ProgressListener): String? = try { val pending = File(root, PENDING) val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}") val installing = DesktopCatalog.interrupted(root).firstOrNull { it.id == DesktopCatalog.DESKTOP_ID } @@ -76,5 +83,29 @@ internal object DesktopRemoval { e.message ?: context.getString(R.string.deskpkg_install_failed) } + private fun removePack(context: Context, root: File, listener: LinuxRuntimeInstaller.ProgressListener): String? = try { + val pending = File(root, PENDING) + val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}") + FileUtils.writeString(pending, DesktopPack.info(root)!!.tag) + check(pending.isFile) { "Could not reserve desktop removal" } + if (marker.exists() && !marker.delete()) error("Could not clear desktop marker") + val removing = context.getString(R.string.desktop_removing) + for ((_, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) { + DesktopFileRemoval.safeFile(root, path)?.takeIf { isRegularFile(it) }?.delete() + } + for (path in DESKTOP_STATE) DesktopPack.inside(root, path)?.let(FileUtils::delete) + val result = if (DesktopPack.installed(root)) DesktopPack.remove(root) { listener.onProgress(removing, it) } else null + DesktopCatalog.discardDesktopInstall(context) + if (!pending.delete()) error("Could not finish desktop removal") + Log.i("DesktopRemoval", "pack: $result") + listener.onProgress(context.getString(R.string.desktop_removed), 100) + null + } catch (e: Exception) { + Log.e("DesktopRemoval", "remove", e) + e.message ?: context.getString(R.string.deskpkg_install_failed) + } + + private val DESKTOP_STATE = listOf("var/lib/PackageKit", "var/cache/swcatalog") + private fun isRegularFile(file: File) = java.nio.file.Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt index f5780da94..cf0d3d7f6 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt @@ -1,7 +1,9 @@ package com.droiddeck.launcher.runtime +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -34,6 +36,9 @@ class DeviceControlsComponent(context: Context, root: File, private val activity private val observer = object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { if (running) publish() } } + private val volumeSteps = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { if (running) publishVolume() } + } private val requests = object : FileObserver(directory.path, CLOSE_WRITE or MOVED_TO) { override fun onEvent(event: Int, path: String?) { if (path in REQUESTS) handler.post { if (running) consume() } @@ -50,13 +55,22 @@ class DeviceControlsComponent(context: Context, root: File, private val activity } } - fun publish(force: Boolean = false) { + private fun publishVolume() { try { directory.mkdirs() val level = audio.getStreamVolume(AudioManager.STREAM_MUSIC) val maximum = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) val muted = audio.isStreamMute(AudioManager.STREAM_MUSIC) write("volume.state", "$level $maximum ${if (muted) 1 else 0}\n") + } catch (e: Exception) { + Log.w(TAG, "Could not publish Android volume", e) + } + } + + fun publish(force: Boolean = false) { + publishVolume() + try { + directory.mkdirs() val value = Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128) write("backlight/droiddeck/brightness", "$value\n") write("backlight/droiddeck/actual_brightness", "$value\n") @@ -81,6 +95,10 @@ class DeviceControlsComponent(context: Context, root: File, private val activity permissionNotified = false context.contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, observer) requests.startWatching() + context.registerReceiver(volumeSteps, IntentFilter().apply { + addAction(VOLUME_CHANGED_ACTION) + addAction(STREAM_MUTE_CHANGED_ACTION) + }, null, handler) refresh.run() } } @@ -90,6 +108,7 @@ class DeviceControlsComponent(context: Context, root: File, private val activity running = false handler.removeCallbacks(refresh) requests.stopWatching() + try { context.unregisterReceiver(volumeSteps) } catch (e: IllegalArgumentException) { } context.contentResolver.unregisterContentObserver(observer) context.getSystemService(NotificationManager::class.java).cancel(PERMISSION_NOTIFICATION) } @@ -170,6 +189,9 @@ class DeviceControlsComponent(context: Context, root: File, private val activity companion object { private const val TAG = "DeviceControls" + // AudioService sends these on every stream step; the Settings observer and 250 ms poll lag a held volume key. + private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION" + private const val STREAM_MUTE_CHANGED_ACTION = "android.media.STREAM_MUTE_CHANGED_ACTION" private const val PERMISSION_CHANNEL = "brightness_permission" private const val PERMISSION_NOTIFICATION = 0x4442 private val REQUESTS = setOf("brightness.request", "volume.request", "mute.request") 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 5a72e9f22..7db52a9e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -43,7 +43,7 @@ object GuestCommand { 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}", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}", "XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}", "DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}", "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", 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 d02fdf0c2..04926193a 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -113,12 +113,16 @@ public final class LinuxRuntime { } public static boolean isInstalled(Context context) { + return hasRootfs(context) && (patchedProot(context) || rootfsProotReady(context)); + } + + /** The rootfs alone: enough to read the game library, while the proot pack can still be downloading. */ + public static boolean hasRootfs(Context context) { if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false; File root = rootDir(context); - return new File(root, "usr/bin/gamescope").isFile() + return (new File(root, "usr/bin/gamescope").isFile() || new File(root, "usr/games/gamescope").isFile()) && (new File(root, SESSION_SCRIPT.substring(1)).isFile() - || new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile()) - && (patchedProot(context) || rootfsProotReady(context)); + || new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile()); } public static File vulkanIcd(Context context) { @@ -341,9 +345,9 @@ public final class LinuxRuntime { } public static String cpuTempSource() { - for (java.util.Map.Entry e : new java.util.TreeMap<>(thermalZones()).entrySet()) { - File temp = new File(e.getValue(), "temp"); - if (e.getKey().contains("cpu") && !e.getKey().contains("gpu") && temp.canRead()) return temp.getPath(); + for (File zone : cpuTempZones()) { + File temp = new File(zone, "temp"); + if (temp.canRead()) return temp.getPath(); } return null; } @@ -353,7 +357,7 @@ public final class LinuxRuntime { return firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu")); } - private static String gpuLoadSource() { + public static String gpuLoadSource() { String kgsl = "/sys/class/kgsl/kgsl-3d0/"; return firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"); } @@ -367,7 +371,7 @@ public final class LinuxRuntime { "/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"}, {gpuTemp, "/sys/class/thermal/thermal_zone28/temp"}, {gpuTemp, "/sys/class/thermal/thermal_zone26/temp"}, - {thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"}, + {ddrTempSource(), "/sys/class/thermal/thermal_zone22/temp"}, }; for (String[] stat : stats) { boolean fed = false; @@ -384,6 +388,36 @@ public final class LinuxRuntime { return path != null ? path : "none"; } + private static String ddrTempSource() { + return thermalZone("ddr"); + } + + // MangoHud reads the GPU and DDR temperatures from these zone numbers (bindAdrenoStats). On phones + // where they are CPU zones, their type follows the temperature put there, so they stay out of the + // CPU average. + private static java.util.Map replacedZoneTypes() { + java.util.Map replaced = new java.util.HashMap<>(); + if (gpuTempSource() != null) { + replaced.put("thermal_zone28", "gpu-thermal"); + replaced.put("thermal_zone26", "gpu-thermal"); + } + if (ddrTempSource() != null) replaced.put("thermal_zone22", "ddr-thermal"); + return replaced; + } + + /** The zones the session's MangoHud averages for the CPU temperature (all typed cpuN-thermal there). */ + public static List cpuTempZones() { + java.util.Map replaced = replacedZoneTypes(); + List zones = new ArrayList<>(); + for (java.util.Map.Entry zone : new java.util.TreeMap<>(thermalZones()).entrySet()) { + String type = zone.getKey(); + if (type.contains("cpu") && !type.contains("gpu") && !replaced.containsKey(zone.getValue().getName())) { + zones.add(zone.getValue()); + } + } + return zones; + } + private static void bindCpuTemps(List cmd, File root) { File name = new File(root, "etc/droiddeck/cpu-thermal-type"); try { @@ -391,9 +425,18 @@ public final class LinuxRuntime { } catch (IOException e) { return; } + java.util.Map replaced = replacedZoneTypes(); for (java.util.Map.Entry zone : thermalZones().entrySet()) { String type = zone.getKey(); - if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) { + String other = replaced.get(zone.getValue().getName()); + if (other != null) { + File typeFile = new File(root, "etc/droiddeck/" + other + "-type"); + try { + if (!typeFile.isFile()) Files.write(typeFile.toPath(), (other + "\n").getBytes(StandardCharsets.US_ASCII)); + bind(cmd, typeFile.getPath() + ":" + new File(zone.getValue(), "type").getPath()); + } catch (IOException ignored) { + } + } else if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) { bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath()); } } @@ -448,12 +491,72 @@ public final class LinuxRuntime { + "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\"; } - public static void writeAccounts(Context context) throws IOException { + public static synchronized void writeAccounts(Context context) throws IOException { File root = rootDir(context); int uid = Process.myUid(); - Files.write(new File(root, "etc/passwd").toPath(), - ("root:x:" + uid + ":" + uid + ":root:/root:/bin/bash\n").getBytes(StandardCharsets.UTF_8)); - Files.write(new File(root, "etc/group").toPath(), - ("root:x:" + uid + ":\n").getBytes(StandardCharsets.UTF_8)); + mergeAccounts(new File(root, "etc/passwd"), 2, + "root:x:" + uid + ":" + uid + ":root:/root:/bin/bash", "root:x:0:0:root:/root:/bin/bash", uid); + mergeAccounts(new File(root, "etc/group"), 2, + "root:x:" + uid + ":", "root:x:0:", uid); + File group = new File(root, "etc/group"); + String groups = new String(Files.readAllBytes(group.toPath()), StandardCharsets.UTF_8); + writeIfChanged(group, groups, withStorageGroups(groups)); + } + + private static final String[][] STORAGE_GROUPS = { + {"sdcard_rw", "1015"}, {"media_rw", "1023"}, {"everybody", "9997"}}; + + static String withStorageGroups(String groups) { + StringBuilder out = new StringBuilder(); + List present = new ArrayList<>(); + for (String line : groups.split("\n")) { + if (line.trim().isEmpty()) continue; + String[] fields = line.split(":", -1); + if (fields.length >= 4) { + for (String[] storage : STORAGE_GROUPS) { + if (fields[0].equals(storage[0])) present.add(storage[0]); + if (!fields[2].equals(storage[1])) continue; + present.add(storage[0]); + if (!java.util.Arrays.asList(fields[3].split(",")).contains("root")) { + fields[3] = fields[3].isEmpty() ? "root" : fields[3] + ",root"; + line = String.join(":", fields); + } + } + } + out.append(line).append('\n'); + } + for (String[] storage : STORAGE_GROUPS) { + if (!present.contains(storage[0])) { + out.append(storage[0]).append(":x:").append(storage[1]).append(":root\n"); + } + } + return out.toString(); + } + + static String mergedAccounts(String existing, int idField, String own, String zero, int uid) { + StringBuilder out = new StringBuilder(own).append('\n').append(zero).append('\n'); + if (existing == null) return out.toString(); + String ownId = Integer.toString(uid); + for (String line : existing.split("\n")) { + if (line.trim().isEmpty()) continue; + String[] fields = line.split(":", -1); + if (fields.length <= idField) continue; + if (fields[0].equals("root") || fields[idField].equals("0") || fields[idField].equals(ownId)) continue; + out.append(line).append('\n'); + } + return out.toString(); + } + + private static void mergeAccounts(File file, int idField, String own, String zero, int uid) throws IOException { + String existing = file.isFile() ? new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8) : null; + writeIfChanged(file, existing, mergedAccounts(existing, idField, own, zero, uid)); + } + + private static void writeIfChanged(File file, String existing, String merged) throws IOException { + if (merged.equals(existing)) return; + File staged = File.createTempFile(file.getName(), ".staged", file.getParentFile()); + Files.write(staged.toPath(), merged.getBytes(StandardCharsets.UTF_8)); + Files.move(staged.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); } } 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 5975f6501..ee3349405 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -35,7 +35,7 @@ public final class LinuxRuntimeInstaller { private static final String TAG = "LinuxRuntimeInstaller"; public static final String CATALOG_URL = - "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json"; + "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs-ubuntu.json"; private static final String VERSION_FILE = ".version"; @@ -88,8 +88,20 @@ public final class LinuxRuntimeInstaller { return v == null ? null : v.trim(); } + private static String catalogUrl() { + if (!com.droiddeck.launcher.BuildConfig.DEBUG) return CATALOG_URL; + try { + Object value = Class.forName("android.os.SystemProperties").getMethod("get", String.class) + .invoke(null, "debug.droiddeck.linuxfs"); + if (value instanceof String && ((String) value).startsWith("http")) return (String) value; + } catch (ReflectiveOperationException e) { + Log.w(TAG, "catalog override: " + e); + } + return CATALOG_URL; + } + public static Release fetchRelease() { - String body = Downloader.downloadString(CATALOG_URL); + String body = Downloader.downloadString(catalogUrl()); if (body == null || body.isEmpty()) return null; try { JSONObject json = new JSONObject(body); @@ -292,7 +304,7 @@ public final class LinuxRuntimeInstaller { recoverInterruptedSwap(root, staging, old); if (!discard(staging, null) || !staging.mkdirs()) return false; if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1); - if (!extract(context, archive, staging, listener)) { + if (!extract(context, archive, staging, root, listener)) { RuntimeFileTree.delete(staging, null); return false; } @@ -346,7 +358,11 @@ public final class LinuxRuntimeInstaller { return false; } new File(root, CARRY_MARKER).delete(); + try { RuntimeFileTree.dropStaleState(root); } + catch (IOException e) { Log.w(TAG, "could not clear caches left by the previous runtime", e); } discard(old, null); + try { com.droiddeck.launcher.session.SessionFiles.INSTANCE.stage(context, root, false); } + catch (Exception e) { Log.w(TAG, "could not stage the session files into the new runtime", e); } return LinuxRuntime.isInstalled(context); } catch (Exception e) { Log.e(TAG, "install", e); @@ -513,6 +529,10 @@ 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) { + return extract(context, archive, destination, destination, listener); + } + + static boolean extract(Context context, File archive, File destination, File linkRoot, ProgressListener listener) { long entries = 0; String extracting = context.getString(R.string.rtinst_extracting); java.util.LinkedHashMap links = new java.util.LinkedHashMap<>(); @@ -537,7 +557,7 @@ public final class LinuxRuntimeInstaller { links.remove(file); links.put(file, entry.getLinkName()); } else if (entry.isLink()) { - File target = ArchivePaths.inside(destination, entry.getLinkName()); + File target = hardLinkTarget(destination, entry.getLinkName()); if (target == null) { Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName()); continue; @@ -548,11 +568,11 @@ public final class LinuxRuntimeInstaller { try { Files.createLink(part.toPath(), target.toPath()); } catch (IOException | UnsupportedOperationException e) { - if (target.isFile()) { + if (!l2sLink(target, part, destination, linkRoot) && target.isFile()) { Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING); } } - if (part.exists()) place(part, file); + if (Files.exists(part.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) place(part, file); } else if (entry.isFile()) { links.remove(file); File part = partFile(file); @@ -589,6 +609,67 @@ public final class LinuxRuntimeInstaller { } } + static File hardLinkTarget(File destination, String name) throws IOException { + java.nio.file.Path path = java.nio.file.Paths.get(name).normalize(); + java.nio.file.Path last = path.getFileName(); + if (last == null || last.toString().isEmpty() || last.toString().equals("..")) return null; + java.nio.file.Path parent = path.getParent(); + File dir = parent == null ? destination : ArchivePaths.inside(destination, parent.toString()); + return dir == null ? null : new File(dir, last.toString()); + } + + private static final String L2S = ".l2s."; + + static boolean l2sLink(File target, File link) { + return l2sLink(target, link, null, null); + } + + static boolean l2sLink(File target, File link, File tree, File linkTree) { + java.nio.file.Path here = tree == null ? null : tree.toPath().toAbsolutePath().normalize(); + java.nio.file.Path there = linkTree == null ? here : linkTree.toPath().toAbsolutePath().normalize(); + java.util.function.UnaryOperator shown = path -> { + java.nio.file.Path p = path.toAbsolutePath().normalize(); + return here == null || !p.startsWith(here) ? p : there.resolve(here.relativize(p)); + }; + java.util.function.UnaryOperator actual = path -> { + java.nio.file.Path p = path.toAbsolutePath().normalize(); + return there == null || !p.startsWith(there) ? p : here.resolve(there.relativize(p)); + }; + try { + java.nio.file.Path original = target.toPath().toAbsolutePath().normalize(); + java.nio.file.Path intermediate; + if (Files.isSymbolicLink(original)) { + intermediate = actual.apply(Files.readSymbolicLink(original)); + if (!intermediate.getFileName().toString().startsWith(L2S) || !Files.isSymbolicLink(intermediate)) return false; + java.nio.file.Path fin = actual.apply(Files.readSymbolicLink(intermediate)); + String name = fin.getFileName().toString(); + int count = Integer.parseInt(name.substring(name.length() - 4)) + 1; + java.nio.file.Path next = fin.resolveSibling(name.substring(0, name.length() - 4) + String.format(java.util.Locale.ROOT, "%04d", count)); + Files.move(fin, next, StandardCopyOption.ATOMIC_MOVE); + Files.delete(intermediate); + Files.createSymbolicLink(intermediate, shown.apply(next)); + } else { + if (!Files.isRegularFile(original, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false; + java.nio.file.Path dir = original.getParent(); + String base = L2S + original.getFileName(); + int suffix = 1; + do { + intermediate = dir.resolve(base + String.format(java.util.Locale.ROOT, "%04d", suffix++)); + } while (Files.exists(intermediate, java.nio.file.LinkOption.NOFOLLOW_LINKS) && suffix < 1000); + java.nio.file.Path fin = dir.resolve(intermediate.getFileName() + ".0002"); + Files.move(original, fin, StandardCopyOption.ATOMIC_MOVE); + Files.createSymbolicLink(intermediate, shown.apply(fin)); + Files.createSymbolicLink(original, shown.apply(intermediate)); + } + Files.deleteIfExists(link.toPath()); + Files.createSymbolicLink(link.toPath(), shown.apply(intermediate)); + return true; + } catch (IOException | RuntimeException e) { + Log.w(TAG, "l2s link " + link + " -> " + target + ": " + e); + return false; + } + } + static File partFile(File file) { return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode())); } 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 485d7178a..974b6fe16 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 android.os.Build import android.util.Log import com.droiddeck.launcher.session.SessionPrefs import java.io.File @@ -37,9 +38,10 @@ object ProotFastPath { fun hostEnv(key: String): Pair = "PROOT_FASTPATH" to key - fun guestEnv(root: File, binds: List, key: String): List = listOf( + fun guestEnv(root: File, binds: List, key: String, sdk: Int = Build.VERSION.SDK_INT): List = listOf( "PROOT_FP_ROOT=" + root.path, "PROOT_FP_BINDS=" + binds.joinToString("|"), "PROOT_FP_KEY=$key", + "PROOT_FP_ANDROID_API=$sdk", ) } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RootSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RootSpawner.kt new file mode 100644 index 000000000..1138bab0d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RootSpawner.kt @@ -0,0 +1,201 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.os.ParcelFileDescriptor +import android.os.Process +import android.util.Log +import com.droiddeck.launcher.core.HostProcess +import com.droiddeck.launcher.session.OrphanReaper +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileDescriptor +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.atomic.AtomicInteger + +object RootSpawner { + private const val TAG = "RootSpawner" + fun socket(context: Context): String = "${context.packageName}.root" + private val seq = AtomicInteger() + @Volatile private var server: LocalServerSocket? = null + + @Synchronized + fun start(context: Context) { + if (server != null) return + val app = context.applicationContext + val name = socket(app) + val socket = try { + LocalServerSocket(name) + } catch (e: IOException) { + Log.e(TAG, "cannot listen on $name", e) + return + } + server = socket + Thread({ + while (true) { + val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break } + Thread({ serve(app, client) }, "root-${seq.get() + 1}").apply { isDaemon = true; start() } + } + }, "root-server").apply { isDaemon = true; start() } + } + + private fun serve(context: Context, client: LocalSocket) { + val id = seq.incrementAndGet() + val received = ArrayList() + client.use { + val out = DataOutputStream(client.outputStream) + try { + if (client.peerCredentials.uid != Process.myUid()) { + Log.w(TAG, "refused root for uid ${client.peerCredentials.uid}") + return + } + val input = DataInputStream(client.inputStream) + val kind = input.readUnsignedByte() + client.ancillaryFileDescriptors?.let { received.addAll(it) } + val body = ByteArray(input.readInt()).also { input.readFully(it) } + client.ancillaryFileDescriptors?.let { received.addAll(it) } + if (kind != 'R'.code) return + run(context, JSONObject(String(body, Charsets.UTF_8)), received, client, input, out, id) + } catch (e: Exception) { + Log.e(TAG, "root $id", e) + runCatching { + frame(out, 'E', (e.message ?: e.toString()).toByteArray()) + frame(out, 'X', "1".toByteArray()) + } + } finally { + received.forEach { fd -> runCatching { android.system.Os.close(fd) } } + } + } + } + + private class Stdio(val redirect: ProcessBuilder.Redirect?, val fd: FileDescriptor?, val dup: ParcelFileDescriptor?) + + private fun stdio(fd: FileDescriptor?, write: Boolean): Stdio { + if (fd == null) return Stdio(null, null, null) + val dup = try { ParcelFileDescriptor.dup(fd) } catch (e: IOException) { return Stdio(null, fd, null) } + val path = File("/proc/self/fd/${dup.fd}") + val opens = try { + if (write) FileOutputStream(path, true).close() else FileInputStream(path).close() + true + } catch (e: IOException) { false } + if (!opens) return Stdio(null, fd, dup) + val redirect = if (write) ProcessBuilder.Redirect.appendTo(path) else ProcessBuilder.Redirect.from(path) + return Stdio(redirect, fd, dup) + } + + private fun pump(from: InputStream, to: OutputStream, name: String) = + Thread({ + val buffer = ByteArray(16384) + try { + while (true) { + val n = from.read(buffer) + if (n < 0) break + to.write(buffer, 0, n) + to.flush() + } + } catch (e: IOException) { + } finally { + runCatching { to.close() } + } + }, name).apply { isDaemon = true; start() } + + private fun run(context: Context, request: JSONObject, fds: List, client: LocalSocket, + input: DataInputStream, out: DataOutputStream, id: Int) { + val root = LinuxRuntime.rootDir(context) + val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } } + if (argv.isEmpty()) throw IOException("no command") + val cwd = request.optString("cwd", "/root").ifEmpty { "/root" } + val cmd = LinuxRuntime.prootPrefix(context, root, cwd, true) + cmd.add(1, "--link2symlink") + LinuxRuntime.lastBinds(context).filterNot { it.startsWith("/proc/self/fd/") }.forEach { cmd.add("-b"); cmd.add(it) } + cmd.add("/usr/bin/env") + cmd.add("-i") + request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> cmd.add("$k=${e.getString(k)}") } } + cmd.addAll(argv) + val builder = ProcessBuilder(cmd).directory(root) + builder.environment().apply { + clear() + put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path) + put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path) + if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1") + LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } + } + val streams = listOf(stdio(fds.getOrNull(0), false), stdio(fds.getOrNull(1), true), stdio(fds.getOrNull(2), true)) + try { + builder.redirectInput(streams[0].redirect ?: ProcessBuilder.Redirect.PIPE) + builder.redirectOutput(streams[1].redirect ?: ProcessBuilder.Redirect.PIPE) + builder.redirectError(streams[2].redirect ?: ProcessBuilder.Redirect.PIPE) + val process = builder.start() + val pid = HostProcess.pidOf(process) + OrphanReaper.keep(pid) + Log.i(TAG, "root $id pid $pid: ${argv.joinToString(" ")}") + synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) } + val pumps = ArrayList() + if (streams[0].redirect == null) { + val fd = streams[0].fd + if (fd != null) pumps.add(pump(FileInputStream(fd), process.outputStream, "root-$id-in").also { it.isDaemon = true }) + else runCatching { process.outputStream.close() } + } + if (streams[1].redirect == null) pumps.add(pump(process.inputStream, + streams[1].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-out")) + if (streams[2].redirect == null) pumps.add(pump(process.errorStream, + streams[2].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-err")) + Thread({ + try { + while (true) { + val kind = input.readUnsignedByte() + val body = ByteArray(input.readInt()).also { input.readFully(it) } + if (kind == 'S'.code) String(body).trim().toIntOrNull()?.let { signal(pid, it) } + } + } catch (e: IOException) { + } + if (process.isAlive) { + Log.i(TAG, "root $id: its caller is gone; stopping it") + signal(pid, android.system.OsConstants.SIGHUP) + android.os.Process.sendSignal(pid, android.system.OsConstants.SIGQUIT) + } + }, "root-$id-watch").apply { isDaemon = true; start() } + val status = process.waitFor() + pumps.filter { it.name.endsWith("-out") || it.name.endsWith("-err") }.forEach { it.join(2000) } + OrphanReaper.release(pid) + Log.i(TAG, "root $id exited $status") + runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } } + } finally { + streams.forEach { s -> runCatching { s.dup?.close() } } + } + } + + private fun signal(top: Int, signal: Int) { + val parents = HashMap() + File("/proc").listFiles()?.forEach { dir -> + val pid = dir.name.toIntOrNull() ?: return@forEach + val stat = try { File(dir, "stat").readText() } catch (e: IOException) { return@forEach } + val fields = stat.substringAfterLast(')').trim().split(' ') + fields.getOrNull(1)?.toIntOrNull()?.let { parents[pid] = it } + } + val tree = HashSet() + var grew = true + while (grew) { + grew = false + for ((pid, parent) in parents) { + if (pid !in tree && (parent == top || parent in tree)) { tree.add(pid); grew = true } + } + } + tree.forEach { runCatching { android.os.Process.sendSignal(it, signal) } } + } + + private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) { + out.writeByte(kind.code) + out.writeInt(payload.size) + out.write(payload) + out.flush() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeAutoUpdate.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeAutoUpdate.kt new file mode 100644 index 000000000..5bb53bbc0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeAutoUpdate.kt @@ -0,0 +1,30 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import com.droiddeck.launcher.BuildConfig + +object RuntimeAutoUpdate { + private const val PREFS = "runtime-auto-update" + const val MAX_ATTEMPTS = 3 + const val RETRY_MS = 10 * 60_000L + + fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS): Boolean = + installed != null && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS && + (attempts == 0 || now - lastAt !in 0 until RETRY_MS) + + fun key(appVersion: Int, release: String) = "$appVersion:$release" + + fun claim(context: Context, installed: String?, release: String?, now: Long = System.currentTimeMillis()): Boolean { + if (release.isNullOrEmpty()) return false + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val key = key(BuildConfig.VERSION_CODE, release) + synchronized(this) { + val attempts = prefs.getInt(key, 0) + if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false + val edit = prefs.edit() + prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.forEach { edit.remove(it) } + edit.putInt(key, attempts + 1).putLong("$key@at", now).apply() + return true + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java index 42e7f5b11..3b2a39ee8 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java @@ -80,7 +80,7 @@ public final class RuntimeFileTree { * be carried. */ static java.util.List userPaths(File tree) throws IOException { - java.util.List paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps")); + java.util.List paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps", "opt/droiddeck/wincomponents")); for (String n : children(new File(tree, "usr/local/share/applications"))) { if (n.endsWith(".desktop") && (n.startsWith("droiddeck-app-") || n.startsWith("droiddeck-appimage-"))) paths.add("usr/local/share/applications/" + n); @@ -109,10 +109,39 @@ public final class RuntimeFileTree { static java.util.List existingUserPaths(File from, File to) throws IOException { java.util.List paths = new java.util.ArrayList<>(); - for (String rel : userPaths(from, to)) if (exists(safePath(from, rel).toPath())) paths.add(rel); + for (String rel : userPaths(from, to)) { + if (!exists(safePath(from, rel).toPath())) continue; + if (rel.startsWith("usr/share/applications/") && exists(safePath(to, rel).toPath())) continue; + paths.add(rel); + } return paths; } + private static final String[] STALE_HOME_STATE = { + ".local/share/droiddeck/pacman", ".cache/droiddeck/pacman-restore.log", + ".cache/appstream", ".cache/discover", ".cache/fontconfig", ".cache/kwin", ".cache/ksplash", + ".cache/ksvg-elements", ".cache/plasmashell", ".cache/kwinsession.kcache", ".cache/icon-cache.kcache", + }; + private static final String[] STALE_HOME_CACHE_PREFIXES = { + "mesa_shader_cache", "ksycoca", "plasma_theme_", "qtshadercache", "event-sound-cache.tdb.", + }; + + static void dropStaleState(File runtimeRoot) throws IOException { + File home = new File(runtimeRoot, "root"); + java.util.List stale = new java.util.ArrayList<>(java.util.Arrays.asList(STALE_HOME_STATE)); + for (String n : children(new File(home, ".cache"))) { + for (String prefix : STALE_HOME_CACHE_PREFIXES) { + if (n.startsWith(prefix)) { stale.add(".cache/" + n); break; } + } + } + IOException failed = null; + for (String rel : stale) { + try { delete(safePath(home, rel), null); } + catch (IOException e) { if (failed == null) failed = e; else failed.addSuppressed(e); } + } + if (failed != null) throw failed; + } + private static java.util.List children(File dir) throws IOException { java.util.List names = new java.util.ArrayList<>(); try (DirectoryStream entries = Files.newDirectoryStream(dir.toPath())) { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt index 19ca93d5f..f334e6cc5 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt @@ -11,14 +11,15 @@ object RuntimeHealth { 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) + fun brokenLibraryLinks(root: File): List = + listOf("usr/lib", "usr/lib/aarch64-linux-gnu").flatMap { dir -> + val lib = File(root, dir) + val names = lib.list() ?: return@flatMap emptyList() + names.sorted().filter { name -> + val path = File(lib, name).toPath() + Files.isSymbolicLink(path) && !resolves(root, path) + }.map { if (dir == "usr/lib") it else "aarch64-linux-gnu/$it" } } - } private fun resolves(root: File, link: Path): Boolean = try { var path = link diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt index 4d1ae8de2..27882ebcf 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -13,6 +13,7 @@ object GameEnvironmentStore { private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json" const val DXVK_CONFIG = "dxvkConfig" const val DXVK_CONFIG_GAMES = "dxvkConfigGames" + const val GAME_DEBUG_LOGS = "gameDebugLogs" @Synchronized fun read(context: Context): GameEnvironment.Config { @@ -87,7 +88,11 @@ object GameEnvironmentStore { } @Synchronized - fun publish(context: Context, config: GameEnvironment.Config = read(context)) { + fun publish( + context: Context, + config: GameEnvironment.Config = read(context), + gameDebugLogsOverride: Boolean? = null, + ) { val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared) val json = encode(resolved) val dxvk = TextureFiltering.dxvkOptions( @@ -110,6 +115,11 @@ object GameEnvironmentStore { ) } if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk) + json.put( + GAME_DEBUG_LOGS, + gameDebugLogsOverride ?: (SessionPrefs.logsEnabled(context) && + SessionPrefs.gameDebugLogsEnabled(context) && SessionPaths.currentSessionLogsRetained(context)), + ) write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString()) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt index 449c2bb7f..e95af8b15 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt @@ -21,7 +21,7 @@ class GpuMemComponent(private val file: File, private val root: File) : SessionP thread = Thread({ while (running) { try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } - if (running && OverlayShown.check(root)) sample()?.let { write(it) } + if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.FusionHudHost.showing)) sample()?.let { write(it) } } }, "gpu-mem").apply { isDaemon = true; start() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt b/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt index b8dab5de1..8d8070f87 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt @@ -51,7 +51,8 @@ object NoSteamLaunch { fun addedGame(context: Context, game: Library.SteamGame): AddedGames.Game? { val id = game.appId.toLong() and 0xFFFFFFFFL - return AddedGames.scan(context).firstOrNull { settingsId(it) == id } + val one = game.gameFiles?.let { AddedGames.single(context, it.path) } + return one?.takeIf { settingsId(it) == id } ?: AddedGames.scan(context).firstOrNull { settingsId(it) == id } } fun nameFor(program: String?, args: List): String? = when (program) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt deleted file mode 100644 index 6453f65f9..000000000 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ /dev/null @@ -1,178 +0,0 @@ -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 -import android.os.SystemClock -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.droiddeck.launcher.R -import com.droiddeck.launcher.gpu.FrameGen -import com.droiddeck.launcher.gpu.Lossless -import com.droiddeck.launcher.wayland.WaylandCompositor -import java.io.File -import java.util.Locale -import java.util.concurrent.atomic.AtomicInteger - -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 samples = FloatArray(GRAPH) - private var graphBase = 0L - private var graphSeen = 0L - - @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 - - private val listener = object : WaylandCompositor.GameListener { - override fun onGameSurface(window: String?, gpuName: String?) { - if (window != null && window != lastWindow) { - lastWindow = window - handler.post { onPresentingWindowChanged?.invoke() } - } - } - override fun onGameFrame() { - frames.incrementAndGet() - } - } - - private val tick = object : Runnable { - override fun run() { - if (!running) return - val now = SystemClock.elapsedRealtime() - val dt = (now - lastTick).coerceAtLeast(1L) / 1000f - lastTick = now - lastFps = frames.getAndSet(0) / dt - text = readout(lastFps) - if (showGraph) publishGraph() else clearGraph() - handler.postDelayed(this, TICK_MS) - } - } - - fun refresh() { - readOptions() - if (!enabled || !anyPart) { - stop() - return - } - if (!running) start() else { - if (!showGraph) clearGraph() else publishGraph() - text = readout(lastFps) - } - } - - fun start() { - readOptions() - if (!enabled || !anyPart || running) return - running = true - lastTick = SystemClock.elapsedRealtime() - frames.set(0) - graphBase = shownTotal() - graphSeen = graphBase - WaylandCompositor.setGameListener(listener) - 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 shownTotal(): Long = try { WaylandCompositor.nativeHudFrameTimes(samples) } catch (t: Throwable) { 0L } - - private fun clearGraph() { - graphBase = shownTotal() - graphSeen = graphBase - if (graph.isNotEmpty()) graph = FloatArray(0) - } - - private fun publishGraph() { - val total = shownTotal() - if (total == graphSeen && graph.isNotEmpty()) return - graphSeen = total - graph = recent(samples, total, graphBase) - } - - 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 "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 } - val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 } - return when { - problem == 1 -> context.getString(R.string.hud_fg_unsupported, name) - problem == 2 -> context.getString(R.string.hud_fg_failed, name) - stats != null && stats[3] > 1f -> String.format(Locale.US, "%s %.0f → %.0f fps", label, base, stats[3]) - else -> context.getString(R.string.hud_fg_starting, label, fps(base)) - } - } - - 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 const val GRAPH_MAX_MS = 50f - - internal fun recent(samples: FloatArray, total: Long, base: Long): FloatArray { - val filled = minOf(total, samples.size.toLong()).toInt() - val shown = minOf(filled.toLong(), (total - base).coerceAtLeast(0L)).toInt() - if (shown < 2) return FloatArray(0) - return FloatArray(shown) { samples[filled - shown + it].coerceIn(0f, GRAPH_MAX_MS) } - } - - 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 c356bf0d0..b4408c48b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -7,6 +7,9 @@ import com.droiddeck.launcher.core.SessionLogCapture import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File +import java.io.Writer +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -16,6 +19,9 @@ object SessionArtifacts { const val COMPLETE_MARKER = ".complete" + private const val RECOVERY_RUNTIME_CAPTURED_MARKER = ".recovery-runtime-captured" + private const val RECOVERY_RUNTIME_EXPIRED_MARKER = ".recovery-runtime-expired" + private val SCRUBBED_MARKER = ".scrubbed-r${LogRedactor.RULES_VERSION}" private val OLD_SCRUBBED_MARKER = Regex("""\.scrubbed-(r?\d+)""") @@ -72,9 +78,9 @@ object SessionArtifacts { copySteamLogs(context, dir, record, steamArch) VacDiagnostics.collect(context, dir) SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log")) - markScrubbed(scrubTree(record)) + val scrubbed = markScrubbed(scrubTree(record)) SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir) - File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n") + if (scrubbed) File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n") } catch (e: Exception) { Log.w(TAG, "collecting session artifacts", e) } @@ -88,15 +94,10 @@ object SessionArtifacts { }?.toList() ?: return if (dirs.isEmpty()) return LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) - val workers = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4) - val pool = java.util.concurrent.Executors.newFixedThreadPool(workers) { r -> - Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "scrub-logs") - } - val done = try { - pool.invokeAll(dirs.map { dir -> java.util.concurrent.Callable { scrubAndMark(dir) } }) - .count { f -> try { f.get() } catch (e: Exception) { false } } - } finally { - pool.shutdown() + var done = 0 + for (dir in dirs) { + if (SessionState.running) break + if (scrubAndMark(dir, stopWhenSessionStarts = true)) done++ } val failed = dirs.size - done Log.i(TAG, "logs: scrubbed $done folders under r${LogRedactor.RULES_VERSION}" + @@ -114,35 +115,121 @@ object SessionArtifacts { private class Record(val dir: File) { val files = HashMap() var failed = false + var deferred = false } - private fun scrubFolder(dir: File, record: Record) { - dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f -> - 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 + private sealed class ScrubResult { + class Done(val scrubbed: Scrubbed) : ScrubResult() + object Failed : ScrubResult() + object Deferred : ScrubResult() + } + + private fun scrubFolder( + dir: File, + record: Record, + shouldStop: () -> Boolean, + beforeReplace: (File) -> Unit, + listFiles: (File) -> Array?, + ): Boolean { + val files = listFiles(dir)?.filter { f -> f.isFile && !f.name.startsWith(".") && f !in record.files } + if (files == null) { + record.failed = true + return true } + for (f in files) { + if (shouldStop()) return false + if (LogRedactor.isBinary(f)) continue + when (val result = scrubFile(dir, f, shouldStop, beforeReplace)) { + is ScrubResult.Done -> record.files[f] = result.scrubbed + ScrubResult.Failed -> record.failed = true + ScrubResult.Deferred -> { + record.failed = true + record.deferred = true + return false + } + } + } + return true } - - private fun scrubFile(dir: File, f: File): Scrubbed? { + private fun scrubFile( + dir: File, + f: File, + shouldStop: () -> Boolean, + beforeReplace: (File) -> Unit, + ): ScrubResult { val tmp = File(dir, ".${f.name}.scrub") return try { val before = Scrubbed(f.length(), f.lastModified()) - tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) } - val scrubbed = tmp.readBytes() - if (scrubbed.contentEquals(f.readBytes())) { + val scrubbed = tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w, shouldStop) } + if (!scrubbed) { tmp.delete() - before.takeIf { f.length() == it.length && f.lastModified() == it.modified } - } else { - if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() } - Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length } + return ScrubResult.Deferred + } + val scrubbedLength = tmp.length() + when (sameContents(tmp, f, shouldStop)) { + null -> { + tmp.delete() + ScrubResult.Deferred + } + true -> { + tmp.delete() + before.takeIf { f.length() == it.length && f.lastModified() == it.modified } + ?.let { ScrubResult.Done(it) } ?: ScrubResult.Failed + } + false -> { + if (shouldStop()) { + tmp.delete() + return ScrubResult.Deferred + } + beforeReplace(f) + if (f.length() != before.length || f.lastModified() != before.modified) { + tmp.delete() + return ScrubResult.Failed + } + if (!tmp.renameTo(f)) { + tmp.delete() + return ScrubResult.Failed + } + Scrubbed(scrubbedLength, f.lastModified()).takeIf { f.length() == it.length } + ?.let { ScrubResult.Done(it) } ?: ScrubResult.Failed + } } } catch (e: Exception) { Log.w(TAG, "could not scrub ${f.name}", e) tmp.delete() - null + ScrubResult.Failed + } + } + + internal fun sameContents(a: File, b: File, shouldStop: () -> Boolean = { false }): Boolean? { + if (shouldStop()) return null + if (a.length() != b.length()) return false + fun readChunk(input: java.io.InputStream, buffer: ByteArray): Int? { + var count = 0 + while (count < buffer.size) { + if (shouldStop()) return null + val read = input.read(buffer, count, buffer.size - count) + if (shouldStop()) return null + if (read < 0) break + if (read == 0) continue + count += read + } + return count + } + return a.inputStream().buffered().use { left -> + b.inputStream().buffered().use { right -> + val leftBuffer = ByteArray(64 * 1024) + val rightBuffer = ByteArray(64 * 1024) + while (true) { + val leftCount = readChunk(left, leftBuffer) ?: return null + val rightCount = readChunk(right, rightBuffer) ?: return null + if (leftCount != rightCount) return false + if (leftCount == 0) break + if (shouldStop()) return null + for (i in 0 until leftCount) if (leftBuffer[i] != rightBuffer[i]) return false + } + true + } } } @@ -183,10 +270,44 @@ object SessionArtifacts { }.toSet() } - internal fun scrubAndMark(dir: File): Boolean = markScrubbed(scrubTree(Record(dir))) + internal fun scrubAndMark( + dir: File, + stopWhenSessionStarts: Boolean = false, + beforeReplace: (File) -> Unit = {}, + listFiles: (File) -> Array? = { it.listFiles() }, + sessionStarted: () -> Boolean = { SessionState.running }, + ): Boolean { + val shouldStop = { stopWhenSessionStarts && sessionStarted() } + val record = scrubTree( + Record(dir), + listFiles = listFiles, + beforeReplace = beforeReplace, + shouldStop = shouldStop, + ) + if (shouldStop()) { + record.failed = true + record.deferred = true + } + return markScrubbed(record) + } - private fun scrubTree(record: Record): Record { - record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) } + private fun scrubTree( + record: Record, + listFiles: (File) -> Array? = { it.listFiles() }, + beforeReplace: (File) -> Unit = {}, + shouldStop: () -> Boolean = { false }, + ): Record { + for (dir in record.dir.walkTopDown().filter { it.isDirectory }) { + if (shouldStop() || !scrubFolder(dir, record, shouldStop, beforeReplace, listFiles)) { + record.failed = true + record.deferred = true + break + } + } + if (shouldStop()) { + record.failed = true + record.deferred = true + } return record } @@ -205,6 +326,7 @@ object SessionArtifacts { private const val HEAD = 4096L private class Mark(val size: Long, val head: String) + private class CopyResult(val files: List, val failed: Boolean, val deferred: Boolean) fun markSteamLogs(dir: File, logs: File) { val lines = logs.listFiles { f -> f.isFile }?.mapNotNull { f -> @@ -240,23 +362,54 @@ object SessionArtifacts { .firstOrNull { m -> heads.getOrPut(minOf(m.size, HEAD)) { head(src, m.size) } == m.head }?.size ?: 0L } - private fun copySteamLogs(context: Context, dir: File, record: Record, steamArch: String? = null) { + private fun copySteamLogs( + context: Context, + dir: File, + record: Record, + steamArch: String? = null, + stopWhenSessionStarts: Boolean = false, + ) { val logs = File(steamArch?.let { SessionPrefs.steamRoot(context, it) } ?: SessionPrefs.activeSteamRoot(context), "logs") if (!logs.isDirectory) return val out = File(dir, "steam").apply { mkdirs() } - copySessionLogs(logs, dir, out).forEach { dst -> record.files[dst] = Scrubbed(dst.length(), dst.lastModified()) } + val result = copySessionLogsResult(logs, dir, out) { stopWhenSessionStarts && SessionState.running } + result.files.forEach { dst -> record.files[dst] = Scrubbed(dst.length(), dst.lastModified()) } + record.failed = record.failed || result.failed + record.deferred = record.deferred || result.deferred Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out") } - internal fun copySessionLogs(logs: File, dir: File, out: File): List { + internal fun copySessionLogs( + logs: File, + dir: File, + out: File, + openWriter: (File) -> Writer = { it.bufferedWriter() }, + ): List = copySessionLogsResult(logs, dir, out, openWriter) { false }.files + + private fun copySessionLogsResult( + logs: File, + dir: File, + out: File, + openWriter: (File) -> Writer = { it.bufferedWriter() }, + shouldStop: () -> Boolean, + ): CopyResult { val marks = steamLogMarks(dir) val copied = ArrayList() - logs.listFiles { f -> f.isFile }?.forEach { src -> + var failed = false + var deferred = false + val sources = logs.listFiles { f -> f.isFile } ?: return CopyResult(copied, true, false) + for (src in sources) { + if (shouldStop()) { + failed = true + deferred = true + break + } val size = src.length() val from = marks?.let { startOf(src, size, it) } ?: 0L - if (marks != null && from == size || size - from >= MAX_STEAM_LOG) return@forEach + if (marks != null && from == size || size - from >= MAX_STEAM_LOG) continue + val dst = File(out, src.name) + val staging = File(out, ".${src.name}.copy") try { - val dst = File(out, src.name) src.inputStream().buffered().use { input -> var left = if (from > 0) from - 1 else 0L while (left > 0) { @@ -270,49 +423,183 @@ object SessionArtifacts { if (c < 0 || c == '\n'.code) break } } - dst.bufferedWriter().use { w -> - input.bufferedReader().forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() } + val complete = openWriter(staging).use { w -> scrubSteamLog(input, w, shouldStop) } + if (!complete) { + staging.delete() + failed = true + deferred = true } } - copied.add(dst) + if (deferred) break + Files.move( + staging.toPath(), + dst.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + copied += dst } catch (e: Exception) { + staging.delete() Log.w(TAG, "could not scrub ${src.name}", e) + failed = true } } - return copied + return CopyResult(copied, failed, deferred) + } + + private fun scrubSteamLog( + input: java.io.InputStream, + out: java.io.Writer, + shouldStop: () -> Boolean, + ): Boolean { + input.reader().use { reader -> + val chunk = CharArray(8192) + val line = StringBuilder(4096) + var oversized = false + var pendingContent = false + var pendingCarriageReturn = false + + fun emit() { + out.write(if (oversized) LogRedactor.OVERSIZED_LINE else LogRedactor.redact(line.toString())) + out.write("\n") + line.setLength(0) + oversized = false + pendingContent = false + } + + while (true) { + if (shouldStop()) return false + val count = reader.read(chunk) + if (count < 0) break + if (shouldStop()) return false + for (i in 0 until count) { + if (shouldStop()) return false + val char = chunk[i] + if (pendingCarriageReturn) { + emit() + pendingCarriageReturn = false + if (char == '\n') continue + } + when (char) { + '\r' -> pendingCarriageReturn = true + '\n' -> emit() + else -> { + pendingContent = true + if (!oversized) { + if (line.length < LogRedactor.MAX_LINE_LENGTH) line.append(char) + else { + line.setLength(0) + oversized = true + } + } + } + } + } + } + if (pendingCarriageReturn || pendingContent) { + if (shouldStop()) return false + emit() + } + } + return true } @Synchronized - fun finishAbandoned(context: Context) { + fun finishAbandoned(context: Context, beforeGuestStart: Boolean = false) { + val stopWhenSessionStarts = !beforeGuestStart val parent = LinuxRuntime.logDir(context) val abandoned = parent.listFiles { f -> SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists() && !inFlight(f) }?.sortedWith(SessionPaths.chronological) ?: return if (abandoned.isEmpty()) return val current = SessionPaths.current() + val latestPreviousSession = SessionPaths.sessionFolders(context).lastOrNull { it != current } LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) - abandoned.forEachIndexed { i, dir -> - if (dir == current) return@forEachIndexed - val newest = i == abandoned.lastIndex + for (dir in abandoned) { + if (stopWhenSessionStarts && SessionState.running) break + if (dir == current) continue + if (beforeGuestStart && dir != latestPreviousSession) continue + val newest = dir == latestPreviousSession try { - File(dir, "ended-without-teardown.txt").writeText( - "This session's process ended without running its own teardown - killed by\n" + - "Android, a native crash, or the device going down - so the files below were\n" + - "gathered when the app next started, at ${now()}.\n" + - "The logs written during the session (session.log, app.log, wayland.log,\n" + - "audio.log, device.txt) were on disk already and are as they were left.\n" + - (if (newest) "" else "Steam's logs are not included: a later session has overwritten them.\n") + - "crash.log holds Android's crash buffer as of the next app start - if this\n" + - "session died of a crash, the entry is in there unless the device rebooted.\n" - ) + val recoveryNote = File(dir, "ended-without-teardown.txt") + val runtimeCaptured = File(dir, RECOVERY_RUNTIME_CAPTURED_MARKER) + val runtimeExpired = File(dir, RECOVERY_RUNTIME_EXPIRED_MARKER) + val recoveryPreviouslyAttempted = recoveryNote.exists() + val recoveryAttempted = recoveryPreviouslyAttempted && + (runtimeCaptured.exists() || runtimeExpired.exists() || !beforeGuestStart) + if (!recoveryAttempted) { + recoveryNote.writeText( + "This session's process ended without running its own teardown - killed by\n" + + "Android, a native crash, or the device going down - so the files below were\n" + + "gathered when the app next started, at ${now()}.\n" + + "The logs written during the session (session.log, app.log, wayland.log,\n" + + "audio.log, device.txt) were on disk already and are as they were left.\n" + + (if (newest) "" else "Steam's logs are not included: a later session has overwritten them.\n") + + "crash.log holds Android's crash buffer as of the next app start - if this\n" + + "session died of a crash, the entry is in there unless the device rebooted.\n" + ) + } + if (recoveryPreviouslyAttempted && !runtimeCaptured.exists() && + (runtimeExpired.exists() || !beforeGuestStart) && + !recoveryNote.readText().contains("runtime capture did not finish")) { + recoveryNote.appendText( + "Steam's logs and VAC evidence are not included: runtime capture did not finish " + + "before its evidence expired.\n" + ) + } + if (recoveryPreviouslyAttempted && !runtimeCaptured.exists() && !beforeGuestStart) { + runtimeExpired.writeText("runtime evidence no longer belongs to this session, at ${now()}\n") + } val record = Record(dir) - if (newest) { - copySteamLogs(context, dir, record, recordedClient(dir)) - VacDiagnostics.collect(context, dir) + if (newest && !recoveryAttempted) { + copySteamLogs( + context, + dir, + record, + steamArch = recordedClient(dir), + stopWhenSessionStarts = stopWhenSessionStarts, + ) + if (!record.deferred && (!stopWhenSessionStarts || !SessionState.running)) { + if (!record.failed && VacDiagnostics.collect(context, dir)) { + try { + runtimeCaptured.writeText("collected at ${now()}\n") + } catch (e: Exception) { + record.failed = true + if (beforeGuestStart) { + runtimeExpired.writeText("runtime capture marker failed, at ${now()}\n") + } + Log.w(TAG, "could not mark runtime capture for $dir", e) + } + } else { + record.failed = true + if (beforeGuestStart) { + runtimeExpired.writeText("runtime capture failed, at ${now()}\n") + } + } + } + } + if (record.deferred || stopWhenSessionStarts && SessionState.running) { + record.failed = true + record.deferred = true + markScrubbed(record) + break + } + val crashLog = File(dir, "crash.log") + if (!crashLog.exists()) { + SessionLogCapture.dumpCrashBuffer(crashLog) + } + if (stopWhenSessionStarts && SessionState.running) { + record.failed = true + record.deferred = true + markScrubbed(record) + break } - SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log")) SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir) - markScrubbed(scrubTree(record)) + if (beforeGuestStart) return + val scrubbed = scrubTree(record) { stopWhenSessionStarts && SessionState.running } + val marked = markScrubbed(scrubbed) + if (scrubbed.deferred) break + if (!marked) continue File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n") Log.i(TAG, "finished the abandoned session folder $dir") } catch (e: Exception) { 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 d9c59b1b8..1f94f3a7e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -25,6 +25,8 @@ object SessionFiles { "usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu", "usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu", "usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules", + "usr/local/bin/droiddeck-library-perms" to "usr/local/bin/droiddeck-library-perms", + "usr/local/bin/droiddeck-ime" to "usr/local/bin/droiddeck-ime", "usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper", "usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js", "etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop", @@ -32,7 +34,7 @@ object SessionFiles { /** 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) } + if (DesktopCatalog.desktopInstalled(root) && !DesktopCatalog.desktopFromPack(root)) try { DesktopCatalog.cleanLegacy(context) } catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) } GameEnvironmentStore.publish(context) WinComponents.publish(context) @@ -70,10 +72,21 @@ object SessionFiles { "usr/local/bin/droiddeck-steam-launch" to "usr/local/bin/droiddeck-steam-launch", "usr/local/bin/droiddeck-desktop-games" to "usr/local/bin/droiddeck-desktop-games", "usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks", + "usr/local/bin/droiddeck-downloads" to "usr/local/bin/droiddeck-downloads", + "usr/local/bin/droiddeck-root" to "usr/local/bin/droiddeck-root", + "usr/local/bin/droiddeck-text" to "usr/local/bin/droiddeck-text", + "usr/local/bin/droiddeck-snapd" to "usr/local/bin/droiddeck-snapd", + "usr/local/bin/droiddeck-emulators" to "usr/local/bin/droiddeck-emulators", + "usr/local/bin/droiddeck-snap-run" to "usr/local/bin/droiddeck-snap-run", + "usr/local/bin/droiddeck-snap-confine" to "usr/local/bin/droiddeck-snap-confine", + "usr/local/bin/droiddeck-snapctl" to "usr/local/bin/droiddeck-snapctl", + "usr/local/bin/droiddeck-systemd-run" to "usr/local/bin/systemd-run", + "usr/local/bin/droiddeck-snap-cli" to "usr/local/bin/snap", "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-gog-maintenance" to "usr/local/bin/droiddeck-gog-maintenance", "usr/local/bin/droiddeck-battlenet" to "usr/local/bin/droiddeck-battlenet", "usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic", "usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam", @@ -234,6 +247,7 @@ object SessionFiles { Log.e(TAG, "could not write steam-compatibility-label") } writeCjkFontConfig(root, etc) + writeDesktopStrings(context, File(etc, "droiddeck").apply { mkdirs() }) val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() } val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged") if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n") @@ -241,6 +255,12 @@ object SessionFiles { stagedSocket.delete() Log.e(TAG, "could not write bwrap-socket") } + val stagedRoot = File(droiddeckEtc, "root-socket.staged") + if (!FileUtils.writeString(stagedRoot, com.droiddeck.launcher.runtime.RootSpawner.socket(context) + "\n") + || !stagedRoot.renameTo(File(droiddeckEtc, "root-socket"))) { + stagedRoot.delete() + Log.e(TAG, "could not write root-socket") + } BootMovie.syncFromSteam(context) val startupMovie = BootMovie.prepare(context) @@ -270,6 +290,7 @@ object SessionFiles { "stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " + "staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}", ) + if (startsSession) disableIdleSleep(home) if (!done) allDone = false if (!bigPictureMovieInstalled) allInstalled = false if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else "" @@ -508,6 +529,43 @@ object SessionFiles { } } + private val IDLE_SLEEP_TIMERS = listOf("IdleSuspendBatterySeconds", "IdleSuspendACSeconds") + + /** + * Sets Steam's Power > "Sleep after" (battery and plugged in) to Disabled at every session start: Android + * owns the device's sleep, and a client that suspends inside the session only shows a black screen. Dimming stays the user's. Runs + * only before the client starts, which rewrites its config on exit. + */ + internal fun disableIdleSleep(steamHome: File): Boolean { + val config = File(steamHome, "config/config.vdf") + val text = runCatching { config.readText() }.getOrNull() ?: return false + val newline = if (text.contains("\r\n")) "\r\n" else "\n" + val steam = findSteamBlock(text) ?: return false + val system = findObject(text, "system", steam.open + 1, steam.close) + val updated = if (system != null) { + val block = text.substring(system.keyStart, system.close + 1) + text.replaceRange(system.keyStart, system.close + 1, IDLE_SLEEP_TIMERS.fold(block) { acc, key -> setScalar(acc, key, "0", 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 == ' ' } + "\t" + val section = (listOf(indent + "\"system\"", indent + "{") + + IDLE_SLEEP_TIMERS.map { "$indent\t\"$it\"\t\t\"0\"" } + (indent + "}")).joinToString(newline) + newline + text.substring(0, lineStart) + section + text.substring(lineStart) + } + val staged = File(config.parentFile, config.name + ".staged") + return try { + if (updated != text) { + staged.writeText(updated) + if (!staged.renameTo(config)) { staged.delete(); return false } + } + true + } catch (e: Exception) { + staged.delete() + Log.w(TAG, "could not disable Steam's idle sleep", 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 @@ -585,6 +643,28 @@ object SessionFiles { * 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 val DESKTOP_STRINGS = listOf( + "desktop_restore_title" to R.string.desktop_restore_title, + "desktop_restore_body" to R.string.desktop_restore_body, + "desktop_restore_done_title" to R.string.desktop_restore_done_title, + "desktop_restore_done_body" to R.string.desktop_restore_done_body, + "desktop_restore_failed_title" to R.string.desktop_restore_failed_title, + "desktop_restore_failed_body" to R.string.desktop_restore_failed_body, + ) + + private fun writeDesktopStrings(context: Context, dir: File) { + val text = DESKTOP_STRINGS.joinToString("") { (key, id) -> + key + "=" + context.getString(id).replace("\\", "\\\\").replace("%", "%%").replace("\n", "\\n") + "\n" + } + val target = File(dir, "strings") + if (FileUtils.readString(target) == text) return + val staged = File(dir, "strings.staged") + if (!FileUtils.writeString(staged, text) || !staged.renameTo(target)) { + staged.delete() + Log.e(TAG, "could not write desktop strings") + } + } + private fun writeCjkFontConfig(root: File, etc: File) { try { val confDir = File(etc, "fonts/conf.d").apply { mkdirs() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLaunchFailure.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLaunchFailure.kt new file mode 100644 index 000000000..22d8b952c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLaunchFailure.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.session + +enum class SessionLaunchFailure { + STEAM_ILLEGAL_INSTRUCTION, + VULKAN_DEVICE_MISSING; + + companion object { + private val ANSI = Regex("\u001b\\[[0-?]*[ -/]*[@-~]") + private val STEAM_EXIT = Regex("== steam exited rc=(\\d+)") + + fun find(lines: Sequence): SessionLaunchFailure? { + var steamStarted = false + var illegalInstruction = false + var missingDevice = false + lines.forEach { raw -> + val line = ANSI.replace(raw, "").trim() + when { + line.startsWith("== droiddeck-session ") -> { + steamStarted = false + illegalInstruction = false + missingDevice = false + } + line.startsWith("== STEP ") && line.contains("starting the Steam client") -> { + steamStarted = true + illegalInstruction = false + } + STEAM_EXIT.matches(line) && steamStarted -> + illegalInstruction = STEAM_EXIT.matchEntire(line)?.groupValues?.get(1) == "132" + line.startsWith("[gamescope]") && line.contains("vulkan:") -> when { + line.contains("failed to find physical device") -> missingDevice = true + line.contains("selecting physical device") -> missingDevice = false + } + } + } + return when { + illegalInstruction -> STEAM_ILLEGAL_INSTRUCTION + missingDevice -> VULKAN_DEVICE_MISSING + else -> null + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt index e67092c1d..f73609f44 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -44,6 +44,10 @@ object SessionPaths { fun current(): File? = dir + /** Whether the current session folder will survive teardown. No session means the next one can retain logs. */ + fun currentSessionLogsRetained(context: Context): Boolean = + dir?.let { it.parentFile == SessionFiles.logDirectory(context) } ?: true + fun lastEnded(): File? = ended?.takeIf { it.isDirectory } @Synchronized 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 77601e3a3..306375cd8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -21,6 +21,8 @@ object SessionPrefs { private const val LEGACY_SUSPEND_DOWNLOADS = "downloads" private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground" private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen" + private const val SLOW_USB_WARNING_OFF = "slowUsbWarningOff" + private const val SLOW_USB_DISMISSED = "slowUsbDismissed" const val CONTROLLER_DECK = "deck" const val CONTROLLER_XBOX360 = "xbox360" @@ -84,6 +86,32 @@ object SessionPrefs { prefs(context).edit().putBoolean("animations", on).apply() } + fun dualScreenMode(context: Context): String = + prefs(context).getString("dualScreenMode", null)?.takeIf { it in com.droiddeck.launcher.input.DualScreen.modes } + ?: com.droiddeck.launcher.input.DualScreen.MODE_AUTO + + fun setDualScreenMode(context: Context, mode: String) { + prefs(context).edit().putString("dualScreenMode", mode).apply() + } + + fun secondScreenPanel(context: Context): String? = prefs(context).getString("secondScreenPanel", null) + + fun setSecondScreenPanel(context: Context, mode: String) { + prefs(context).edit().putString("secondScreenPanel", mode).apply() + } + + fun dualScreenLauncher(context: Context): Boolean = prefs(context).getBoolean("dualScreenLauncher", true) + + fun setDualScreenLauncher(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("dualScreenLauncher", on).apply() + } + + fun dualScreenSessionControls(context: Context): Boolean = prefs(context).getBoolean("dualScreenSessionControls", true) + + fun setDualScreenSessionControls(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("dualScreenSessionControls", on).apply() + } + fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true) fun setGameStoresEnabled(context: Context, on: Boolean) { @@ -125,6 +153,30 @@ object SessionPrefs { prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply() } + fun libraryCardLevel(context: Context): Int = prefs(context).getInt("libraryCardLevel", 2) + + fun setLibraryCardLevel(context: Context, level: Int) { + prefs(context).edit().putInt("libraryCardLevel", level).apply() + } + + fun libraryShowTitle(context: Context): Boolean = prefs(context).getBoolean("libraryShowTitle", true) + + fun setLibraryShowTitle(context: Context, show: Boolean) { + prefs(context).edit().putBoolean("libraryShowTitle", show).apply() + } + + fun libraryShowSize(context: Context): Boolean = prefs(context).getBoolean("libraryShowSize", true) + + fun setLibraryShowSize(context: Context, show: Boolean) { + prefs(context).edit().putBoolean("libraryShowSize", show).apply() + } + + fun railLabels(context: Context): Boolean = prefs(context).getBoolean("railLabels", false) + + fun setRailLabels(context: Context, show: Boolean) { + prefs(context).edit().putBoolean("railLabels", show).apply() + } + fun libraryShowUninstalled(context: Context): Boolean = prefs(context).getBoolean("libraryShowUninstalled", false) fun setLibraryShowUninstalled(context: Context, show: Boolean) { @@ -195,7 +247,7 @@ object SessionPrefs { prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply() } - fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context)) + fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), false) fun setHudEnabled(context: Context, on: Boolean) { prefs(context).edit().putBoolean(hudKey(context), on).apply() @@ -233,6 +285,20 @@ object SessionPrefs { prefs(context).edit().putInt("hudOpacity", percent.coerceIn(HUD_OPACITY_RANGE)).apply() } + fun fusionHudConfig(context: Context): String? = prefs(context).getString("fusionHud", null) + + fun setFusionHudConfig(context: Context, config: String) { + prefs(context).edit().putString("fusionHud", config).apply() + } + + fun fusionHudPosition(context: Context): Pair? = + prefs(context).getString("fusionHudPos", null)?.split(',')?.mapNotNull { it.toFloatOrNull() } + ?.takeIf { it.size == 2 }?.let { it[0] to it[1] } + + fun setFusionHudPosition(context: Context, x: Float, y: Float) { + prefs(context).edit().putString("fusionHudPos", "$x,$y").apply() + } + const val HUD_COLOR_MINT = "mint" val HUD_COLORS = listOf(HUD_COLOR_MINT, "white", "sky", "amber", "violet") val HUD_OPACITY_RANGE = 30..100 @@ -249,6 +315,18 @@ object SessionPrefs { prefs(context).edit().putBoolean("lowLatency", on).apply() } + fun evdevMouse(context: Context): Boolean = prefs(context).getBoolean("evdevMouse", false) + + fun setEvdevMouse(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("evdevMouse", on).apply() + } + + fun directCursor(context: Context): Boolean = prefs(context).getBoolean("directCursor", false) + + fun setDirectCursor(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("directCursor", on).apply() + } + fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false) fun setBackActionsInverted(context: Context, inverted: Boolean) { @@ -603,6 +681,7 @@ object SessionPrefs { fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true) fun vsyncThread(context: Context): Boolean = prefs(context).getBoolean("vsyncThread", true) + fun renderOnCommit(context: Context): Boolean = prefs(context).getBoolean("renderOnCommit", true) fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() } fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true) @@ -630,8 +709,74 @@ object SessionPrefs { fun logsEnabled(context: Context): Boolean = prefs(context).getBoolean("logs", true) - fun setLogsEnabled(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("logs", on).apply() + fun setLogsEnabled(context: Context, on: Boolean): Boolean = synchronized(GameEnvironmentStore) { + setLogPreference( + context, + key = "logs", + oldValue = logsEnabled(context), + newValue = on, + newEffective = on && gameDebugLogsEnabled(context) && SessionPaths.currentSessionLogsRetained(context), + ) + } + + /** Extra Proton, Wine and graphics diagnostics for the next game launch; off unless chosen. */ + fun gameDebugLogsEnabled(context: Context): Boolean = + prefs(context).getBoolean("gameDebugLogsEnabled", false) + + fun setGameDebugLogsEnabled(context: Context, on: Boolean): Boolean = synchronized(GameEnvironmentStore) { + setLogPreference( + context, + key = "gameDebugLogsEnabled", + oldValue = gameDebugLogsEnabled(context), + newValue = on, + newEffective = logsEnabled(context) && on && SessionPaths.currentSessionLogsRetained(context), + ) + } + + private fun setLogPreference( + context: Context, + key: String, + oldValue: Boolean, + newValue: Boolean, + newEffective: Boolean, + ): Boolean { + if (oldValue == newValue) return true + val saved = prefs(context) + val oldEffective = logsEnabled(context) && gameDebugLogsEnabled(context) && + SessionPaths.currentSessionLogsRetained(context) + fun publish(value: Boolean) = GameEnvironmentStore.publish(context, gameDebugLogsOverride = value) + if (oldEffective == newEffective) { + if (saved.edit().putBoolean(key, newValue).commit()) return true + saved.edit().putBoolean(key, oldValue).commit() + return false + } + if (oldEffective && !newEffective) { + try { + publish(false) + } catch (e: Exception) { + android.util.Log.e("GameEnvironment", "Could not disable game diagnostics", e) + return false + } + if (saved.edit().putBoolean(key, newValue).commit()) return true + saved.edit().putBoolean(key, oldValue).commit() + runCatching { publish(oldEffective) } + .onFailure { android.util.Log.e("GameEnvironment", "Could not restore game diagnostics", it) } + return false + } + if (!saved.edit().putBoolean(key, newValue).commit()) { + saved.edit().putBoolean(key, oldValue).commit() + return false + } + return try { + publish(newEffective) + true + } catch (e: Exception) { + saved.edit().putBoolean(key, oldValue).commit() + runCatching { publish(oldEffective) } + .onFailure { android.util.Log.e("GameEnvironment", "Could not restore game diagnostics", it) } + android.util.Log.e("GameEnvironment", "Could not update game diagnostics", e) + false + } } fun battleNetNoSteamSet(context: Context): Boolean = prefs(context).getBoolean("battleNetNoSteamSet", false) @@ -942,12 +1087,26 @@ object SessionPrefs { /** Compositor frame limit that releases buffers to the game unpaced. */ const val FPS_UNPACED = -1 + /** The drawer's frame limit slider: its first stop is Off. */ + const val FPS_LIMIT_OFF = 15 + const val FPS_LIMIT_MAX = 120 + /** * Buffers go back to the game as soon as they are replaced instead of once per refresh; - * Steam's Frame Limit in the Quick Access menu caps games. Mirroring still follows the glasses' refresh rate. + * Steam's Frame Limit in the Quick Access menu caps Steam games, and the drawer's limit caps games + * launched without Steam. Mirroring still follows the glasses' refresh rate. */ - fun compositorFpsLimit(mirrorRefreshLimit: Int): Int = - if (mirrorRefreshLimit <= 0) FPS_UNPACED else mirrorRefreshLimit + const val PIP_FPS = 30 + + fun compositorFpsLimit(mirrorRefreshLimit: Int, sessionLimit: Int = 0, pip: Boolean = false): Int { + val limit = when { + mirrorRefreshLimit <= 0 && sessionLimit <= 0 -> FPS_UNPACED + mirrorRefreshLimit <= 0 -> sessionLimit + sessionLimit <= 0 -> mirrorRefreshLimit + else -> minOf(mirrorRefreshLimit, sessionLimit) + } + return if (pip && mirrorRefreshLimit <= 0 && (limit <= 0 || limit > PIP_FPS)) PIP_FPS else limit + } val upscalerChoices = listOf( 0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR", @@ -1128,7 +1287,7 @@ object SessionPrefs { resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP else prefMode(mode) - fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false) + fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", true) fun setPipAutoEnter(context: Context, enabled: Boolean) { prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply() @@ -1171,7 +1330,8 @@ object SessionPrefs { } fun suspendPolicy(context: Context, mode: String): String = - prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL) + prefs(context).getString("suspendPolicy.${prefMode(mode)}", + if (mode == SessionService.MODE_STEAM) SUSPEND_NATIVE else SUSPEND_MANUAL) ?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it } ?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) } ?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it } @@ -1197,6 +1357,18 @@ object SessionPrefs { prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply() } + fun slowUsbWarningOff(context: Context): Boolean = prefs(context).getBoolean(SLOW_USB_WARNING_OFF, false) + + fun setSlowUsbWarningOff(context: Context) { + prefs(context).edit().putBoolean(SLOW_USB_WARNING_OFF, true).apply() + } + + fun slowUsbDismissed(context: Context): Set = prefs(context).getStringSet(SLOW_USB_DISMISSED, null)?.toSet().orEmpty() + + fun setSlowUsbDismissed(context: Context, keys: Set) { + prefs(context).edit().putStringSet(SLOW_USB_DISMISSED, keys.toSet()).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 24e9ef553..892fc802a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -91,6 +91,7 @@ class SessionService : Service() { private var screenOn = true private var sessionDisplayId = Display.DEFAULT_DISPLAY private var manualPauseRequested = false + private var manualHold = false private var steamSleepToken: String? = null private var nativeSleepToken: String? = null private var nativeSleepReady = false @@ -274,7 +275,11 @@ class SessionService : Service() { val hold = intent?.getBooleanExtra(EXTRA_HOLD, false) == true if (hold == signInHold) return START_NOT_STICKY signInHold = hold - if (hold) manualPauseRequested = false + if (hold) { + manualPauseRequested = false + manualHold = false + SessionState.manualPause = false + } suspendAttemptFailed = false updateSuspendPolicy() return START_NOT_STICKY @@ -304,6 +309,14 @@ class SessionService : Service() { updateSuspendPolicy() return START_NOT_STICKY } + ACTION_PAUSE_NOW -> { + if (!SessionState.running || SessionState.suspended || signInHold) return START_NOT_STICKY + manualHold = true + SessionState.manualPause = true + suspendAttemptFailed = false + updateSuspendPolicy() + return START_NOT_STICKY + } ACTION_RESUME -> { resumeSession() return START_NOT_STICKY @@ -321,6 +334,7 @@ class SessionService : Service() { return START_NOT_STICKY } SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM + SessionState.fpsLimit = 0 suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) activityVisible = true @@ -346,6 +360,8 @@ class SessionService : Service() { SessionState.resumeRequested = false SessionState.resuming = false SessionState.resumeFailed = false + manualHold = false + SessionState.manualPause = false SessionState.pipActive = false pipTask = false SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM) @@ -385,10 +401,10 @@ class SessionService : Service() { } private fun collectSessionArtifacts(dir: File, steamArch: String?) { + SessionLogCapture.stopFor(dir) try { SessionArtifacts.collect(this, dir, "session stopped", steamArch) } finally { - SessionLogCapture.stopFor(dir) SessionPaths.release(this, dir) } } @@ -461,13 +477,20 @@ class SessionService : Service() { SessionFiles.stage(this, root, startsSession = true) // Earlier sessions' reports still read the Steam logs and VAC marker this session replaces. SessionArtifacts.awaitCollections() - SessionArtifacts.finishAbandoned(this) + SessionArtifacts.finishAbandoned(this, beforeGuestStart = true) VacDiagnostics.arm(this) com.droiddeck.launcher.agent.AgentGuest.reset(this) com.droiddeck.launcher.stores.gog.GogComet.begin() com.droiddeck.launcher.stores.StoreLaunchRequests.start(this) val sessionDir = openSessionFolder() + try { + GameEnvironmentStore.publish(this) + } catch (e: Exception) { + Log.e(TAG, "could not publish the game environment", e) + stopSession(-1) + return + } val sessionLog = File(sessionDir, "session.log") val prootLine = "== proot: ${LinuxRuntime.prootBinary(this).path} (${LinuxRuntime.prootSource(this)})" Log.i(TAG, prootLine) @@ -513,10 +536,6 @@ class SessionService : Service() { // gamescope's WSI layer and winex11 reuse their per-present window checks for this long. guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=100") guest.add("WINE_OFFSCREEN_CHECK_MS=100") - // gamescope turns buffers around slower than an uncapped game draws; more images keep it drawing. - // The WSI layer goes back to its default count while Steam's Frame Limit holds the game. - guest.add("GAMESCOPE_WSI_MIN_IMAGE_COUNT=8") - guest.add("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT=1") guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz)) guest.add("BL_LOG=" + sessionLog.path) guest.add("BL_DEBUG_DIR=" + sessionDir.path) @@ -524,6 +543,7 @@ class SessionService : Service() { val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() } val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() SessionState.deckPad = false + SessionState.secondScreenAutoOpened = false deckBinds = emptyList() inputSysfs = null if (controllersOn) { @@ -697,6 +717,7 @@ class SessionService : Service() { if (!pulse.setMicSuspended(false)) Log.w(TAG, "could not resume microphone source") }, zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"), + clockControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-clock"), pagedOut = { processes, unacknowledged, residentKb, swapKb, swapBeforeKb, millis -> Log.i(TAG, "paged out $processes processes ($unacknowledged unacknowledged) in $millis ms: " + "resident ${residentKb / 1024} MB, zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB") @@ -734,7 +755,7 @@ class SessionService : Service() { guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this)) guest.add("HOME=/root") guest.add("USER=root") - guest.add("PATH=/usr/local/bin:/usr/bin:/bin") + guest.add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games") guest.add("TERM=xterm-256color") guest.add("LANG=C.UTF-8") guest.add("BL_STEAM_LANGUAGE=" + SteamLanguage.forLocale( @@ -858,7 +879,7 @@ class SessionService : Service() { } private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File, sessionDir: File) { - FakeInputWriter.prepareRingSlots(fakeInputDir, 4) + FakeInputWriter.prepareRingSlots(fakeInputDir, 5) val builtinPad = SessionPrefs.builtinPad(this) val hiddenPad = SessionState.mode == MODE_STEAM && !builtinPad && !com.droiddeck.launcher.input.PadBridge.anyControllerConnected() @@ -1016,7 +1037,7 @@ class SessionService : Service() { Log.w(TAG, "added games: ${root.host} is not a readable folder this session") } } - for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRoots(this)) { + for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRootsForXodus(this)) { if (host.isDirectory && host.canRead()) { File(LinuxRuntime.rootDir(this), guest.removePrefix("/")).mkdirs() binds.add(host.path + ":" + guest) @@ -1332,6 +1353,8 @@ class SessionService : Service() { if (panelVisible) activityVisible = true screenOn = readScreenOn() manualPauseRequested = false + manualHold = false + SessionState.manualPause = false suspendDownloadNow = false if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake() completeNativeSleep() @@ -1539,12 +1562,12 @@ class SessionService : Service() { manualPauseRequested = false } val controller = suspendController ?: return - if (downloadsBlockingSuspend) acquireLocks() - val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) { + if (downloadsBlockingSuspend && !manualHold) acquireLocks() + val steamPause = SessionState.mode == MODE_STEAM && (manualHold || !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 && @@ -1552,7 +1575,7 @@ class SessionService : Service() { prepareNativeSleep() } val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady - val shouldSuspend = steamSleepToken != null || when (suspendPolicy) { + val shouldSuspend = steamSleepToken != null || (manualHold && prepared) || when (suspendPolicy) { SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend @@ -1600,6 +1623,8 @@ class SessionService : Service() { SessionEvents.record("session.suspend_cancelled") } else { suspendAttemptFailed = true + manualHold = false + SessionState.manualPause = false Log.w(TAG, "could not confirm that the session stopped") } } @@ -1648,7 +1673,7 @@ class SessionService : Service() { } private fun resumeSteamSleepOnReturn() { - if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) { + if (!manualHold && suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) { if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake() completeNativeSleep() completeSteamSleep() @@ -1706,42 +1731,48 @@ class SessionService : Service() { }.onFailure { Log.w(TAG, "could not request Steam wake", it) } } - private fun finishSessionStop(status: Int, stoppedGen: Int) { + private fun finishSessionStop(status: Int, stoppedGen: Int, afterStopped: () -> Unit = {}) { mainHandler.post { - if (stoppedGen != sessionGen || SessionState.running) { - Log.i(TAG, "session $stoppedGen finished stopping after a new one started") - return@post + try { + if (stoppedGen != sessionGen || SessionState.running) { + Log.i(TAG, "session $stoppedGen finished stopping after a new one started") + return@post + } + SessionState.suspended = false + SessionState.resumeRequested = false + SessionState.resuming = false + SessionState.resumeFailed = false + manualHold = false + SessionState.manualPause = false + SessionState.pipActive = false + pipTask = false + SessionState.guestPid = -1 + com.droiddeck.launcher.agent.AgentGuest.stop() + com.droiddeck.launcher.stores.StoreLaunchRequests.stop() + Thread({ com.droiddeck.launcher.stores.CloudSaves.uploadAllDirty(applicationContext, "session-end", running = false) }, "cloud-session-end").start() + runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) } + .onFailure { Log.w(TAG, "clearing the agent's session environment", it) } + releaseLocks() + MemoryLog.stop() + com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(applicationContext) + if (status == 0) { + SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status)) + } else { + val code = SessionState.failureCode ?: "GUEST_EXIT" + val message = SessionState.failureMessage ?: "Session guest exited with status $status" + val failureStatus = SessionState.failureStatus ?: status + SessionEvents.fail(code, message, failureStatus) + } + SessionState.steamVr = false + com.droiddeck.launcher.glasses.Glasses.cancelSteamVrStart() + com.droiddeck.launcher.glasses.Glasses.setVrActive(false) + com.droiddeck.launcher.glasses.Glasses.set3dForVr(false) + SessionState.notifyEnded(status) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } finally { + afterStopped() } - SessionState.suspended = false - SessionState.resumeRequested = false - SessionState.resuming = false - SessionState.resumeFailed = false - SessionState.pipActive = false - pipTask = false - SessionState.guestPid = -1 - com.droiddeck.launcher.agent.AgentGuest.stop() - com.droiddeck.launcher.stores.StoreLaunchRequests.stop() - Thread({ com.droiddeck.launcher.stores.CloudSaves.uploadAllDirty(applicationContext, "session-end", running = false) }, "cloud-session-end").start() - runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) } - .onFailure { Log.w(TAG, "clearing the agent's session environment", it) } - releaseLocks() - MemoryLog.stop() - com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(applicationContext) - if (status == 0) { - SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status)) - } else { - val code = SessionState.failureCode ?: "GUEST_EXIT" - val message = SessionState.failureMessage ?: "Session guest exited with status $status" - val failureStatus = SessionState.failureStatus ?: status - SessionEvents.fail(code, message, failureStatus) - } - SessionState.steamVr = false - com.droiddeck.launcher.glasses.Glasses.cancelSteamVrStart() - com.droiddeck.launcher.glasses.Glasses.setVrActive(false) - com.droiddeck.launcher.glasses.Glasses.set3dForVr(false) - SessionState.notifyEnded(status) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() } } @@ -1772,7 +1803,9 @@ class SessionService : Service() { SessionEvents.record("guest.exited", mapOf("status" to status)) SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) SessionState.running = false - SessionPaths.take() to SessionState.steamClientArch + val ended = SessionPaths.take() + if (ended != null) SessionArtifacts.collectionStarted(ended) + ended to SessionState.steamClientArch } SessionState.micAvailable = false SessionState.micEnabled = false @@ -1801,8 +1834,6 @@ class SessionService : Service() { suspendController = null if (ended != null) { VacDiagnostics.snapshot(this, ended) - SessionArtifacts.collectionStarted(ended) - Thread({ collectSessionArtifacts(ended, endedArch) }, "session-collect").start() } components.reversed().forEach { try { @@ -1812,6 +1843,9 @@ class SessionService : Service() { } } components.clear() + val collectEnded = { + if (ended != null) Thread({ collectSessionArtifacts(ended, endedArch) }, "session-collect").start() + } FakeInputWriter.releaseAllRingSlots() com.droiddeck.launcher.input.SteamTouchDevice.release() val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP @@ -1823,13 +1857,13 @@ class SessionService : Service() { auxiliary.forEach { (pid, started) -> teardown(pid, started) } if (prootPid > 1) teardown(prootPid) killGuestLeftovers() - finishSessionStop(status, stoppedGen) + finishSessionStop(status, stoppedGen, collectEnded) if (launcherEnded) importLauncherGames() }, "session-teardown").start() } else { Thread({ com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession) - finishSessionStop(status, stoppedGen) + finishSessionStop(status, stoppedGen, collectEnded) if (launcherEnded) importLauncherGames() }, "gog-session-stop").start() } @@ -1847,6 +1881,9 @@ class SessionService : Service() { } override fun onTaskRemoved(rootIntent: Intent?) { + if (rootIntent?.component?.className in setOf( + com.droiddeck.launcher.ui.DualScreenActivity::class.java.name, + com.droiddeck.launcher.ui.SessionTypingActivity::class.java.name)) return if (pipTask || xrActivityVisible) { Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy") activityVisible = false @@ -2068,6 +2105,7 @@ class SessionService : Service() { const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION" const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION" const val ACTION_SUSPEND_NOW = "com.droiddeck.launcher.SUSPEND_NOW" + private const val ACTION_PAUSE_NOW = "com.droiddeck.launcher.PAUSE_NOW" private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN" const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME" const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE" @@ -2142,6 +2180,7 @@ class SessionService : Service() { fun stop(context: Context) { if (!SessionState.running) { SessionState.stopRequested = true + SessionState.installCancellation?.cancel() return } context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP)) @@ -2187,6 +2226,10 @@ class SessionService : Service() { context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SIGN_IN_HOLD).putExtra(EXTRA_HOLD, hold)) } + fun pause(context: Context) { + context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PAUSE_NOW)) + } + fun resume(context: Context) { if (!SessionState.running) return if (SessionState.suspended) SessionState.resumeRequested = true 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 52544f2fc..088c8b7d0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -37,6 +37,9 @@ object SessionState { @Volatile var installing: String? = null + @Volatile + var installCancellation: com.droiddeck.launcher.core.Downloader.Cancellation? = null + @Volatile var stopRequested = false @@ -53,6 +56,7 @@ object SessionState { var resuming by mutableStateOf(false) var resumeFailures by mutableIntStateOf(0) var resumeFailed by mutableStateOf(false) + var manualPause by mutableStateOf(false) @Volatile var pipActive = false @Volatile var program: String? = null @@ -87,6 +91,7 @@ object SessionState { @Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE @Volatile var secondScreenDisplay = -1 + @Volatile var secondScreenAutoOpened = false @Volatile var mode = "steam" @@ -103,6 +108,10 @@ object SessionState { @Volatile var upscaleRatio: Float = 0f + /** Frame limit chosen in the drawer for a game launched without Steam; 0 is off. */ + @Volatile + var fpsLimit: Int = 0 + @Volatile var firstFrameSeen = 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 5d0e32f99..be994b4d2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -3,6 +3,9 @@ package com.droiddeck.launcher.session import android.os.Process import java.io.File import java.io.IOException +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.RejectedExecutionException @@ -19,6 +22,8 @@ internal class SessionSuspendController( private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }, private val executor: ExecutorService = Executors.newSingleThreadExecutor(), private val zramControl: File? = null, + private val clockControl: File? = null, + private val nowNanos: () -> Long = System::nanoTime, private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit = { _, _, _, _, _, _ -> }, ) { @@ -43,6 +48,7 @@ internal class SessionSuspendController( @Volatile private var closing = false @Volatile private var cancelled = false private var frozen = emptyList() + private var frozenSince: Long? = null fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) { cancelled = false @@ -93,6 +99,7 @@ internal class SessionSuspendController( } catch (_: Exception) { } finally { frozen = records.values.toList() + if (complete && frozenSince == null) frozenSince = nowNanos() if (!complete) { if (restoreSafely(frozen)) frozen = emptyList() runAudio(resumeAudio) @@ -111,6 +118,7 @@ internal class SessionSuspendController( fun resume(completion: (Boolean) -> Unit) { submit({ val records = frozen + advanceClock() runAudio(resumeAudio) val resumed = restoreSafely(records) if (resumed) frozen = emptyList() @@ -121,6 +129,7 @@ internal class SessionSuspendController( fun closeAndResume(completion: () -> Unit) { closing = true submit({ + advanceClock() if (restoreSafely(frozen)) frozen = emptyList() completion() executor.shutdown() @@ -135,6 +144,23 @@ internal class SessionSuspendController( } } + private fun advanceClock() { + val since = frozenSince ?: return + frozenSince = null + val control = clockControl?.takeIf { it.isFile && it.length() >= Long.SIZE_BYTES } ?: return + try { + RandomAccessFile(control, "rw").use { file -> + val bytes = ByteArray(Long.SIZE_BYTES) + file.readFully(bytes) + val shift = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).long.coerceAtLeast(0L) + + (nowNanos() - since).coerceAtLeast(0L) + file.seek(0L) + file.write(ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).putLong(shift).array()) + } + } catch (_: IOException) { + } + } + private fun runAudio(action: () -> Unit) { try { action() 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 27c97b2ba..00ebf2f2a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -12,16 +12,17 @@ object SettingsReset { internal val SESSION_KEYS = setOf( "orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt", + "dualScreenMode", "dualScreenLauncher", "dualScreenSessionControls", "gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature", "storesInstallLanguage", - "hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController", + "hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "fusionHud", "fusionHudPos", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController", "gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController", - "stretch16x9", "picturePosition", + "stretch16x9", "picturePosition", "railLabels", "gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop", "tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient", "directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus", "noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite", - "guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics", + "guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "storageDiagnostics", "upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias", "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds", "bootMovieApplyNext", "bootMovieSteamSeen", @@ -37,6 +38,7 @@ object SettingsReset { internal fun isSessionSetting(key: String) = key in SESSION_KEYS || SESSION_PREFIXES.any { key.startsWith(it) } fun resetAll(context: Context, gameProfiles: Boolean, gameScopes: Collection = emptyList()): Boolean { + if (!SessionPrefs.setGameDebugLogsEnabled(context, false)) return false var ok = true val session = context.getSharedPreferences("session", Context.MODE_PRIVATE) session.edit().apply { session.all.keys.filter { isSessionSetting(it) }.forEach { remove(it) } }.commit() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamImportPrompt.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamImportPrompt.kt new file mode 100644 index 000000000..736db34d9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamImportPrompt.kt @@ -0,0 +1,26 @@ +package com.droiddeck.launcher.session + +import org.json.JSONObject +import java.io.File + +object SteamImportPrompt { + data class Ask(val token: String, val games: List) + + private fun folder(root: File) = File(root, "root/.cache/droiddeck") + + fun read(root: File): Ask? { + val text = runCatching { File(folder(root), "steam-import.json").readText() }.getOrNull() ?: return null + val json = runCatching { JSONObject(text) }.getOrNull() ?: return null + val token = json.optString("token").takeIf { it.isNotEmpty() } ?: return null + val list = json.optJSONArray("games") + val games = if (list == null) emptyList() else (0 until list.length()).mapNotNull { list.optString(it).takeIf(String::isNotBlank) } + return Ask(token, games) + } + + fun answer(root: File, token: String, now: Boolean): Boolean = runCatching { + val dir = folder(root).apply { mkdirs() } + val temp = File(dir, "steam-import.answer.tmp") + temp.writeText(JSONObject().put("token", token).put("choice", if (now) "now" else "later").toString()) + temp.renameTo(File(dir, "steam-import.answer")) + }.getOrDefault(false) +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/UsbLink.kt b/app/src/main/java/com/droiddeck/launcher/session/UsbLink.kt new file mode 100644 index 000000000..fb322838d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/UsbLink.kt @@ -0,0 +1,122 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.storage.StorageManager +import com.droiddeck.launcher.frontend.AddedGames +import java.io.File + +object UsbLink { + data class Link(val device: String, val mbps: Double, val name: String, val portMbps: Double?, val plug: String = "") + + class SlowDrive(val key: String, val name: String, val mbps: Double, val portMbps: Double?) { + val portLimited get() = portMbps != null && portMbps < FAST_MBPS + } + + const val FAST_MBPS = 5000.0 + + fun markShown(context: Context, drive: SlowDrive) = synchronized(this) { + SessionPrefs.setSlowUsbDismissed(context, SessionPrefs.slowUsbDismissed(context) + drive.key) + } + + fun stillDue(context: Context, drive: SlowDrive) = + !SessionPrefs.slowUsbWarningOff(context) && drive.key !in SessionPrefs.slowUsbDismissed(context) + + fun slowLibrary(context: Context): SlowDrive? { + if (SessionPrefs.slowUsbWarningOff(context)) return null + val mounts = runCatching { File("/proc/self/mounts").readText() }.getOrNull() ?: return null + val sm = context.getSystemService(StorageManager::class.java) + val boot = runCatching { android.provider.Settings.Global.getInt(context.contentResolver, android.provider.Settings.Global.BOOT_COUNT) }.getOrDefault(0) + val drives = libraries(context).mapNotNull { path -> + val link = forPath(path, mounts) ?: return@mapNotNull null + if (link.mbps >= FAST_MBPS) return@mapNotNull null + val volume = runCatching { sm?.getStorageVolume(File(path))?.getDescription(context) }.getOrNull() + SlowDrive(dismissKey(boot, link), link.name.ifBlank { volume.orEmpty() }.ifBlank { File(path).name }, link.mbps, link.portMbps) + } + return synchronized(this) { + val dismissed = SessionPrefs.slowUsbDismissed(context) + val current = dismissed.intersect(drives.map { it.key }.toSet()) + if (current != dismissed) SessionPrefs.setSlowUsbDismissed(context, current) + drives.firstOrNull { it.key !in current } + } + } + + internal fun dismissKey(boot: Int, link: Link) = "$boot/${link.plug}/${link.device}@${link.mbps}" + + private fun libraries(context: Context): List { + val pref = SessionPrefs.gameStorage(context) + val storage = when (pref) { + SessionPrefs.GAME_STORAGE_OFF -> null + "" -> GameStorage.effective(context)?.path + else -> pref + } + val folders = runCatching { AddedGames.roots(context).map { it.host.absolutePath } }.getOrDefault(emptyList()) + return (listOfNotNull(storage) + folders).map(::storagePath).filter { external(it) }.distinct() + } + + internal fun storagePath(path: String) = if (path.startsWith("/mnt/media_rw/")) "/storage/" + path.removePrefix("/mnt/media_rw/") else path + + internal fun external(path: String) = path.startsWith("/storage/") && !path.startsWith("/storage/emulated/") && !path.startsWith("/storage/self/") + + fun forPath(path: String, mounts: String, sys: File = File("/sys")): Link? { + val device = mountDevice(rawPath(path), mounts) ?: return null + val block = blockDir(device, sys) ?: return null + val usb = usbDevice(block, sys) ?: return null + val mbps = readSpeed(usb) ?: return null + val name = listOf("manufacturer", "product").mapNotNull { read(File(usb, it)) }.joinToString(" ").trim() + val plug = listOf("busnum", "devnum").mapNotNull { read(File(usb, it)) }.joinToString("-") + return Link(device, mbps, name, portSpeed(usb, sys), plug) + } + + internal fun rawPath(path: String) = if (external(path)) "/mnt/media_rw/" + path.removePrefix("/storage/") else path + + internal fun mountDevice(path: String, mounts: String): String? { + var best: Pair? = null + for (line in mounts.lineSequence()) { + val parts = line.split(' ') + if (parts.size < 2) continue + val point = parts[1].replace("\\040", " ") + if (path != point && !path.startsWith("${point.trimEnd('/')}/")) continue + if (best == null || point.length > best.first.length) best = point to parts[0] + } + val source = best?.second ?: return null + Regex(":(\\d+),(\\d+)$").find(source)?.let { return "${it.groupValues[1]}:${it.groupValues[2]}" } + return if (source.startsWith("/dev/block/") || source.startsWith("/dev/sd")) "name:" + source.substringAfterLast('/') else null + } + + internal fun blockDir(device: String, sys: File): File? { + if (device.startsWith("name:")) return File(sys, "class/block/${device.removePrefix("name:")}").takeIf { it.exists() }?.canonicalFile + File(sys, "dev/block/$device").takeIf { it.exists() }?.let { return it.canonicalFile } + return File(sys, "class/block").listFiles()?.firstOrNull { read(File(it, "dev")) == device }?.canonicalFile + } + + internal fun usbDevice(block: File, sys: File): File? { + val top = sys.canonicalFile + var dir: File? = block + while (dir != null && dir != top) { + if (File(dir, "speed").isFile && File(dir, "idVendor").isFile) return dir + dir = dir.parentFile + } + return null + } + + private fun portSpeed(usb: File, sys: File): Double? { + val bus = read(File(usb, "busnum")) ?: return null + val hub = File(sys, "bus/usb/devices/usb$bus").takeIf { it.exists() }?.canonicalFile ?: return null + val controller = hub.parentFile ?: return null + return controller.listFiles { f -> f.name.matches(Regex("usb\\d+")) }?.mapNotNull { readSpeed(it) }?.maxOrNull() + } + + private fun readSpeed(dir: File) = read(File(dir, "speed"))?.toDoubleOrNull() + + private fun read(file: File) = runCatching { file.readText().trim() }.getOrNull()?.takeIf { it.isNotEmpty() } + + fun generation(mbps: Double) = when { + mbps <= 12 -> "USB 1.1" + mbps < FAST_MBPS -> "USB 2.0" + mbps < 10000 -> "USB 3.2 Gen 1" + mbps < 20000 -> "USB 3.2 Gen 2" + else -> "USB 3.2 Gen 2x2" + } + + fun megabytesPerSecond(mbps: Double) = (mbps / 8 * 0.7).toInt() +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt index 568188209..2f0d03c28 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt @@ -68,13 +68,13 @@ object VacDiagnostics { } } - fun collect(context: Context, dir: File) { + fun collect(context: Context, dir: File): Boolean { val root = LinuxRuntime.rootDir(context) val kept = File(dir, SNAPSHOT_MARKER).takeIf { it.isFile } val marker = kept ?: File(root, MARKER) val launchList = if (kept != null) File(dir, SNAPSHOT_LAUNCHES) else File(root, LAUNCHES) - if (!marker.isFile) return - try { + if (!marker.isFile) return true + return try { val armed = marker.readLines().map { it.trim() } val since = (armed.getOrNull(0)?.toLongOrNull() ?: 0L) * 1000 - 5_000 val arch = armed.getOrNull(1)?.takeIf { it.isNotEmpty() } ?: SessionPrefs.steamClientArch(context) @@ -125,8 +125,10 @@ object VacDiagnostics { File(dir, SNAPSHOT_MARKER).delete() File(dir, SNAPSHOT_LAUNCHES).delete() Log.i(TAG, "VAC diagnostics: ${consoles.size} console log(s), ${steamHits.size} client VAC line(s), into $dir") + true } catch (e: Exception) { Log.w(TAG, "collecting VAC diagnostics", e) + false } } diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt index 71eb79198..aa0a76cb2 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt @@ -19,7 +19,7 @@ enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM class GameUsage( val appId: Int, val name: String, val art: File?, val library: String, val internal: Boolean, val lastPlayed: Long, val game: Long, val prefix: Long, val shaders: Long, val workshop: Long, val shaderDir: File?, - val x64: Boolean = false, + val x64: Boolean = false, val prefixDir: File? = null, ) { val data: Long get() = prefix + shaders + workshop val total: Long get() = game + data @@ -175,14 +175,16 @@ object StorageScan { } if (!m.installed) continue val gameBytes = dir?.let { File(lib.root, "steamapps/common/$it") }?.takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0 - val prefix = File(lib.data, "steamapps/compatdata/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0 + val compatRoot = File(lib.data, "steamapps/compatdata").takeIf { it.isDirectory } ?: File(lib.root, "steamapps/compatdata") + val prefixDir = File(compatRoot, m.appId.toString()).takeIf { it.isDirectory } + val prefix = prefixDir?.let { walker.measure(it) } ?: 0 val shaderDir = File(lib.data, "steamapps/shadercache/${m.appId}").takeIf { it.isDirectory } val shaders = shaderDir?.let { walker.measure(it) } ?: 0 val workshop = File(lib.root, "steamapps/workshop/content/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0 val known = art[lib.x64]?.get(m.appId) games.add(GameUsage( m.appId, known?.name ?: m.name, known?.art, lib.label, lib.internal, m.lastPlayed, - gameBytes, prefix, shaders, workshop, shaderDir, lib.x64, + gameBytes, prefix, shaders, workshop, shaderDir, lib.x64, prefixDir, )) } } diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt index 0724dd8dd..f7086336e 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt @@ -140,6 +140,13 @@ object StorageState { FileOps.delete(dir) }) + fun resetPrefix(context: Context, game: GameUsage) = act(context, "prefix-${game.appId}", { _, fresh -> + val dir = fresh.games.firstOrNull { it.appId == game.appId && it.x64 == game.x64 && it.prefixDir?.path == game.prefixDir?.path } + ?.prefixDir ?: error("prefix not found") + check(FileOps.delete(dir)) { "cannot delete $dir" } + true + }) + fun removeLeftover(context: Context, l: Leftover) = act(context, "leftover-${l.appId}", { _, fresh -> val leftover = fresh.leftovers.firstOrNull { it.appId == l.appId && it.prefix?.path == l.prefix?.path && it.shaders?.path == l.shaders?.path } ?: return@act false diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt index aa42aa75e..e4d828372 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt @@ -63,7 +63,7 @@ class StoreGameSidecar( val file = File(folder, FILE_NAME) val tmp = File(folder, "$FILE_NAME.tmp") tmp.writeText(toJson().toString(2)) - if (!tmp.renameTo(file)) { file.delete(); tmp.renameTo(file) } + if (!tmp.renameTo(file)) java.nio.file.Files.move(tmp.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) } companion object { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt index c4faac54e..d1acc8937 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt @@ -137,10 +137,7 @@ object StoreInstallRoot { .filter { !isInternal(context, it) && (library == null || !it.absolutePath.startsWith("$library/")) } .map { root -> val base = guestFor(context, root) - val guest = if (used.add(base)) base else { - val suffix = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value) - "$base-$suffix".also { used.add(it) } - } + val guest = if (used.add(base)) base else "$base-${pathKey(root)}".also { used.add(it) } root to guest } } @@ -168,15 +165,20 @@ object StoreInstallRoot { /** Xodus has its own PRoot mount table and cannot see the selected Steam library's SD bind. */ fun externalRootsForXodus(context: Context): List> { - val roots = externalRoots(context).toMutableList() + val roots = externalRoots(context) val selectedLibrary = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) } - ?.takeUnless { isInternal(context, it) } - if (selectedLibrary != null && roots.none { it.first.absolutePath == selectedLibrary.absolutePath }) { - roots += selectedLibrary to guestFor(context, selectedLibrary) - } - return roots + ?.takeUnless { isInternal(context, it) } ?: return roots + return withSelectedLibrary(roots, selectedLibrary, guestFor(context, selectedLibrary)) } + internal fun withSelectedLibrary(roots: List>, library: File, base: String): List> { + if (roots.any { it.first.absolutePath == library.absolutePath }) return roots + val guest = if (roots.none { it.second == base }) base else "$base-${pathKey(library)}" + return roots + (library to guest) + } + + private fun pathKey(root: File): String = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value) + fun externalBindSpecs(context: Context): List = externalBindSpecs(externalRoots(context), LinuxRuntime.rootDir(context)) fun externalBindSpecsForXodus(context: Context): List = @@ -192,7 +194,7 @@ object StoreInstallRoot { private fun guestFor(context: Context, root: File): String { val sm = context.getSystemService(StorageManager::class.java) val uuid = runCatching { sm?.getStorageVolume(root)?.uuid }.getOrNull()?.takeIf { it.isNotBlank() }?.replace(Regex("[^A-Za-z0-9-]"), "") - val key = uuid ?: "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value) + val key = uuid ?: pathKey(root) return "$GUEST_EXTERNAL/$key" } @@ -254,6 +256,7 @@ object StoreInstallRoot { val sidecar = StoreGameSidecar.read(folder) when { sidecar == null -> Unfinished(store, null, folder) + store == Store.GOG && sidecar.state == StoreGameSidecar.STATE_UPDATING -> null !sidecar.isInstalled -> Unfinished(store, sidecar.id, folder) else -> null } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt index 9b73f8cc8..2f4df83f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -30,7 +30,12 @@ object StoreInstalls { it.write(before.copy(state = StoreGameSidecar.STATE_UPDATING, installVersion = "").toJson().toString(2).toByteArray(Charsets.UTF_8)) it.fd.sync() } - Files.move(stage.toPath(), StoreGameSidecar.file(folder).toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + val record = StoreGameSidecar.file(folder).toPath() + try { + Files.move(stage.toPath(), record, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (e: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(stage.toPath(), record, StandardCopyOption.REPLACE_EXISTING) + } } finally { stage.delete() } 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 3edac79a4..ff44ca763 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -81,7 +81,7 @@ object StoresState { runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetClient.syncAccount(app) } val found = Store.entries.mapNotNull { store -> StoreAccounts.displayName(app, store)?.let { store to it } } val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder -> - StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) } + StoreGameSidecar.read(folder)?.takeIf { it.isInstalled || it.store == Store.GOG && it.state == StoreGameSidecar.STATE_UPDATING }?.let { InstalledStoreGame(it, folder) } } + runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetBackend.installedGames(app) }.getOrDefault(emptyList()) val partial = StoreInstallRoot.unfinished(app) val version = if (StoresNative.available) StoresNative.version ?: "" else "" @@ -95,6 +95,7 @@ object StoresState { engine = version accountsLoaded = true } + com.droiddeck.launcher.stores.gog.GogMaintenance.checkInstalled(app, onDisk) } } } @@ -191,7 +192,10 @@ object StoresState { backend?.signOut(app) if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) } else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store) - if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchCollections.clear() + if (store == Store.ITCH) { + com.droiddeck.launcher.stores.itch.ItchCollections.clear() + com.droiddeck.launcher.stores.itch.ItchUpdates.clear(app) + } 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); libraryAsked.remove(store) @@ -282,6 +286,7 @@ object StoresState { main.post { installed = installed.filterNot { it.folder.absolutePath == game.folder.absolutePath } uninstalling.remove(key) + if (game.sidecar.store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchUpdates.applied(app, key) } }) main.post(onDone) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt index 8683efa06..57f07742c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt @@ -31,6 +31,9 @@ data class DownloadEntry( val stageItemsTotal: Int = 0, val diskBytes: Long = 0L, val stagesPassed: Int = 0, + val interruptible: Boolean = true, + val reusedBytes: Long = 0L, + val appliedBytes: Long = 0L, ) { fun passed(s: DownloadStage): Boolean = state == DownloadState.INSTALLED || (stagesPassed and (1 shl s.ordinal)) != 0 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 cfb1324f0..baae64b3c 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 @@ -21,6 +21,11 @@ object DownloadQueue { fun diskSize(bytes: Long) = update(key) { if (bytes > 0) it.copy(diskBytes = bytes) else it } fun progress(bytesDone: Long, bytesTotal: Long, detail: String? = null, @Suppress("UNUSED_PARAMETER") speedBps: Long = -1L) = measure(key, bytesDone, bytesTotal, detail?.let(::stripSpeed)) fun log(line: String) = StoresState.logLine(line) + fun interruptible(value: Boolean) = update(key) { + check(value || !cancelled.get()) { "The operation was paused before installer application" } + it.copy(interruptible = value) + } + fun maintenance(reusedBytes: Long, appliedBytes: Long) = update(key) { it.copy(reusedBytes = reusedBytes, appliedBytes = appliedBytes) } } private fun stripSpeed(s: String): String = s.replace(Regex("\\s+\\S+ [KMG]B/s\\s*$"), "").trimEnd() @@ -166,12 +171,13 @@ object DownloadQueue { fun pause(key: String) { val item = synchronized(lock) { items[key] } ?: return synchronized(lock) { + if (!item.entry.interruptible) return if (item.entry.state == DownloadState.QUEUED) { item.entry = item.entry.copy(state = DownloadState.PAUSED, queuePosition = 0); renumber(); publishLocked(); return } if (item.entry.state != DownloadState.RUNNING) return item.pauseRequested = true + item.cancelled.set(true) + item.job?.cancel(deleteFiles = false) } - item.cancelled.set(true) - item.job?.cancel(deleteFiles = false) StoresState.logLine("paused \"${item.entry.name}\"") } @@ -204,17 +210,19 @@ object DownloadQueue { val wasRunning: Boolean synchronized(lock) { item = items[key] ?: return + if (!item.entry.interruptible) return wasRunning = item.entry.state == DownloadState.RUNNING + if (wasRunning) { + item.cancelled.set(true) + item.job?.cancel(deleteFiles = true) + } if (!wasRunning) { item.entry = item.entry.copy(state = DownloadState.CANCELLED, queuePosition = 0, finishedAt = System.currentTimeMillis()) renumber() publishLocked() } } - if (wasRunning) { - item.cancelled.set(true) - item.job?.cancel(deleteFiles = true) - } else { + if (!wasRunning) { Thread({ runCatching { item.factory?.invoke()?.cancel(deleteFiles = true) } }, "store-dl-cancel").start() } StoresState.logLine("cancelled \"${item.entry.name}\"") 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 e9a0acbcf..99d08d6b8 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 @@ -45,7 +45,7 @@ object SavedQueue { val f = File(context.filesDir, FILE) val tmp = File(context.filesDir, "$FILE.tmp") tmp.writeText(out.toString()) - tmp.renameTo(f) + java.nio.file.Files.move(tmp.toPath(), f.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) }.onFailure { Log.w(TAG, "could not save the queue", it) } } } @@ -68,7 +68,12 @@ object SavedQueue { for ((item, root, paused) in back) { if (DownloadQueue.entry(key(item)) != null) continue if (paused) DownloadQueue.holdPaused(key(item)) - StoresState.install(app, item, root) + if (item.store == Store.GOG && item.extra.containsKey(com.droiddeck.launcher.stores.gog.GogMaintenance.OPERATION)) { + Thread({ + runCatching { com.droiddeck.launcher.stores.gog.GogMaintenance.restore(app, item) } + .onFailure { DownloadQueue.recordFailed(app, DownloadEntry(item.store, item.id, item.title), it.message ?: "GOG recovery failed") } + }, "gog-restore").start() + } else StoresState.install(app, item, root) } } }, "store-queue-restore").start() 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 8e58fab03..9332bb986 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 @@ -33,6 +33,7 @@ object GogAuth { @Synchronized private fun saveAccount(context: Context, accessToken: String, refreshToken: String?, userId: String?) { + GogMaintenance.reset() var username = "" runCatching { val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = accessToken, userAgent = GALAXY_UA) 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 bf62da5b5..59a370775 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 @@ -28,6 +28,7 @@ object GogBackend : StoreBackend { } override fun signOut(context: Context) { + GogMaintenance.reset() GogPrefs.get(context).edit().remove("library_cache").remove("library_cache_v2").remove("library_synced_at").apply() } @@ -53,7 +54,11 @@ object GogBackend : StoreBackend { StoresState.post { StoresState.status.remove(store) when (result) { - is GogLibrary.SyncResult.Ok -> { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { GogLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store) } + is GogLibrary.SyncResult.Ok -> { + StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { GogLibrary.toCatalogItem(app, it) }) + StoresState.problems.remove(store) + GogMaintenance.checkInstalled(app, StoresState.installed) + } is GogLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message GogLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store) GogLibrary.SyncResult.Busy -> {} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogContent.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogContent.kt new file mode 100644 index 000000000..3d86fd585 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogContent.kt @@ -0,0 +1,187 @@ +package com.droiddeck.launcher.stores.gog + +import android.content.Context +import com.droiddeck.launcher.stores.StoreLanguage +import com.droiddeck.launcher.stores.StoreNet +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.zip.GZIPInputStream +import java.util.zip.InflaterInputStream + +internal object GogContent { + const val SYSTEM = "https://content-system.gog.com" + const val CDN = "https://gog-cdn-fastly.gog.com/content-system" + + fun token(context: Context) = GogAuth.validToken(context) ?: error("Sign in to GOG and retry") + + fun builds(id: String, token: String): JSONObject { + val result = json("$SYSTEM/products/$id/os/windows/builds?generation=2", token) + if (result.optJSONArray("items")?.length() != 0) return result + return json("$SYSTEM/products/$id/os/windows/builds?generation=1", token) + } + + fun product(id: String, token: String): JSONObject = json("https://api.gog.com/products/$id?expand=downloads", token) + + fun downloadFilename(url: String): String = java.net.URLDecoder.decode(URL(url).path.substringAfterLast('/').replace("+", "%2B"), "UTF-8") + + fun resolveDownload(download: GogOfflineDownload, token: String): GogOfflineDownload = download.copy(parts = download.parts.map { part -> + val resolved = GogDownloadManager.resolveRedirect(part.url, token) ?: error("The GOG download link is unavailable") + val connection = URL(resolved).openConnection() as HttpURLConnection + try { + connection.requestMethod = "HEAD" + connection.connectTimeout = 20_000; connection.readTimeout = 30_000 + check(connection.responseCode in 200..299) { "GOG download metadata failed (HTTP ${connection.responseCode})" } + val disposition = connection.getHeaderField("Content-Disposition").orEmpty() + val supplied = Regex("filename=\"?([^\";]+)", RegexOption.IGNORE_CASE).find(disposition)?.groupValues?.get(1).orEmpty() + val remote = downloadFilename(resolved).takeIf { it.endsWith(".exe", true) || it.endsWith(".bin", true) }.orEmpty() + val name = part.name.ifBlank { supplied.ifBlank { remote } } + check(name.isNotBlank()) { "GOG did not provide the installer part filename" } + part.copy(name = name, size = connection.contentLengthLong.coerceAtLeast(0)) + } finally { connection.disconnect() } + }) + + fun json(url: String, token: String? = null): JSONObject { + require(URL(url).protocol == "https") { "GOG metadata requires HTTPS" } + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 20_000; connection.readTimeout = 30_000 + connection.setRequestProperty("User-Agent", GogAuth.GALAXY_UA) + if (token != null && URL(url).host.let { it == "gog.com" || it.endsWith(".gog.com") }) connection.setRequestProperty("Authorization", "Bearer $token") + check(connection.responseCode in 200..299) { "GOG metadata request failed (HTTP ${connection.responseCode})" } + val raw = connection.inputStream.readBytes() + val text = when { + raw.size > 1 && raw[0] == 0x78.toByte() -> InflaterInputStream(raw.inputStream()).bufferedReader().use { it.readText() } + raw.size > 1 && raw[0] == 0x1f.toByte() && raw[1] == 0x8b.toByte() -> GZIPInputStream(raw.inputStream()).bufferedReader().use { it.readText() } + else -> raw.toString(Charsets.UTF_8) + } + return JSONObject(text) + } finally { connection.disconnect() } + } + + fun snapshot(old: GogInstallRecord, build: GogBuild, token: String, + metadata: (String, String?) -> JSONObject = ::json): GogInstallRecord { + check(build.link.isNotBlank()) { "GOG build manifest is unavailable" } + val manifest = metadata(build.link, token) + val files = JSONArray() + val depots = if (build.generation == 1) manifest.optJSONObject("product")?.optJSONArray("depots") ?: manifest.optJSONArray("depot") else manifest.optJSONArray("depots") + check(depots != null) { "GOG build has no depot list" } + val resolvedLanguage = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), old.language) + val selectedBitness = if (depots.objects().any { it.optJSONArray("osBitness")?.strings()?.contains("64") == true }) "64" else "32" + val owned = if (old.products.any { it != old.id }) StoreNet.get("https://embed.gog.com/user/data/games", bearer = token)?.let { JSONObject(it).optJSONArray("owned")?.strings()?.toSet() } else emptySet() + val products = old.products.filter { it == old.id || owned?.contains(it) == true } + check(old.products.all { it == old.id || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" } + for (depot in depots.objects()) { + if (depot.has("redist")) continue + val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id)) + val product = ids.firstOrNull { it in products } ?: continue + val languages = depot.optJSONArray("languages")?.strings().orEmpty() + if (!languageSelected(languages, resolvedLanguage)) continue + val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty() + if (bitness.isNotEmpty() && selectedBitness !in bitness) continue + val hash = depot.optString("manifest") + val content = when { + hash.isNotEmpty() && build.generation == 2 -> metadata("$CDN/v2/meta/${cdnPath(hash)}", null) + hash.isNotEmpty() -> { + val timestamp = manifest.getJSONObject("product").getString("timestamp") + metadata("$CDN/v1/manifests/$product/windows/$timestamp/$hash", token) + } + depot.has("files") -> JSONObject().put("depot", depot) + else -> error("GOG depot manifest is missing") + } + val entries = content.getJSONObject("depot").optJSONArray(if (build.generation == 2) "items" else "files") ?: error("GOG depot files are missing") + for (entry in entries.objects()) { + val type = entry.optString("type") + check(type != "DepotLink") { "This GOG build contains unsupported file links" } + val support = depot.optBoolean("support") || entry.optBoolean("support") || entry.optJSONArray("flags")?.strings()?.contains("support") == true + val relative = entry.getString("path").replace('\\', '/').trimEnd('/').let { if (build.generation == 1) it.trimStart('/') else it } + val path = if (support) (if (build.generation == 1) "support/$relative" else "support/$product/$relative") else relative + val directory = type == "DepotDirectory" || entry.has("directory") + val chunks = entry.optJSONArray("chunks") ?: JSONArray() + val size = if (build.generation == 2) chunks.objects().sumOf { it.optLong("size") } else entry.optLong("size") + files.put(JSONObject().put("path", path).put("size", size).put("directory", directory) + .put("md5", entry.optString("md5").ifBlank { entry.optString("hash") }.takeIf { it.matches(Regex("[a-fA-F0-9]{32}")) }.orEmpty()) + .put("sha256", entry.optString("sha256")).put("chunks", chunks).put("product", product) + .put("offset", entry.optLong("offset")).put("remotePath", entry.optString("url").substringBefore('?'))) + } + } + check(files.length() > 0) { "GOG build contains no matching files" } + return old.copy(source = "depot", generation = build.generation, build = build.id, version = build.version, + branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true) + } + + fun languageSelected(list: List, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected) + + fun cdnPath(hash: String): String { + require(hash.matches(Regex("[a-fA-F0-9]{32,64}"))) { "Invalid GOG content hash" } + return "${hash.take(2)}/${hash.substring(2, 4)}/$hash" + } + + fun links(record: GogInstallRecord, token: String, patches: Boolean = false): JSONObject { + val out = JSONObject() + for (id in record.products) { + val path = if (record.generation == 1) "/windows/${record.manifest.optJSONObject("product")?.optString("timestamp")}/" else "/" + val query = "_version=2&" + (if (record.generation == 1) "type=depot" else "generation=2") + "&path=${java.net.URLEncoder.encode(path, "UTF-8")}" + if (patches) "&root=%2Fpatches%2Fstore" else "" + val response = json("$SYSTEM/products/$id/secure_link?$query", token) + val bases = response.optJSONArray("urls")?.objects().orEmpty().mapNotNull { item -> + val params = item.optJSONObject("parameters") + var url = item.optString("url_format") + params?.keys()?.forEach { key -> url = url.replace("{$key}", params.getString(key)) } + url.takeIf { it.startsWith("https://") } + } + check(bases.isNotEmpty()) { "GOG refused a download link" } + out.put(id, JSONArray(bases)) + } + return out + } + + fun patches(old: GogInstallRecord, target: GogInstallRecord, token: String): JSONArray { + if (old.generation != 2 || target.generation != 2 || old.build.isBlank() || old.build == target.build) return JSONArray() + return runCatching { + val index = json("$SYSTEM/products/${old.id}/patches?_version=4&from_build_id=${old.build}&to_build_id=${target.build}", token) + val meta = json(index.getString("link"), token) + if (meta.optString("algorithm") != "xdelta3") return@runCatching JSONArray() + val result = JSONArray() + for (depot in meta.getJSONArray("depots").objects()) { + if (depot.optString("productId") !in target.products || !languageSelected(depot.optJSONArray("languages")?.strings().orEmpty(), target.language)) continue + val data = json("$CDN/v2/patches/meta/${cdnPath(depot.getString("manifest"))}") + for (item in data.getJSONObject("depot").getJSONArray("items").objects()) if (item.optString("type") == "DepotDiff") + result.put(JSONObject(item.toString()).put("product", depot.getString("productId"))) + } + result + }.getOrElse { JSONArray() } + } + + fun setupFiles(record: GogInstallRecord, token: String): Pair? { + if (!record.manifest.optBoolean("scriptInterpreter")) return null + if (record.files.objects().none { file -> record.products.any { file.optString("path").substringAfterLast('/') == "goggame-$it.script" } }) return null + val index = json("$SYSTEM/dependencies/repository?generation=2") + val repository = json(index.getString("repository_manifest")) + val depot = repository.getJSONArray("depots").objects().firstOrNull { it.optString("dependencyId") == "ISI" } + ?: error("GOG setup interpreter is unavailable") + val content = json("$CDN/v2/dependencies/meta/${cdnPath(depot.getString("manifest"))}") + val files = JSONArray() + for (entry in content.getJSONObject("depot").getJSONArray("items").objects()) { + if (entry.optString("type") != "DepotFile") continue + val chunks = entry.getJSONArray("chunks") + files.put(JSONObject().put("path", "support/" + entry.getString("path").replace('\\', '/')) + .put("size", chunks.objects().sumOf { it.getLong("size") }).put("md5", entry.optString("md5")) + .put("chunks", chunks).put("product", "redist")) + } + val response = json("$SYSTEM/open_link?generation=2&_version=2&path=%2Fdependencies%2Fstore%2F", token) + val bases = GogDownloadManager.parseCdnUrls(response.toString()) + check(bases.isNotEmpty()) { "GOG setup download links are unavailable" } + return files to JSONObject().put("redist", JSONArray(bases)) + } + + fun sanitize(value: JSONObject): JSONObject { + val result = JSONObject() + for (key in value.keys()) { + if (key.lowercase() in setOf("clientsecret", "access_token", "refresh_token", "link", "urls", "url")) continue + val v = value.get(key) + result.put(key, when (v) { is JSONObject -> sanitize(v); is JSONArray -> JSONArray((0 until v.length()).map { i -> v.get(i).let { if (it is JSONObject) sanitize(it) else it } }); else -> v }) + } + return result + } +} 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 dae63e5a2..9b66b95de 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 @@ -106,7 +106,7 @@ public final class GogDownloadManager { String builds1Json = httpGet(builds1Url, null); if (builds1Json == null) builds1Json = httpGet(builds1Url, token); if (builds1Json == null) throw new InstallException("No builds available for this game"); - Gen1Outcome one = runGen1(ctx, game, token, builds1Json, installPath, cb, cancelled); + Gen1Outcome one = runGen1(ctx, game, token, builds1Json, installPath, cb, cancelled, lang); if (one.result != null || cancelled.get()) return one.result; if (!"NO_CS_BUILDS".equals(one.error)) throw new InstallException("Download failed: " + one.error); cb.onProgress("No Galaxy builds; fetching the installer…", 12); @@ -250,6 +250,11 @@ public final class GogDownloadManager { if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; } cb.onProgress("Finishing…", 96); + try { + GogMaintenance.captureDepot(game.gameId, installPath, gm.buildId, buildsJson, manifest, new JSONArray(depotJsons), resolved); + } catch (Exception e) { + cb.onLog("gog: maintenance inventory not recorded: " + e.getMessage()); + } writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8")); deleteCounted(new File(installPath, ".gog_chunks"), cb); String clientId = manifest.optString("clientId", null); @@ -279,7 +284,7 @@ public final class GogDownloadManager { String buildId = null, manifestUrl = null; for (int i = 0; i < items.length(); i++) { JSONObject item = items.getJSONObject(i); - if ("windows".equals(item.optString("os"))) { + if ("windows".equals(item.optString("os")) && (item.isNull("branch") || item.optString("branch").isEmpty())) { buildId = item.optString("build_id"); manifestUrl = item.optString("link"); if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url"); @@ -328,7 +333,7 @@ public final class GogDownloadManager { private static final class Gen1Outcome { Result result; String error; } - private static Gen1Outcome runGen1(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled) { + private static Gen1Outcome runGen1(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) { Gen1Outcome out = new Gen1Outcome(); try { JSONObject builds = new JSONObject(buildsJson); @@ -346,6 +351,15 @@ public final class GogDownloadManager { String manifestStr = decompressBytes(raw); if (manifestStr == null) { out.error = "manifest decompress failed"; return out; } JSONObject manifest = new JSONObject(manifestStr); + if (manifest.has("product")) { + try { + out.result = GogMaintenance.installContent(ctx, game, installPath, builds, language, cb, cancelled); + if (out.result == null && !cancelled.get()) out.error = "no usable public Windows build"; + } catch (Exception e) { + out.error = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + } + return out; + } JSONArray depots = manifest.optJSONArray("depot"); if (depots == null) { out.error = "no depot array"; return out; } List files = new ArrayList<>(); @@ -472,6 +486,7 @@ public final class GogDownloadManager { String exe = pickExe(installPath, null, game.title); if (exe.isEmpty()) throw new InstallException("The installer was extracted but no game executable was found"); cb.onLog("gog: installer extracted natively; game exe " + exe); + GogMaintenance.captureOffline(game.gameId, installPath, downloads); return new Result(exe, "", total); } catch (InstallerException e) { throw e; @@ -576,13 +591,13 @@ public final class GogDownloadManager { return cleaned.isEmpty() ? "game" : cleaned; } - private static String resolveRedirect(String url, String token) { + static String resolveRedirect(String url, String token) { try { if (url.startsWith("/")) url = "https://www.gog.com" + url; for (int hop = 0; hop < 5; hop++) { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(TIMEOUT); conn.setInstanceFollowRedirects(false); - if (token != null) conn.setRequestProperty("Authorization", "Bearer " + token); + if (token != null && (new URL(url).getHost().equals("gog.com") || new URL(url).getHost().endsWith(".gog.com"))) conn.setRequestProperty("Authorization", "Bearer " + token); int code = conn.getResponseCode(); String location = conn.getHeaderField("Location"); if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { @@ -686,6 +701,7 @@ public final class GogDownloadManager { cb.onBytes(shown, planned, speedBps.get()); } @Override public void onLog(String line) { cb.onLog(line); } + @Override public void onMaintenance(long networkBytes, long reusedBytes, long appliedBytes) {} @Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) { okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown(); } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogEstimate.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogEstimate.kt new file mode 100644 index 000000000..2374ff8c5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogEstimate.kt @@ -0,0 +1,58 @@ +package com.droiddeck.launcher.stores.gog + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.security.MessageDigest + +internal object GogEstimate { + data class Size(val download: Long, val required: Long) + private data class Chunk(val file: File, val offset: Long) + + fun range(file: File, offset: Long, size: Long, md5: String): Boolean { + if (!file.isFile || offset < 0 || size < 0 || file.length() < offset + size || !md5.matches(Regex("[a-fA-F0-9]{32}"))) return false + return runCatching { + RandomAccessFile(file, "r").use { input -> + input.seek(offset) + val digest = MessageDigest.getInstance("MD5"); val buffer = ByteArray(64 * 1024); var left = size + while (left > 0) { val n = input.read(buffer, 0, minOf(left, buffer.size.toLong()).toInt()); check(n > 0); digest.update(buffer, 0, n); left -= n } + digest.digest().joinToString("") { "%02x".format(it) }.equals(md5, true) + } + }.getOrDefault(false) + } + + fun calculate(folder: File, old: GogInstallRecord, target: GogInstallRecord, patches: JSONArray, reuseChunks: Boolean = true): Size { + val reusable = HashMap, MutableList>() + for (file in old.files.objects()) { + val source = GogFiles.inside(folder, file.getString("path")); var offset = 0L + for (chunk in file.optJSONArray("chunks")?.objects().orEmpty()) { + reusable.getOrPut(chunk.optString("md5").lowercase() to chunk.optLong("size")) { ArrayList() }.add(Chunk(source, offset)) + offset += chunk.optLong("size") + } + } + var download = 0L; var required = 0L + for (file in target.files.objects()) { + if (file.optBoolean("directory") || GogFiles.matches(folder, file)) continue + val chunks = file.optJSONArray("chunks")?.objects().orEmpty() + val stamp = MessageDigest.getInstance("MD5").digest(file.getString("path").toByteArray()).joinToString("") { "%02x".format(it) } + val stage = File(GogFiles.dir(folder), "stage/$stamp") + var offset = 0L + var cost = if (chunks.isEmpty()) file.getLong("size") else chunks.sumOf { chunk -> + val size = chunk.getLong("size"); val hash = chunk.getString("md5") + val exists = reuseChunks && (range(stage, offset, size, hash) || reusable[hash.lowercase() to size].orEmpty().any { range(it.file, it.offset, size, hash) }) + offset += size + if (exists) 0L else chunk.optLong("compressedSize") + } + val patch = patches.objects().firstOrNull { it.optString("path_target").replace('\\', '/') == file.getString("path") } + if (reuseChunks && patch != null) { + val source = GogFiles.inside(folder, patch.getString("path_source").replace('\\', '/')) + val delta = patch.getJSONArray("chunks").objects().sumOf { it.optLong("compressedSize") } + if (delta < cost && source.isFile && GogFiles.hash(source).equals(patch.getString("md5_source"), true)) cost = delta + } + download = Math.addExact(download, cost) + required = Math.addExact(required, maxOf(0, file.getLong("size") - stage.length()) + cost) + } + return Size(download, required) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogFallback.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogFallback.kt new file mode 100644 index 000000000..0f2d5b9d6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogFallback.kt @@ -0,0 +1,115 @@ +package com.droiddeck.launcher.stores.gog + +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean +import java.util.zip.InflaterInputStream + +internal object GogFallback { + fun transfer(folder: File, plan: JSONObject, handle: DownloadQueue.JobHandle?, stop: AtomicBoolean, + callback: GogDownloadManager.Callback? = null) { + val stageRoot = File(GogFiles.dir(folder), "stage").apply { mkdirs() } + val files = plan.getJSONArray("files") + GogFiles.validate(files, folder) + var wire = 0L + fun progress(n: Int) { wire += n; handle?.progress(wire, plan.optLong("downloadBytes").coerceAtLeast(wire)); callback?.onBytes(wire, plan.optLong("downloadBytes").coerceAtLeast(wire), 0) } + for ((index, item) in files.objects().withIndex()) { + if (stop.get()) return + val destination = GogFiles.inside(folder, item.getString("path")) + if (item.optBoolean("directory")) { java.nio.file.Files.createDirectories(destination.toPath()); continue } + if (GogFiles.matches(folder, item)) continue + val stage = File(stageRoot, java.security.MessageDigest.getInstance("MD5").digest(item.getString("path").toByteArray()).joinToString("") { "%02x".format(it) }) + val chunks = item.optJSONArray("chunks")?.objects().orEmpty() + FileOutputStream(stage).use { output -> + if (chunks.isNotEmpty()) { + for (chunk in chunks) { + if (stop.get()) return + val packed = File(stageRoot, "${stage.name}.packed") + val urls = endpoints(plan.getJSONObject("links"), item.getString("product"), GogContent.cdnPath(chunk.getString("compressedMd5"))) + download(urls, packed, chunk.getLong("compressedSize"), stop, ::progress) + check(GogFiles.hash(packed).equals(chunk.getString("compressedMd5"), true)) { "GOG compressed chunk checksum mismatch" } + val digest = java.security.MessageDigest.getInstance("MD5") + var count = 0L + packed.inputStream().buffered().use { raw -> + raw.mark(2); val first = raw.read(); raw.reset() + val stored = chunk.getLong("size") == chunk.getLong("compressedSize") && chunk.getString("md5").equals(chunk.getString("compressedMd5"), true) + val input = if (!stored && first == 0x78) InflaterInputStream(raw) else raw + val buffer = ByteArray(64 * 1024) + while (true) { + if (stop.get()) return + val n = input.read(buffer); if (n < 0) break + count += n; check(count <= chunk.getLong("size")) { "GOG chunk exceeds expected size" } + digest.update(buffer, 0, n); output.write(buffer, 0, n) + } + } + check(count == chunk.getLong("size") && digest.digest().joinToString("") { "%02x".format(it) }.equals(chunk.getString("md5"), true)) { "GOG chunk checksum mismatch" } + packed.delete() + } + } else if (item.getLong("size") > 0) { + val remote = item.getString("remotePath") + val urls = if (remote.startsWith("https://")) listOf(remote) else endpoints(plan.getJSONObject("links"), item.getString("product"), remote.trimStart('/')) + val range = item.getLong("offset") + val raw = File(stageRoot, "${stage.name}.range") + download(urls, raw, item.getLong("size"), stop, ::progress, range) + raw.inputStream().use { it.copyTo(output) }; raw.delete() + } + output.fd.sync() + } + if (stop.get()) return + check(stage.length() == item.getLong("size")) { "GOG file size mismatch" } + if (item.optString("md5").isNotBlank()) check(GogFiles.hash(stage).equals(item.getString("md5"), true)) { "GOG file checksum mismatch" } + if (item.optString("sha256").isNotBlank()) check(GogFiles.hash(stage, "SHA-256").equals(item.getString("sha256"), true)) { "GOG file checksum mismatch" } + destination.parentFile!!.mkdirs() + GogFiles.inside(folder, item.getString("path")) + GogFiles.replace(stage, destination) + handle?.stage(DownloadStage.INSTALL); handle?.stageProgress((index + 1).toLong(), files.length().toLong()) + callback?.onProgress("Downloading: ${destination.name}", ((index + 1) * 100 / files.length())) + } + val targetPaths = files.objects().mapTo(HashSet()) { it.getString("path").lowercase(java.util.Locale.ROOT) } + for (old in plan.optJSONArray("oldFiles")?.objects().orEmpty()) { + if (stop.get()) return + if (old.getString("path").lowercase(java.util.Locale.ROOT) !in targetPaths && GogFiles.matches(folder, old)) + check(GogFiles.inside(folder, old.getString("path")).delete()) { "Could not remove obsolete GOG file" } + } + } + + fun endpoints(links: JSONObject, product: String, path: String): List = links.getJSONArray(product).strings().map { base -> + if (base.contains("{GALAXY_PATH}")) base.replace("{GALAXY_PATH}", path) + else base.substringBefore('?').trimEnd('/') + "/" + path + if ('?' in base) "?" + base.substringAfter('?') else "" + } + + fun download(urls: List, destination: File, size: Long, stop: AtomicBoolean, progress: (Int) -> Unit, range: Long? = null) { + var failure: Throwable? = null + for (attempt in 0..2) for (url in urls) { + if (stop.get()) return + val connection = URL(url).openConnection() as HttpURLConnection + try { + check(URL(url).protocol == "https" || URL(url).host in setOf("127.0.0.1", "localhost")) + connection.connectTimeout = 20_000; connection.readTimeout = 30_000 + if (range != null) connection.setRequestProperty("Range", "bytes=$range-${range + size - 1}") + check(connection.responseCode == if (range == null) 200 else 206) { "GOG download failed (HTTP ${connection.responseCode})" } + FileOutputStream(destination).use { output -> + connection.inputStream.use { input -> + val buffer = ByteArray(64 * 1024); var count = 0L + while (true) { + if (stop.get()) return + val n = input.read(buffer); if (n < 0) break + count += n; check(size <= 0 || count <= size) { "GOG response exceeds expected size" } + output.write(buffer, 0, n); progress(n) + } + check(size <= 0 || count == size) { "Incomplete GOG download" } + } + output.fd.sync() + } + return + } catch (e: Exception) { failure = e } + finally { connection.disconnect() } + } + throw failure ?: IllegalStateException("No GOG download URL") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogInstallRecord.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogInstallRecord.kt new file mode 100644 index 000000000..df0799102 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogInstallRecord.kt @@ -0,0 +1,176 @@ +package com.droiddeck.launcher.stores.gog + +import com.droiddeck.launcher.stores.StoreGameSidecar +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.Locale + +internal enum class GogOperation { UPDATE, REPAIR, PATCH, REFRESH } + +internal data class GogInstallRecord( + val id: String, + val source: String, + val generation: Int = 0, + val build: String = "", + val version: String = "", + val branch: String = "", + val language: String = "en-US", + val products: List = listOf(id), + val manifest: JSONObject = JSONObject(), + val files: JSONArray = JSONArray(), + val verified: Boolean = true, +) { + fun json() = JSONObject().put("schema", 1).put("id", id).put("source", source) + .put("generation", generation).put("build", build).put("version", version).put("branch", branch) + .put("language", language).put("products", JSONArray(products)).put("manifest", manifest) + .put("files", files).put("verified", verified) + + fun write(folder: File) = GogFiles.write(File(GogFiles.dir(folder), "installed.json"), json()) + + companion object { + fun parse(o: JSONObject): GogInstallRecord { + require(o.optInt("schema") == 1) { "Unsupported GOG installation record" } + return GogInstallRecord(o.getString("id"), o.getString("source"), o.optInt("generation"), + o.optString("build"), o.optString("version"), o.optString("branch"), o.optString("language", "en-US"), + o.optJSONArray("products")?.strings().orEmpty().ifEmpty { listOf(o.getString("id")) }, + o.optJSONObject("manifest") ?: JSONObject(), o.optJSONArray("files") ?: JSONArray(), o.optBoolean("verified", true)) + } + + fun read(folder: File): GogInstallRecord? = GogFiles.read(File(GogFiles.dir(folder), "installed.json"))?.let(::parse) + + fun authenticBuild(folder: File, id: String): String = GogFiles.read(File(folder, "goggame-$id.id"))?.optString("buildId").orEmpty() + .ifBlank { GogFiles.read(File(folder, "goggame-$id.info"))?.optString("buildId").orEmpty() } + + fun migrate(folder: File, sidecar: StoreGameSidecar): GogInstallRecord { + read(folder)?.let { require(it.id == sidecar.id); return it } + val legacy = GogFiles.read(File(folder, "_gog_manifest.json")) + val info = GogFiles.read(File(folder, "goggame-${sidecar.id}.info")) + val build = authenticBuild(folder, sidecar.id).ifBlank { legacy?.optString("buildId").orEmpty().ifBlank { sidecar.installVersion } } + val ids = folder.listFiles().orEmpty().mapNotNull { Regex("goggame-([0-9]+)\\.info").matchEntire(it.name)?.groupValues?.get(1) } + return GogInstallRecord(sidecar.id, if (legacy != null || sidecar.installVersion.isNotBlank()) "depot" else "offline", + legacy?.optInt("generation", if (build.isNotBlank()) 2 else 0) ?: if (build.isNotBlank()) 2 else 0, + build, info?.optString("versionName").orEmpty(), language = sidecar.extra[com.droiddeck.launcher.stores.StoreLanguage.EXTRA] ?: "en-US", + products = (listOf(sidecar.id) + ids).distinct()).also { it.write(folder) } + } + } +} + +internal object GogFiles { + fun dir(folder: File): File = inside(folder, ".droiddeck-gog").apply { mkdirs() } + fun journal(folder: File): File = File(dir(folder), "operation.json") + + fun inside(root: File, raw: String): File { + val path = raw.replace('\\', '/') + require(path.isNotBlank() && !path.startsWith('/') && ':' !in path && '\u0000' !in path && + path.split('/').none { it.isEmpty() || it == "." || it == ".." }) { "Invalid GOG path: $raw" } + val base = root.canonicalFile + var out = base + for (part in path.split('/')) { + out = File(out, part) + require(!Files.isSymbolicLink(out.toPath())) { "GOG path is a symbolic link: $raw" } + } + require(out.canonicalFile.toPath().startsWith(base.toPath())) { "GOG path leaves installation" } + return out + } + + fun read(file: File): JSONObject? { + require(!Files.isSymbolicLink(file.toPath())) { "GOG metadata is a symbolic link" } + return if (!file.exists()) null else JSONObject(file.readText()) + } + + fun write(file: File, value: JSONObject) { + require(!Files.isSymbolicLink(file.toPath())) + file.parentFile!!.mkdirs() + val tmp = File.createTempFile(".gog-", ".stage", file.parentFile) + try { + FileOutputStream(tmp).use { it.write(value.toString().toByteArray(Charsets.UTF_8)); it.fd.sync() } + replace(tmp, file) + } finally { tmp.delete() } + } + + fun replace(from: File, to: File) { + require(!Files.isSymbolicLink(to.toPath())) + try { Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } + catch (_: java.nio.file.AtomicMoveNotSupportedException) { Files.move(from.toPath(), to.toPath(), StandardCopyOption.REPLACE_EXISTING) } + } + + fun hash(file: File, algorithm: String = "MD5"): String { + val digest = MessageDigest.getInstance(algorithm) + file.inputStream().use { input -> val buffer = ByteArray(128 * 1024); while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) } } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + fun matches(folder: File, file: JSONObject): Boolean { + val f = inside(folder, file.getString("path")) + if (!Files.isRegularFile(f.toPath(), LinkOption.NOFOLLOW_LINKS) || f.length() != file.getLong("size")) return false + val md5 = file.optString("md5") + if (md5.matches(Regex("[a-fA-F0-9]{32}"))) return hash(f).equals(md5, true) + val sha = file.optString("sha256") + if (sha.isNotBlank()) return hash(f, "SHA-256").equals(sha, true) + val chunks = file.optJSONArray("chunks")?.objects().orEmpty() + var offset = 0L + return chunks.isNotEmpty() && chunks.all { chunk -> + val result = GogEstimate.range(f, offset, chunk.getLong("size"), chunk.getString("md5")) + offset += chunk.getLong("size"); result + } || f.length() == 0L + } + + fun validate(files: JSONArray, folder: File) { + val paths = HashSet() + val components = HashMap() + for (file in files.objects()) { + val path = file.getString("path").replace('\\', '/') + inside(folder, path) + require(!path.startsWith(".droiddeck-", true) && path != StoreGameSidecar.FILE_NAME) { "GOG content overlaps launcher metadata" } + require(paths.add(path.lowercase(Locale.ROOT))) { "GOG paths differ only by case: $path" } + var parent = folder + var relative = "" + for (part in path.split('/')) { + relative = if (relative.isEmpty()) part else "$relative/$part" + val previous = components.putIfAbsent(relative.lowercase(Locale.ROOT), relative) + require(previous == null || previous == relative) { "GOG directory paths differ only by case: $path" } + require(parent.list()?.none { it != part && it.equals(part, true) } != false) { "An existing path differs only by case: $path" } + parent = File(parent, part) + } + } + } +} + +internal fun JSONArray.objects(): List = (0 until length()).map { getJSONObject(it) } +internal fun JSONArray.strings(): List = (0 until length()).map { optString(it) } + +internal data class GogBuild(val id: String, val version: String, val generation: Int, val branch: String, val link: String) { + companion object { + fun select(builds: JSONObject, pinned: String = "", branch: String = ""): GogBuild? { + val items = builds.optJSONArray("items")?.objects().orEmpty().filter { it.optString("os", "windows") == "windows" } + val item = if (pinned.isNotBlank()) items.firstOrNull { it.optString("build_id") == pinned || it.optString("legacy_build_id") == pinned } + else items.firstOrNull { it.optString("branch").let { b -> (if (b == "null") "" else b) == branch } } + return item?.let { GogBuild(it.optString("build_id"), it.optString("version_name"), it.optInt("generation", 2), + it.optString("branch").let { b -> if (b == "null") "" else b }, + it.optString("link").ifBlank { it.optString("meta_url").ifBlank { it.optJSONArray("urls")?.optJSONObject(0)?.optString("url").orEmpty() } }) } + } + } +} + +internal data class GogOfflinePart(val id: String, val name: String, val url: String, val size: Long, val md5: String) +internal data class GogOfflineDownload(val id: String, val name: String, val version: String, val language: String, val parts: List) { + val size: Long get() = parts.sumOf { it.size } + companion object { + fun parse(downloads: JSONObject, kind: String): List = downloads.optJSONArray(kind)?.objects().orEmpty() + .filter { it.optString("os") == "windows" }.mapNotNull { entry -> + val files = entry.optJSONArray("files")?.objects().orEmpty() + val parts = files.mapNotNull { f -> + val url = f.optString("downlink").ifBlank { f.optString("manualUrl") } + if (url.isBlank()) null else GogOfflinePart(f.optString("id"), f.optString("filename"), url, f.optLong("size"), f.optString("md5")) + } + if (parts.isEmpty()) null else GogOfflineDownload(entry.optString("id").ifBlank { parts.first().id }, + entry.optString("name", "GOG download"), entry.optString("version"), entry.optString("language"), parts) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogMaintenance.kt new file mode 100644 index 000000000..796e0dbcb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogMaintenance.kt @@ -0,0 +1,415 @@ +package com.droiddeck.launcher.stores.gog + +import android.content.Context +import androidx.compose.runtime.mutableStateMapOf +import com.droiddeck.launcher.agent.AgentGuest +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.runtime.GuestCommand +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.stores.* +import com.droiddeck.launcher.stores.download.* +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal object GogMaintenance { + enum class State { UNKNOWN, CHECKING, CURRENT, AVAILABLE, FAILED } + data class Check(val state: State, val version: String = "", val reason: String = "") + data class Proposal(val folder: File, val before: GogInstallRecord, val target: GogInstallRecord, + val operation: GogOperation, val downloadBytes: Long, val diskBytes: Long, val patch: GogOfflineDownload? = null, val deltas: JSONArray = JSONArray(), val fullFiles: Boolean = false) + + val checks = mutableStateMapOf() + private val workers = Executors.newFixedThreadPool(2) + private val checkedAt = java.util.concurrent.ConcurrentHashMap() + private val inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet() + @Volatile private var account = "" + private val revision = java.util.concurrent.atomic.AtomicLong() + private const val HELPER = "/usr/local/bin/droiddeck-gog-maintenance" + const val OPERATION = "gogOperation" + const val FOLDER = "gogFolder" + + fun reset() { + revision.incrementAndGet() + account = ""; checkedAt.clear() + StoresState.post { checks.clear() } + } + + fun checkInstalled(context: Context, games: List) { + val user = StoreAccounts.displayName(context, Store.GOG).orEmpty() + if (user != account) { reset(); account = user } + if (user.isBlank()) return + games.filter { it.sidecar.store == Store.GOG && it.sidecar.isInstalled }.forEach { check(context, it) } + } + + fun check(context: Context, game: InstalledStoreGame, force: Boolean = false) { + val key = game.folder.absolutePath + if (!force && System.currentTimeMillis() - (checkedAt[key] ?: 0) < 30 * 60_000) return + if (!inFlight.add(key)) return + val owner = revision.get() + StoresState.post { checks[game.sidecar.id] = Check(State.CHECKING) } + workers.execute { + val result = runCatching { + var old = GogInstallRecord.migrate(game.folder, game.sidecar) + if (!old.verified || GogFiles.journal(game.folder).exists()) Check(State.UNKNOWN, reason = "Finish or recover the GOG maintenance operation first") + else { + val token = GogContent.token(context) + if (old.source == "depot") { + val builds = GogContent.builds(old.id, token) + if (old.files.length() == 0 && old.build.isNotBlank()) GogBuild.select(builds, pinned = old.build)?.let { old = old.copy(branch = it.branch, generation = it.generation) } + val latest = GogBuild.select(builds, branch = old.branch) + when { latest == null -> Check(State.UNKNOWN, reason = "No build is available on the installed branch") + old.build.isBlank() -> Check(State.UNKNOWN, latest.version, "The installed build is unknown") + latest.id == old.build -> Check(State.CURRENT, old.version) + else -> Check(State.AVAILABLE, latest.version) } + } else { + val downloads = GogContent.product(old.id, token).getJSONObject("downloads") + val installer = offlineInstaller(downloads, old.language) + if (old.version.isBlank()) Check(State.UNKNOWN, installer.version, "The installed version is unknown") + else Check(if (old.version == installer.version) State.CURRENT else State.AVAILABLE, installer.version) + } + } + }.getOrElse { Check(State.FAILED, reason = it.message ?: "GOG update check failed") } + if (revision.get() == owner && (result.state == State.CURRENT || result.state == State.AVAILABLE)) checkedAt[key] = System.currentTimeMillis() + inFlight.remove(key) + StoresState.post { if (revision.get() == owner) checks[game.sidecar.id] = result } + } + } + + fun offlinePatches(context: Context, game: InstalledStoreGame): List = + GogOfflineDownload.parse(GogContent.product(game.sidecar.id, GogContent.token(context)).getJSONObject("downloads"), "patches") + + internal fun offlineInstaller(downloads: JSONObject, language: String): GogOfflineDownload { + val installers = GogOfflineDownload.parse(downloads, "installers") + return installers.firstOrNull { it.language.equals(language, true) } + ?: installers.firstOrNull { !it.language.equals("Neutral", true) && StoreLanguage.depotAccepted(arrayOf(it.language), language) } + ?: installers.firstOrNull { it.language.equals("Neutral", true) } + ?: error("No Windows offline installer is available for the installed language ($language)") + } + + fun prepare(context: Context, game: InstalledStoreGame, operation: GogOperation, patch: GogOfflineDownload? = null, resume: Boolean = false): Proposal { + val folder = game.folder.canonicalFile + var old = GogInstallRecord.migrate(folder, game.sidecar) + val journal = GogFiles.read(GogFiles.journal(folder)) + require(journal == null || journal.optString("folder") == folder.path) { "GOG recovery folder does not match" } + if (journal != null && journal.optString("phase") in setOf("patch-starting", "patch-applying", "patch-uncertain") && operation != GogOperation.REFRESH) error("The patch was interrupted. Inspect the installer result or use a full offline refresh; it will not be rerun automatically.") + require(journal == null || operation == GogOperation.REFRESH || journal.optString("operation") == operation.name) { "Resume the existing GOG operation or select a full offline refresh" } + val token = GogContent.token(context) + if (operation == GogOperation.PATCH) { + if (old.source == "depot" && old.manifest.length() == 0 && old.build.isNotBlank()) { + val exact = GogBuild.select(GogContent.builds(old.id, token), pinned = old.build) + ?: error("The installed GOG setup manifest is unavailable; use a full offline refresh") + old = GogContent.snapshot(old, exact, token) + } + check(patch != null) { "Select a GOG patch" } + if (resume) require(journal?.optString("downloadVersion") == patch.version) { "The selected patch changed; select it again in Offline patches" } + val selected = GogContent.resolveDownload(patch, token) + return Proposal(folder, old, old.copy(source = "offline", version = selected.version, verified = false), operation, selected.size, + selected.size + folder.walkTopDown().onEnter { !java.nio.file.Files.isSymbolicLink(it.toPath()) && it.name != ".droiddeck-gog" }.filter { it.isFile }.sumOf { it.length() }, selected) + } + if (operation == GogOperation.REFRESH || old.source != "depot") { + require(operation != GogOperation.REPAIR) { "This offline installation has no depot manifest. Use a full offline refresh to restore its files." } + val download = GogContent.resolveDownload(offlineInstaller(GogContent.product(old.id, token).getJSONObject("downloads"), old.language), token) + if (resume) require(journal?.optString("downloadId") == download.id && journal.optString("downloadVersion") == download.version) { "The previous offline installer is no longer available; select a new full offline refresh" } + if (old.products.any { it != old.id }) { + val pinned = journal?.optJSONObject("target")?.optString("build").orEmpty() + val build = GogBuild.select(GogContent.builds(old.id, token), pinned = pinned, branch = old.branch) + ?: error("No depot build is available to preserve installed DLC during this refresh. Use Update instead.") + val target = GogContent.snapshot(old, build, token) + GogFiles.validate(target.files, folder) + val native = GogNative.isAvailable() + val remaining = GogEstimate.calculate(folder, old, target, JSONArray(), reuseChunks = native) + return Proposal(folder, old, target, GogOperation.REFRESH, Math.addExact(download.size, remaining.download), + Math.addExact(Math.multiplyExact(download.size, 3), remaining.required), download, fullFiles = !native) + } + val target = old.copy(source = "offline", build = "", generation = 0, version = download.version, manifest = JSONObject(), files = JSONArray(), verified = true) + return Proposal(folder, old, target, GogOperation.REFRESH, download.size, download.size * 3, download) + } + require(old.verified || operation != GogOperation.REPAIR) { "The patched installation must be reconciled before repair" } + val pinned = journal?.optJSONObject("target")?.let(GogInstallRecord::parse) + val builds = GogContent.builds(old.id, token) + if (old.files.length() == 0 && old.build.isNotBlank()) GogBuild.select(builds, pinned = old.build)?.let { old = old.copy(branch = it.branch, generation = it.generation) } + val build = GogBuild.select(builds, + pinned = pinned?.build ?: if (operation == GogOperation.REPAIR) old.build.ifBlank { error("The installed GOG build is unknown; update it first") } else "", + branch = pinned?.branch ?: old.branch) ?: error("The requested GOG build is no longer available") + val target = GogContent.snapshot(old, build, token) + GogFiles.validate(target.files, folder) + var before = journal?.optJSONObject("before")?.let(GogInstallRecord::parse) ?: old + if (before.files.length() == 0 && before.build.isNotBlank()) { + val historical = GogBuild.select(builds, pinned = before.build) + if (historical != null) before = runCatching { GogContent.snapshot(before, historical, token) }.getOrDefault(before) + } + val deltas = GogContent.patches(before, target, token) + val native = GogNative.isAvailable() + val size = GogEstimate.calculate(folder, before, target, deltas, reuseChunks = native) + return Proposal(folder, before, target, operation, size.download, size.required, deltas = deltas, fullFiles = !native) + } + + fun resume(context: Context, game: InstalledStoreGame): Proposal { + val journal = GogFiles.read(GogFiles.journal(game.folder)) ?: error("GOG operation journal is missing") + val operation = GogOperation.valueOf(journal.getString("operation")) + val patch = if (operation == GogOperation.PATCH) offlinePatches(context, game) + .firstOrNull { it.id == journal.optString("downloadId") } ?: error("The selected GOG patch is no longer available") else null + return prepare(context, game, operation, patch, resume = true) + } + + fun recoverPatch(context: Context, game: InstalledStoreGame) { + val folder = game.folder.canonicalFile + check(!DownloadQueue.isActive(game.key)) { "Wait for the GOG operation to finish" } + val journal = GogFiles.read(GogFiles.journal(folder)) ?: error("GOG patch journal is missing") + require(journal.optString("folder") == folder.path && journal.optString("operation") == GogOperation.PATCH.name) + require(journal.optString("phase") in setOf("patch-starting", "patch-applying", "patch-uncertain")) + RandomAccessFile(GogFiles.inside(folder, ".droiddeck-gog/lease"), "rw").use { lease -> + val lock = runCatching { lease.channel.tryLock() }.getOrNull() ?: error("Close the patch installer before verifying its result") + lock.use { + ensureIdle(context, folder) + val before = GogInstallRecord.parse(journal.getJSONObject("before")) + val buildId = GogInstallRecord.authenticBuild(folder, before.id).ifBlank { before.build } + check(buildId.isNotBlank()) { "The resulting build is unknown. Use a full offline refresh." } + val token = GogContent.token(context) + val build = GogBuild.select(GogContent.builds(before.id, token), pinned = buildId) + ?: error("The resulting build manifest is unavailable. Use a full offline refresh.") + val target = GogContent.snapshot(before, build, token) + GogFiles.validate(target.files, folder) + check(target.files.objects().all { it.optBoolean("directory") || GogFiles.matches(folder, it) }) { + "The files do not match the reported build. Use a full offline refresh to recover." + } + val sidecar = StoreGameSidecar.parse(journal.getJSONObject("sidecar").toString()) ?: error("The original GOG settings are missing") + target.write(folder) + StoreInstalls.complete(context, folder, sidecar.copy(installVersion = target.build, state = StoreGameSidecar.STATE_INSTALLED)) + check(GogFiles.journal(folder).delete()) { "Could not finish the GOG recovery record" } + } + } + checkedAt.remove(folder.path) + DownloadQueue.dismiss(game.key) + StoresState.notifyLibraryChanged(context) + StoresState.post { checks.remove(game.sidecar.id) } + } + + fun enqueue(context: Context, game: InstalledStoreGame, proposal: Proposal) { + val app = context.applicationContext + require(game.folder.canonicalFile == proposal.folder) + check(!DownloadQueue.isActive("gog:${game.sidecar.id}")) { "GOG maintenance is already queued" } + check(!StoreInstallRoot.insufficientSpace(proposal.diskBytes, StoreInstallRoot.availableBytes(proposal.folder))) { "Not enough free space for GOG maintenance" } + val journal = JSONObject().put("schema", 1).put("folder", proposal.folder.path).put("operation", proposal.operation.name) + .put("before", proposal.before.json()).put("target", proposal.target.json()).put("sidecar", game.sidecar.toJson()).put("phase", "download") + proposal.patch?.let { journal.put("downloadId", it.id).put("downloadVersion", it.version) } + GogFiles.write(GogFiles.journal(proposal.folder), journal) + val item = CatalogItem(Store.GOG, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, + extra = mapOf(OPERATION to proposal.operation.name, FOLDER to proposal.folder.path)) + SavedQueue.remember(app, item, null) + enqueueJob(app, item, proposal) + } + + private fun enqueueJob(context: Context, item: CatalogItem, proposal: Proposal) { + DownloadQueue.enqueue(context, DownloadEntry(Store.GOG, item.id, item.title, cover = item.tallImageUrl, + bytesTotal = proposal.downloadBytes, location = StoreInstallRoot.labelFor(context, proposal.folder))) { Job(context, proposal) } + } + + fun restore(context: Context, item: CatalogItem) { + val folder = File(item.extra[FOLDER] ?: error("Missing GOG recovery folder")).canonicalFile + require(StoreInstallRoot.gameFolders(context).any { it.canonicalFile == folder }) { "GOG recovery folder is unavailable" } + val sidecar = StoreGameSidecar.read(folder) ?: error("GOG installation record is missing") + require(sidecar.store == Store.GOG && sidecar.id == item.id) + val journal = GogFiles.read(GogFiles.journal(folder)) ?: error("GOG operation journal is missing") + val operation = GogOperation.valueOf(journal.getString("operation")) + if (journal.optString("phase") in setOf("patch-starting", "patch-applying", "patch-uncertain")) { + DownloadQueue.recordFailed(context, DownloadEntry(Store.GOG, item.id, item.title), "The patch result needs recovery; open Offline patches") + return + } + val game = InstalledStoreGame(sidecar, folder) + SavedQueue.remember(context, item, null) + enqueueJob(context, item, resume(context, game)) + } + + private class Job(val app: Context, val proposal: Proposal) : DownloadQueue.DownloadJob { + val cancelled = AtomicBoolean(false) + @Volatile var applyingPatch = false + @Volatile private var running = false + @Volatile private var deleteCaches = false + override fun cancel(deleteFiles: Boolean) { + if (applyingPatch) return + deleteCaches = deleteCaches || deleteFiles + cancelled.set(true) + if (!running && deleteCaches) cleanCaches() + } + + override fun run(handle: DownloadQueue.JobHandle): String? { + running = true + try { return perform(handle) } + finally { + running = false; applyingPatch = false; handle.interruptible(true) + if (deleteCaches) cleanCaches() + } + } + + private fun cleanCaches() { + for (name in listOf("stage", "downloads", "extracted")) { + val root = GogFiles.inside(proposal.folder, ".droiddeck-gog/$name") + if (root.exists()) java.nio.file.Files.walk(root.toPath()).use { paths -> + paths.sorted(java.util.Comparator.reverseOrder()).forEach { java.nio.file.Files.deleteIfExists(it) } + } + } + } + + private fun perform(handle: DownloadQueue.JobHandle): String? { + val folder = proposal.folder + val journal = GogFiles.read(GogFiles.journal(folder)) ?: error("GOG operation journal is missing") + val sidecar = StoreGameSidecar.parse(journal.getJSONObject("sidecar").toString()) ?: error("GOG previous installation is missing") + if (proposal.operation == GogOperation.PATCH) { + val result = GogOffline.apply(app, proposal, handle, cancelled, onApplying = { applyingPatch = true; handle.interruptible(false) }) + applyingPatch = false; handle.interruptible(true) + if (!result || cancelled.get()) return null + } else { + RandomAccessFile(GogFiles.inside(folder, ".droiddeck-gog/lease"), "rw").use { lease -> + val lock = runCatching { lease.channel.tryLock() }.getOrNull() ?: error("Close the GOG game before updating it") + lock.use { + ensureIdle(app, folder) + sidecar.copy(state = StoreGameSidecar.STATE_UPDATING).write(folder) + GogFiles.write(GogFiles.journal(folder), journal.put("phase", "applying")) + if (proposal.operation == GogOperation.REFRESH) { + GogOffline.refresh(app, proposal, handle, cancelled) + if (!cancelled.get() && proposal.target.source == "depot") transfer(app, proposal, handle, cancelled) + } else transfer(app, proposal, handle, cancelled) + if (cancelled.get()) return null + } + } + } + if (cancelled.get()) return null + val target = GogInstallRecord.read(folder)?.takeIf { proposal.operation == GogOperation.PATCH || proposal.operation == GogOperation.REFRESH && proposal.target.source != "depot" } ?: proposal.target + check(target.verified) { "The patch completed, but its resulting version could not be verified. Inspect the game or use a full offline refresh." } + target.write(folder) + StoreInstalls.complete(app, folder, sidecar.copy(installVersion = target.build.ifBlank { target.version }, state = StoreGameSidecar.STATE_INSTALLED)) + check(GogFiles.journal(folder).delete()) { "Could not finish the GOG recovery record" } + runCatching { cleanCaches() }.onFailure { handle.log("gog: completed; could not remove expendable cache: ${it.message}") } + checkedAt.remove(folder.path) + StoresState.post { checks.remove(sidecar.id) } + return folder.path + } + } + + private fun ensureIdle(context: Context, folder: File) { + val game = AddedGames.single(context, folder.path) ?: return + check(SessionState.gameId != (game.appId and 0xffffffffL).toString()) { "Close the GOG game before maintenance" } + if (SessionState.running) { + val id = (game.steamAppId?.toLong() ?: game.appId) and 0xffffffffL + val guest = AddedGames.guestPath(context, folder) ?: error("The GOG folder is unavailable to Linux") + if (SessionState.suspended || AgentGuest.hello(context) == null) { + val root = AddedGames.guestPath(context, SessionPrefs.activeSteamRoot(context)) ?: error("The Steam folder is unavailable to Linux") + val rc = GuestCommand.run(context, listOf("/usr/bin/env", "STEAM_COMPAT_CLIENT_INSTALL_PATH=$root", HELPER, "idle", guest, id.toString(), game.guestExe), + extraBinds = StoreInstallRoot.externalBindSpecs(context), logName = "gog-idle", onLine = StoresState::logLine) + check(rc == 0) { "Close the GOG game before maintenance" } + return + } + val request = JSONObject().put("kind", "exec").put("argv", JSONArray(listOf(HELPER, "idle", guest, id.toString(), game.guestExe))).put("timeout", 15) + val result = AgentGuest.call(context, request, 20_000, trusted = true) + check(result.optBoolean("ok") && result.optInt("exitCode", -1) == 0) { "Close the GOG game before maintenance" } + } + } + + private fun transfer(context: Context, proposal: Proposal, handle: DownloadQueue.JobHandle?, stop: AtomicBoolean, callback: GogDownloadManager.Callback? = null) { + val token = GogContent.token(context) + var patches = proposal.deltas + var previousWire = 0L + for (attempt in 0..4) { + if (stop.get()) return + val links = GogContent.links(proposal.target, GogContent.token(context)) + val patchLinks = if (patches.length() > 0) runCatching { GogContent.links(proposal.target, token, patches = true) }.getOrElse { patches = JSONArray(); JSONObject() } else JSONObject() + val plan = JSONObject().put("files", proposal.target.files).put("oldFiles", proposal.before.files) + .put("patches", patches).put("links", links).put("patchLinks", patchLinks).put("downloadBytes", proposal.downloadBytes) + val optimized = GogNative.isAvailable() + val remaining = GogEstimate.calculate(proposal.folder, proposal.before, proposal.target, patches, reuseChunks = optimized).required + check(!StoreInstallRoot.insufficientSpace(remaining, StoreInstallRoot.availableBytes(proposal.folder))) { "Not enough free space for remaining GOG maintenance" } + handle?.diskSize(remaining) + if (!optimized) { + try { GogFallback.transfer(proposal.folder, plan, handle, stop, callback); return } + catch (e: Exception) { + if (stop.get()) return + if (attempt == 4 || Regex("HTTP (401|403|404|500)").find(e.message.orEmpty()) == null) throw e + handle?.log("gog: refreshing file-download links; verified replacements are retained") + continue + } + } + val latch = CountDownLatch(1) + var runWire = 0L + var success = false; var expired = false; var failure = "GOG download failed" + val listener = object : GogNative.Listener { + override fun onProgress(bytesDone: Long, bytesTotal: Long, filesDone: Int, filesTotal: Int, file: String, fileBytes: Long, verified: Boolean) { + handle?.stage(if (verified) DownloadStage.VERIFY else DownloadStage.INSTALL) + handle?.stageProgress(filesDone.toLong(), filesTotal.toLong(), filesDone, filesTotal) + callback?.onStage(if (verified) "verify" else "install", filesDone.toLong(), filesTotal.toLong(), filesDone, filesTotal) + } + override fun onBytes(bytesDone: Long, bytesTotal: Long) { + runWire = bytesDone + handle?.progress(previousWire + bytesDone, (previousWire + bytesTotal).coerceAtLeast(previousWire + bytesDone)) + callback?.onBytes(previousWire + bytesDone, (previousWire + bytesTotal).coerceAtLeast(previousWire + bytesDone), 0) + } + override fun onLog(line: String) { handle?.log(line); callback?.onLog(line) } + override fun onMaintenance(networkBytes: Long, reusedBytes: Long, appliedBytes: Long) { handle?.maintenance(reusedBytes, appliedBytes) } + override fun onComplete(ok: Boolean, cancelled: Boolean, linkExpiry: Boolean, error: String, bytesWritten: Long, filesDone: Int) { + success = ok; expired = linkExpiry; failure = error; latch.countDown() + } + } + val native = GogNative.start(2, arrayOf(plan.toString()), emptyArray(), proposal.folder.path, emptyArray(), "", 1, 1, false, "gog maintenance", listener) + check(native != 0L) { "GOG native engine could not start" } + try { while (!latch.await(200, TimeUnit.MILLISECONDS)) if (stop.get()) GogNative.cancel(native) } + finally { GogNative.release(native) } + if (stop.get()) return + if (success) { + for (file in proposal.target.files.objects()) if (!file.optBoolean("directory") && file.optString("md5").isBlank() && file.optString("sha256").isBlank()) + file.put("sha256", GogFiles.hash(GogFiles.inside(proposal.folder, file.getString("path")), "SHA-256")) + return + } + if (!expired || attempt == 4) error(failure) + previousWire += runWire + handle?.log("gog: refreshing expired download links; validated files and chunks are retained") + } + } + + @JvmStatic + fun installContent(context: Context, game: GogGame, folder: File, builds: JSONObject, language: String, + callback: GogDownloadManager.Callback, cancelled: AtomicBoolean): GogDownloadManager.Result? { + val build = GogBuild.select(builds) ?: return null + folder.mkdirs() + val old = GogInstallRecord(game.gameId, "depot", language = language) + val target = GogContent.snapshot(old, build, GogContent.token(context)) + GogFiles.validate(target.files, folder) + val bytes = target.files.objects().sumOf { it.optJSONArray("chunks")?.objects()?.sumOf { c -> c.optLong("compressedSize") }?.takeIf { n -> n > 0 } ?: it.optLong("size") } + val disk = target.files.objects().sumOf { it.optLong("size") } + check(!StoreInstallRoot.insufficientSpace(disk, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" } + callback.onSizes(bytes, disk) + transfer(context, Proposal(folder, old, target, GogOperation.UPDATE, bytes, disk), null, cancelled, callback) + if (cancelled.get()) return null + target.write(folder) + val result = GogDownloadManager.Result(GogDownloadManager.pickExe(folder, null, game.title), build.id, disk) + result.language = target.language + result.dependencies = target.manifest.optJSONArray("dependencies")?.strings().orEmpty() + result.dependenciesRead = true + return result + } + + @JvmStatic + fun captureDepot(id: String, folder: File, build: String, buildsJson: String, manifest: JSONObject, depots: JSONArray, language: String) { + val selected = GogBuild.select(JSONObject(buildsJson), pinned = build) + val files = JSONArray() + for (depot in depots.strings()) for (item in JSONObject(depot).getJSONObject("depot").getJSONArray("items").objects()) { + val chunks = item.optJSONArray("chunks") ?: continue + files.put(JSONObject().put("path", item.getString("path").replace('\\', '/')).put("size", chunks.objects().sumOf { it.optLong("size") }) + .put("md5", item.optString("md5")).put("chunks", chunks).put("product", id)) + } + GogFiles.validate(files, folder) + GogInstallRecord(id, "depot", 2, build, selected?.version.orEmpty(), selected?.branch.orEmpty(), language, + manifest = GogContent.sanitize(manifest), files = files).write(folder) + } + + @JvmStatic + fun captureOffline(id: String, folder: File, downloads: JSONObject) { + val selected = GogOfflineDownload.parse(downloads, "installers").firstOrNull() ?: return + GogInstallRecord(id, "offline", version = selected.version, language = selected.language).write(folder) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt index f8ab86b14..80024ab6a 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt @@ -15,6 +15,8 @@ object GogNative { fun onBytes(bytesDone: Long, bytesTotal: Long) + fun onMaintenance(networkBytes: Long, reusedBytes: Long, appliedBytes: Long) + fun onLog(line: String) fun onComplete(success: Boolean, cancelled: Boolean, linkExpiry: Boolean, error: String, bytesWritten: Long, filesDone: Int) @@ -26,7 +28,7 @@ object GogNative { fun isAvailable(): Boolean { available?.let { return it } val ok = try { - StoresNative.ensureLoaded() && nativeProbe() == 1 + StoresNative.ensureLoaded() && nativeProbe() == 2 } catch (t: Throwable) { Log.w(TAG, "native GOG engine unavailable: ${t.javaClass.simpleName}: ${t.message}") false diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogOffline.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogOffline.kt new file mode 100644 index 000000000..02ff54f26 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogOffline.kt @@ -0,0 +1,200 @@ +package com.droiddeck.launcher.stores.gog + +import android.content.Context +import com.droiddeck.launcher.agent.AgentGuest +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.runtime.GuestCommand +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +internal object GogOffline { + private const val HELPER = "/usr/local/bin/droiddeck-gog-maintenance" + + internal data class Layout(val source: File, val prefix: String) + + internal fun layout(extracted: File, installed: File, id: String, executable: String): Layout { + val nested = executable.startsWith("app/", true) && !File(installed, "goggame-$id.info").isFile + return Layout(extracted, if (nested) "app/" else "") + } + + internal fun validateDepotInstaller(extracted: File, target: GogInstallRecord) { + if (target.source != "depot") return + val builds = listOf(extracted, File(extracted, "app")).map { GogInstallRecord.authenticBuild(it, target.id) }.filter { it.isNotBlank() } + require(builds.isNotEmpty() && builds.all { it == target.build } && target.build.isNotBlank()) { + "The offline installer does not identify the selected depot build. Use Update to preserve installed DLC." + } + } + + private fun download(context: Context, proposal: GogMaintenance.Proposal, handle: DownloadQueue.JobHandle, stop: AtomicBoolean): File { + val patch = proposal.patch ?: error("GOG download selection is missing") + val root = File(GogFiles.dir(proposal.folder), "downloads").apply { mkdirs() } + var first: File? = null; var bytes = 0L + for ((index, part) in patch.parts.withIndex()) { + if (stop.get()) return first ?: File(root, "cancelled") + val resolved = GogDownloadManager.resolveRedirect(part.url, GogContent.token(context)) ?: error("The GOG download link is unavailable") + val serverName = GogContent.downloadFilename(resolved).takeIf { it.endsWith(".exe", true) || it.endsWith(".bin", true) } + val name = part.name.ifBlank { serverName ?: error("GOG did not provide the installer part filename") } + require('/' !in name && '\\' !in name && name !in setOf(".", "..")) { "Invalid GOG installer filename" } + val file = GogFiles.inside(root, name) + val receipt = File(root, "part-$index.json") + val saved = GogFiles.read(receipt) + val complete = file.isFile && (part.size <= 0 || file.length() == part.size) && when { + part.md5.isNotBlank() -> GogFiles.hash(file).equals(part.md5, true) + saved?.optString("id") == part.id && saved.optString("filename") == name -> GogFiles.hash(file, "SHA-256") == saved.optString("sha256") + else -> false + } + if (!complete) { + val staging = GogFiles.inside(root, "$name.partial") + GogFallback.download(listOf(resolved), staging, part.size, stop, { n -> bytes += n; handle.progress(bytes, proposal.downloadBytes.coerceAtLeast(bytes)) }) + if (stop.get()) return first ?: file + if (part.md5.isNotBlank()) check(GogFiles.hash(staging).equals(part.md5, true)) { "GOG installer checksum mismatch" } + GogFiles.replace(staging, file) + GogFiles.write(receipt, JSONObject().put("id", part.id).put("filename", name).put("sha256", GogFiles.hash(file, "SHA-256"))) + } + if (index == 0) { check(file.extension.equals("exe", true)) { "GOG patch has no setup executable" }; first = file } + } + return first ?: error("GOG download has no files") + } + + fun refresh(context: Context, proposal: GogMaintenance.Proposal, handle: DownloadQueue.JobHandle, stop: AtomicBoolean) { + val installer = download(context, proposal, handle, stop) + if (stop.get()) return + val extracted = File(GogFiles.dir(proposal.folder), "extracted").apply { mkdirs() } + val source = AddedGames.guestPath(context, installer) ?: error("The GOG installer is not visible to Linux") + val target = AddedGames.guestPath(context, extracted) ?: error("The GOG staging folder is not visible to Linux") + handle.stage(DownloadStage.INSTALL, "Extracting the full offline installer") + val rc = GuestCommand.run(context, listOf("/usr/local/bin/droiddeck-innoextract", "--gog", "--silent", "-d", target, source), + extraBinds = StoreInstallRoot.externalBindSpecs(context), cancelled = stop, logName = "gog-refresh", onLine = handle::log) + if (stop.get()) return + check(rc == 0) { "GOG installer extraction failed (exit $rc)" } + applyRefresh(proposal, extracted, handle, stop) + } + + internal fun applyRefresh(proposal: GogMaintenance.Proposal, extracted: File, handle: DownloadQueue.JobHandle?, stop: AtomicBoolean) { + validateDepotInstaller(extracted, proposal.target) + val sidecar = StoreGameSidecar.read(proposal.folder) ?: error("The original GOG settings are missing") + val layout = layout(extracted, proposal.folder, proposal.before.id, sidecar.exe) + val files = layout.source.walkTopDown().onEnter { directory -> + check(!java.nio.file.Files.isSymbolicLink(directory.toPath())) { "GOG installer contains a symbolic link" } + directory.parentFile != extracted || directory.name !in setOf("tmp", "commonappdata", "userappdata", "localappdata") + }.filter { it != layout.source && it != File(extracted, "app") }.map { file -> + check(!java.nio.file.Files.isSymbolicLink(file.toPath())) + val relative = file.relativeTo(layout.source).invariantSeparatorsPath.removePrefix("app/") + JSONObject().put("path", layout.prefix + relative) + .put("size", if (file.isDirectory) 0 else file.length()).put("directory", file.isDirectory) + .put("md5", if (file.isDirectory) "" else GogFiles.hash(file)).put("product", proposal.before.id) to file + }.toList().groupBy { it.first.getString("path") }.map { (_, entries) -> + check(entries.size == 1 || entries.all { it.first.getBoolean("directory") }) { "GOG installer has conflicting file locations" } + entries.first() + } + check(files.any { !it.first.getBoolean("directory") }) { "GOG installer contained no game files" } + val inventory = JSONArray(files.map { it.first }) + GogFiles.validate(inventory, proposal.folder) + val executable = sidecar.exe.replace('\\', '/') + check(files.any { !it.first.getBoolean("directory") && it.first.getString("path").equals(executable, true) } || + proposal.before.verified && proposal.before.files.length() > 0 && + proposal.before.files.objects().none { it.getString("path").equals(executable, true) } && GogFiles.inside(proposal.folder, executable).isFile) { + "The offline installer did not extract the game's executable. No installed files were changed." + } + val installed = proposal.target.copy(files = inventory) + GogFiles.write(File(GogFiles.dir(proposal.folder), "refresh-target.json"), installed.json()) + for ((index, entry) in files.withIndex()) { + if (stop.get()) return + val (file, sourceFile) = entry + val destination = GogFiles.inside(proposal.folder, file.getString("path")) + if (file.getBoolean("directory")) java.nio.file.Files.createDirectories(destination.toPath()) else { + destination.parentFile!!.mkdirs() + GogFiles.replace(sourceFile, destination) + } + handle?.stageProgress((index + 1).toLong(), files.size.toLong()) + } + if (proposal.target.source != "depot") { + val targetPaths = files.mapTo(HashSet()) { it.first.getString("path").lowercase(java.util.Locale.ROOT) } + for (old in proposal.before.files.objects()) if (old.getString("path").lowercase(java.util.Locale.ROOT) !in targetPaths && GogFiles.matches(proposal.folder, old)) + check(GogFiles.inside(proposal.folder, old.getString("path")).delete()) + installed.write(proposal.folder) + } + } + + fun apply(context: Context, proposal: GogMaintenance.Proposal, handle: DownloadQueue.JobHandle, stop: AtomicBoolean, onApplying: () -> Unit): Boolean { + check(GogFiles.read(GogFiles.journal(proposal.folder))?.optString("phase") in setOf("download", "patch-ready")) { + "The patch result needs recovery. Inspect the installer or use a full offline refresh; the patch will not be run again." + } + val installer = download(context, proposal, handle, stop) + if (stop.get()) return false + val setup = GogContent.setupFiles(proposal.before, GogContent.token(context)) + if (setup != null) { + GogFallback.transfer(proposal.folder, JSONObject().put("files", setup.first).put("links", setup.second) + .put("downloadBytes", setup.first.objects().sumOf { it.getJSONArray("chunks").objects().sumOf { chunk -> chunk.getLong("compressedSize") } }), handle, stop) + if (stop.get()) return false + } + val added = AddedGames.single(context, proposal.folder.path) ?: error("The GOG game shortcut is unavailable") + val id = (added.steamAppId?.toLong() ?: added.appId) and 0xffffffffL + val folder = AddedGames.guestPath(context, proposal.folder) ?: error("The GOG folder is unavailable to Linux") + val executable = AddedGames.guestPath(context, installer) ?: error("The GOG patch is unavailable to Linux") + context.startActivity(android.content.Intent(context, com.droiddeck.launcher.SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, if (SessionState.running) SessionState.mode else SessionService.MODE_DESKTOP) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)) + val deadline = System.currentTimeMillis() + 120_000 + while (AgentGuest.hello(context) == null && System.currentTimeMillis() < deadline) { if (stop.get()) return false; Thread.sleep(250) } + check(AgentGuest.hello(context) != null) { "The Linux session did not become ready; open it and retry" } + val journal = GogFiles.read(GogFiles.journal(proposal.folder)) ?: error("GOG patch journal is missing") + journal.put("phase", "patch-ready") + File(GogFiles.dir(proposal.folder), "patch-status.json").delete() + GogFiles.write(GogFiles.journal(proposal.folder), journal) + val request = JSONObject().put("kind", "exec").put("timeout", 15) + .put("env", JSONObject().put("DROIDDECK_GOG_DESKTOP", if (SessionState.mode == SessionService.MODE_DESKTOP) "1" else "0")) + .put("argv", JSONArray(listOf(HELPER, "start", folder, id.toString(), executable, added.guestExe))) + if (stop.get()) return false + onApplying() + val response = AgentGuest.call(context, request, 20_000, trusted = true) + check(response.optBoolean("ok") && response.optInt("exitCode", -1) == 0) { "The GOG patch installer could not start; inspect the GOG log" } + handle.stage(DownloadStage.INSTALL, "Use the Linux session to finish or cancel the patch installer") + var nextStatus = System.currentTimeMillis() + 5_000 + while (true) { + val status = GogFiles.read(File(GogFiles.dir(proposal.folder), "patch-status.json")) + if (status?.optString("state") in setOf("done", "failed", "uncertain")) { + check(status?.optString("state") == "done") { status?.optString("reason") ?: "GOG patch failed" } + break + } + check(SessionState.running) { "The patch session ended. Its result needs recovery; the patch will not be rerun automatically." } + check(!SessionState.suspended) { "The patch session was suspended. Resume it and verify recovery before retrying." } + if (System.currentTimeMillis() >= nextStatus) { + val query = JSONObject().put("kind", "exec").put("timeout", 5).put("argv", JSONArray(listOf(HELPER, "status", folder))) + val answer = AgentGuest.call(context, query, 10_000, trusted = true) + check(answer.optBoolean("ok") && answer.optInt("exitCode", -1) == 0) { "The patch helper could not be reached. Resume the Linux session and verify recovery." } + nextStatus = System.currentTimeMillis() + 5_000 + } + Thread.sleep(500) + } + val info = GogFiles.read(File(proposal.folder, "goggame-${proposal.before.id}.info")) + val version = info?.optString("versionName").orEmpty() + val build = GogInstallRecord.authenticBuild(proposal.folder, proposal.before.id) + var record = proposal.target.copy(build = build, version = version, files = JSONArray(), manifest = JSONObject(), verified = false) + record.write(proposal.folder) + if (build.isNotBlank()) { + record = runCatching { + val token = GogContent.token(context) + val target = GogBuild.select(GogContent.builds(record.id, token), pinned = build) + val snapshot = target?.let { GogContent.snapshot(record, it, token) } + snapshot?.takeIf { it.files.objects().all { f -> f.optBoolean("directory") || GogFiles.matches(proposal.folder, f) } } ?: record + }.getOrDefault(record) + } + record.write(proposal.folder) + if (!record.verified) { + val before = StoreGameSidecar.read(proposal.folder) ?: error("The GOG installation record is missing") + before.copy(installVersion = "", state = StoreGameSidecar.STATE_UPDATING).write(proposal.folder) + val operation = GogFiles.read(GogFiles.journal(proposal.folder))!! + GogFiles.write(GogFiles.journal(proposal.folder), operation.put("phase", "patch-uncertain")) + } + return true + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchArchives.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchArchives.kt new file mode 100644 index 000000000..fd08c9d6b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchArchives.kt @@ -0,0 +1,45 @@ +package com.droiddeck.launcher.stores.itch + +import java.io.File +import java.io.FileInputStream +import java.nio.file.Files + +internal object ItchArchives { + private val PACKED = setOf("zip", "7z", "rar", "tar", "gz", "bz2", "xz") + + fun kind(file: File, filename: String): String { + val extension = filename.substringAfterLast('.', "").lowercase() + if (extension == "exe") return "exe" + return signature(file) ?: extension + } + + private fun signature(file: File): String? { + val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull() + ?: return null + 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 -> null + } + } + + fun inner(folder: File): File? { + val found = ArrayList() + fun walk(dir: File, depth: Int) { + val files = dir.listFiles() ?: return + for (file in files) { + if (Files.isSymbolicLink(file.toPath())) continue + if (file.isFile && signature(file) in PACKED) found.add(file) + else if (file.isDirectory && depth < 2 && !file.name.startsWith(".")) walk(file, depth + 1) + } + } + walk(folder, 0) + return found.maxByOrNull { it.length() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBuilds.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBuilds.kt new file mode 100644 index 000000000..95712037d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBuilds.kt @@ -0,0 +1,114 @@ +package com.droiddeck.launcher.stores.itch + +import com.droiddeck.launcher.stores.StoreNet +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +internal object ItchBuilds { + private const val BASE = "https://api.itch.io" + private const val MAX_STEPS = 4 + + class Step(val buildId: Long, val patchFile: Long, val patchSize: Long, val signatureFile: Long) + + fun buildId(upload: JSONObject): String { + val value = upload.opt("build_id") ?: upload.opt("buildId") + return when (value) { + is Number -> value.toLong().takeIf { it > 0 }?.toString().orEmpty() + is String -> value.takeIf { it.all(Char::isDigit) }?.toLongOrNull()?.takeIf { it > 0 }?.toString().orEmpty() + else -> "" + } + } + + fun path(key: String, from: String, to: String, downloadKeyId: String): List? { + val fromId = from.toLongOrNull() ?: return null + val toId = to.toLongOrNull() ?: return null + val reply = ItchApi.get("/builds/$fromId/upgrade-paths/$toId" + keyQuery(downloadKeyId), key) ?: return null + return steps(reply, fromId, toId) { filesOf(key, it, downloadKeyId) } + } + + fun steps(reply: JSONObject, fromId: Long, toId: Long, filesFor: (JSONObject) -> JSONArray?): List? { + val builds = (reply.optJSONObject("upgrade_path") ?: reply.optJSONObject("upgradePath"))?.optJSONArray("builds") ?: return null + val ids = (0 until builds.length()).mapNotNull { builds.optJSONObject(it) } + val after = ids.filter { it.optLong("id") != fromId } + if (after.isEmpty() || after.size > MAX_STEPS || after.last().optLong("id") != toId) return null + val steps = after.map { build -> + val files = filesFor(build) ?: return null + val patch = pick(files, "patch") ?: return null + val signature = pick(files, "signature") + Step(build.optLong("id"), patch.optLong("id"), patch.optLong("size"), signature?.optLong("id") ?: 0L) + } + return steps.takeIf { it.last().signatureFile > 0 } + } + + fun fileUrl(buildId: Long, fileId: Long, downloadKeyId: String): String = + "$BASE/wharf/builds/$buildId/files/$fileId/download" + keyQuery(downloadKeyId) + + fun download(url: String, key: String, target: File, cancelled: AtomicBoolean, progress: (Long) -> Unit): Boolean { + val first = open(url, key) + try { + val code = first.responseCode + val location = first.getHeaderField("Location") + if (code in 300..399 && location != null) { + first.disconnect() + val next = URL(URL(url), location).toString() + if (!next.startsWith("https://")) return false + return save(open(next, null), target, cancelled, progress) + } + return save(first, target, cancelled, progress) + } finally { + runCatching { first.disconnect() } + } + } + + private fun save(conn: HttpURLConnection, target: File, cancelled: AtomicBoolean, progress: (Long) -> Unit): Boolean { + try { + if (conn.responseCode !in 200..299) return false + var done = 0L + val buffer = ByteArray(128 * 1024) + conn.inputStream.use { input -> + FileOutputStream(target).use { out -> + while (true) { + if (cancelled.get()) return false + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + done += read + progress(done) + } + } + } + return true + } finally { + runCatching { conn.disconnect() } + } + } + + private fun open(url: String, key: String?): HttpURLConnection = + (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 30_000 + readTimeout = 60_000 + instanceFollowRedirects = false + setRequestProperty("User-Agent", StoreNet.BROWSER_UA) + setRequestProperty("Accept", "*/*") + if (key != null) setRequestProperty("Authorization", "Bearer $key") + } + + private fun filesOf(key: String, build: JSONObject, downloadKeyId: String): JSONArray? = + build.optJSONArray("files") + ?: ItchApi.get("/builds/${build.optLong("id")}" + keyQuery(downloadKeyId), key)?.optJSONObject("build")?.optJSONArray("files") + + private fun pick(files: JSONArray, type: String): JSONObject? { + val all = (0 until files.length()).mapNotNull { files.optJSONObject(it) } + .filter { it.optString("type") == type && it.optString("state", "uploaded") == "uploaded" } + return all.firstOrNull { subType(it) == "optimized" } ?: all.firstOrNull { subType(it) == "default" } + } + + private fun subType(file: JSONObject): String = file.optString("sub_type", file.optString("subType", "default")) + + private fun keyQuery(downloadKeyId: String): String = if (downloadKeyId.isBlank()) "" else "?download_key_id=$downloadKeyId" +} 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 index 5037de863..d4dcf1983 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt @@ -13,6 +13,7 @@ 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.MemoryLimitException import org.apache.commons.compress.archivers.sevenz.SevenZFile import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.archivers.zip.ZipFile @@ -25,6 +26,7 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL +import java.nio.file.Files import java.util.concurrent.atomic.AtomicBoolean internal class ItchInstaller( @@ -33,6 +35,10 @@ internal class ItchInstaller( private val folder: File, ) : DownloadQueue.DownloadJob { private val cancelled = AtomicBoolean(false) + private var work: File = folder + private var updating = false + private var previous: StoreGameSidecar? = null + private val commit = Any() override fun run(handle: DownloadQueue.JobHandle): String? { val key = ItchAuth.key(app) @@ -40,7 +46,10 @@ internal class ItchInstaller( val downloadKeyId = item.extra["downloadKeyId"].orEmpty() if (downloadKeyId.isBlank() && !item.isFree) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned)) val gameId = ItchGame.resolveId(item) ?: throw IllegalStateException(app.getString(R.string.stores_itch_no_game)) - val resuming = StoreGameSidecar.read(folder)?.let { it.state == StoreGameSidecar.STATE_INSTALLING && it.id == item.id } == true + val before = StoreGameSidecar.read(folder) + previous = before + updating = before != null && (before.isInstalled || before.state == StoreGameSidecar.STATE_UPDATING) + val resuming = before?.let { it.state == StoreGameSidecar.STATE_INSTALLING && it.id == item.id } == true StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl) handle.stage(DownloadStage.MANIFEST) val uploads = ItchApi.uploads(key, gameId, downloadKeyId) @@ -53,19 +62,38 @@ internal class ItchInstaller( val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id) scratch.mkdirs() val archive = File(scratch, filename.ifBlank { "download.bin" }) + val buildId = ItchBuilds.buildId(upload) 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, resuming) + var patched = updating && !resuming && previous?.isInstalled == true && + patch(key, downloadKeyId, uploadId, buildId, size, scratch, handle) + if (cancelled.get()) { + if (patched) dropCaches(scratch) + return null + } + var picked: String? = null + if (patched) { + picked = install(archive, filename, handle, resuming, true) + if (picked == null) { + if (cancelled.get()) return null + handle.log("itch.io: the patched copy of ${item.title} has no exe, doing a full update") + patched = false + } + } + if (!patched) { + 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) + picked = install(archive, filename, handle, resuming, false) + } + picked } catch (t: Throwable) { - if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch)) + if (!cancelled.get()) dropCaches(scratch) throw t } if (exe == null) { if (cancelled.get()) return null - StoreInstalls.discard(app, folder, listOf(scratch)) + dropCaches(scratch) throw IllegalStateException(app.getString(R.string.stores_itch_no_exe)) } archive.delete() @@ -75,20 +103,34 @@ internal class ItchInstaller( id = item.id, title = item.title, exe = exe, + args = previous?.args.orEmpty(), + env = previous?.env.orEmpty(), 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), + extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId, "buildId" to buildId), cloud = false, ) StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) } + ItchUpdates.applied(app, item.key) 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))) + if (deleteFiles) dropCaches(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)) + } + + private fun dropCaches(scratch: File) { + if (!updating) { + StoreInstalls.discard(app, folder, listOf(scratch)) + return + } + synchronized(commit) { + StoreInstalls.deleteTree(scratch) + ItchStaging.delete(File(folder, ItchStaging.DIR)) + } } private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long, @@ -157,66 +199,114 @@ internal class ItchInstaller( } } - private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle, resuming: Boolean): String? { - folder.mkdirs() + private fun patch(key: String, downloadKeyId: String, uploadId: String, buildId: String, fullSize: Long, scratch: File, handle: DownloadQueue.JobHandle): Boolean { + val installed = previous?.extra.orEmpty() + val from = installed["buildId"].orEmpty() + if (installed["uploadId"] != uploadId || from.isBlank() || buildId.isBlank() || from == buildId) return false + val out = File(folder, ItchStaging.DIR) + ItchStaging.delete(out) + scratch.mkdirs() + val outcome = try { + ItchPatch.run(app, key, downloadKeyId, from, buildId, fullSize, folder, out, scratch, handle, cancelled) + } catch (t: Exception) { + ItchPatch.Outcome.Failed("${t.javaClass.simpleName} ${t.message}") + } + when (outcome) { + ItchPatch.Outcome.Applied -> { + handle.log("itch.io: patched ${item.title} from build $from to $buildId") + return true + } + ItchPatch.Outcome.Cancelled -> Unit + is ItchPatch.Outcome.Failed -> handle.log("itch.io: no patch for ${item.title}, doing a full update (${outcome.reason})") + } + ItchStaging.delete(out) + return false + } + + private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle, resuming: Boolean, patched: Boolean = false): String? { + work = if (updating) File(folder, ItchStaging.DIR) else folder + if (updating && !patched) ItchStaging.delete(work) + work.mkdirs() if (resuming) clearPartialInstall() - 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 exe = try { + if (!patched) unpack(archive, filename, handle) + val hint = filename.substringBeforeLast('.') + var picked = StoreExe.pick(work, hint.ifBlank { null }, item.title) + var passes = 0 + while (picked.isBlank() && passes < 2 && !cancelled.get()) { + val inner = ItchArchives.inner(work) ?: break + handle.log("itch.io: no exe yet, unpacking ${inner.name}") + unpack(inner, inner.name, handle) + if (cancelled.get()) break + inner.delete() + picked = StoreExe.pick(work, inner.name.substringBeforeLast('.').takeIf { it.endsWith(".exe", ignoreCase = true) }, item.title) + passes++ + } + picked + } catch (t: Throwable) { + if (updating) ItchStaging.delete(work) + throw t } - 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}") + if (updating) ItchStaging.delete(work) return null } + if (updating) synchronized(commit) { + if (cancelled.get()) { + ItchStaging.delete(work) + return null + } + try { + StoreInstalls.markUpdating(folder) + } catch (t: Throwable) { + ItchStaging.delete(work) + throw t + } + ItchStaging.merge(work, folder) + ItchStaging.delete(work) + } 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 unpack(archive: File, filename: String, handle: DownloadQueue.JobHandle) { + when (val kind = ItchArchives.kind(archive, filename)) { + "zip" -> unzip(archive, handle) + "7z" -> try { + sevenZip(archive, handle) + } catch (e: MemoryLimitException) { + handle.log("itch.io: ${archive.name} needs more memory than the app has, unpacking it in the runtime") + unrar(archive, handle, kind) + } + "rar" -> unrar(archive, handle, kind) + "exe" -> copy(archive, File(work, filename.ifBlank { "game.exe" })) + "tar" -> untar(archive, null, handle) + "gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle) + else -> copy(archive, File(work, filename.ifBlank { "game" })) } } private fun target(name: String): File? { - val file = File(folder, name.replace('\\', '/')) - val root = folder.canonicalPath + val file = File(work, name.replace('\\', '/')) + val root = work.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) + private fun unrar(archive: File, handle: DownloadQueue.JobHandle, kind: String) { + if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, kind)) + val binds = if (listOf(app.filesDir, app.cacheDir).any { work.absolutePath.startsWith(it.absolutePath + "/") }) null + else listOf(work.absolutePath) fun bsdtar(args: List, onLine: (String) -> Unit) = - GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line -> + GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true, cancelled = cancelled) { 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 -> + val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", work.absolutePath)) { line -> if (line.startsWith("x ")) handle.stageProgress(++done, entries) else { if (tail.size == 5) tail.removeFirst() tail.addLast(line) @@ -233,28 +323,31 @@ internal class ItchInstaller( 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) + val limitKb = (Runtime.getRuntime().maxMemory() / 4 / 1024).coerceIn(32L * 1024, Int.MAX_VALUE.toLong()).toInt() + Files.newByteChannel(archive.toPath()).use { channel -> + SevenZFile.builder().setSeekableByteChannel(channel).setMaxMemoryLimitKb(limitKb).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) + } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPatch.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPatch.kt new file mode 100644 index 000000000..9feebb10d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPatch.kt @@ -0,0 +1,124 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import com.droiddeck.launcher.core.AddonPack +import com.droiddeck.launcher.runtime.GuestCommand +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +internal object ItchPatch { + private const val DIR = "/usr/local/lib/droiddeck-butler" + + sealed interface Outcome { + data object Applied : Outcome + data object Cancelled : Outcome + data class Failed(val reason: String) : Outcome + } + + fun ready(context: Context): Boolean { + if (!AddonPack.BUTLER.available || !LinuxRuntime.isInstalled(context)) return false + if (!AddonPack.BUTLER.installed(context)) { + AddonPack.BUTLER.ensure(context) + return false + } + return stage(context) + } + + private val stageLock = Any() + + private fun stage(context: Context): Boolean = synchronized(stageLock) { stageLocked(context) } + + private fun stageLocked(context: Context): Boolean { + val pack = AddonPack.BUTLER.dir(context) + val root = File(LinuxRuntime.rootDir(context), DIR.removePrefix("/")) + val packaged = File(pack, "version").readText() + if (runCatching { File(root, "version").readText() }.getOrNull() == packaged && File(root, "butler").isFile) 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 run( + app: Context, key: String, downloadKeyId: String, from: String, to: String, fullSize: Long, installed: File, out: File, scratch: File, + handle: DownloadQueue.JobHandle, cancelled: AtomicBoolean, + ): Outcome { + if (!ready(app)) return Outcome.Failed("the patch tool is not ready") + val steps = ItchBuilds.path(key, from, to, downloadKeyId) ?: return Outcome.Failed("no patch path from build $from to $to") + if (fullSize > 0 && steps.sumOf { it.patchSize } > fullSize) return Outcome.Failed("the patches are larger than a full download") + val dir = File(scratch, "patches").apply { deleteRecursively(); mkdirs() } + try { + handle.stage(DownloadStage.DOWNLOAD) + val total = steps.sumOf { it.patchSize } + var finished = 0L + val patches = steps.mapIndexed { index, step -> + val file = File(dir, "$index.pwr") + val base = finished + val ok = ItchBuilds.download(ItchBuilds.fileUrl(step.buildId, step.patchFile, downloadKeyId), key, file, cancelled) { + handle.progress(base + it, total) + } + if (cancelled.get()) return Outcome.Cancelled + if (!ok) return Outcome.Failed("build ${step.buildId}: the patch did not download") + finished += step.patchSize + file + } + val last = steps.last() + val signature = File(dir, "final.sig") + val got = ItchBuilds.download(ItchBuilds.fileUrl(last.buildId, last.signatureFile, downloadKeyId), key, signature, cancelled) {} + if (cancelled.get()) return Outcome.Cancelled + if (!got) return Outcome.Failed("build ${last.buildId}: the signature did not download") + handle.stage(DownloadStage.INSTALL) + return apply(app, patches, signature, installed, out, scratch, cancelled) + } finally { + StoreInstalls.deleteTree(dir) + } + } + + private fun apply(app: Context, patches: List, signature: File, installed: File, out: File, scratch: File, cancelled: AtomicBoolean): Outcome { + var source = installed + var intermediate: File? = null + try { + patches.forEachIndexed { index, patch -> + val finalStep = index == patches.lastIndex + val target = if (finalStep) out else File(scratch, "patched-$index") + ItchStaging.delete(target) + target.mkdirs() + val staging = File(scratch, "butler-staging-$index").apply { deleteRecursively(); mkdirs() } + val args = mutableListOf("$DIR/butler", "apply", "--assume-yes", "--staging-dir", staging.absolutePath, "-d", target.absolutePath) + if (finalStep) args += listOf("--signature", signature.absolutePath) + args += listOf(patch.absolutePath, source.absolutePath) + val binds = listOf(installed, out, scratch).map { it.absolutePath } + .filter { path -> listOf(app.filesDir, app.cacheDir).none { path.startsWith(it.absolutePath + "/") } } + .distinct().takeIf { it.isNotEmpty() } + val tail = ArrayDeque() + val code = try { + GuestCommand.run(app, args, logName = "itch-butler", extraBinds = binds, fastPath = true, cancelled = cancelled) { line -> + if (!line.startsWith("proot ")) { + if (tail.size == 4) tail.removeFirst() + tail.addLast(line) + } + } + } finally { + StoreInstalls.deleteTree(staging) + } + if (cancelled.get()) return Outcome.Cancelled + if (code != 0) return Outcome.Failed("butler exited $code: ${tail.joinToString(" | ")}") + intermediate?.let { StoreInstalls.deleteTree(it) } + intermediate = if (finalStep) null else target + source = target + } + return Outcome.Applied + } finally { + intermediate?.let { StoreInstalls.deleteTree(it) } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchStaging.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchStaging.kt new file mode 100644 index 000000000..73e8f8596 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchStaging.kt @@ -0,0 +1,30 @@ +package com.droiddeck.launcher.stores.itch + +import java.io.File +import java.nio.file.Files + +internal object ItchStaging { + const val DIR = ".droiddeck-update" + const val RECORD = ".droiddeck-store.json" + + fun merge(from: File, to: File, top: Boolean = true) { + for (child in from.listFiles().orEmpty()) { + if (top && child.name == RECORD) continue + val dest = File(to, child.name) + val destPath = dest.toPath() + if (child.isDirectory && !Files.isSymbolicLink(child.toPath()) && dest.isDirectory && !Files.isSymbolicLink(destPath)) { + merge(child, dest, false) + continue + } + if (Files.isSymbolicLink(destPath) || dest.exists()) { + delete(dest) + } + Files.move(child.toPath(), destPath) + } + } + + fun delete(file: File) { + if (!Files.isSymbolicLink(file.toPath()) && file.isDirectory) file.listFiles().orEmpty().forEach { delete(it) } + file.delete() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUpdates.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUpdates.kt new file mode 100644 index 000000000..2a1d105ed --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUpdates.kt @@ -0,0 +1,187 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.StoresState +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean + +internal class ItchUpdate(val uploadId: String, val updatedAt: String, val filename: String, val replaced: Boolean) + +internal object ItchUpdates { + private const val KEY_AT = "updates_checked_at" + private const val KEY_FOUND = "updates_found" + private const val THROTTLE_MS = 6 * 60 * 60 * 1000L + private const val GAP_MS = 1_000L + + val available = mutableStateMapOf() + var checkingKey by mutableStateOf(null) + private set + + private val running = AtomicBoolean(false) + private val restored = AtomicBoolean(false) + @Volatile private var generation = 0 + + internal fun evaluate(uploads: JSONArray?, uploadId: String, version: String): ItchUpdate? { + if (uploads == null || version.isBlank()) return null + val current = ItchUploads.byId(uploads, uploadId) + if (current != null) { + val stamp = ItchUploads.updatedAt(current) + return if (stamp > version) ItchUpdate(uploadId, stamp, ItchUploads.filename(current), false) else null + } + val next = ItchUploads.newestWindows(uploads) ?: return null + val stamp = ItchUploads.updatedAt(next) + if (uploadId.isBlank() && stamp <= version) return null + return ItchUpdate(next.optString("id"), stamp, ItchUploads.filename(next), uploadId.isNotBlank()) + } + + internal fun encode(found: Map): String = JSONObject().also { out -> + for ((key, u) in found) out.put(key, JSONObject().put("uploadId", u.uploadId).put("updatedAt", u.updatedAt).put("filename", u.filename).put("replaced", u.replaced)) + }.toString() + + internal fun decode(raw: String?): Map { + val json = runCatching { JSONObject(raw.orEmpty()) }.getOrNull() ?: return emptyMap() + val out = LinkedHashMap() + for (key in json.keys()) { + val u = json.optJSONObject(key) ?: continue + val id = u.optString("uploadId") + if (id.isBlank()) continue + out[key] = ItchUpdate(id, u.optString("updatedAt"), u.optString("filename"), u.optBoolean("replaced")) + } + return out + } + + private fun restore(app: Context) { + if (!restored.compareAndSet(false, true)) return + val started = generation + val saved = decode(ItchPrefs.get(app).getString(KEY_FOUND, null)) + StoresState.post { + if (started != generation) return@post + for ((k, v) in saved) if (k !in available) available[k] = v + } + } + + private fun save(app: Context) { + ItchPrefs.get(app).edit().putString(KEY_FOUND, encode(available.toMap())).apply() + } + + fun check(context: Context) { + val app = context.applicationContext + if (!running.compareAndSet(false, true)) return + val started = generation + Thread({ + try { + restore(app) + val prefs = ItchPrefs.get(app) + if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) return@Thread + val key = ItchAuth.key(app) + if (key.isBlank()) return@Thread + val games = StoresState.installed.toList().filter { it.sidecar.store == Store.ITCH && it.sidecar.isInstalled } + val found = LinkedHashMap() + val answered = HashSet() + val snapshot = HashMap>() + var failed = false + games.forEachIndexed { i, game -> + if (i > 0) Thread.sleep(GAP_MS) + val extra = game.sidecar.extra + snapshot[game.key] = game.sidecar.installVersion to extra["uploadId"].orEmpty() + val gameId = extra["gameId"].orEmpty() + if (gameId.isNotEmpty()) { + val uploads = ItchApi.uploads(key, gameId, extra["downloadKeyId"].orEmpty()) + if (uploads == null) failed = true + else { + answered += game.key + evaluate(uploads, extra["uploadId"].orEmpty(), game.sidecar.installVersion)?.let { found[game.key] = it } + } + } else failed = true + } + if (started != generation) return@Thread + StoresState.post { + if (started != generation) return@post + for (k in answered) { + val now = StoresState.installed.firstOrNull { it.key == k } ?: continue + if (snapshot[k] != now.sidecar.installVersion to now.sidecar.extra["uploadId"].orEmpty()) continue + val u = found[k] + if (u != null) available[k] = u else available.remove(k) + } + save(app) + } + if (!failed && started == generation) prefs.edit().putLong(KEY_AT, System.currentTimeMillis()).apply() + StoresState.logLine("itch.io: checked ${games.size} installed games, ${found.size} with updates") + } finally { + running.set(false) + } + }, "itch-updates").start() + } + + fun checkGame(context: Context, game: InstalledStoreGame) { + val app = context.applicationContext + if (checkingKey != null) return + checkingKey = game.key + val started = generation + val version = game.sidecar.installVersion + val uploadId = game.sidecar.extra["uploadId"].orEmpty() + Thread({ + try { + val key = ItchAuth.key(app) + val extra = game.sidecar.extra + val gameId = extra["gameId"].orEmpty() + val uploads = if (key.isBlank() || gameId.isEmpty()) null else ItchApi.uploads(key, gameId, extra["downloadKeyId"].orEmpty()) + val found = evaluate(uploads, uploadId, version) + StoresState.post { + if (started != generation) return@post + val now = StoresState.installed.firstOrNull { it.key == game.key } + if (uploads != null && now != null && now.sidecar.installVersion == version && now.sidecar.extra["uploadId"].orEmpty() == uploadId) { + if (found != null) available[game.key] = found else available.remove(game.key) + save(app) + } + val text = when { + uploads == null -> app.getString(R.string.stores_sync_failed) + found != null -> app.getString(R.string.stores_itch_updates_found, 1) + else -> app.getString(R.string.upd_current_status) + } + Toast.makeText(app, text, Toast.LENGTH_SHORT).show() + } + StoresState.logLine("itch.io: checked ${game.sidecar.title}, ${if (found != null) "update available" else "up to date"}") + } finally { + StoresState.post { checkingKey = null } + } + }, "itch-update-check").start() + } + + fun start(context: Context, game: InstalledStoreGame, update: ItchUpdate) { + val app = context.applicationContext + if (StoresState.uninstalling.containsKey(game.key)) return + val root = game.folder.parentFile?.parentFile ?: return + val extra = game.sidecar.extra + ("uploadId" to update.uploadId) + val item = CatalogItem( + Store.ITCH, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, + isFree = extra["downloadKeyId"].isNullOrBlank(), owned = true, extra = extra, + ) + StoresState.install(app, item, root) + } + + fun applied(context: Context, key: String) { + val app = context.applicationContext + StoresState.post { + available.remove(key) + save(app) + } + } + + fun clear(context: Context) { + val app = context.applicationContext + generation++ + ItchPrefs.get(app).edit().remove(KEY_AT).remove(KEY_FOUND).apply() + StoresState.post { available.clear() } + } +} 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 index 7e3ed94b4..208c11169 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt @@ -18,6 +18,14 @@ internal object ItchUploads { return candidates.maxWithOrNull(compareBy({ it.optString("updated_at", it.optString("updatedAt")) }, { it.optLong("id") })) } + fun newestWindows(uploads: JSONArray?): JSONObject? { + if (uploads == null) return null + val windows = (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) } + .filter { it.optString("storage") != "external" && !it.optBoolean("demo") && windows(it) } + val games = windows.filter { it.optString("type") in GAME_TYPES } + return games.ifEmpty { windows }.maxWithOrNull(compareBy({ updatedAt(it) }, { it.optLong("id") })) + } + fun windows(upload: JSONObject): Boolean { val platforms = upload.optJSONObject("platforms") if (platforms != null) { @@ -52,6 +60,8 @@ internal object ItchUploads { return (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) }.firstOrNull { it.optString("id") == id } } + fun updatedAt(upload: JSONObject): String = upload.optString("updated_at", upload.optString("updatedAt")) + 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/ui/BottomScreenTyping.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomScreenTyping.kt new file mode 100644 index 000000000..50f62b8cf --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomScreenTyping.kt @@ -0,0 +1,158 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.CommitTextCommand +import androidx.compose.ui.text.input.DeleteAllCommand +import androidx.compose.ui.text.input.EditCommand +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.PlatformTextInputService +import androidx.compose.ui.text.input.SetSelectionCommand +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TextInputService +import androidx.compose.ui.text.input.TextInputSession +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R + +class TypingRequest( + initial: TextFieldValue, + val imeOptions: ImeOptions, + private val onValue: (TextFieldValue) -> Unit, + private val onImeAction: (ImeAction) -> Unit, + private val onClose: () -> Unit = {}, +) { + var value by mutableStateOf(initial) + + fun send(next: TextFieldValue) { + value = next + onValue(next) + } + + fun action() = onImeAction(imeOptions.imeAction) + + fun close() = onClose() + + companion object { + internal fun editsFor(next: TextFieldValue): List = listOf( + DeleteAllCommand(), + CommitTextCommand(next.text, 1), + SetSelectionCommand(next.selection.start, next.selection.end), + ) + } +} + +internal val LocalBottomTyping = staticCompositionLocalOf { false } + +internal class BottomScreenTextInput(private val fallback: TextInputService) : PlatformTextInputService { + private var session: TextInputSession? = null + + override fun startInput( + value: TextFieldValue, imeOptions: ImeOptions, + onEditCommand: (List) -> Unit, onImeActionPerformed: (ImeAction) -> Unit, + ) { + session?.let { fallback.stopInput(it) } + session = null + if (DualScreenPanel.attached) DualScreenPanel.startTyping(TypingRequest(value, imeOptions, { onEditCommand(TypingRequest.editsFor(it)) }, onImeActionPerformed)) + else session = fallback.startInput(value, imeOptions, onEditCommand, onImeActionPerformed) + } + + override fun stopInput() { + session?.let { fallback.stopInput(it) } + session = null + DualScreenPanel.stopTyping() + } + + override fun showSoftwareKeyboard() { + val active = session + if (active != null) active.showSoftwareKeyboard() else DualScreenPanel.showTyping() + } + + override fun hideSoftwareKeyboard() { + val active = session + if (active != null) active.hideSoftwareKeyboard() else DualScreenPanel.hideTyping() + } + + override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) { + val active = session + if (active != null) active.updateState(oldValue, newValue) else DualScreenPanel.updateTyping(newValue) + } +} + +@Composable +internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) { + val colors = MaterialTheme.colorScheme + var field by remember(request) { mutableStateOf(request.value) } + LaunchedEffect(request.value) { + if (request.value.text != field.text) field = request.value + } + val focus = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(request) { + kotlinx.coroutines.delay(120) + runCatching { focus.requestFocus() } + keyboard?.show() + } + val options = request.imeOptions + val secret = options.keyboardType == KeyboardType.Password || options.keyboardType == KeyboardType.NumberPassword + Column( + Modifier.fillMaxSize().background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.dual_typing_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, + color = colors.onSurfaceVariant, modifier = Modifier.weight(1f)) + Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(stringResource(R.string.dual_typing_done)) } + } + OutlinedTextField( + value = field, + onValueChange = { next -> + field = next + request.send(next) + }, + singleLine = options.singleLine, + maxLines = if (options.singleLine) 1 else 4, + textStyle = MaterialTheme.typography.titleLarge, + visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions( + capitalization = options.capitalization, autoCorrect = options.autoCorrect, + keyboardType = options.keyboardType, imeAction = options.imeAction, + ), + keyboardActions = KeyboardActions(onAny = { + request.action() + onDone() + }), + modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("dual-typing-field"), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt index f606e6008..b7f5d44db 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt @@ -33,6 +33,9 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.layout.layout import kotlinx.coroutines.delay import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue @@ -40,6 +43,20 @@ import androidx.compose.runtime.getValue /** A grid of cards with a game page that floods out of the opened card and drains back into it. */ internal val LocalDetailFloor = compositionLocalOf<() -> Float> { { Float.MAX_VALUE } } +/** How far the host reaches past the page's padding, so a game page can paint to the screen edge and still inset its content. */ +internal class DetailInset(val horizontal: Dp, val top: Dp) +internal val LocalDetailInset = compositionLocalOf { DetailInset(0.dp, 0.dp) } + +private fun Modifier.bleed(horizontal: Dp, top: Dp): Modifier = + if (horizontal == 0.dp && top == 0.dp) this else layout { measurable, constraints -> + val h = horizontal.roundToPx() + val t = top.roundToPx() + val placeable = measurable.measure( + Constraints.fixed(constraints.maxWidth + h * 2, if (constraints.hasBoundedHeight) constraints.maxHeight + t else constraints.minHeight), + ) + layout(constraints.maxWidth, placeable.height - t) { placeable.place(-h, -t) } + } + @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @Composable internal fun CardDetailHost( @@ -48,6 +65,8 @@ internal fun CardDetailHost( gridScrolled: () -> Boolean, onDetailFocus: (Boolean) -> Unit, modifier: Modifier = Modifier, + bleedHorizontal: Dp = 0.dp, + bleedTop: Dp = 0.dp, wrap: @Composable (key: String, content: @Composable () -> Unit) -> Unit = { _, content -> content() }, grid: @Composable () -> Unit, detail: @Composable (key: String) -> Unit, @@ -86,7 +105,7 @@ internal fun CardDetailHost( val gridAlpha by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "gridFade") val fade = with(density) { 14.dp.toPx() } Box( - modifier.clipToBounds() + modifier.bleed(bleedHorizontal, bleedTop).clipToBounds() .onGloballyPositioned { viewportAt = it.positionInRoot(); viewportH = it.size.height } .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } .drawWithContent { @@ -100,7 +119,7 @@ internal fun CardDetailHost( ) { CompositionLocalProvider(LocalFrontFocus provides if (covering) null else ff) { Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().padding(horizontal = bleedHorizontal).padding(top = bleedTop) .graphicsLayer { scaleX = gridScale; scaleY = gridScale; alpha = gridAlpha } .focusProperties { enter = { if (coveringNow.value) FocusRequester.Cancel else FocusRequester.Default } } .focusGroup(), @@ -111,7 +130,7 @@ internal fun CardDetailHost( val body: @Composable () -> Unit = { Column( Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures { } } - .verticalScroll(detailScroll).padding(top = 4.dp, bottom = 16.dp), + .verticalScroll(detailScroll).padding(bottom = 16.dp), ) { Column( Modifier.fillMaxWidth() @@ -120,7 +139,7 @@ internal fun CardDetailHost( .focusGroup(), ) { val floor = { viewportAt.y + viewportH - with(density) { 16.dp.toPx() } - detailScroll.value } - CompositionLocalProvider(LocalDetailFloor provides floor) { detail(key) } + CompositionLocalProvider(LocalDetailFloor provides floor, LocalDetailInset provides DetailInset(bleedHorizontal, bleedTop + 4.dp)) { detail(key) } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt index ff39fb00f..0fab26798 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt @@ -93,6 +93,9 @@ fun ControllerMappingPage(mapping: Map, onPick: (String, String) lede = stringResource(R.string.ctrl_mapping_lede), onBack = onBack, ) { + SettingsGroup(stringResource(R.string.shortcuts_title)) { + PhysicalShortcutRows() + } SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) { val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) } for ((id, name) in ControllerPrefs.mappable) { 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 825b6980f..1afa23b71 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt @@ -38,6 +38,7 @@ class DeckControlsPanel( private val onQam: () -> Unit, private val pad: PadBridge? = null, private val portraitDock: Boolean = false, + private val onMenu: (() -> Unit)? = null, ) : LinearLayout(context) { private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) } @@ -90,6 +91,10 @@ class DeckControlsPanel( addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44))) } addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) + onMenu?.let { + addView(menuButton(context, it)) + addView(space(dp(8))) + } if (SessionState.mode == SessionService.MODE_STEAM) { addView(roundButton("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu, wide = true)) addView(space(dp(8))) @@ -249,39 +254,35 @@ class DeckControlsPanel( show() } - private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) = - TextView(context).apply { - text = label - contentDescription = description - textSize = if (wide) 13f else 20f - typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - letterSpacing = if (wide) 0.08f else 0f - gravity = Gravity.CENTER - setTextColor(TEXT) - background = outline(CORNER_PILL, SURFACE) - setPadding(if (wide) dp(16) else 0, 0, if (wide) dp(16) else 0, 0) - minWidth = dp(44) - layoutParams = LayoutParams(if (wide) LayoutParams.WRAP_CONTENT else dp(44), dp(44)) - setOnClickListener { - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - onClick() - } + /** Whether a finger going down at ([x], [y]) lands on one of the panel's controls. */ + fun takesTouchAt(x: Float, y: Float): Boolean = controlUnder(this, x, y) + + // Every control here takes the touches it gets: buttons and tabs (clickable), the scrolling tab strip, + // and the drawn surfaces, pads and face buttons. The layouts around them do not. + private fun controlUnder(view: View, x: Float, y: Float): Boolean { + if (view.visibility != VISIBLE || x < 0f || y < 0f || x >= view.width || y >= view.height) return false + if (view.isClickable || view is HorizontalScrollView || view is Surface || view is DirectionPad || view is FaceButtons) return true + val group = view as? ViewGroup ?: return false + return (group.childCount - 1 downTo 0).any { i -> + val child = group.getChildAt(i) + controlUnder(child, x + group.scrollX - child.x, y + group.scrollY - child.y) } + } + + private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) = + roundButton(context, label, description, onClick, wide) private fun space(width: Int) = View(context).apply { layoutParams = LayoutParams(width, 1) } - private fun outline(radius: Float, fill: Int) = GradientDrawable().apply { - cornerRadius = radius * resources.displayMetrics.density - setColor(fill) - setStroke(dp(1), LINE) - } + private fun outline(radius: Float, fill: Int) = outline(context, radius, fill) private fun filled(radius: Float, fill: Int) = GradientDrawable().apply { cornerRadius = radius * resources.displayMetrics.density setColor(fill) } - private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt() + private fun dp(value: Int): Int = dp(context, value) + private abstract class Surface(context: Context) : View(context) { protected val density = resources.displayMetrics.density @@ -686,5 +687,47 @@ class DeckControlsPanel( private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7) private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A) private val KNOB = Color.rgb(0x2A, 0x2D, 0x33) + + internal fun dp(context: Context, value: Int): Int = (value * context.resources.displayMetrics.density + 0.5f).toInt() + + internal fun outline(context: Context, radius: Float, fill: Int) = GradientDrawable().apply { + cornerRadius = radius * context.resources.displayMetrics.density + setColor(fill) + setStroke(dp(context, 1), LINE) + } + + internal fun roundButton(context: Context, label: String, description: String, onClick: () -> Unit, wide: Boolean = false) = + TextView(context).apply { + text = label + contentDescription = description + textSize = if (wide) 13f else 20f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + letterSpacing = if (wide) 0.08f else 0f + gravity = Gravity.CENTER + setTextColor(TEXT) + background = outline(context, CORNER_PILL, SURFACE) + setPadding(if (wide) dp(context, 16) else 0, 0, if (wide) dp(context, 16) else 0, 0) + minWidth = dp(context, 44) + layoutParams = LinearLayout.LayoutParams(if (wide) LinearLayout.LayoutParams.WRAP_CONTENT else dp(context, 44), dp(context, 44)) + setOnClickListener { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + } + + internal fun menuButton(context: Context, onClick: () -> Unit) = android.widget.ImageView(context).apply { + setImageResource(R.drawable.ic_stat_session) + setColorFilter(TEXT) + contentDescription = context.getString(R.string.touch_droiddeck_menu) + scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE + val pad = dp(context, 11) + setPadding(pad, pad, pad, pad) + background = outline(context, CORNER_PILL, SURFACE) + layoutParams = LinearLayout.LayoutParams(dp(context, 44), dp(context, 44)) + setOnClickListener { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + } } } 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 39862c082..28768454c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt @@ -174,7 +174,7 @@ private fun DesktopDock( val pal = LocalPalette.current val narrow = LocalNarrowPane.current val available = s.emulators.filter { !it.installed } - val flatpaks = com.droiddeck.launcher.store.StoreState.installed + val flatpaks = com.droiddeck.launcher.store.StoreState.installed.filter { f -> apps.none { it.key == "flatpak:${f.id}" } } val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + flatpaks.map { "flatpak:${it.id}" } var lastAt by remember { mutableStateOf(null) } var dockAt by remember { mutableStateOf(null) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DualScreenPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DualScreenPanel.kt new file mode 100644 index 000000000..348ad43ff --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DualScreenPanel.kt @@ -0,0 +1,371 @@ +package com.droiddeck.launcher.ui + +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.graphics.Color +import androidx.compose.ui.platform.LocalContext +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.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R + +class DualPanelModel( + val keys: List, + val selected: String, + val badges: Set, + val storeDownloads: Int, + val running: String?, + val theme: String, + val appScale: Int, + val onSelect: (String) -> Unit, + val onResume: () -> Unit, +) + +object DualScreenPanel { + var model by mutableStateOf(null) + private var displayId = -1 + private var activity: DualScreenActivity? = null + set(value) { field = value; available = value != null || launching } + private var launching = false + set(value) { field = value; available = value || activity != null } + private var ownerTask = -1 + private var ownerYielded = false + + var typing by mutableStateOf(null) + private set + var typingShown by mutableStateOf(false) + private set + + val shownOn: Int get() = if (activity != null || launching) displayId else -1 + val attached: Boolean get() = activity != null + var available by mutableStateOf(false) + private set + + fun startTyping(request: TypingRequest) { + typing = request + typingShown = true + } + + fun stopTyping() { + typing = null + typingShown = false + } + + fun stopTyping(request: TypingRequest) { + if (typing === request) stopTyping() + } + + fun showTyping() { + if (typing != null) typingShown = true + } + + fun finishTyping() { + val request = typing + hideTyping() + request?.close() + } + + fun hideTyping() { + typingShown = false + } + + fun updateTyping(value: TextFieldValue) { + val request = typing ?: return + if (request.value != value) request.value = value + } + + fun show(context: Context, display: Int, task: Int) { + if (shownOn == display) { ownerTask = task; return } + hide() + displayId = display + ownerTask = task + ownerYielded = false + launching = true + val intent = Intent(context, DualScreenActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION) + runCatching { + context.startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(display).toBundle()) + }.onFailure { launching = false; displayId = -1 } + } + + fun hide() { + stopTyping() + launching = false + displayId = -1 + activity?.finishAndRemoveTask() + activity = null + } + + fun hide(task: Int) { + if (ownerTask == task || ownerTask < 0) hide() + } + + internal fun attach(host: DualScreenActivity): Boolean { + if (!launching && activity == null) return false + if (activity !== host) activity?.finishAndRemoveTask() + activity = host + launching = false + return true + } + + // The panel resumes when an app opens over the owner on the primary screen; pulling the owner back would hide that app. + fun yieldTo(task: Int, app: Boolean) { + if (ownerTask == task) ownerYielded = app + } + + internal fun returnFocus(context: Context) { + if (ownerYielded) return + val task = ownerTask.takeIf { it >= 0 } ?: return + runCatching { (context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager).moveTaskToFront(task, 0) } + } + + internal fun reclaimFocus(context: Context) { + if (ownerYielded) return + val task = ownerTask.takeIf { it >= 0 } ?: return + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + runCatching { + manager.appTasks.firstOrNull { + @Suppress("DEPRECATION") + (if (android.os.Build.VERSION.SDK_INT >= 29) it.taskInfo.taskId else it.taskInfo.persistentId) == task + }?.moveToFront() ?: manager.moveTaskToFront(task, 0) + } + } + + internal fun detach(host: DualScreenActivity) { + if (activity === host) { + activity = null + displayId = -1 + } + } +} + +class DualScreenActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!DualScreenPanel.attach(this)) { + finishAndRemoveTask() + return + } + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + applyImmersiveMode() + setContent { + val model = DualScreenPanel.model + val request = DualScreenPanel.typing + val typingOn = request != null && DualScreenPanel.typingShown + LaunchedEffect(typingOn) { acceptFocus(typingOn) } + DroidDeckTheme(model?.theme, appScale = model?.appScale ?: com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) { + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + if (request != null && typingOn) TypingPanel(request) { if (DualScreenPanel.typing === request) DualScreenPanel.finishTyping() } + else if (model != null) DualPanelContent(model) + } + } + } + } + + private fun acceptFocus(on: Boolean) { + if (on) { + unfocusPending = false + window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + runCatching { (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager).moveTaskToFront(taskId, 0) } + } else { + unfocusPending = true + DualScreenPanel.returnFocus(this) + window.decorView.postDelayed({ + if (!unfocusPending || DualScreenPanel.typingShown) return@postDelayed + if (window.decorView.hasWindowFocus()) DualScreenPanel.returnFocus(this) else dropFocus() + }, 400) + } + } + + private var unfocusPending = false + + private fun dropFocus() { + unfocusPending = false + } + + override fun onResume() { + super.onResume() + if (!DualScreenPanel.typingShown) DualScreenPanel.returnFocus(this) + } + + override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) { + super.onTopResumedActivityChanged(isTopResumedActivity) + if (isTopResumedActivity && !DualScreenPanel.typingShown) DualScreenPanel.returnFocus(this) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + applyImmersiveMode() + if (!DualScreenPanel.typingShown) DualScreenPanel.reclaimFocus(this) + } else if (unfocusPending && !DualScreenPanel.typingShown) dropFocus() + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onBackPressed() { + if (DualScreenPanel.typingShown) DualScreenPanel.finishTyping() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (DualScreenPanel.typingShown && event.keyCode == KeyEvent.KEYCODE_BUTTON_B) { + if (event.action == KeyEvent.ACTION_UP && !event.isCanceled) DualScreenPanel.finishTyping() + return true + } + return super.dispatchKeyEvent(event) + } + + override fun onDestroy() { + DualScreenPanel.detach(this) + super.onDestroy() + } + + private fun applyImmersiveMode() { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) + } +} + +@Composable +internal fun DualPanelContent(model: DualPanelModel) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.app_name), fontSize = 15.sp, fontWeight = FontWeight.Bold, color = pal.signal) + Spacer(Modifier.size(12.dp)) + val (label, _) = railLabelIcon(model.selected) + Text(stringResource(label), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, + modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + PanelClock() + } + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { + val columns = if (model.keys.size > 6) 4 else 3 + val rows = (model.keys.size + columns - 1) / columns + val gap = 12.dp + val tileHeight = ((maxHeight - gap * (rows - 1)) / rows).coerceAtLeast(64.dp) + Column(verticalArrangement = Arrangement.spacedBy(gap)) { + for (row in model.keys.chunked(columns)) { + Row(Modifier.fillMaxWidth().height(tileHeight), horizontalArrangement = Arrangement.spacedBy(gap)) { + for (key in row) PanelTile(key, key == model.selected, key in model.badges, + if (key == "stores") model.storeDownloads else 0, Modifier.weight(1f)) { model.onSelect(key) } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } + if (model.running != null) { + Row( + Modifier.fillMaxWidth().height(56.dp).clip(RoundedCornerShape(16.dp)).background(pal.good.copy(alpha = 0.18f)) + .clickable(role = Role.Button, onClick = model.onResume).testTag("dual-resume").padding(horizontal = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(10.dp).clip(CircleShape).background(pal.good)) + Spacer(Modifier.size(12.dp)) + Text(stringResource(R.string.rail_resume_named, model.running), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, + color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun PanelTile(key: String, current: Boolean, badge: Boolean, count: Int, modifier: Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val (label, icon) = railLabelIcon(key) + val shape = RoundedCornerShape(18.dp) + Box( + modifier.fillMaxSize().clip(shape) + .background(if (current) pal.signal.copy(alpha = 0.16f) else colors.surface) + .border(if (current) 2.dp else 1.dp, if (current) pal.signal else Color.White.copy(alpha = 0.06f), shape) + .clickable(role = Role.Tab, onClick = onClick).testTag("dual-rail-$key"), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(icon, contentDescription = null, tint = if (current) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(34.dp)) + Text(stringResource(label), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, + color = if (current) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp)) + } + if (badge) Box(Modifier.align(Alignment.TopEnd).padding(12.dp).size(10.dp).clip(CircleShape).background(AttentionAmber)) + if (count > 0) Text( + if (count > 9) "9+" else count.toString(), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, + modifier = Modifier.align(Alignment.TopEnd).padding(10.dp).clip(RoundedCornerShape(8.dp)).background(pal.signal) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + } +} + +@Composable +private fun PanelClock() { + val context = LocalContext.current + var now by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + now = System.currentTimeMillis() + kotlinx.coroutines.delay(60_000L - now % 60_000L) + } + } + Text(android.text.format.DateFormat.getTimeFormat(context).format(java.util.Date(now)), fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@Composable +internal fun DualPanelPublisher(s: FrontEndState, selected: String, onSelect: (String) -> Unit, a: FrontEndActions) { + val badges = buildSet { + if (setupNeedsAttention(s)) add("setup") + if (s.updates.hasUpdate) add("updates") + } + val model = DualPanelModel(navigationKeys(s), selected, badges, s.storeDownloadsActive, s.running, s.theme, s.appScale, onSelect, a.onResume) + androidx.compose.runtime.SideEffect { DualScreenPanel.model = model } + androidx.compose.runtime.DisposableEffect(Unit) { onDispose { DualScreenPanel.model = null } } +} + +internal fun dualPageStep(event: android.view.KeyEvent): Int? = when (event.keyCode) { + android.view.KeyEvent.KEYCODE_BUTTON_L2 -> -1 + android.view.KeyEvent.KEYCODE_BUTTON_R2 -> 1 + else -> null +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt index 452a6160c..bb1370c15 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -94,7 +94,12 @@ internal class FocusGlide(view: View?) { internal val LocalFocusGlide = staticCompositionLocalOf { null } internal object RingVeil { - var count by mutableIntStateOf(0) + private var state = mutableIntStateOf(0) + var count: Int + get() = state.intValue + set(value) { state.intValue = value } + + internal fun reset() { state = mutableIntStateOf(0) } } @Composable 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 efc00297c..a8f3f829f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -202,7 +202,6 @@ internal fun BusyChip(s: FrontEndState) { internal fun RuntimeChip(s: FrontEndState) = when { s.busy -> Chip(if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage, ok = false) s.removalPending -> Chip(stringResource(R.string.runtime_removal_incomplete), ok = false) - !s.ready -> Chip(stringResource(R.string.games_runtime_first_play), ok = false) s.available != null && s.available != s.installed -> Chip(stringResource(R.string.games_runtime_update), ok = false) else -> Chip(stringResource(R.string.games_runtime_ready), ok = true) } 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 b7ac8ee43..dcd1f50ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -66,6 +66,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import com.droiddeck.launcher.session.SessionPrefs +import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -126,6 +128,20 @@ internal fun navigationKeys(s: FrontEndState) = buildList { add("setup"); add("updates") } +internal object RailLabels { + var shown by mutableStateOf(true) + private set + + fun load(context: android.content.Context) { + shown = SessionPrefs.railLabels(context) + } + + fun set(context: android.content.Context, show: Boolean) { + shown = show + SessionPrefs.setRailLabels(context, show) + } +} + @Composable internal fun SideRail( s: FrontEndState, selected: String, @@ -133,7 +149,9 @@ internal fun SideRail( ) { val colors = MaterialTheme.colorScheme val compact = LocalConfiguration.current.screenHeightDp < 420 - val iconOnly = isNarrowScreen() + val railContext = LocalContext.current + remember { RailLabels.load(railContext) } + val iconOnly = isNarrowScreen() || !RailLabels.shown Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp), @@ -173,24 +191,28 @@ internal fun SideRail( } } +internal fun railLabelIcon(key: String): Pair = when (key) { + "steam" -> R.string.rail_steam to Icons.Outlined.SportsEsports + "games" -> R.string.rail_games to Icons.Outlined.VideoLibrary + "stores" -> R.string.rail_stores to Icons.Outlined.LocalMall + "desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows + "components" -> R.string.rail_components to Icons.Outlined.Layers + "android-apps" -> R.string.rail_apps to Icons.Outlined.Apps + "setup" -> R.string.rail_setup to Icons.Outlined.Tune + else -> R.string.rail_updates to Icons.Outlined.SystemUpdate +} + +internal fun setupNeedsAttention(s: FrontEndState): Boolean = !s.ready || (s.available != null && s.available != s.installed) || + PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) + @Composable private fun NavigationItems( s: FrontEndState, shown: String, compact: Boolean, iconOnly: Boolean, height: Dp, width: Dp, place: (String) -> Modifier, onSelect: (String) -> Unit, onFocusSelect: (String) -> Unit, ) { - val setupNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) || - PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) + val setupNeedsAttention = setupNeedsAttention(s) for (key in navigationKeys(s)) { - val (label, icon) = when (key) { - "steam" -> R.string.rail_steam to Icons.Outlined.SportsEsports - "games" -> R.string.rail_games to Icons.Outlined.VideoLibrary - "stores" -> R.string.rail_stores to Icons.Outlined.LocalMall - "desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows - "components" -> R.string.rail_components to Icons.Outlined.Layers - "android-apps" -> R.string.rail_apps to Icons.Outlined.Apps - "setup" -> R.string.rail_setup to Icons.Outlined.Tune - else -> R.string.rail_updates to Icons.Outlined.SystemUpdate - } + val (label, icon) = railLabelIcon(key) key(key) { RailItem(stringResource(label), icon, key, shown == key, compact, iconOnly, height, width, badge = (key == "setup" && setupNeedsAttention) || (key == "updates" && s.updates.hasUpdate), 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 69abceb38..eec57cfb9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -69,6 +69,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.layout.onGloballyPositioned @@ -99,6 +102,8 @@ class FrontEndState( val frameGenLabel: String, val romsDir: String?, val logsEnabled: Boolean, + val gameDebugLogsEnabled: Boolean = false, + val gameDebugLogsNeedsRestart: Boolean = false, val vacDiagnostics: Boolean = false, val agentCommands: Boolean = false, val clearCachedApps: Boolean = false, @@ -125,6 +130,11 @@ class FrontEndState( val integrationFocus: com.droiddeck.launcher.core.SteamIntegration.Feature? = null, val androidApps: List = emptyList(), val secondScreenDisplays: List = emptyList(), + val dualScreen: Boolean = false, + val dualScreenDevice: Boolean = false, + val dualScreenMode: String = com.droiddeck.launcher.input.DualScreen.MODE_AUTO, + val dualScreenLauncher: Boolean = true, + val dualScreenControls: Boolean = true, val packages: List? = null, val packageCatalogLoading: Boolean = false, val packageBusyId: String? = null, @@ -208,6 +218,7 @@ class FrontEndActions( val onFiles: () -> Unit, val onBrowseFiles: (File) -> Unit = {}, val onLogs: () -> Unit, + val onGameDebugLogs: () -> Unit = {}, val onVacDiagnostics: () -> Unit = {}, val onAgentCommands: () -> Unit = {}, val onClearCachedApps: (Boolean) -> Unit = {}, @@ -226,6 +237,9 @@ class FrontEndActions( val onLauncherOrientation: (Int) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, + val onDualScreenMode: (String) -> Unit = {}, + val onDualScreenLauncher: (Boolean) -> Unit = {}, + val onDualScreenControls: (Boolean) -> Unit = {}, val onDownloadSource: (String) -> Unit = {}, val onDownloadMirror: (String) -> Unit = {}, val onDownloadPrefix: (String) -> Unit = {}, @@ -255,7 +269,7 @@ class FrontEndActions( val onCopyPhantomCommand: (Boolean) -> Unit = {}, val onDismissPhantomGate: () -> Unit = {}, val onStartWirelessAdbPairing: () -> Unit = {}, - val onStartPairingGuide: (Int?) -> Boolean = { false }, + val onStartPairingGuide: (Int?, Boolean) -> Boolean = { _, _ -> false }, val onShowPhantomGate: () -> Unit = {}, val onOpenNotificationSettings: () -> Unit = {}, val controller: ControllerActions? = null, @@ -398,10 +412,14 @@ internal suspend fun focusSoon(target: () -> FocusRequester) { @Composable fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) { val frontFocus = remember { FrontFocus() } + val textInput = androidx.compose.ui.platform.LocalTextInputService.current + val bottomTextInput = remember(textInput) { textInput?.let { androidx.compose.ui.text.input.TextInputService(BottomScreenTextInput(it)) } } androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { CompositionLocalProvider( LocalFrontFocus provides frontFocus, LocalNarrowPane provides (maxWidth < NarrowPaneWidth), + androidx.compose.ui.platform.LocalTextInputService provides (if (s.dualScreen) bottomTextInput else textInput), + LocalBottomTyping provides s.dualScreen, ) { FrontEndScreenBody(s, a, page, frontFocus) } } } @@ -428,6 +446,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) } var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) } var developerChoiceGuided by rememberSaveable { mutableStateOf(false) } + var developerChoiceToggle by rememberSaveable { mutableStateOf(false) } var wirelessAdbDesiredEnabled by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } val colors = MaterialTheme.colorScheme @@ -437,13 +456,26 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val processSettingsPageVisible = phantomGateVisible || showWirelessAdbFix || showDeveloperDisplayChoice val requestDeveloperOptions = { developerChoiceGuided = false + developerChoiceToggle = false if (s.secondScreenDisplays.isEmpty()) a.onOpenDeveloperOptions(null) else showDeveloperDisplayChoice = true } val openDeveloperOptionsOn: (Int?) -> Unit = { displayId -> if (developerChoiceGuided) a.onStartWirelessAdbPairing() - if (!developerChoiceGuided || !a.onStartPairingGuide(displayId)) a.onOpenDeveloperOptions(displayId) + val guided = developerChoiceGuided || developerChoiceToggle + if (!guided || !a.onStartPairingGuide(displayId, developerChoiceToggle)) a.onOpenDeveloperOptions(displayId) developerChoiceGuided = false + developerChoiceToggle = false + } + val requestChildProcessToggle = { + if (!PhantomProcessLimit.hasDeveloperToggle() || com.droiddeck.launcher.core.GlobalSettings.developerOptionsOn(ctx)) { + requestDeveloperOptions() + } else { + developerChoiceGuided = false + developerChoiceToggle = true + if (s.secondScreenDisplays.isEmpty()) openDeveloperOptionsOn(null) + else showDeveloperDisplayChoice = true + } } val startGuidedPairing = { if (!com.droiddeck.launcher.core.WirelessAdbPairingService.notificationsEnabled(ctx)) { @@ -533,6 +565,18 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val window = LocalWindowInfo.current val androidContext = androidx.compose.ui.platform.LocalContext.current var anyFocused by remember { mutableStateOf(false) } + val paneRequester = remember { FocusRequester() } + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + suspend fun focusDualPane() { + repeat(30) { + withFrameNanos { } + if (anyFocused) return + runCatching { paneRequester.requestFocus() } + if (anyFocused) return + focusManager.moveFocus(FocusDirection.Down) + if (anyFocused) return + } + } androidx.compose.foundation.layout.BoxWithConstraints( modifier = Modifier.fillMaxSize().background(colors.background) .windowInsetsPadding(if (s.launcherFullscreen) WindowInsets.displayCutout else WindowInsets.systemBars) @@ -543,7 +587,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com if (processSettingsPageVisible) return@LaunchedEffect snapshotFlow { window.isWindowFocused }.first { it } if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) - focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } + if (s.dualScreen && frontFocus.primaryAttached == 0) focusDualPane() + else focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } } var openPage by remember { mutableStateOf(null) } LaunchedEffect(s.pageKey) { @@ -565,16 +610,22 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com return@LaunchedEffect } if (anyFocused) return@LaunchedEffect - focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } + if (s.dualScreen && frontFocus.primaryAttached == 0) focusDualPane() + else focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } } LaunchedEffect(railPicks) { if (railPicks == 0 || processSettingsPageVisible) return@LaunchedEffect kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect - withTimeoutOrNull(1_000) { snapshotFlow { frontFocus.primaryAttached > 0 }.first { it } } ?: return@LaunchedEffect + val entry = withTimeoutOrNull(1_000) { snapshotFlow { frontFocus.primaryAttached > 0 }.first { it } } + if (entry == null) { + if (s.dualScreen) focusDualPane() + return@LaunchedEffect + } focusWithinFrames({ frontFocus.focusedRail == null && anyFocused }) { frontFocus.paneEntry() } } val paneFocus = Modifier + .focusRequester(paneRequester) .focusProperties { enter = { frontFocus.paneEntry() } exit = { direction -> @@ -600,12 +651,31 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() } } var screenAt by remember { mutableStateOf(Offset.Zero) } + if (s.dualScreen) DualPanelPublisher(s, railSelection, { key -> + if (processSettingsPageVisible) return@DualPanelPublisher + if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) + focusManager.clearFocus(force = true) + onRailSelect(key) + }, a) CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) { LauncherLayout( - bottomNavigation, pal.line, - Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }, + bottomNavigation && !s.dualScreen, pal.line, + Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() } + .then(if (s.dualScreen) Modifier.onKeyEvent { event -> + val step = dualPageStep(event.nativeKeyEvent) ?: return@onKeyEvent false + if (event.type == KeyEventType.KeyDown && !processSettingsPageVisible) { + val keys = navigationKeys(s) + val at = keys.indexOf(railSelectionNow.value) + keys.getOrNull(at + step)?.let { key -> + PageSwipe.direction = step + onRailSelect(key) + } + } + true + } else Modifier), navigation = { - if (bottomNavigation) BottomBar(s, railSelection, onRailSelect, onRailFocus, a, railFocus) + if (s.dualScreen) Unit + else if (bottomNavigation) BottomBar(s, railSelection, onRailSelect, onRailFocus, a, railFocus) else SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight()) }, ) { @@ -624,7 +694,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com if (s.secondScreenDisplays.isEmpty()) a.onAndroidApp(app, null) else appToChooseDisplay = app }, - onOpenDeveloperOptions = requestDeveloperOptions, + onOpenDeveloperOptions = requestChildProcessToggle, onRequestWirelessAdb = requestWirelessAdbFix, onPairWirelessDebugging = startGuidedPairing, ) @@ -659,7 +729,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com PhantomProcessGatePage( status = s.phantomProcessStatus, onDismiss = a.onDismissPhantomGate, - onOpenDeveloperOptions = requestDeveloperOptions, + onOpenDeveloperOptions = requestChildProcessToggle, applyingSavedPairing = applyingSavedPairing, onFixWithWirelessDebugging = fixPhantomLimit, onEnterAddressManually = { requestWirelessAdbFix(false) }, @@ -696,6 +766,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com onDismiss = { showDeveloperDisplayChoice = false developerChoiceGuided = false + developerChoiceToggle = false }, ) } 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 754094913..383c5356a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -389,6 +389,9 @@ internal fun SetupPanel( ) ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, s.animationsEnabled, onChange = a.onAnimationsEnabled) + val railContext = androidx.compose.ui.platform.LocalContext.current + remember { RailLabels.load(railContext) } + ToggleRow(host, "rail-labels", stringResource(R.string.setup_rail_labels), stringResource(R.string.setup_rail_labels_hint), RailLabels.shown) { RailLabels.set(railContext, it) } LanguageRow(host, s.language, a.onLanguage) ToggleRow(host, "show-stores", stringResource(R.string.setup_stores), null, s.gameStoresEnabled, onChange = a.onGameStoresEnabled) } @@ -418,6 +421,23 @@ internal fun SetupPanel( if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off), s.launcherFullscreen, ) { a.onLauncherFullscreen(it) } + if (s.dualScreenDevice) { + ChoiceRow( + host, "dual-screen", stringResource(R.string.setup_dual_screen), stringResource(R.string.setup_dual_screen_hint), + listOf( + com.droiddeck.launcher.input.DualScreen.MODE_AUTO to stringResource(R.string.setup_dual_screen_auto), + com.droiddeck.launcher.input.DualScreen.MODE_ON to stringResource(R.string.setup_dual_screen_on), + com.droiddeck.launcher.input.DualScreen.MODE_OFF to stringResource(R.string.setup_dual_screen_off), + ), + s.dualScreenMode, onPick = a.onDualScreenMode, + ) + } + if (s.dualScreenDevice && s.dualScreenMode != com.droiddeck.launcher.input.DualScreen.MODE_OFF) { + ToggleRow(host, "dual-screen-menu", stringResource(R.string.setup_dual_screen_menu), + stringResource(R.string.setup_dual_screen_menu_hint), s.dualScreenLauncher) { a.onDualScreenLauncher(it) } + ToggleRow(host, "dual-screen-controls", stringResource(R.string.setup_dual_screen_controls), + stringResource(R.string.setup_dual_screen_controls_hint), s.dualScreenControls) { a.onDualScreenControls(it) } + } } } val art: @Composable () -> Unit = { @@ -475,6 +495,12 @@ internal fun SetupPanel( 4 -> { SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) { ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() } + ToggleRow( + host, "game-debug-logs", stringResource(R.string.setup_game_debug_logs), + stringResource(if (s.gameDebugLogsNeedsRestart) R.string.setup_game_debug_logs_restart else R.string.setup_game_debug_logs_hint), + s.gameDebugLogsEnabled, + enabled = s.logsEnabled, + ) { a.onGameDebugLogs() } ToggleRow(host, "vacdiag", stringResource(R.string.setup_vac_diag), stringResource(R.string.setup_vac_diag_hint), s.vacDiagnostics) { a.onVacDiagnostics() } ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() } ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs, @@ -835,9 +861,8 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi verticalArrangement = Arrangement.spacedBy(3.dp), modifier = modifier.paneItem(id) .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .glideBorder(hot, Shape14, pal.signal, pal.line2) + .then(if (LocalGlass.current != null) Modifier.glass(Shape14, if (hot) 0.04f else 0f) else Modifier.clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)) + .glideBorder(hot, Shape14, pal.signal, if (LocalGlass.current != null) Color.Transparent else pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 12.dp), 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 0455366d2..6f1aad142 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings @@ -163,6 +164,8 @@ internal fun PrimaryButton( ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current + val pillMode = LocalPillButtons.current + val btnShape = if (pillMode) CircleShape else Shape12 val frontFocus = if (main) LocalFrontFocus.current else null if (frontFocus != null) DisposableEffect(Unit) { frontFocus.primaryAttached++ @@ -180,22 +183,22 @@ internal fun PrimaryButton( horizontalArrangement = if (fill) Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) else Arrangement.spacedBy(8.dp), modifier = modifier.then(track) .onGloballyPositioned { placed[0] = it } - .heightIn(min = if (compact) 44.dp else 48.dp) + .heightIn(min = if (pillMode) 40.dp else if (compact) 44.dp else 48.dp) .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier) - .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) + .graphicsLayer { alpha = if (LaunchOrigin.flooding === placed) 0f else 1f; scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = btnShape; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal } + .clip(btnShape) .background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant))) - .then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, Shape12)) - .glideBorder(hot, Shape12, pal.signal, hotFillAlpha = 0f) + .then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, btnShape)) + .glideBorder(hot, btnShape, pal.signal, hotFillAlpha = 0f) .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = null, enabled = enabled) { placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) } onClick() } .padding( - start = if (fill) 12.dp else if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp, - end = if (fill) 12.dp else if (large) 26.dp else if (compact) 10.dp else 18.dp, - top = if (large) 16.dp else if (compact) 7.dp else 11.dp, - bottom = if (large) 16.dp else if (compact) 7.dp else 11.dp, + start = if (fill) 12.dp else if (large) 20.dp else if (pillMode) 18.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp, + end = if (fill) 12.dp else if (large) 26.dp else if (pillMode) 20.dp else if (compact) 10.dp else 18.dp, + top = if (large) 16.dp else if (pillMode) 8.dp else if (compact) 7.dp else 11.dp, + bottom = if (large) 16.dp else if (pillMode) 8.dp else if (compact) 7.dp else 11.dp, ), ) { val fg = if (enabled) colors.onPrimary else colors.onSurfaceVariant @@ -240,22 +243,24 @@ internal fun ProgressLabel(label: @Composable (Modifier) -> Unit, progress: Floa @Composable internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme + val pill = LocalPillButtons.current + val shape = if (pill) CircleShape else Shape12 val src = remember { MutableInteractionSource() } val hot = rememberHot(src) && enabled val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale") val pal = LocalPalette.current - val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") + val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = if (pill) 0.09f else 0.03f), Motion.tw(250), label = "secFill") Box( 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) + modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (pill) 40.dp else if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(shape).background(fill).glideBorder(hot, shape, pal.signal, if (pill) Color.White.copy(alpha = 0.14f) else pal.line2) .alpha(if (enabled) 1f else 0.5f) .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), + .padding(horizontal = if (pill) 16.dp else if (compact) 10.dp else 16.dp, vertical = if (pill) 8.dp else if (compact) 7.dp else 11.dp), ) { - ProgressLabel({ m -> Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1, modifier = m) }, - progress, fontSize = if (compact) 12.sp else 15.sp) + ProgressLabel({ m -> Text(text, fontSize = if (compact || pill) 13.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1, modifier = m) }, + progress, fontSize = if (compact || pill) 13.sp else 15.sp) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FusionHudSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/FusionHudSettings.kt new file mode 100644 index 000000000..4fdac9566 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FusionHudSettings.kt @@ -0,0 +1,133 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R +import com.droiddeck.launcher.hud.FusionSize + +/** Bannerlator's Fusion HUD settings (DeckOverlaySettings): one row per key of the HUD's config string. */ +@Composable +internal fun FusionHudSettings(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { + val cfg = a.hudConfig + fun on(key: String) = cfg[key] == "1" + fun int(key: String, fallback: Int) = cfg[key]?.toIntOrNull() ?: fallback + + SettingsGroup(stringResource(R.string.drawer_hud)) { + ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn, chipModifier = track("hud"), onChange = a.onHud) + if (!a.hudOn) return@SettingsGroup + val size = FusionSize.from(cfg["hudSize"]) + ChoiceRow(host, "hud-size", stringResource(R.string.hud_size), stringResource(R.string.hud_size_hint), + listOf( + FusionSize.FULL to stringResource(R.string.hud_size_full), + FusionSize.TILES to stringResource(R.string.hud_size_tiles), + FusionSize.PILL to stringResource(R.string.hud_size_pill), + FusionSize.MINIMAL to stringResource(R.string.hud_size_minimal), + FusionSize.MEGA to stringResource(R.string.hud_size_mega), + ), + size, chipModifier = track("hud-size")) { a.onHudSetting("hudSize", it.token) } + ToggleRow(host, "hud-locked", stringResource(R.string.hud_locked), stringResource(R.string.hud_locked_hint), on("hudLocked"), + chipModifier = track("hud-locked")) { a.onHudSetting("hudLocked", flag(it)) } + } + if (!a.hudOn) return + + SettingsGroup(stringResource(R.string.hud_group_look)) { + SliderRow(stringResource(R.string.hud_scale), null, int("hudScale", 100), 50..150, step = 5, format = { "$it%" }, + modifier = track("hud-scale")) { a.onHudSetting("hudScale", it.toString()) } + SliderRow(stringResource(R.string.drawer_hud_opacity), null, int("hudOpacity", 80), 0..100, step = 5, format = { "$it%" }, + modifier = track("hud-opacity")) { a.onHudSetting("hudOpacity", it.toString()) } + ChoiceRow(host, "hud-color", stringResource(R.string.drawer_hud_color), null, + listOf("soft" to stringResource(R.string.hud_color_soft), "mid" to stringResource(R.string.hud_color_mid), + "vivid" to stringResource(R.string.hud_color_vivid)), + cfg["hudColor"] ?: "mid", chipModifier = track("hud-color")) { a.onHudSetting("hudColor", it) } + SliderRow(stringResource(R.string.hud_outline), null, int("hudOutline", 40), 0..100, step = 10, + modifier = track("hud-outline")) { a.onHudSetting("hudOutline", it.toString()) } + ChoiceRow(host, "hud-outline-color", stringResource(R.string.hud_outline_color), null, + listOf("0" to stringResource(R.string.hud_outline_gray), "1" to stringResource(R.string.hud_outline_theme)), + if (cfg["hudOutlineAccent"] == "0") "0" else "1", chipModifier = track("hud-outline-color")) { a.onHudSetting("hudOutlineAccent", it) } + } + + val size = FusionSize.from(cfg["hudSize"]) + val chips = size.supportedChips() + EXTRA[size].orEmpty() + SettingsGroup(stringResource(R.string.hud_group_metrics)) { + for (metric in METRICS) { + if (metric.chip !in chips || metric.chip in HIDDEN[size].orEmpty()) continue + ToggleRow(host, "hud-${metric.key}", stringResource(metric.label), null, on(metric.key), + chipModifier = track("hud-${metric.key}")) { a.onHudSetting(metric.key, flag(it)) } + } + } + + val tempsShown = ("Temp" in chips && on("showTemp")) || ("GPU temp" in chips && on("showGpuTemp")) + if (tempsShown) SettingsGroup(stringResource(R.string.hud_group_temps)) { + ChoiceRow(host, "hud-temp-unit", stringResource(R.string.hud_temp_unit), null, + listOf("c" to "°C", "f" to "°F"), if (cfg["tempUnit"].equals("f", true)) "f" else "c", + chipModifier = track("hud-temp-unit")) { a.onHudSetting("tempUnit", it) } + val bands = when { + cfg["tempBands"] == "0" -> "0" + cfg["tempAuto"] == "0" -> "2" + else -> "1" + } + ChoiceRow(host, "hud-temp-bands", stringResource(R.string.hud_temp_bands), stringResource(R.string.hud_temp_bands_hint), + listOf("0" to stringResource(R.string.hud_temp_bands_off), "1" to stringResource(R.string.hud_temp_bands_auto), + "2" to stringResource(R.string.hud_temp_bands_manual)), + bands, chipModifier = track("hud-temp-bands")) { + a.onHudSetting("tempBands", if (it == "0") "0" else "1") + a.onHudSetting("tempAuto", if (it == "2") "0" else "1") + } + if (bands == "2") { + SliderRow(stringResource(R.string.hud_temp_red_cpu), null, int("tempRedCpu", 90), 50..110, format = { "$it°C" }, + modifier = track("hud-red-cpu")) { a.onHudSetting("tempRedCpu", it.toString()) } + SliderRow(stringResource(R.string.hud_temp_red_gpu), null, int("tempRedGpu", 90), 50..110, format = { "$it°C" }, + modifier = track("hud-red-gpu")) { a.onHudSetting("tempRedGpu", it.toString()) } + SliderRow(stringResource(R.string.hud_temp_red_battery), null, int("tempRedBat", 48), 35..60, format = { "$it°C" }, + modifier = track("hud-red-battery")) { a.onHudSetting("tempRedBat", it.toString()) } + } + } + + SettingsGroup(stringResource(R.string.hud_group_more), detail = stringResource(R.string.hud_credit)) { + ActionRow(stringResource(R.string.hud_export), stringResource(R.string.hud_export_hint), + stringResource(R.string.hud_export_button), onClick = a.onHudExport, modifier = track("hud-export")) + ActionRow(stringResource(R.string.hud_reset), null, stringResource(R.string.hud_reset_button), + onClick = a.onHudReset, modifier = track("hud-reset")) + } +} + +private fun flag(on: Boolean) = if (on) "1" else "0" + +// The Pill also draws the battery temperature and watts. +private val EXTRA = mapOf(FusionSize.PILL to setOf("Temp", "Power")) + +private class HudMetric(val chip: String, val key: String, val label: Int) + +// FusionSize.supportedChips() names; Wrapper and DX version have nothing to show in DroidDeck. +private val METRICS = listOf( + HudMetric("FPS", "showFPS", R.string.drawer_hud_fps), + HudMetric("FPS graph", "showFPSGraph", R.string.hud_metric_graph), + HudMetric("FPS .1", "fpsDecimal", R.string.hud_metric_decimal), + HudMetric("0.01% low", "showLow001", R.string.hud_metric_low001), + HudMetric("Engine", "showEngine", R.string.hud_metric_engine), + HudMetric("GPU model", "showGpuModel", R.string.hud_metric_gpu_model), + HudMetric("GPU", "showGPULoad", R.string.hud_metric_gpu), + HudMetric("GPU temp", "showGpuTemp", R.string.hud_metric_gpu_temp), + HudMetric("CPU", "showCPUUsage", R.string.hud_metric_cpu), + HudMetric("Per-core", "showPerCore", R.string.hud_metric_per_core), + HudMetric("Temp", "showTemp", R.string.hud_metric_temp), + HudMetric("RAM", "showRAM", R.string.hud_metric_ram), + HudMetric("VRAM", "showVram", R.string.hud_metric_vram), + HudMetric("Swap", "showSwap", R.string.hud_metric_swap), + HudMetric("Battery", "showBattery", R.string.hud_metric_battery), + HudMetric("Power", "showPower", R.string.hud_metric_power), + HudMetric("Network", "showNet", R.string.hud_metric_network), + HudMetric("Resolution", "showResolution", R.string.hud_metric_resolution), + HudMetric("Proton", "showProton", R.string.hud_metric_proton), + HudMetric("Session", "showSession", R.string.hud_metric_session), + HudMetric("Clock", "showClock", R.string.hud_metric_clock), +) + +// Switches a size lists but never draws (FusionHudView): the Pill and Minimal always show the FPS, the +// Pill has no graph, Tiles has no graph and no 0.01% low. +private val HIDDEN = mapOf( + FusionSize.PILL to setOf("FPS", "FPS graph"), + FusionSize.MINIMAL to setOf("FPS"), + FusionSize.TILES to setOf("FPS graph", "0.01% low"), +) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt index 5350d2afa..aa193883d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -1,6 +1,15 @@ package com.droiddeck.launcher.ui import androidx.compose.foundation.LocalIndication +import androidx.compose.ui.semantics.Role +import androidx.compose.runtime.CompositionLocalProvider +import com.droiddeck.launcher.stores.GameFacts +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.graphics.Shadow +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -29,6 +38,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.VideogameAsset +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -170,7 +181,7 @@ internal fun GameDetail( val scored = scores != null && (scores.metacritic != Lookup.None || scores.hltb != Lookup.None) val prefix = game?.protonPrefix?.takeIf { it.isDirectory } val tabs = buildList { - if (description.isNotBlank() || developer.isNotBlank() || publisher.isNotBlank() || releaseDate.isNotBlank() || (scored && stacked)) add(GameTab.ABOUT) + if (description.isNotBlank() || developer.isNotBlank() || publisher.isNotBlank() || releaseDate.isNotBlank() || scored || (!s.shortcutPicker && title.isNotBlank() && title != id)) add(GameTab.ABOUT) if (achievementState != null) add(GameTab.ACHIEVEMENTS) val cloud = game?.library == Library.XBOX_CLOUD if (game != null && !cloud) add(GameTab.LAUNCH) @@ -196,56 +207,64 @@ internal fun GameDetail( val at = tabs.indexOf(tab).coerceAtLeast(0) picked = tabs[(at + step + tabs.size) % tabs.size] } - Column(Modifier.fillMaxWidth().onFocusChanged { inPage = it.hasFocus }.bumpers(onPrevious = { cycle(-1) }, onNext = { cycle(1) })) { - Rise(0) { - Box(Modifier.padding(bottom = 12.dp)) { BackLink(backLabel, compact = true, onClick = onBack) } - } - val hero: @Composable () -> Unit = { - DisposableEffect(Unit) { onDispose { StoresMotion.heroAt = null } } - GameHero( - title, item, installed, game, - Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 210.dp).onGloballyPositioned { StoresMotion.heroAt = it.boundsInRoot() }, - corner = { if (!s.shortcutPicker) RemoveButton(title, game, installed, a) }, - scores = scores.takeIf { !stacked }, - ) { - HeroEyebrow(source, game, item, installed, download?.location ?: failed?.location) - GameTitle(title, game?.logo ?: item?.logoUrl, if (narrow) 26.sp else 34.sp) - if (!failed?.error.isNullOrBlank()) Text( - failed!!.error!!, fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.error, maxLines = 3, overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(4.dp)) - GameActions(store, id, game, item, installed, s, a, Modifier.focusRequester(lead)) - } - } - if (StoresMotion.heroLands) hero() else Rise(1) { hero() } - if (!s.shortcutPicker) Rise(2) { - GameResourcesRow(title.takeUnless { it == id }.orEmpty(), game, Modifier.padding(top = 6.dp)) - } - if (tab != null) Rise(2) { - Column(Modifier.fillMaxWidth().padding(top = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - val labels = tabs.map { t -> - when (t) { - GameTab.ABOUT -> stringResource(R.string.game_tab_about) - GameTab.ACHIEVEMENTS -> achievementState?.list?.takeIf { it.isNotEmpty() }?.let { list -> - stringResource(R.string.game_tab_achievements_count, stringResource(R.string.achievements_title), achievementState.done, list.size) - } ?: stringResource(R.string.achievements_title) - GameTab.LAUNCH -> stringResource(R.string.game_tab_launch) - GameTab.SAVES -> stringResource(if (Store.byId(source).let { it == Store.GOG || it == Store.EPIC }) R.string.game_tab_saves_cloud else R.string.games_saves) - GameTab.INSTALL -> stringResource(R.string.game_tab_install) + val art: Any? = game?.hero ?: item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover + val artModel: Any? = if (art is String) cardArtRequest(art, item?.tallImageUrl) else art + val inSteam = !s.shortcutPicker && source != Library.SOURCE_STEAM && (game != null || installed != null) && !(game?.library == Library.XBOX_CLOUD && installed == null) + val inset = LocalDetailInset.current + GameBackdrop(artModel, art ?: game?.art, hueOf(title), fadeTop = inset.top < 8.dp, onArtPlaced = { StoresMotion.heroAt = it }) { + Column(Modifier.fillMaxWidth().padding(horizontal = inset.horizontal).padding(top = inset.top).onFocusChanged { inPage = it.hasFocus }.bumpers(onPrevious = { cycle(-1) }, onNext = { cycle(1) })) { + Rise(0) { + Row(Modifier.fillMaxWidth().padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.glass(CircleShape)) { BackLink(backLabel, compact = true, onClick = onBack) } + Spacer(Modifier.weight(1f)) + if (!s.shortcutPicker) CompositionLocalProvider(LocalPillButtons provides true) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (installed != null && installed.sidecar.store == Store.ITCH) ItchCheckButton(installed) + RemoveButton(title, game, installed, a) + } } } - TabStrip( - labels, tabs.indexOf(tab), { picked = tabs[it] }, Modifier.onFocusChanged { onStrip = it.hasFocus }, - focusRequesters = tabs.map { tabFocus.getValue(it) }, bumpers = tabs.size > 1, compact = narrow, - ) - Box(Modifier.fillMaxWidth().onFocusChanged { if (inPanel && !it.hasFocus) dropped++; inPanel = it.hasFocus }.testTag("game-tab-${tab.name.lowercase()}")) { - when (tab) { - GameTab.ABOUT -> AboutTab(developer, publisher, releaseDate, description, scores, stacked) - GameTab.ACHIEVEMENTS -> achievementState?.let { st -> AchievementsTab(StoreAchievements.forSource(store?.id)?.label.orEmpty(), st) } - GameTab.LAUNCH -> if (game != null) LaunchTab(game, s, a, hopRail) - GameTab.SAVES -> if (game != null && prefix != null) SavesTab(game, prefix, a) - GameTab.INSTALL -> InstallTab(game, installed, item, store) + } + val header: @Composable () -> Unit = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + GameStatusLine(source, game, item, installed, download?.location ?: failed?.location, inSteam) + GameTitle(title, game?.logo ?: item?.logoUrl, if (narrow) 30.sp else 40.sp) + if (!failed?.error.isNullOrBlank()) Text( + failed!!.error!!, fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error, maxLines = 3, overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + GameCapsule(scores) { GameActions(store, id, game, item, installed, s, a, Modifier.focusRequester(lead), compactMenu = stacked) } + } + } + if (StoresMotion.heroLands) header() else Rise(1) { header() } + if (tab != null) Rise(2) { + Column(Modifier.fillMaxWidth().padding(top = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + val labels = tabs.map { t -> + when (t) { + GameTab.ABOUT -> stringResource(R.string.game_tab_about) + GameTab.ACHIEVEMENTS -> achievementState?.list?.takeIf { it.isNotEmpty() && !stacked }?.let { list -> + stringResource(R.string.game_tab_achievements_count, stringResource(R.string.achievements_title), achievementState.done, list.size) + } ?: stringResource(R.string.achievements_title) + GameTab.LAUNCH -> stringResource(R.string.game_tab_launch) + GameTab.SAVES -> stringResource(if (Store.byId(source).let { it == Store.GOG || it == Store.EPIC }) R.string.game_tab_saves_cloud else R.string.games_saves) + GameTab.INSTALL -> stringResource(R.string.game_tab_install) + } + } + GameTabBar( + labels, tabs.indexOf(tab), { picked = tabs[it] }, Modifier.onFocusChanged { onStrip = it.hasFocus }, + focusRequesters = tabs.map { tabFocus.getValue(it) }, bumpers = tabs.size > 1, compact = stacked, + ) + Box(Modifier.fillMaxWidth().onFocusChanged { if (inPanel && !it.hasFocus) dropped++; inPanel = it.hasFocus }.testTag("game-tab-${tab.name.lowercase()}")) { + when (tab) { + GameTab.ABOUT -> AboutTab(developer, publisher, releaseDate, description, scores, stacked) { + if (!s.shortcutPicker) GameResourcesRow(title.takeUnless { it == id }.orEmpty(), game) + } + GameTab.ACHIEVEMENTS -> achievementState?.let { st -> AchievementsTab(StoreAchievements.forSource(store?.id)?.label.orEmpty(), st) } + GameTab.LAUNCH -> if (game != null) LaunchTab(game, s, a, hopRail) + GameTab.SAVES -> if (game != null && prefix != null) SavesTab(game, prefix, a) + GameTab.INSTALL -> InstallTab(game, installed, item, store) + } } } } @@ -254,42 +273,21 @@ internal fun GameDetail( } @Composable -private fun GameHero( - title: String, item: CatalogItem?, installed: InstalledStoreGame?, game: Library.SteamGame?, modifier: Modifier, - corner: @Composable BoxScope.() -> Unit, scores: GameScores?, content: @Composable ColumnScope.() -> Unit, -) { - val colors = MaterialTheme.colorScheme - Box(modifier.clip(Shape16).background(artBrush(hueOf(title)))) { - val wide: Any? = game?.hero ?: item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover - if (wide != null) { - // The card it came from keeps its own bitmap under the hero until the wide art is decoded. - val request = if (wide is String) cardArtRequest(wide, item?.tallImageUrl) else wide - AsyncImage(model = request, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - } else game?.art?.let { art -> - AsyncImage( - model = art, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize().blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f }, - ) - } - Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.55f to colors.background.copy(alpha = 0.58f), 1f to Color.Transparent))) - var scoresWidth by remember { mutableStateOf(0) } - if (scores != null) { - 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).onSizeChanged { scoresWidth = it.width }) - } - val scoresSpace = with(LocalDensity.current) { if (scores != null && scoresWidth > 0) scoresWidth.toDp() + 16.dp else 0.dp } - Column( - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.align(Alignment.BottomStart).padding(start = 22.dp, end = 22.dp + scoresSpace, top = 18.dp, bottom = 18.dp), - content = content, - ) - Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner) +private fun SourceBadge(source: String) { + val c = sourceColours(source) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.glass(CircleShape, 0.06f).padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(c.dot)) + Text(sourceLabel(source), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, color = MaterialTheme.colorScheme.onBackground, maxLines = 1) } } @Composable -private fun HeroEyebrow(source: String, game: Library.SteamGame?, item: CatalogItem?, installed: InstalledStoreGame?, downloadAt: String?) { +private fun GameStatusLine(source: String, game: Library.SteamGame?, item: CatalogItem?, installed: InstalledStoreGame?, downloadAt: String?, inSteam: Boolean) { val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val folder = installed?.folder ?: game?.gameFiles val passName = item?.includedWith.orEmpty() @@ -305,10 +303,68 @@ private fun HeroEyebrow(source: String, game: Library.SteamGame?, item: CatalogI else -> listOf(stringResource(R.string.stores_eyebrow_store)) } val size = if (folder != null && (installed != null || game != null)) diskSize(installed, folder) else (item?.sizeBytes ?: 0L).takeIf { it > 0 } - val line = (state + listOfNotNull(size?.let { formatBytes(it) })).filter { it.isNotBlank() }.joinToString(" · ") - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SourceChip(source) - Text(line.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) + val line = (state + listOfNotNull(size?.let { formatBytes(it) })).filter { it.isNotBlank() }.joinToString(" · ").replaceFirstChar { it.uppercase() } + val muted = colors.onBackground.copy(alpha = 0.78f) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SourceBadge(source) + Text(line, fontSize = 12.sp, color = muted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (inSteam) { + Text("·", fontSize = 12.sp, color = muted.copy(alpha = 0.5f)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Icon(Icons.Outlined.VideogameAsset, contentDescription = null, tint = muted, modifier = Modifier.size(15.dp)) + Text(stringResource(R.string.stores_in_steam_label), fontSize = 12.sp, color = muted, maxLines = 1) + Icon(Icons.Filled.Check, contentDescription = null, tint = pal.good, modifier = Modifier.size(14.dp)) + } + } + } +} + +/** One frosted pill that holds the game's actions and its headline scores. */ +@Composable +private fun GameCapsule(scores: GameScores?, actions: @Composable () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().glass(RoundedCornerShape(30.dp)).padding(6.dp), + ) { + CompositionLocalProvider(LocalPillButtons provides true) { Box(Modifier.weight(1f)) { actions() } } + if (scores != null) CapsuleScores(scores, Modifier.padding(start = 8.dp, end = 10.dp)) + } +} + +@Composable +private fun GameTabBar( + 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 + Row(modifier.fillMaxWidth().glass(CircleShape).padding(4.dp), verticalAlignment = Alignment.CenterVertically) { + if (bumpers) BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) } + tabs.forEachIndexed { i, label -> + val on = i == selected + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pick = { onSelect(i) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + .testTag("tab-$i") + .then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier) + .clip(CircleShape) + .background(if (on) pal.signal.copy(alpha = 0.3f) else if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) + .glideBorder(hot, CircleShape, pal.signal, if (on) pal.signal.copy(alpha = 0.5f) else Color.Transparent) + .hoverable(src).selectable(selected = on, interactionSource = src, indication = null, role = Role.Tab, onClick = pick) + .controllerConfirm(onClick = pick) + .padding(horizontal = 6.dp, vertical = if (compact) 8.dp else 10.dp), + ) { + Text( + label, fontSize = if (compact) 13.sp else 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, + ) + } + } + if (bumpers) BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) } } } @@ -318,17 +374,18 @@ private fun GameTitle(title: String, logo: Any?, titleSize: TextUnit) { if (logo != null && !failed) AsyncImage( model = logo, contentDescription = title, contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, onError = { failed = true }, - modifier = Modifier.padding(vertical = 4.dp).height((titleSize.value * 2.2f).dp).widthIn(max = (titleSize.value * 9f).dp).testTag("game-logo"), + modifier = Modifier.padding(vertical = 4.dp).height((titleSize.value * 1.5f).dp).widthIn(max = (titleSize.value * 7f).dp).clip(RoundedCornerShape(10.dp)).testTag("game-logo"), ) else Text( title, fontSize = titleSize, lineHeight = titleSize * 1.12f, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, + style = TextStyle(shadow = Shadow(Color.Black.copy(alpha = 0.55f), blurRadius = 24f)), ) } @Composable internal fun GameActions( store: Store?, id: String?, game: Library.SteamGame?, item: CatalogItem?, installed: InstalledStoreGame?, - s: FrontEndState, a: FrontEndActions, leadOn: Modifier, + s: FrontEndState, a: FrontEndActions, leadOn: Modifier, compactMenu: Boolean = false, ) { val ctx = LocalContext.current val storeKey = if (store != null && id != null) "${store.id}:$id" else null @@ -346,17 +403,20 @@ internal fun GameActions( val gone = storeKey?.let { StoresState.uninstalling[it] } val xboxBusy = store == Store.XBOX && id != null && XboxContent.busy(id) PrimaryButton( - stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy, + stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false), main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn, ) { if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(ctx, store, id, s, a) } - if ((game?.source ?: store?.id) != Library.SOURCE_STEAM) ActionChip(stringResource(R.string.stores_in_steam), ok = true) if (game != null) { - FileButtons(game, a) - GameShortcutMenu(game, a) - EditArtButton(game) + if (compactMenu) GameMoreMenu(game, a) else { + FileButtons(game, a) + GameShortcutMenu(game, a) + EditArtButton(game) + } } + if (store == Store.ITCH && installed != null) ItchUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null) + if (store == Store.GOG && installed != null) GogMaintenanceActions(installed) if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item) if (store == Store.XBOX && installed != null && id != null) { SecondaryButton(stringResource(R.string.stores_xbox_update), enabled = download == null && !XboxRuntime.isActive(id)) { @@ -514,10 +574,10 @@ private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) { /** The pencil opens the editor each kind of game always had: the shortcut editor for added games, art for the rest. */ @Composable -private fun EditArtButton(g: Library.SteamGame) { +private fun rememberEditArt(g: Library.SteamGame): (() -> Unit)? { val ctx = LocalContext.current val folder = g.gameFiles - val open: (() -> Unit)? = when { + return when { g.library == Library.ADDED && g.source == Library.ADDED && folder != null -> { { GamesDialogState.editing = EditRequest(folder.path, openTarget = false, fromSummary = false, preview = EditPreview.of(ctx, g, folder.path)) } } @@ -525,28 +585,64 @@ private fun EditArtButton(g: Library.SteamGame) { g.library == Library.ADDED && g.source != Library.ADDED && folder != null -> { { GamesDialogState.storeArt = g } } else -> null } - if (open != null) IconAction(Icons.Outlined.Edit, stringResource(R.string.common_edit), "game:edit", onClick = open) } @Composable -private fun IconAction(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, id: String, danger: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) { +private fun EditArtButton(g: Library.SteamGame) { + val open = rememberEditArt(g) ?: return + IconAction(Icons.Outlined.Edit, stringResource(R.string.game_edit_art), "game:edit", onClick = open) +} + +/** Narrow screens keep one More button in the capsule instead of a row of small ones. */ +@Composable +private fun GameMoreMenu(g: Library.SteamGame, a: FrontEndActions) { + var open by remember(g.gameId) { mutableStateOf(false) } + val edit = rememberEditArt(g) + Box { + SecondaryButton(stringResource(R.string.game_more)) { open = !open } + AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_more), compact = true) { first -> + var lead: FocusRequester? = first + fun take(): FocusRequester? = lead.also { lead = null } + g.gameFiles?.takeIf { it.isDirectory }?.let { dir -> MenuItem(stringResource(R.string.games_files), checked = false, focusRequester = take()) { open = false; a.onBrowseFiles(dir) } } + g.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> MenuItem(stringResource(R.string.games_prefix), checked = false, focusRequester = take()) { open = false; a.onBrowseFiles(dir) } } + MenuItem(stringResource(R.string.game_shortcut_add), checked = false, focusRequester = take()) { open = false; a.onGameShortcut(g) } + MenuItem(stringResource(R.string.game_file_export), checked = false) { open = false; a.onExportGameFile(g) } + MenuItem(stringResource(R.string.game_link_copy), checked = false) { open = false; a.onCopyGameLink(g) } + edit?.let { run -> MenuItem(stringResource(R.string.game_edit_art), checked = false) { open = false; run() } } + } + } +} + +@Composable +internal fun IconAction(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, id: String, danger: Boolean = false, enabled: Boolean = true, frosted: Boolean = false, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme + val pill = LocalPillButtons.current + val shape = if (pill) CircleShape else Shape12 val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) && enabled val accent = if (danger) colors.error else pal.signal - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem(id).downToFirstTile().size(44.dp).clip(Shape12) - .background(if (hot) accent.copy(alpha = if (danger) 0.22f else 0.14f) else if (danger) Color.Black.copy(alpha = 0.34f) else Color.White.copy(alpha = 0.03f)) - .glideBorder(hot, Shape12, accent, pal.line2) - .alpha(if (enabled) 1f else 0.45f) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, enabled = enabled, onClick = onClick) - .controllerConfirm(enabled = enabled, onClick = onClick) - .semantics { contentDescription = label }, - ) { - Icon(icon, contentDescription = null, tint = if (danger && hot) colors.error else if (danger) Color.White else colors.onBackground, modifier = Modifier.size(18.dp)) + val fill = when { + hot -> accent.copy(alpha = if (danger) 0.22f else 0.14f) + pill -> Color.White.copy(alpha = 0.09f) + danger -> Color.Black.copy(alpha = 0.34f) + else -> Color.White.copy(alpha = 0.03f) } + val button: @Composable () -> Unit = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem(id).downToFirstTile().size(if (pill) 40.dp else 44.dp).clip(shape) + .background(fill) + .glideBorder(hot, shape, accent, if (pill) Color.White.copy(alpha = 0.14f) else pal.line2) + .alpha(if (enabled) 1f else 0.45f) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .semantics { contentDescription = label }, + ) { + Icon(icon, contentDescription = null, tint = if (danger && hot) colors.error else if (danger && !pill) Color.White else colors.onBackground, modifier = Modifier.size(18.dp)) + } + } + if (frosted) Box(Modifier.glass(CircleShape)) { button() } else button() } @Composable @@ -559,7 +655,7 @@ private fun RemoveButton(title: String, game: Library.SteamGame?, installed: Ins val gone = StoresState.uninstalling[key] if (gone != null) { val uninstalling = stringResource(R.string.stores_uninstalling) - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(Color.Black.copy(alpha = 0.34f)).semantics { contentDescription = uninstalling }) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).glass(CircleShape).semantics { contentDescription = uninstalling }) { if (gone > 0f) CircularProgressIndicator(progress = { gone }, strokeWidth = 2.dp, color = colors.error, trackColor = colors.error.copy(alpha = 0.22f), modifier = Modifier.size(20.dp)) else CircularProgressIndicator(strokeWidth = 2.dp, color = colors.error, modifier = Modifier.size(20.dp)) } @@ -573,7 +669,7 @@ private fun RemoveButton(title: String, game: Library.SteamGame?, installed: Ins val busy = StoresState.download(key)?.isActive == true || (installed.sidecar.store == Store.XBOX && XboxContent.busy(installed.sidecar.id)) val placed = remember { arrayOfNulls(1) } Box(Modifier.onGloballyPositioned { placed[0] = it }) { - IconAction(Icons.Outlined.Delete, remove, "game:remove", danger = true, enabled = !busy) { + IconAction(Icons.Outlined.Delete, remove, "game:remove", danger = true, enabled = !busy, frosted = true) { val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() ?: return@IconAction var confirmed = false Steps.ask(StepAsk(anchor = at, side = StepSide.Left, accent = colors.error, items = 3, pillCorner = 12.dp, maxWidth = 340.dp, @@ -596,7 +692,7 @@ private fun RemoveButton(title: String, game: Library.SteamGame?, installed: Ins var asking by remember(g.gameId) { mutableStateOf(false) } val shortcut = kind == GameRemoval.Kind.SHORTCUT val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall) - IconAction(Icons.Outlined.Delete, label, "game:remove", danger = true) { asking = true } + IconAction(Icons.Outlined.Delete, label, "game:remove", danger = true, frosted = true) { asking = true } if (asking) ConfirmDialog( title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name), text = when { @@ -621,14 +717,10 @@ private fun InstallButton(label: String, modifier: Modifier = Modifier, onInstal } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun AboutTab(developer: String, publisher: String, releaseDate: String, description: String, scores: GameScores?, stacked: Boolean) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().tabPanel()) { - if (scores != null) Box(Modifier.readable()) { StoreGameFacts(developer, publisher, releaseDate, scores, scoresInHero = !stacked) } - else if (developer.isNotBlank() || publisher.isNotBlank() || releaseDate.isNotBlank()) { - val parts = listOf(developer, publisher, com.droiddeck.launcher.stores.GameFacts.releaseLabel(releaseDate).orEmpty()).filter { it.isNotBlank() }.distinct() - Text(parts.joinToString(" · "), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.readable()) - } +private fun AboutTab(developer: String, publisher: String, releaseDate: String, description: String, scores: GameScores?, stacked: Boolean, resources: @Composable () -> Unit) { + val aboutText: @Composable () -> Unit = { if (description.isNotBlank()) { val floor = LocalDetailFloor.current val density = LocalDensity.current @@ -637,12 +729,35 @@ private fun AboutTab(developer: String, publisher: String, releaseDate: String, StoreAbout(description, room, Modifier.fillMaxWidth().then(fill)) } } + if (stacked) Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().tabPanel()) { + val released = GameFacts.releaseLabel(releaseDate).orEmpty() + val credits = listOf(developer, publisher, released).filter { it.isNotBlank() }.distinct() + if (credits.isNotEmpty()) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.readable()) { + credits.forEachIndexed { i, part -> + Text( + part, fontSize = 13.sp, fontWeight = if (i == 0 && part == developer) FontWeight.SemiBold else FontWeight.Normal, + color = if (i == 0 && part == developer) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, + modifier = Modifier.clip(CircleShape).background(Color.White.copy(alpha = 0.07f)).border(1.dp, Color.White.copy(alpha = 0.12f), CircleShape).padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + aboutText() + resources() + } else Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) { + if (description.isNotBlank()) Column(Modifier.weight(1.3f).tabPanel()) { aboutText() } + Column((if (description.isNotBlank()) Modifier.weight(1f) else Modifier.fillMaxWidth()).tabPanel(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(headingUppercase(stringResource(R.string.store_details)), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) + if (scores != null) StoreDetailRows(developer, publisher, releaseDate, scores) else StoreDetailRows(developer, publisher, releaseDate, GameScores(Lookup.None, Lookup.None)) + Box(Modifier.padding(top = 8.dp)) { resources() } + } + } } private val AboutHeight = 260.dp @Composable internal fun Modifier.tabPanel(): Modifier { + if (LocalGlass.current != null) return glass(RoundedCornerShape(22.dp), 0.22f).padding(horizontal = 18.dp, vertical = 14.dp) val shape = RoundedCornerShape(16.dp) return clip(shape).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line, shape).padding(horizontal = 16.dp, vertical = 14.dp) } @@ -674,7 +789,7 @@ private fun StoreAbout(text: String, height: Dp, modifier: Modifier = Modifier) Text(text, fontSize = 14.sp, lineHeight = 21.sp, color = colors.onSurfaceVariant, modifier = Modifier.verticalScroll(scroll).padding(horizontal = 4.dp, vertical = 6.dp)) if (scroll.value < scroll.maxValue) Box( Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(28.dp) - .background(Brush.verticalGradient(listOf(Color.Transparent, colors.surface))), + .background(Brush.verticalGradient(listOf(Color.Transparent, if (LocalGlass.current != null) colors.background.copy(alpha = 0.72f) else colors.surface))), ) } } @@ -828,12 +943,12 @@ internal fun Modifier.readable(): Modifier { } @Composable -private fun FactRow(label: String, value: String) { +internal fun FactRow(label: String, value: String, labelWidth: androidx.compose.ui.unit.Dp = 150.dp) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth().readable().padding(horizontal = 8.dp, vertical = 7.dp), ) { - Text(label, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(150.dp)) + Text(label, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(labelWidth)) Text(value, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameGlass.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameGlass.kt new file mode 100644 index 000000000..dd5668ee8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameGlass.kt @@ -0,0 +1,277 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.imageLoader +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import coil.size.Precision +import kotlin.math.max + +/** Buttons in a capsule render as pills; elsewhere they keep their rounded-rectangle shape. */ +internal val LocalPillButtons = compositionLocalOf { false } + +/** + * The page's blurred art, shared so frosted surfaces can draw the part of it behind them. + * Coordinates are read when drawing, so a surface and the backdrop always agree on where they are. + */ +internal class GlassBackdrop { + var painter: Painter? = null + var coords: LayoutCoordinates? = null + var light: Boolean = false + var ready by mutableStateOf(false) + + fun offsetOf(surface: LayoutCoordinates?): Offset? { + val base = coords?.takeIf { it.isAttached } ?: return null + val at = surface?.takeIf { it.isAttached } ?: return null + return base.localPositionOf(at, Offset.Zero) + } + + fun paint(scope: DrawScope, size: Size) { + val art = painter ?: return + scope.drawCover(art, size, if (light) LightAmbient else DarkAmbient) + } + + /** Drawn inside a layer, so it fades what was painted before it toward the page's end. */ + fun fadeBottom(scope: DrawScope, size: Size) { + val fade = minOf(size.height * 0.5f, with(scope) { 240.dp.toPx() }) + if (size.height <= 0f) return + scope.drawRect(Brush.verticalGradient((1f - fade / size.height) to Color.Black, 1f to Color.Transparent), size = size, blendMode = BlendMode.DstIn) + } +} + +internal val LocalGlass = compositionLocalOf { null } + +private val DarkAmbient = ColorFilter.colorMatrix( + ColorMatrix().apply { + setToSaturation(1.3f) + timesAssign(ColorMatrix(floatArrayOf(0.55f, 0f, 0f, 0f, 0f, 0f, 0.55f, 0f, 0f, 0f, 0f, 0f, 0.55f, 0f, 0f, 0f, 0f, 0f, 1f, 0f))) + }, +) +private val LightAmbient = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(1.1f) }) + +private fun DrawScope.drawCover(painter: Painter, into: Size, filter: ColorFilter) { + val intrinsic = painter.intrinsicSize + if (!intrinsic.isSpecified) return + val iw = intrinsic.width + val ih = intrinsic.height + if (iw <= 0f || ih <= 0f || into.width <= 0f || into.height <= 0f) return + val scale = max(into.width / iw, into.height / ih) + val w = iw * scale + val h = ih * scale + clipRect(0f, 0f, into.width, into.height) { + translate((into.width - w) * AmbientBiasX, (into.height - h) * AmbientBiasY) { + with(painter) { draw(Size(w, h), colorFilter = filter) } + } + } +} + +private const val AmbientBiasX = 0.6f +private const val AmbientBiasY = 0.05f + +private fun ambientRequest(ctx: android.content.Context, model: Any?): ImageRequest? = + model?.let { ImageRequest.Builder(ctx).data(it).size(AmbientPixels, AmbientPixels).precision(Precision.EXACT).memoryCacheKey("wash:$it").build() } + +private const val AmbientPixels = 6 + +/** How washed-out the art is: average brightness less its saturation, from a tiny decode, so pale art is dimmed while dark and vivid art are left alone. */ +@Composable +private fun rememberArtBrightness(model: Any?): Float { + val ctx = LocalContext.current + val data = (model as? ImageRequest)?.data ?: model + val brightness by produceState(0.3f, data) { + if (data == null) return@produceState + val request = ImageRequest.Builder(ctx).data(data).size(SamplePixels, SamplePixels).precision(Precision.EXACT) + .allowHardware(false).memoryCacheKey("lum:$data").build() + val bitmap = (ctx.imageLoader.execute(request).drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap ?: return@produceState + var light = 0.0 + var saturation = 0.0 + for (y in 0 until bitmap.height) for (x in 0 until bitmap.width) { + val pixel = Color(bitmap.getPixel(x, y)) + val top = maxOf(pixel.red, pixel.green, pixel.blue) + light += pixel.luminance() + if (top > 0f) saturation += (top - minOf(pixel.red, pixel.green, pixel.blue)) / top + } + val count = bitmap.width * bitmap.height + value = ((light - SaturationWeight * saturation) / count).toFloat().coerceAtLeast(0f) + } + return brightness +} + +private const val SamplePixels = 8 +private const val SaturationWeight = 0.6 + +/** + * A page whose game art fills the background: a blurred wash behind everything and the sharp art + * across the top, fading into the page. Surfaces inside draw frosted glass over the wash. + */ +@Composable +internal fun GameBackdrop( + art: Any?, ambient: Any?, fallbackHue: Float, + fadeTop: Boolean, + modifier: Modifier = Modifier, + onArtPlaced: (androidx.compose.ui.geometry.Rect?) -> Unit = {}, + content: @Composable () -> Unit, +) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val glass = remember { GlassBackdrop() } + glass.light = colors.background.luminance() > 0.5f + val wash = rememberAsyncImagePainter(remember(ambient) { ambientRequest(ctx, ambient ?: art) }) + glass.painter = if (ambient != null || art != null) wash else null + val edge = 24.dp + val artMin = with(LocalDensity.current) { ArtMin.toPx() } + Box( + modifier.fillMaxWidth().onGloballyPositioned { + glass.coords = it + if (!glass.ready) glass.ready = true + if (art == null) { + val at = it.boundsInRoot() + onArtPlaced(Rect(at.left, at.top, at.right, at.top + artMin)) + } + }, + ) { + DisposableEffect(art == null) { onDispose { if (art == null) onArtPlaced(null) } } + Box( + Modifier.matchParentSize() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + if (glass.painter == null) drawRect(artBrush(fallbackHue), alpha = 0.55f) else glass.paint(this, size) + drawRect(colors.background.copy(alpha = if (glass.light) 0.7f else 0.58f)) + if (fadeTop) drawRect(Brush.verticalGradient(0f to Color.Transparent, (edge.toPx() / size.height).coerceAtMost(0.5f) to Color.Black), blendMode = BlendMode.DstIn) + glass.fadeBottom(this, size) + }, + ) + if (art != null) ArtLayer(art, ambient ?: art, fadeTop, onArtPlaced) + CompositionLocalProvider(LocalGlass provides glass) { content() } + } +} + +@Composable +private fun ArtLayer(model: Any?, sample: Any?, fadeTop: Boolean, onPlaced: (androidx.compose.ui.geometry.Rect?) -> Unit) { + val colors = MaterialTheme.colorScheme + val light = colors.background.luminance() > 0.5f + val pale = ((rememberArtBrightness(sample) - 0.28f) / 0.5f).coerceIn(0f, 1f) + val dim = if (light) 1f else 1f - 0.4f * pale + val artFilter = remember(dim) { ColorFilter.colorMatrix(ColorMatrix(floatArrayOf(dim, 0f, 0f, 0f, 0f, 0f, dim, 0f, 0f, 0f, 0f, 0f, dim, 0f, 0f, 0f, 0f, 0f, 1f, 0f))) } + BoxWithConstraints(Modifier.fillMaxWidth()) { + val height = (maxWidth * 0.4f).coerceIn(ArtMin, ArtMax) + DisposableEffect(Unit) { onDispose { onPlaced(null) } } + Box( + Modifier.fillMaxWidth().height(height) + .onGloballyPositioned { onPlaced(it.boundsInRoot()) } + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + drawRect(Brush.horizontalGradient(0.08f to Color.Transparent, 0.55f to Color.Black), blendMode = BlendMode.DstIn) + drawRect(Brush.verticalGradient(0.55f to Color.Black, 1f to Color.Transparent), blendMode = BlendMode.DstIn) + if (fadeTop) drawRect(Brush.verticalGradient(0f to Color.Transparent, (24.dp.toPx() / size.height) to Color.Black), blendMode = BlendMode.DstIn) + }, + ) { + AsyncImage( + model = model, contentDescription = null, contentScale = ContentScale.Crop, + alignment = BiasAlignment(0.45f, -0.35f), + colorFilter = artFilter, + modifier = Modifier.matchParentSize(), + ) + Box( + Modifier.matchParentSize().drawBehind { + drawRect(colors.background.copy(alpha = if (light) 0.1f else 0.12f + 0.3f * pale)) + drawRect( + Brush.radialGradient( + 0f to colors.background.copy(alpha = if (light) 0.5f else 0.66f), + 0.55f to colors.background.copy(alpha = if (light) 0.2f else 0.3f), + 1f to Color.Transparent, + center = Offset(size.width * 0.22f, size.height * 0.55f), radius = size.width * 0.55f, + ), + ) + }, + ) + } + } +} + +private val ArtMin = 220.dp +private val ArtMax = 340.dp + +/** A frosted surface: the page's wash behind it, a dark tint, and a thin rim. Plain surface when there is no backdrop. */ +@Composable +internal fun Modifier.glass(shape: Shape, lift: Float = 0f): Modifier { + val g = LocalGlass.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + if (g == null) return clip(shape).background(colors.surface).border(1.dp, pal.line2, shape) + val light = g.light + val tint = colors.background.copy(alpha = if (light) 0.62f else 0.46f + lift) + val rim = if (light) Color.Black.copy(alpha = 0.1f) else Color.White.copy(alpha = 0.12f + lift) + val self = remember { arrayOfNulls(1) } + var placed by remember { mutableIntStateOf(0) } + val last = remember { longArrayOf(Long.MIN_VALUE) } + return clip(shape) + .onGloballyPositioned { c -> + self[0] = c + val at = g.offsetOf(c) ?: return@onGloballyPositioned + val key = (at.x.toLong() shl 32) xor (at.y.toLong() and 0xffffffffL) + if (key != last[0]) { last[0] = key; placed++ } + } + .drawBehind { + val base = g.coords?.size + if (placed >= 0 && g.ready && base != null) g.offsetOf(self[0])?.let { at -> + val wash = Size(base.width.toFloat(), base.height.toFloat()) + drawIntoCanvas { canvas -> + canvas.saveLayer(Rect(0f, 0f, size.width, size.height), Paint()) + translate(-at.x, -at.y) { g.paint(this, wash); g.fadeBottom(this, wash) } + canvas.restore() + } + } + drawRect(tint) + } + .border(1.dp, rim, shape) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameResources.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameResources.kt index 78384480a..3eb42902b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameResources.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameResources.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -70,7 +71,8 @@ private fun ResourceLink(link: GameResource) { val pal = LocalPalette.current val source = remember { MutableInteractionSource() } val hot = rememberHot(source) - val shape = RoundedCornerShape(8.dp) + val glass = LocalGlass.current != null + val shape = if (glass) RoundedCornerShape(99.dp) else RoundedCornerShape(8.dp) val label = stringResource(link.label) val description = listOfNotNull(label, link.detail?.let { stringResource(it) }, stringResource(R.string.game_resources_browser)).joinToString(". ") val open = { openUrl(ctx, link.url) } @@ -78,13 +80,13 @@ private fun ResourceLink(link: GameResource) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.paneItem("resource:${link.label}").heightIn(min = 44.dp).clip(shape) - .background(if (hot) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .glideBorder(hot, shape, pal.signal, restColor = Color.Transparent) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (glass) Color.White.copy(alpha = 0.07f) else Color.Transparent) + .glideBorder(hot, shape, pal.signal, restColor = if (glass) Color.White.copy(alpha = 0.12f) else Color.Transparent) .semantics { contentDescription = description } .hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = open) - .controllerConfirm(onClick = open).padding(horizontal = 8.dp), + .controllerConfirm(onClick = open).padding(horizontal = if (glass) 14.dp else 8.dp), ) { - Text(label, fontSize = 12.sp, fontWeight = FontWeight.Medium, color = tint, maxLines = 1) + Text(label, fontSize = if (glass) 13.sp else 12.sp, fontWeight = FontWeight.Medium, color = if (glass && !hot) MaterialTheme.colorScheme.onBackground else tint, maxLines = 1) Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = tint.copy(alpha = 0.65f), modifier = Modifier.size(11.dp)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GogMaintenanceUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/GogMaintenanceUi.kt new file mode 100644 index 000000000..57f692339 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GogMaintenanceUi.kt @@ -0,0 +1,193 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.background +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Build +import androidx.compose.material3.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.formatBytes +import com.droiddeck.launcher.stores.StoreLanguage +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadState +import com.droiddeck.launcher.stores.gog.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +internal fun GogMaintenanceActions(game: InstalledStoreGame) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val scope = rememberCoroutineScope() + var menu by remember { mutableStateOf(false) } + var patches by remember { mutableStateOf?>(null) } + var proposal by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var recoverablePatch by remember { mutableStateOf(false) } + val key = "gog:${game.sidecar.id}" + val download = com.droiddeck.launcher.stores.StoresState.download(key) + val active = download?.isActive == true + val check = GogMaintenance.checks[game.sidecar.id] + LaunchedEffect(game.folder.path) { GogMaintenance.check(ctx, game) } + LaunchedEffect(menu, active) { + if (menu) { + recoverablePatch = false + val result = withContext(Dispatchers.IO) { runCatching { + GogFiles.read(GogFiles.journal(game.folder))?.let { + it.optString("operation") == GogOperation.PATCH.name && it.optString("phase") in setOf("patch-starting", "patch-applying", "patch-uncertain") + } == true + } } + result.onSuccess { recoverablePatch = it }.onFailure { error = it.message } + } + } + fun prepare(operation: GogOperation, patch: GogOfflineDownload? = null) { + menu = false; patches = null; busy = true; error = null + scope.launch { + runCatching { withContext(Dispatchers.IO) { GogMaintenance.prepare(ctx, game, operation, patch) } } + .onSuccess { proposal = it }.onFailure { error = it.message } + busy = false + } + } + if (active) { + ActionChip(downloadLabel(download!!), ok = false) + if (download.interruptible) { + SecondaryButton(stringResource(if (download.state == DownloadState.PAUSED) R.string.stores_dl_resume else R.string.stores_dl_pause)) { + if (download.state == DownloadState.PAUSED) DownloadQueue.resume(ctx, key) else DownloadQueue.pause(key) + } + } else Text(stringResource(R.string.gog_patch_in_session), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else if (!game.sidecar.isInstalled || GogFiles.journal(game.folder).exists()) { + SecondaryButton(stringResource(R.string.gog_resume), modifier = Modifier.testTag("gog-resume")) { + busy = true + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + GogMaintenance.resume(ctx, game) + } + }.onSuccess { proposal = it }.onFailure { error = it.message } + busy = false + } + } + } else if (check?.state == GogMaintenance.State.AVAILABLE) { + SecondaryButton(stringResource(R.string.gog_update, check.version.ifBlank { game.sidecar.title }), enabled = !busy, modifier = Modifier.testTag("gog-update")) { prepare(GogOperation.UPDATE) } + } + androidx.compose.foundation.layout.Box { + val manageLabel = stringResource(if (busy) R.string.common_checking else R.string.gog_maintenance) + if (LocalPillButtons.current) androidx.compose.foundation.layout.Box(Modifier.testTag("gog-maintenance")) { + IconAction(Icons.Outlined.Build, manageLabel, "game:gog-maintenance", enabled = !busy && !active) { menu = !menu } + } else SecondaryButton(manageLabel, enabled = !busy && !active, modifier = Modifier.testTag("gog-maintenance")) { menu = !menu } + AnchoredMenu(menu, { menu = false }, title = stringResource(R.string.gog_maintenance), compact = true) { first -> + MenuItem(stringResource(R.string.common_update), checked = false) { prepare(GogOperation.UPDATE) } + MenuItem(stringResource(R.string.gog_check), checked = false, focusRequester = first) { menu = false; GogMaintenance.check(ctx, game, true) } + MenuItem(stringResource(R.string.gog_repair), checked = false, modifier = Modifier.testTag("gog-repair")) { prepare(GogOperation.REPAIR) } + MenuItem(stringResource(R.string.gog_patches), checked = false, modifier = Modifier.testTag("gog-patches")) { + menu = false; busy = true + scope.launch { + runCatching { withContext(Dispatchers.IO) { GogMaintenance.offlinePatches(ctx, game) } } + .onSuccess { patches = it }.onFailure { error = it.message } + busy = false + } + } + MenuItem(stringResource(R.string.gog_refresh), checked = false) { prepare(GogOperation.REFRESH) } + if (recoverablePatch) MenuItem(stringResource(R.string.gog_recover), checked = false, modifier = Modifier.testTag("gog-recover")) { + menu = false; busy = true + scope.launch { + runCatching { withContext(Dispatchers.IO) { GogMaintenance.recoverPatch(ctx, game) } }.onFailure { error = it.message } + busy = false + } + } + } + } + if (check?.state == GogMaintenance.State.FAILED || check?.state == GogMaintenance.State.UNKNOWN) { + if (check.reason.isNotBlank()) Text(check.reason, fontSize = 12.sp, color = colors.onSurfaceVariant) + } + patches?.let { list -> + val shown = rememberShown { patches = null } + val close = { shown.targetState = false } + AppDialog(shown, close, "gogPatches", wide = false, maxWidth = 460.dp) { + DialogTitleRow(stringResource(R.string.gog_patches), close) + Text(game.sidecar.title, fontSize = 12.sp, lineHeight = 16.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.gog_patch_requirements), fontSize = 12.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant) + if (list.isEmpty()) Text(stringResource(R.string.gog_no_patches), fontSize = 13.sp, color = colors.onSurfaceVariant) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + list.forEach { patch -> + ListRow(onClick = { close(); prepare(GogOperation.PATCH, patch) }) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(patch.name, fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(listOf(StoreLanguage.displayName(patch.language).ifBlank { patch.language }, patch.version, formatBytes(patch.size)).filter { it.isNotBlank() }.joinToString(" · "), + fontSize = 11.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text(stringResource(R.string.gog_select_patch), fontSize = 12.sp, color = LocalPalette.current.signal) + } + } + } + SecondaryButton(stringResource(R.string.gog_refresh)) { prepare(GogOperation.REFRESH) } + } + } + proposal?.let { selected -> + val shown = rememberShown { proposal = null } + val close = { shown.targetState = false } + val action = when (selected.operation) { + GogOperation.UPDATE -> R.string.common_update + GogOperation.REPAIR -> R.string.gog_repair_action + GogOperation.PATCH -> R.string.gog_open_patch + GogOperation.REFRESH -> R.string.gog_refresh_action + } + AppDialog(shown, close, "gogMaintenance", wide = false, maxWidth = 460.dp) { + DialogTitleRow(stringResource(action), close) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(listOf(game.sidecar.title, selected.before.version).filter { it.isNotBlank() }.joinToString(" · "), fontSize = 12.sp, lineHeight = 16.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (selected.operation != GogOperation.REPAIR) Text(selected.patch?.name ?: selected.target.version.ifBlank { selected.target.build }, fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, + color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surface.copy(alpha = 0.65f)).padding(12.dp)) { + GogMetric(stringResource(R.string.gog_download_label), formatBytes(selected.downloadBytes), Modifier.weight(1f)) + GogMetric(stringResource(if (selected.operation == GogOperation.PATCH || selected.operation == GogOperation.REFRESH) R.string.gog_space_estimate_label else R.string.gog_space_label), + formatBytes(selected.diskBytes), Modifier.weight(1f)) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.gog_managed_files), fontSize = 12.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant) + if (selected.fullFiles) Text(stringResource(R.string.gog_full_files), fontSize = 12.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant) + if (selected.operation == GogOperation.PATCH) Text(stringResource(R.string.gog_patch_installer_note), fontSize = 12.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + PrimaryButton(stringResource(action), enabled = !busy, modifier = Modifier.testTag("gog-apply")) { + busy = true + close() + scope.launch { + runCatching { withContext(Dispatchers.IO) { GogMaintenance.enqueue(ctx, game, selected) } }.onFailure { error = it.message } + busy = false + } + } + SecondaryButton(stringResource(R.string.common_cancel), onClick = close) + } + } + } + error?.let { Text(it, fontSize = 12.sp, color = colors.error) } +} + +@Composable +private fun GogMetric(label: String, value: String, modifier: Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, fontSize = 11.sp, lineHeight = 15.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchUpdateButton.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchUpdateButton.kt new file mode 100644 index 000000000..d916af32b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchUpdateButton.kt @@ -0,0 +1,61 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.itch.ItchUpdate +import com.droiddeck.launcher.stores.itch.ItchUpdates + +@Composable +internal fun ItchCheckButton(installed: InstalledStoreGame) { + val ctx = LocalContext.current + IconAction(Icons.Filled.Refresh, stringResource(R.string.store_check_updates), "game:check-updates", enabled = ItchUpdates.checkingKey == null, frosted = true) { + ItchUpdates.checkGame(ctx, installed) + } +} + +@Composable +internal fun ItchUpdateButton(installed: InstalledStoreGame, enabled: Boolean) { + val ctx = LocalContext.current + val update = ItchUpdates.available[installed.key] ?: return + var asking by remember { mutableStateOf(false) } + SecondaryButton(stringResource(R.string.common_update), enabled = enabled) { + if (update.replaced) asking = true else ItchUpdates.start(ctx, installed, update) + } + if (asking) ReplacedDialog(installed, update) { asking = false } +} + +@Composable +private fun ReplacedDialog(installed: InstalledStoreGame, update: ItchUpdate, onDismiss: () -> Unit) { + val ctx = LocalContext.current + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + AppDialog(shown, close, "itchUpdate", wide = false, maxWidth = 460.dp) { + DialogTitleRow(installed.sidecar.title, close) + Text( + stringResource(R.string.stores_itch_update_replaced, update.filename.ifBlank { update.uploadId }), + fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton(stringResource(R.string.common_update)) { + close() + ItchUpdates.start(ctx, installed, update) + } + SecondaryButton(stringResource(R.string.common_cancel)) { close() } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Layout.kt b/app/src/main/java/com/droiddeck/launcher/ui/Layout.kt index 594492f84..a002b10a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Layout.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Layout.kt @@ -31,11 +31,11 @@ internal fun LauncherLayout( }) { children, constraints -> val width = constraints.maxWidth val height = constraints.maxHeight - val line = 1.dp.roundToPx() val nav = children[2].measure(Constraints( minWidth = if (bottomNavigation) width else 0, maxWidth = width, minHeight = if (bottomNavigation) 0 else height, maxHeight = height, )) + val line = if ((if (bottomNavigation) nav.height else nav.width) == 0) 0 else 1.dp.roundToPx() val contentWidth = if (bottomNavigation) width else (width - nav.width - line).coerceAtLeast(0) val contentHeight = if (bottomNavigation) (height - nav.height - line).coerceAtLeast(0) else height val pane = children[0].measure(Constraints.fixed(contentWidth, contentHeight)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt index 0f1b579c9..fe0866e8a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -3,6 +3,8 @@ package com.droiddeck.launcher.ui import android.view.KeyEvent import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background @@ -14,9 +16,12 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -27,6 +32,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -35,12 +41,15 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.ViewList import androidx.compose.material.icons.filled.ViewModule +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.outlined.Download import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -55,11 +64,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -88,9 +99,13 @@ import com.droiddeck.launcher.stores.StoreSizes import com.droiddeck.launcher.stores.StoresState import com.droiddeck.launcher.stores.arrangeStoreTabs import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadStage +import com.droiddeck.launcher.stores.download.DownloadState import com.droiddeck.launcher.stores.formatBytes import com.droiddeck.launcher.stores.itch.ItchCollection import com.droiddeck.launcher.stores.itch.ItchCollections +import com.droiddeck.launcher.stores.itch.ItchUpdates +import com.droiddeck.launcher.stores.gog.GogMaintenance import com.droiddeck.launcher.stores.retainOrder import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter import com.droiddeck.launcher.stores.sortGames @@ -145,6 +160,37 @@ internal fun librarySources(entries: List, order: List): L .filter { source -> entries.any { it.source == source } } /** Survives leaving the page, so Stores can open the Library on one store's owned games. */ +internal object LibraryDisplay { + val scales = listOf(0.75f, 0.875f, 1f, 1.2f, 1.45f) + var level by mutableIntStateOf(2) + private set + var title by mutableStateOf(true) + private set + var size by mutableStateOf(true) + private set + + fun load(context: android.content.Context) { + level = SessionPrefs.libraryCardLevel(context).coerceIn(0, scales.lastIndex) + title = SessionPrefs.libraryShowTitle(context) + size = SessionPrefs.libraryShowSize(context) + } + + fun step(context: android.content.Context, by: Int) { + level = (level + by).coerceIn(0, scales.lastIndex) + SessionPrefs.setLibraryCardLevel(context, level) + } + + fun toggleTitle(context: android.content.Context) { + title = !title + SessionPrefs.setLibraryShowTitle(context, title) + } + + fun toggleSize(context: android.content.Context) { + size = !size + SessionPrefs.setLibraryShowSize(context, size) + } +} + internal object LibraryView { var source by mutableStateOf(LIBRARY_ALL) var query by mutableStateOf("") @@ -169,6 +215,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String, val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current LaunchedEffect(Unit) { StoresState.refresh(ctx) } + remember { LibraryDisplay.load(ctx) } val stores = s.gameStoresEnabled && !s.shortcutPicker val signedIn = Store.entries.filter { StoresState.isSignedIn(it) } LaunchedEffect(stores, signedIn) { if (stores) signedIn.forEach { StoresState.loadLibrary(ctx, it) } } @@ -194,7 +241,10 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String, val source = LibraryView.source.takeIf { it == LIBRARY_ALL || it in sources } ?: LIBRARY_ALL val q = LibraryView.query.trim().lowercase() val xboxFilter = if (source == Store.XBOX.id) XboxLibraryFilter.current else XboxLibraryFilter.ALL - LaunchedEffect(source, itchSignedIn) { if (source == Store.ITCH.id && itchSignedIn) ItchCollections.load(ctx) } + LaunchedEffect(source, itchSignedIn) { if (source == Store.ITCH.id && itchSignedIn) { + ItchCollections.load(ctx) + ItchUpdates.check(ctx) + } } val collection = if (source == Store.ITCH.id) ItchCollections.chosen() else null val pool = if (collection == null) all else collectionEntries(all, ItchCollections.gamesOf(collection.id).orEmpty()) val matching = pool.filter { @@ -305,11 +355,12 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String, gridScrolled = { grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 }, onDetailFocus = { detailFocused = it }, modifier = modifier.padding(horizontal = if (narrow) 14.dp else 22.dp).padding(top = if (narrow) 10.dp else 14.dp), + bleedHorizontal = if (narrow) 14.dp else 22.dp, bleedTop = if (narrow) 10.dp else 14.dp, wrap = { key, content -> StoreTheme(GameRef.parse(key).let { r -> r.store ?: r.game(s.steamGames)?.source?.let { Store.byId(it) } }) { content() } }, grid = { LibraryGrid( s, a, grid, visible, split, visible.size, sources, source, sizesPending, syncing, - showUninstalled = listUninstalled.takeIf { stores }, onShowUninstalled = setShowUninstalled, + showUninstalled = listUninstalled.takeIf { !s.shortcutPicker },onShowUninstalled = setShowUninstalled, hiddenUninstalled = hiddenUninstalled, onSource = { LibraryView.source = it }, onCycle = { step -> @@ -354,7 +405,7 @@ private fun LibraryGrid( ) { val listMode = s.storesView == "list" val capsules = s.storesView == "capsules" - val tileWidth = if (capsules) CapsuleWidth else CardWidth + val tileWidth = (if (capsules) CapsuleWidth else if (listMode) CardWidth else LibraryCardWidth) * LibraryDisplay.scales[LibraryDisplay.level] val focus = LocalFrontFocus.current val scope = rememberCoroutineScope() val density = androidx.compose.ui.platform.LocalDensity.current @@ -378,10 +429,11 @@ private fun LibraryGrid( if (key.action == KeyEvent.ACTION_DOWN) { val target = "card:${visible[next].key}" val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2 + val currentIndex = headerItems + index + if (split == 0) 0 else if (index < split) 1 else 2 val shown = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } if (!shown || (focus.attached[target] ?: 0) == 0 || runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { - grid.scrollToItem(targetIndex) + launch { grid.revealRow(currentIndex, targetIndex, direction, with(density) { 10.dp.toPx() }) } focusWithinFrames({ focus.last == target }) { focus.items[target] ?: FocusRequester.Default } } } @@ -391,7 +443,7 @@ private fun LibraryGrid( } val picker = s.shortcutPicker && e.game != null val choose = if (picker) ({ a.onSteamGame(e.game!!) }) else null - if (listMode) LibraryRow(e, first, navigation, choose, onOpen) else LibraryTile(e, capsules, first, navigation, choose, onOpen) + if (listMode) LibraryRow(e, first, navigation, choose, onOpen) else LibraryTile(e, capsules, first, navigation, choose, onOpen, showSource = source == LIBRARY_ALL) } LazyVerticalGrid( columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, @@ -439,6 +491,20 @@ private fun LibraryGrid( } } +internal suspend fun LazyGridState.revealRow(currentIndex: Int, targetIndex: Int, direction: Int, gapPx: Float) { + val current = layoutInfo.visibleItemsInfo.firstOrNull { it.index == currentIndex } + if (current != null) { + val row = current.size.height + gapPx + val delta = if (direction > 0) { + current.offset.y + 2 * row - gapPx - (layoutInfo.viewportSize.height - layoutInfo.afterContentPadding) + } else { + current.offset.y - row - layoutInfo.beforeContentPadding + } + if (direction > 0 && delta > 0f || direction < 0 && delta < 0f) animateScrollBy(delta, tween(durationMillis = 160, easing = FastOutSlowInEasing)) + } + if (layoutInfo.visibleItemsInfo.none { it.index == targetIndex }) scrollToItem(targetIndex) +} + private fun LazyGridScope.libraryGrid(entries: List, empty: Int, tile: @Composable (LibraryEntry) -> Unit) { if (entries.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -458,7 +524,7 @@ private fun LibraryHeader( ModeHeader( stringResource(R.string.rail_games), LibraryView.query, { LibraryView.query = it }, placeholder = stringResource(R.string.stores_search_library), - clearSearchFocus = searchCleared, + clearSearchFocus = searchCleared, inlineWhenStacked = true, lead = { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 2.dp, end = 4.dp)) { Text(stringResource(R.string.rail_games), fontSize = if (narrow) 20.sp else 24.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) @@ -467,6 +533,7 @@ private fun LibraryHeader( } }, ) { + DisplayButton() if (showUninstalled != null || xbox) LibraryFilterButton(showUninstalled, onShowUninstalled, xbox) if (collections) CollectionsButton() SortButton(s, a) @@ -526,7 +593,7 @@ private fun entryArt(e: LibraryEntry, tall: Boolean): Any? { } @Composable -private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit) { +private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit, showSource: Boolean) { val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) @@ -550,9 +617,73 @@ private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navig .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open) .controllerConfirm(onClick = choose ?: open), ) { - CardFace(e.title, wide, tall, download, hot, capsule, dim = !e.installed && download == null, - badge = { SourceChip(e.source, small = true, modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) }) { - LibraryMarker(e, download) + LibraryArt(e, wide, tall, download, hot, capsule, showSource) + } +} + +internal val LibraryCardWidth = 150.dp + +internal fun libraryTileWidth() = LibraryCardWidth * LibraryDisplay.scales[LibraryDisplay.level] + +@Composable +private fun LibraryArt(e: LibraryEntry, wide: Any?, tall: Any?, download: DownloadEntry?, hot: Boolean, capsule: Boolean, showSource: Boolean) { + val chip = when { + download != null -> "${download.percent}%" + e.item != null && StoresState.isUnfinished(e.item) -> stringResource(R.string.stores_resume) + e.installed && libraryUpdateAvailable(e) -> stringResource(R.string.common_update) + else -> null + } + ArtCard( + e.title, wide, tall, download, hot, capsule, chip, + badge = if (showSource) ({ SourceChip(e.source, small = true, modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) }) else null, + ) { LibrarySize(e) } +} + +@Composable +internal fun ArtCard( + title: String, wide: Any?, tall: Any?, download: DownloadEntry?, hot: Boolean, capsule: Boolean, chip: String?, + badge: (@Composable BoxScope.() -> Unit)? = null, detail: @Composable () -> Unit, +) { + val url = if (capsule) tall ?: wide else wide ?: tall + val art = cardArtRequest(url, tall) + val ratio = if (capsule) 5f / 7f else 16f / 9f + var offRatio by remember(url) { mutableStateOf(false) } + var trim by remember(url) { mutableStateOf(false) } + val blurs = android.os.Build.VERSION.SDK_INT >= 31 + Box( + Modifier.fillMaxWidth().aspectRatio(ratio).background(Color(0xFF101318)) + .then(if (url == null) Modifier.background(artBrush(hueOf(title))) else Modifier), + ) { + if (art != null && offRatio) { + AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(32.dp)) + Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.18f else 0.6f))) + } + if (art != null) AsyncImage( + model = art, contentDescription = null, contentScale = if (trim) ContentScale.Crop else ContentScale.Fit, modifier = Modifier.matchParentSize(), + onSuccess = { r -> + val d = r.result.drawable + if (d.intrinsicWidth > 0 && d.intrinsicHeight > 0) { + val shape = d.intrinsicWidth.toFloat() / d.intrinsicHeight + trim = !capsule && shape > ratio * 1.25f + offRatio = !trim && kotlin.math.abs(shape / ratio - 1f) > 0.03f + } + }, + ) + badge?.invoke(this) + if (chip != null) Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) { StateChip(chip, ChipTone.BUSY, small = true) } + val showName = LibraryDisplay.title || hot + val showDetail = LibraryDisplay.size || hot + if (showName || showDetail) Column( + verticalArrangement = Arrangement.spacedBy(1.dp), + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + .background(Brush.verticalGradient(0f to Color.Transparent, 0.6f to Color.Black.copy(alpha = 0.78f), 1f to Color.Black.copy(alpha = 0.92f))) + .padding(start = 8.dp, end = 8.dp, top = 20.dp, bottom = if (download != null) 14.dp else 6.dp), + ) { + if (showName) Text(title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = if (hot) 2 else 1, overflow = TextOverflow.Ellipsis, lineHeight = 13.sp) + if (showDetail) detail() + } + if (download != null) Box(Modifier.align(Alignment.BottomCenter).padding(start = 6.dp, end = 6.dp, bottom = 5.dp)) { + ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY) } } } @@ -572,22 +703,34 @@ private fun RequestSize(e: LibraryEntry) { LaunchedEffect(item.key) { StoreSizes.request(ctx, item) } } +private fun librarySize(e: LibraryEntry): Long? = if (e.installed) e.storeKey?.let { StoreSizes.onDisk[it] } + ?: e.game?.gameFiles?.let { FolderSizes.known[it.path] } + else e.item?.let { StoreSizes.size(it) }?.takeIf { it > 0 } + @Composable -internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?) { +private fun LibrarySize(e: LibraryEntry) { + val size = librarySize(e)?.takeIf { it > 0 } ?: return + Text(formatBytes(size), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) +} + +@Composable +internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?, showDownload: Boolean = true) { val pal = LocalPalette.current - val size = if (e.installed) e.storeKey?.let { StoreSizes.onDisk[it] } - ?: e.game?.gameFiles?.let { FolderSizes.known[it.path] } - else e.item?.let { StoreSizes.size(it) }?.takeIf { it > 0 } + val size = librarySize(e) when { download != null -> Text("${download.percent}%", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) e.item != null && StoresState.isUnfinished(e.item) -> Text(stringResource(R.string.stores_resume), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) else -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + if (e.installed && libraryUpdateAvailable(e)) Text(stringResource(R.string.common_update), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) if (size != null && size > 0) Text(formatBytes(size), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) - if (!e.installed) Icon(Icons.Outlined.Download, stringResource(R.string.stores_section_not_installed), tint = Color.White, modifier = Modifier.size(12.dp)) + if (!e.installed && showDownload) Icon(Icons.Outlined.Download, stringResource(R.string.stores_section_not_installed), tint = Color.White, modifier = Modifier.size(12.dp)) } } } +private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available } == true || + e.source == Store.GOG.id && (e.item?.id ?: e.game?.storeId)?.let { GogMaintenance.checks[it]?.state } == GogMaintenance.State.AVAILABLE + @Composable private fun LibraryRow(e: LibraryEntry, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit) { val colors = MaterialTheme.colorScheme @@ -608,6 +751,7 @@ private fun LibraryRow(e: LibraryEntry, first: Boolean, navigation: Modifier, ch val size = if (e.installed && folder != null) diskSize(inst, folder) else e.item?.let { StoreSizes.size(it) }?.takeIf { it > 0 } val status = when { download != null -> downloadLabel(download) + e.installed && libraryUpdateAvailable(e) -> stringResource(R.string.common_update) !e.installed -> stringResource(R.string.stores_section_not_installed) e.game != null -> playedSpan(e.game.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played) else -> stringResource(R.string.stores_installed_chip) @@ -755,6 +899,45 @@ private fun CollectionsButton() { } } +@Composable +internal fun DisplayButton() { + val pal = LocalPalette.current + val ctx = LocalContext.current + val title = stringResource(R.string.library_display) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton(title, Icons.Filled.Visibility, "display") { + val from = at ?: return@HeaderIconButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = 4, + handle = { Icon(Icons.Filled.Visibility, null, tint = pal.signal, modifier = Modifier.size(18.dp)) }, + ) { + StepTitle(title) + StepList { + val last = LibraryDisplay.scales.lastIndex + StepChoice( + stringResource(R.string.library_display_larger), icon = Icons.Filled.Add, enabled = open && LibraryDisplay.level < last, + trailing = "${LibraryDisplay.level + 1}/${last + 1}", + modifier = Modifier.fillMaxWidth().stepItem(0).focusRequester(first), + ) { LibraryDisplay.step(ctx, 1) } + StepChoice( + stringResource(R.string.library_display_smaller), icon = Icons.Filled.Remove, enabled = open && LibraryDisplay.level > 0, + modifier = Modifier.fillMaxWidth().stepItem(1), + ) { LibraryDisplay.step(ctx, -1) } + StepChoice( + stringResource(R.string.library_display_title), selected = LibraryDisplay.title, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(2), + ) { LibraryDisplay.toggleTitle(ctx) } + StepChoice( + stringResource(R.string.library_display_size), selected = LibraryDisplay.size, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(3), + ) { LibraryDisplay.toggleSize(ctx) } + } + }) + } + } +} + @Composable private fun SortButton(s: FrontEndState, a: FrontEndActions) { val pal = LocalPalette.current 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 9c24f551c..d81015d61 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -306,6 +306,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: if (steam && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) { Note(stringResource(R.string.back_native_qam_hint)) } + PhysicalShortcutRows() } } if (tab == SheetTab.AUDIO && steam) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialog.kt new file mode 100644 index 000000000..3ad5cea7a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialog.kt @@ -0,0 +1,189 @@ +package com.droiddeck.launcher.ui + +import android.util.Log +import android.view.KeyEvent +import android.view.Window +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.PhysicalShortcuts + +@Composable +internal fun PhysicalShortcutRows( + onChanged: () -> Unit = {}, + modifierFor: (PhysicalShortcuts.Action) -> Modifier = { Modifier }, +) { + val context = LocalContext.current + var bindings by remember { mutableStateOf(PhysicalShortcuts.read(context)) } + var editing by remember { mutableStateOf(null) } + Column { + for (action in PhysicalShortcuts.Action.entries) { + val assigned = bindings.filter { it.action == action } + SettingsRow( + stringResource(action.label), + assigned.joinToString { "${it.button.label} · ${it.button.name}" }.ifEmpty { null }, + ) { + SecondaryButton( + stringResource(if (assigned.isEmpty()) R.string.game_settings_default else R.string.ctrl_custom), + compact = true, + modifier = modifierFor(action).width(LocalChipMinWidth.current) + .testTag("shortcuts-assign-${action.name}"), + ) { editing = action } + } + } + } + editing?.let { action -> + PhysicalShortcutsDialog(action, onDismiss = { editing = null }, onChanged = { + bindings = PhysicalShortcuts.read(context) + onChanged() + }) + } +} + +@Composable +internal fun PhysicalShortcutsDialog( + action: PhysicalShortcuts.Action, + onDismiss: () -> Unit, + onChanged: () -> Unit = {}, +) { + val context = LocalContext.current + val bindings = remember { PhysicalShortcuts.read(context) } + var candidate by remember { mutableStateOf(null) } + var listening by remember { mutableStateOf(true) } + var capturedDevice by remember { mutableStateOf(-1) } + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnBackPress = !listening, usePlatformDefaultWidth = false), + ) { + CaptureShortcutKeys { event -> + if (listening && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && + (event.keyCode == KeyEvent.KEYCODE_BACK || event.keyCode == KeyEvent.KEYCODE_BUTTON_B)) { + listening = false + onDismiss() + true + } else if (!listening || PhysicalShortcuts.isVolumeKey(event)) { + false + } else { + if (candidate == null && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + PhysicalShortcuts.button(event)?.let { button -> + candidate = button + capturedDevice = event.deviceId + Log.i("PhysicalShortcuts", "capture device=${button.name} key=${event.keyCode} " + + "scan=${event.scanCode} source=0x${event.source.toString(16)}") + } + } else if (event.action == KeyEvent.ACTION_UP && event.deviceId == capturedDevice && + candidate?.matches(event) == true) { + listening = false + candidate?.let { button -> + if (bindings.none { it.button.sameInput(button) && it.action != action }) { + PhysicalShortcuts.assign(context, button, action) + onChanged() + onDismiss() + } + } + } + true + } + } + CompositionLocalProvider(LocalFrontFocus provides null) { + Surface( + shape = RoundedCornerShape(16.dp), + modifier = Modifier.padding(12.dp).widthIn(max = 560.dp).fillMaxWidth() + .heightIn(max = (LocalConfiguration.current.screenHeightDp - 24).dp), + ) { + Column( + Modifier.verticalScroll(rememberScrollState()).padding(18.dp).controllerBack(onDismiss), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.shortcuts_capture, stringResource(action.label)), + style = MaterialTheme.typography.titleLarge) + val button = candidate + if (button == null) { + Text(stringResource(R.string.shortcuts_press)) + } else { + Text(stringResource(R.string.shortcuts_detected, button.name, button.label)) + if (listening) { + Text(stringResource(R.string.shortcuts_release)) + } else { + val replacements = bindings.filter { + it.button.sameInput(button) || (it.button.device == button.device && it.action == action) + } + for (conflict in replacements) { + Text("${conflict.button.label} · ${conflict.button.name}") + Text(stringResource(R.string.shortcuts_conflict, stringResource(conflict.action.label))) + } + SecondaryButton(stringResource(R.string.shortcuts_save), modifier = Modifier.testTag("shortcuts-save")) { + PhysicalShortcuts.assign(context, button, action) + onChanged() + onDismiss() + } + } + SecondaryButton(stringResource(R.string.shortcuts_retry), modifier = Modifier.testTag("shortcuts-retry")) { + candidate = null + capturedDevice = -1 + listening = true + } + } + Text(stringResource(R.string.shortcuts_limitations), style = MaterialTheme.typography.bodySmall) + for (binding in bindings.filter { it.action == action }) { + ActionRow( + stringResource(R.string.shortcuts_detected, binding.button.name, binding.button.label), + null, stringResource(R.string.ctrl_reset), { + PhysicalShortcuts.remove(context, binding) + onChanged() + onDismiss() + }, + modifier = Modifier.testTag("shortcuts-remove"), + ) + } + SecondaryButton(stringResource(R.string.common_close), modifier = Modifier.testTag("shortcuts-cancel"), + onClick = onDismiss) + } + } + } + } +} + +@Composable +private fun CaptureShortcutKeys(onKey: (KeyEvent) -> Boolean) { + val currentOnKey by rememberUpdatedState(onKey) + val window = (LocalView.current.parent as DialogWindowProvider).window + DisposableEffect(window) { + val previous = window.callback + // Capture before dialog navigation so Back and gamepad buttons can be assigned. + window.callback = object : Window.Callback by previous { + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + currentOnKey(event) || previous.dispatchKeyEvent(event) + } + onDispose { window.callback = previous } + } +} 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 04809ea5c..2138a31a8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -6,6 +6,7 @@ import android.content.ClipboardManager import android.content.Context import android.graphics.Canvas import android.graphics.Color +import android.os.Build import android.os.Bundle import android.util.Log import android.view.Display @@ -15,6 +16,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewConfiguration +import android.view.WindowInsets import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.Button @@ -22,6 +24,10 @@ import android.widget.FrameLayout import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.droiddeck.launcher.input.KeyboardHost import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PointerGestures @@ -47,7 +53,14 @@ class SecondScreenPresentation( private val onQam: () -> Unit, private val onClose: () -> Unit, private val pad: PadBridge? = null, + private val onKeyboardClosed: () -> Unit = {}, + private val onMenu: (() -> Unit)? = null, + private val menu: (@androidx.compose.runtime.Composable () -> Unit)? = null, ) : Presentation(sessionActivity, display) { + private val frame = FrameLayout(context) + private var menuView: androidx.compose.ui.platform.ComposeView? = null + var menuShown = false + private set private val root = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL setBackgroundColor(Color.rgb(15, 18, 22)) @@ -55,6 +68,10 @@ class SecondScreenPresentation( } private var currentMode = SecondScreenMode.NONE private var keyboardHost: KeyboardHost? = null + private var keyboardButton: TextView? = null + private var keyboardPending = false + private var imeSeen = false + private var fieldKeyboard = false private var terminalSession: TerminalSession? = null private var terminalView: TerminalView? = null private var terminalPid = -1 @@ -73,18 +90,97 @@ class SecondScreenPresentation( created = true window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - setContentView(root) + frame.addView(root, FrameLayout.LayoutParams(-1, -1)) + setContentView(frame) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + root.setOnApplyWindowInsetsListener { view, insets -> + if (insets.isVisible(WindowInsets.Type.ime())) imeSeen = true + else if (imeSeen) keyboardClosed() + view.onApplyWindowInsets(insets) + } + } applyImmersiveMode() renderMode() } + fun requestKeyboard(forField: Boolean = false) { + if (!showsKeyboard || keyboardHost == null) return + if (forField) fieldKeyboard = true + keyboardPending = true + if (window?.decorView?.hasWindowFocus() == true) showPendingKeyboard() + } + + private fun showPendingKeyboard() { + keyboardPending = false + keyboardButton?.text = context.getString(R.string.second_hide_keyboard) + for (delay in longArrayOf(150L, 450L)) root.postDelayed({ + val host = keyboardHost + if (host != null && !imeSeen && showsKeyboard) host.show() + }, delay) + } + + val fieldKeyboardOpen: Boolean get() = fieldKeyboard + + fun closeFieldKeyboard() { + if (!fieldKeyboard) return + keyboardPending = false + keyboardClosed() + } + + private fun keyboardClosed() { + fieldKeyboard = false + imeSeen = false + keyboardHost?.takeIf { it.shown }?.hide() + keyboardButton?.text = context.getString(R.string.second_show_keyboard) + onKeyboardClosed() + } + fun showMode(mode: SecondScreenMode) { if (currentMode == mode && isShowing) return currentMode = mode if (created) renderMode() } + val showsKeyboard: Boolean get() = isShowing && !menuShown && currentMode == SecondScreenMode.KEYBOARD_TRACKPAD + + val showsControls: Boolean get() = isShowing && currentMode != SecondScreenMode.NONE + + fun showMenu(on: Boolean) { + if (on == menuShown) return + val content = menu ?: return + menuShown = on + if (on) { + keyboardHost?.takeIf { it.shown }?.hide() + window?.decorView?.let { decor -> + decor.setViewTreeLifecycleOwner(sessionActivity) + decor.setViewTreeViewModelStoreOwner(sessionActivity) + decor.setViewTreeSavedStateRegistryOwner(sessionActivity) + decor.setViewTreeOnBackPressedDispatcherOwner(sessionActivity) + } + val view = menuView ?: androidx.compose.ui.platform.ComposeView(context).apply { + isFocusable = true + isFocusableInTouchMode = true + defaultFocusHighlightEnabled = false + setContent { content() } + frame.addView(this, FrameLayout.LayoutParams(-1, -1)) + menuView = this + } + view.visibility = View.VISIBLE + root.visibility = View.INVISIBLE + view.requestFocus() + } else { + menuView?.let { frame.removeView(it); it.disposeComposition() } + menuView = null + root.visibility = View.VISIBLE + } + } + + fun focusMenu(): Boolean = menuView?.requestFocus() == true + + fun dispatchMenuKey(event: KeyEvent): Boolean = menuView?.dispatchKeyEvent(event) == true + fun closeControls() { + showMenu(false) closeTerminal() keyboardHost?.hide() keyboardHost = null @@ -92,23 +188,31 @@ class SecondScreenPresentation( } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (currentMode == SecondScreenMode.KEYBOARD_TRACKPAD) { - if (sessionActivity.dispatchKeyEvent(event)) return true - } + val forward = menuShown || currentMode != SecondScreenMode.TERMINAL || PadBridge.isControllerKeyEvent(event) + if (forward && sessionActivity.dispatchKeyEvent(event)) return true return super.dispatchKeyEvent(event) } + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (PadBridge.isControllerMotionEvent(event) && sessionActivity.dispatchGenericMotionEvent(event)) return true + return super.dispatchGenericMotionEvent(event) + } + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) { applyImmersiveMode() sessionActivity.refreshClipboard() + if (keyboardPending) showPendingKeyboard() } } private fun renderMode() { + fieldKeyboard = false keyboardHost?.hide() keyboardHost = null + keyboardButton = null + keyboardPending = false closeTerminal() root.removeAllViews() val mode = if (currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad) SecondScreenMode.NONE @@ -118,7 +222,7 @@ class SecondScreenPresentation( when (mode) { SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad() SecondScreenMode.TERMINAL -> renderTerminal() - SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams()) + SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad, onMenu = onMenu), weightParams()) SecondScreenMode.NONE -> Unit } } @@ -129,49 +233,48 @@ class SecondScreenPresentation( val controls = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL orientation = LinearLayout.HORIZONTAL - setPadding(dp(12), dp(8), dp(12), dp(8)) - setBackgroundColor(0xE6191E24.toInt()) + setPadding(dp(12), dp(10), dp(12), dp(6)) + setBackgroundColor(Color.BLACK) } - controls.addView(Button(context).apply { - text = "×" - contentDescription = context.getString(R.string.deck_close) - minWidth = dp(48) - setOnClickListener { onClose() } - }) + fun gap() = View(context).apply { layoutParams = LinearLayout.LayoutParams(dp(8), 1) } + controls.addView(DeckControlsPanel.roundButton(context, "×", context.getString(R.string.deck_close), onClose)) controls.addView(TextView(context).apply { text = context.getString(R.string.second_trackpad) textSize = 14f - setTextColor(Color.WHITE) + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + setTextColor(Color.rgb(0x7A, 0x80, 0x8A)) + setPadding(dp(14), 0, dp(8), 0) + isSingleLine = true }, LinearLayout.LayoutParams(0, -2, 1f)) - val keyboardButton = Button(context).apply { - text = context.getString(R.string.second_show_keyboard) - setOnClickListener { - val host = keyboardHost ?: return@setOnClickListener - if (host.shown) { - host.hide() - text = context.getString(R.string.second_show_keyboard) - } else { - host.show() - text = context.getString(R.string.second_hide_keyboard) + lateinit var keyboardButton: TextView + keyboardButton = DeckControlsPanel.roundButton(context, context.getString(R.string.second_show_keyboard), + context.getString(R.string.second_show_keyboard), { + val host = keyboardHost + if (host != null) { + host.toggle() + keyboardButton.text = context.getString(if (host.shown) R.string.second_hide_keyboard else R.string.second_show_keyboard) } - } - } + }, wide = true) controls.addView(keyboardButton) + this.keyboardButton = keyboardButton + onMenu?.let { + controls.addView(gap()) + controls.addView(DeckControlsPanel.menuButton(context, it)) + } 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() } - }) + controls.addView(gap()) + controls.addView(DeckControlsPanel.roundButton(context, "STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu, wide = true)) + controls.addView(gap()) + controls.addView(DeckControlsPanel.roundButton(context, "•••", context.getString(R.string.drawer_open_qam), onQam, wide = true)) } panel.addView(controls, FrameLayout.LayoutParams(-1, -2, Gravity.TOP)) root.addView(panel, weightParams()) - val host = KeyboardHost(context) { sessionActivity.dispatchKeyEvent(it) } + val host = KeyboardHost(context) { event -> + val handled = sessionActivity.dispatchKeyEvent(event) + if (fieldKeyboard && event.action == KeyEvent.ACTION_UP && + (event.keyCode == KeyEvent.KEYCODE_ENTER || event.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) root.post { closeFieldKeyboard() } + handled + } keyboardHost = host root.addView(host, ViewGroup.LayoutParams(1, 1)) } @@ -329,8 +432,22 @@ class SecondScreenPresentation( if (oldw == 0 || oldh == 0) gestures.place(w / 2f, h / 2f) } + private val surface = android.graphics.RectF() + private val fill = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(0x0A, 0x0B, 0x0D) } + private val edge = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + style = android.graphics.Paint.Style.STROKE + color = Color.rgb(0x26, 0x29, 0x2E) + strokeWidth = resources.displayMetrics.density + } + override fun onDraw(canvas: Canvas) { canvas.drawColor(Color.BLACK) + val inset = dp(12).toFloat() + surface.set(inset, dp(72).toFloat(), width - inset, height - inset) + if (surface.height() <= inset) return + val radius = dp(20).toFloat() + canvas.drawRoundRect(surface, radius, radius, fill) + canvas.drawRoundRect(surface, radius, radius, edge) } override fun onTouchEvent(event: MotionEvent): Boolean = gestures.onTouch(event) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt index fb123fbd8..e7db9c561 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -4,7 +4,8 @@ internal enum class SessionDrawerPage(val firstControl: String) { CONTROLLER("touch"), GLASSES("glasses-game"), SECOND_SCREEN("second-screen:none"), - DISPLAY("hud"), + DISPLAY("fg"), + HUD("hud"), EFFECTS("look"), VR("steamvr"), GAMES("anisotropy"), 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 f3dfbcf82..85a146294 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.session.LoadingState -import com.droiddeck.launcher.session.PerfHud import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.animation.core.FastOutSlowInEasing @@ -81,8 +80,8 @@ import androidx.compose.ui.text.style.TextOverflow @Composable -fun HudText(text: String, graph: FloatArray = FloatArray(0), color: String = "mint", opacity: Int = 70) { - if (text.isEmpty() && graph.size < 2) return +fun HudText(text: String, color: String = "mint", opacity: Int = 70) { + if (text.isEmpty()) 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)) @@ -99,49 +98,13 @@ fun HudText(text: String, graph: FloatArray = FloatArray(0), color: String = "mi ) .padding(horizontal = 8.dp, vertical = 5.dp), ) { - if (text.isNotEmpty()) Text( + 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 = PerfHud.GRAPH_MAX_MS - 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, - ) } } } @@ -156,17 +119,6 @@ private fun hudInk(color: String): Color = Color( }.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) 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 d9a50a20d..c74910bf2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -29,6 +29,9 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.heightIn +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.AutoFixHigh import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Home @@ -38,9 +41,11 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.ScreenShare +import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PictureInPictureAlt import androidx.compose.material.icons.automirrored.outlined.ExitToApp import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material3.Icon import androidx.compose.material3.RadioButton @@ -148,6 +153,9 @@ import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenMode +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_HOLD +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_NONE import kotlinx.coroutines.flow.collect import java.util.Locale @@ -156,6 +164,7 @@ private val SessionDrawerPage.title: Int get() = when (this) { SessionDrawerPage.GLASSES -> R.string.drawer_page_glasses SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen SessionDrawerPage.DISPLAY -> R.string.drawer_page_display + SessionDrawerPage.HUD -> R.string.drawer_hud SessionDrawerPage.VR -> R.string.vr_title SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects SessionDrawerPage.GAMES -> R.string.game_settings_title @@ -167,6 +176,7 @@ private val SessionDrawerPage.icon get() = when (this) { SessionDrawerPage.GLASSES -> GlassesIcon SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows + SessionDrawerPage.HUD -> Icons.Outlined.Speed SessionDrawerPage.VR -> VrHeadsetIcon SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh SessionDrawerPage.GAMES -> Icons.Outlined.Layers @@ -200,11 +210,7 @@ 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 hudConfig: Map = emptyMap(), val frameGen: FrameGen.Mode, val lossless: Lossless.State, val oscMode: String, @@ -237,12 +243,12 @@ 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 onHudSetting: (String, String) -> Unit = { _, _ -> }, + val onHudReset: () -> Unit = {}, + val onHudExport: () -> Unit = {}, val onFrameGenPick: (FrameGen.Mode) -> Unit, + val fpsLimit: Int = 0, + val onFpsLimit: (Int) -> Unit = {}, val onImportLossless: () -> Unit, val onKeyboard: () -> Unit, val onHardwareKeyboard: () -> Unit, @@ -262,8 +268,15 @@ class DrawerActions( val onBackActionsInverted: (Boolean) -> Unit, val lowLatency: Boolean = true, val onLowLatency: (Boolean) -> Unit = {}, + val evdevMouse: Boolean = false, + val onEvdevMouse: (Boolean) -> Unit = {}, + val directCursor: Boolean = false, + val onDirectCursor: (Boolean) -> Unit = {}, val onTouch: (String) -> Unit, val onGestureSettings: () -> Unit = {}, + val touchActive: String = "", + val directHoldBehavior: String = MOUSE_BEHAVIOR_CLICK, + val onDirectHoldBehavior: (String) -> Unit = {}, val onScreenKeys: (() -> Unit)? = null, val onScreenLayouts: (() -> Unit)? = null, val screenKeysShown: Boolean = true, @@ -285,6 +298,7 @@ class DrawerActions( val pipSupported: Boolean = false, val pipAutoEnter: Boolean = false, val onPip: () -> Unit = {}, + val onPause: () -> Unit = {}, val onPipAutoEnter: (Boolean) -> Unit = {}, val unfoldedControls: Boolean? = null, val onUnfoldedControls: (Boolean) -> Unit = {}, @@ -324,7 +338,8 @@ class DrawerActions( @OptIn(ExperimentalComposeUiApi::class) @Composable -internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) { +internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions, docked: Boolean = false, + backRequest: Int = 0, onLayerChange: (Boolean) -> Unit = {}) { 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) } @@ -354,6 +369,18 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont if (host.open != null) host.open = null else a.onClose() } BackHandler(enabled = open && confirmStop) { confirmStop = false } + val layerOpen = open && (host.open != null || appToChooseDisplay != null || confirmStop) + LaunchedEffect(layerOpen) { onLayerChange(layerOpen) } + var seenBackRequest by remember { mutableStateOf(backRequest) } + LaunchedEffect(backRequest) { + if (backRequest == seenBackRequest) return@LaunchedEffect + seenBackRequest = backRequest + when { + confirmStop -> confirmStop = false + appToChooseDisplay != null -> appToChooseDisplay = null + host.open != null -> host.open = null + } + } LaunchedEffect(page) { host.open = null; appToChooseDisplay = null } LaunchedEffect(a.selectedGameProfileId) { if (host.open == "fex") host.open = null @@ -384,6 +411,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont val first = when { page == SessionDrawerPage.GLASSES -> "glasses-tab-$glassesTab" page == SessionDrawerPage.SESSION && a.isHomeApp -> "apps" + page == SessionDrawerPage.SECOND_SCREEN -> "second-screen:${a.secondScreenMode.id}" else -> page.firstControl } if (turned) focus.forget(page) @@ -395,9 +423,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } focus.forget(page) focus.request(first) + if (first != page.firstControl) repeat(4) { + androidx.compose.runtime.withFrameNanos { } + if (focus.focused == first) return@LaunchedEffect + focus.request(page.firstControl) + } } } - if (open || veil > 0.01f) { + if (!docked && (open || veil > 0.01f)) { val closeLabel = stringResource(R.string.common_close) Box( modifier = Modifier.fillMaxSize() @@ -408,18 +441,18 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } 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) + val sheetWidth = if (docked) maxWidth else minOf(360.dp, maxWidth * 0.92f) val narrow = sheetWidth < 320.dp AnimatedVisibility( open, - enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)), - exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)), + enter = if (docked) fadeIn(Motion.tw(160)) else slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)), + exit = if (docked) fadeOut(Motion.tw(140)) else slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)), ) { FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) { Column( modifier = Modifier .fillMaxSize() - .background(pal.background.copy(alpha = 0.97f)) + .background(if (docked) pal.background else pal.background.copy(alpha = 0.97f)) .blockTouches() .focusGroup() .controllerBack { @@ -427,17 +460,22 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } .padding(12.dp), ) { - val title = a.title ?: if (a.steam) stringResource(R.string.drawer_steam) else stringResource(R.string.drawer_desktop) - if (narrow) Text(title, fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 44.dp), ) { - if (narrow) Spacer(Modifier.weight(1f)) else Text(title, fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (docked) { + SteamActionPill( + stringResource(R.string.common_close), Icons.Outlined.Close, focus.track(page, "close"), + ) { host.open = null; a.onClose() } + Spacer(Modifier.width(4.dp)) + BottomScreenModes(a, focus.track(page, "bottom-modes")) { host.open = null } + } + Spacer(Modifier.weight(1f)) + SteamActionPill( + stringResource(R.string.stores_dl_pause), Icons.Outlined.Pause, focus.track(page, "pause"), + ) { host.open = null; a.onPause() } if (a.pipSupported) SteamActionPill( stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt, focus.track(page, "pip"), ) { host.open = null; a.onPip() } @@ -495,35 +533,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)), ) { CompositionLocalProvider(LocalChipMinWidth provides 96.dp, LocalCompactSettings provides true) { when (page) { + SessionDrawerPage.HUD -> FusionHudSettings(host, a) { key -> focus.track(page, key) } SessionDrawerPage.DISPLAY -> { 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 offLabel = stringResource(R.string.frame_gen_off) val fgLabel = FrameGen.label(LocalContext.current, a.frameGen) SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) { Box { @@ -531,6 +545,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless) } } + if (a.direct) SliderRow( + stringResource(R.string.drawer_frame_limit), stringResource(R.string.drawer_frame_limit_note), + a.fpsLimit.takeIf { it > 0 } ?: SessionPrefs.FPS_LIMIT_OFF, + SessionPrefs.FPS_LIMIT_OFF..SessionPrefs.FPS_LIMIT_MAX, step = 5, + format = { if (it <= SessionPrefs.FPS_LIMIT_OFF) offLabel else it.toString() }, + modifier = focus.track(page, "fps-limit"), + onChange = { a.onFpsLimit(if (it <= SessionPrefs.FPS_LIMIT_OFF) 0 else it) }) } SettingsGroup(stringResource(R.string.display_image_scaling)) { ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null, @@ -564,10 +585,20 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont 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_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) - if (a.touchMode == SessionPrefs.TOUCH_GESTURES) { - ActionRow(stringResource(R.string.gesture_settings), null, + if (a.touchActive == SessionPrefs.TOUCH_DIRECT) + ToggleRow(host, "direct-cursor", stringResource(R.string.drawer_direct_cursor), + null, a.directCursor, + chipModifier = focus.track(page, "direct-cursor"), onChange = a.onDirectCursor) + when (a.touchActive) { + SessionPrefs.TOUCH_GESTURES -> ActionRow(stringResource(R.string.gesture_settings), null, stringResource(R.string.ctrl_edit), a.onGestureSettings, modifier = focus.track(page, "gesture-settings")) + SessionPrefs.TOUCH_DIRECT -> ChoiceRow(host, "long-press", stringResource(R.string.gesture_long_press), null, + listOf(MOUSE_BEHAVIOR_NONE to stringResource(R.string.gesture_hold_left), + MOUSE_BEHAVIOR_CLICK to stringResource(R.string.gesture_right_click), + MOUSE_BEHAVIOR_HOLD to stringResource(R.string.gesture_hold_right)), + a.directHoldBehavior, chipModifier = focus.track(page, "long-press"), + onPick = a.onDirectHoldBehavior) } if (a.onScreenKeys != null) { SettingsRow(stringResource(R.string.screen_keys), null, stacked = narrow) { @@ -614,6 +645,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont if (a.steam) ChoiceRow(host, "back-double-tap", stringResource(R.string.back_double_tap_window), null, SessionPrefs.BACK_DOUBLE_TAP_MS_OPTIONS.map { it to stringResource(R.string.back_tap_ms, it) }, a.backDoubleTapMs, chipModifier = focus.track(page, "back-double-tap"), onPick = a.onBackDoubleTapMs) + PhysicalShortcutRows(modifierFor = { focus.track(page, "physical-shortcut-${it.name}") }) 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) @@ -627,6 +659,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont ToggleRow(host, "low-latency", stringResource(R.string.drawer_low_latency), stringResource(R.string.drawer_low_latency_hint), a.lowLatency, chipModifier = focus.track(page, "low-latency"), onChange = a.onLowLatency) + ToggleRow(host, "evdev-mouse", stringResource(R.string.drawer_evdev_mouse), + null, a.evdevMouse, + chipModifier = focus.track(page, "evdev-mouse"), onChange = a.onEvdevMouse) } SettingsGroup(stringResource(R.string.drawer_keyboard)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) { @@ -832,6 +867,8 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit val fill by animateColorAsState(if (hot) colors.error.copy(alpha = 0.18f) else Color.Transparent, Motion.tw(220), label = "dangerFill") val shape = RoundedCornerShape(20.dp) val stopDescription = stringResource(R.string.stop_session) + val stopLabel = stringResource(R.string.drawer_stop) + var iconOnly by remember(stopLabel, androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp) { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }.testTag("drawer-stop") @@ -841,7 +878,56 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit .padding(start = 12.dp, end = 14.dp), ) { Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp)) - Text(stringResource(R.string.drawer_stop), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + if (!iconOnly) Text( + stopLabel, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error, + maxLines = 1, softWrap = false, onTextLayout = { if (it.hasVisualOverflow) iconOnly = true }, + ) + } +} + +@Composable +private fun BottomScreenModes(a: DrawerActions, modifier: Modifier, onPick: () -> Unit) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val modes = buildList { + if (com.droiddeck.launcher.session.SessionState.deckPad) add(SecondScreenMode.DECK_CONTROLS to Icons.Outlined.SportsEsports) + add(SecondScreenMode.KEYBOARD_TRACKPAD to Icons.Outlined.Keyboard) + add(SecondScreenMode.TERMINAL to Icons.Outlined.Terminal) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.focusGroup().clip(RoundedCornerShape(26.dp)).border(1.dp, pal.line, RoundedCornerShape(26.dp)).padding(4.dp), + ) { + modes.forEach { (mode, icon) -> + val selected = a.secondScreenMode == mode + val source = remember { MutableInteractionSource() } + val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value + val shape = RoundedCornerShape(20.dp) + val pick = { onPick(); a.onSecondScreenMode(mode) } + val label = stringResource(mode.label) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(width = 52.dp, height = 40.dp).semantics { contentDescription = label } + .clip(shape) + .background(when { + selected -> pal.signal + hot -> pal.signal.copy(alpha = 0.14f) + else -> Color.Transparent + }) + .glideBorder(hot, shape, pal.signal) + .hoverable(source) + .selectable(selected, interactionSource = source, indication = LocalIndication.current, role = Role.Tab, onClick = pick) + .controllerConfirm(onClick = pick), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(22.dp), + tint = when { + selected -> colors.background + hot -> pal.signal + else -> colors.onBackground + }) + } + } } } @@ -1676,6 +1762,13 @@ private fun StopConfirm( content = null } } + LaunchedEffect(open, controllerActive) { + if (!open || !controllerActive || stopFocused || cancelFocused) return@LaunchedEffect + delay(Motion.ms(300).toLong()) + withFrameNanos { } + if (stopFocused || cancelFocused) return@LaunchedEffect + runCatching { cancelFocus.requestFocus() } + } val p = pill ?: return Box(Modifier.fillMaxSize()) { @@ -1703,7 +1796,7 @@ private fun StopConfirm( .padding(start = 12.dp, end = 14.dp), ) { Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp)) - Text(stringResource(R.string.drawer_stop), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + Text(stringResource(R.string.drawer_stop), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1, softWrap = false) } val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right) Column( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt index afe9f0ba4..2b9127306 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt @@ -77,6 +77,7 @@ class PausedFrame(val image: ImageBitmap, val bounds: android.graphics.Rect) private const val CARD_SCALE = 0.82f private const val FRAME_WAIT_MS = 600L +private val PopEasing = CubicBezierEasing(0.3f, 1.5f, 0.4f, 1f) private fun tone(saturation: Float, brightness: Float): ColorMatrix = ColorMatrix().apply { @@ -90,6 +91,7 @@ fun SessionPausedOverlay( engaged: Boolean, failures: Int, frame: PausedFrame?, + animateIn: Boolean, title: String, onResume: () -> Unit, onFrameNeeded: () -> Unit, @@ -106,6 +108,18 @@ fun SessionPausedOverlay( LaunchedEffect(suspended, engaged) { when { suspended && !engaged -> { + if (!shown && animateIn) { + if (currentFrame == null) { + onFrameNeeded() + withTimeoutOrNull(FRAME_WAIT_MS) { snapshotFlow { currentFrame }.first { it != null } } + } + shown = true + launch { press.animateTo(1f, Motion.sp(0.5f, Spring.StiffnessMedium)) } + launch { ring.animateTo(0f, Motion.tw(200)) } + launch { lift.animateTo(1f, Motion.tw(520)) } + orb.animateTo(1f, Motion.tw(380, delay = if (orb.value < 0.5f) 260 else 0, easing = PopEasing)) + return@LaunchedEffect + } if (!shown) { if (currentFrame == null) { onFrameNeeded() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionTypingActivity.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionTypingActivity.kt new file mode 100644 index 000000000..5efb7a2f5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionTypingActivity.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.ui + +import android.app.Activity +import android.app.ActivityManager +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle + +object SessionTyping { + private var activity: SessionTypingActivity? = null + private var launching = false + + fun focusDisplay(context: Context, displayId: Int) { + if (activity != null || launching) return + launching = true + val intent = Intent(context, SessionTypingActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION) + runCatching { + context.startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle()) + }.onFailure { launching = false } + } + + fun returnFocus(context: Context, task: Int) { + close() + if (task < 0) return + runCatching { (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).moveTaskToFront(task, 0) } + } + + fun close() { + launching = false + activity?.finish() + activity = null + } + + internal fun attach(host: SessionTypingActivity): Boolean { + if (!launching) return false + launching = false + activity = host + return true + } + + internal fun detach(host: SessionTypingActivity) { + if (activity === host) activity = null + } +} + +class SessionTypingActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!SessionTyping.attach(this)) finish() + } + + override fun onStop() { + super.onStop() + if (!isFinishing) finish() + } + + override fun onDestroy() { + SessionTyping.detach(this) + super.onDestroy() + } +} 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 6725ac319..50df03a74 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -210,7 +210,7 @@ internal fun TabStrip( } @Composable -private fun BumperKey(label: String, description: String, onClick: () -> Unit) { +internal fun BumperKey(label: String, description: String, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val cap = RoundedCornerShape(7.dp) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SlowUsbDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/SlowUsbDialog.kt new file mode 100644 index 000000000..6ba15e324 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SlowUsbDialog.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Usb +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.session.UsbLink + +@Composable +internal fun SlowUsbDialog(drive: UsbLink.SlowDrive, never: Boolean, onNever: (Boolean) -> Unit, onDismiss: () -> Unit) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + val speed = if (drive.mbps % 1.0 == 0.0) drive.mbps.toLong().toString() else drive.mbps.toString() + val message = if (drive.portLimited) R.string.slow_usb_port else R.string.slow_usb_cable + AppDialog(shown, close, "slowUsb", wide = false, maxWidth = 520.dp) { + DialogTitleRow(stringResource(R.string.slow_usb_title), close) { + Icon(Icons.Filled.Usb, null, tint = LocalPalette.current.signal, modifier = Modifier.size(20.dp)) + } + Text( + stringResource(message, drive.name, UsbLink.generation(drive.mbps), speed, UsbLink.megabytesPerSecond(drive.mbps)), + fontSize = 13.sp, lineHeight = 18.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val flip = { onNever(!never) } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().clip(Shape12) + .background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape12) + .hoverable(src) + .clickable(interactionSource = src, indication = null, role = Role.Switch, onClick = flip) + .controllerConfirm(onClick = flip) + .padding(start = 14.dp, end = 4.dp), + ) { + Text(stringResource(R.string.slow_usb_never), fontSize = 14.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f)) + ToggleSwitch(never, label = stringResource(R.string.slow_usb_never), modifier = Modifier.focusProperties { canFocus = false }) { onNever(it) } + } + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + PrimaryButton(stringResource(R.string.slow_usb_dismiss), modifier = Modifier.focusRequester(focus), onClick = close) + } + } +} 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 4a93056dd..671bcabe1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -142,7 +142,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() }, words = { val runtimeStatus: @Composable () -> Unit = { - if (s.busy || !s.ready || (s.available != null && s.available != s.installed)) RuntimeChip(s) + if (s.busy || (s.available != null && s.available != s.installed)) RuntimeChip(s) else if (s.integration.suggested && signedIn && !com.droiddeck.launcher.core.PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)) IntegrationSuggestion(s.integration, { a.onSteamIntegration(null) }, a.onDismissIntegration) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamImportDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamImportDialog.kt new file mode 100644 index 000000000..b262504dd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamImportDialog.kt @@ -0,0 +1,45 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R + +@Composable +internal fun SteamImportDialog(games: List, onAnswer: (Boolean) -> Unit) { + val now = remember { booleanArrayOf(false) } + val shown = rememberShown { onAnswer(now[0]) } + val close = { shown.targetState = false } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + val names = if (games.size <= 3) games.joinToString(", ") + else stringResource(R.string.steam_import_names_more, games.take(3).joinToString(", "), games.size - 3) + AppDialog(shown, close, "steamImport", wide = false, maxWidth = 520.dp) { + DialogTitleRow(stringResource(R.string.steam_import_title), close) { + Icon(Icons.Filled.RestartAlt, null, tint = LocalPalette.current.signal, modifier = Modifier.size(20.dp)) + } + Text( + stringResource(R.string.steam_import_body, names), + fontSize = 13.sp, lineHeight = 18.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.steam_import_later)) { now[0] = false; close() } + PrimaryButton(stringResource(R.string.steam_import_now), modifier = Modifier.focusRequester(focus), onClick = { now[0] = true; close() }) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamIntegrationGroup.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamIntegrationGroup.kt index d7f030695..8e2380e94 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamIntegrationGroup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamIntegrationGroup.kt @@ -201,7 +201,7 @@ internal fun IntegrationSuggestion(state: SteamIntegration.State, onOpen: () -> } val closeSrc = remember { MutableInteractionSource() } val closeHot = rememberHot(closeSrc) - val later = stringResource(R.string.integration_later) + val later = stringResource(R.string.common_close) Box( contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 4.dp).size(32.dp).clip(CircleShape) 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 badd3f1f4..0c1f66b9d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -270,13 +270,10 @@ private fun Overview(report: StorageReport, sessionRunning: Boolean, onCleanUp: val safe = StorageState.safeBytes(report) val low = report.free < StorageState.LOW_SPACE_BYTES ActionStatus() - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(18.dp), - modifier = Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14) - .padding(horizontal = 18.dp, vertical = 14.dp), - ) { - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val summary = Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14) + .padding(horizontal = 18.dp, vertical = 14.dp) + val usage: @Composable (Modifier) -> Unit = { m -> Column(m) { + FlowRow(verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(stringResource(R.string.storage_internal), fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) Text("· " + stringResource(R.string.storage_used_of, gb(used), gb(report.total)), fontSize = 15.sp, color = colors.onSurfaceVariant) Pill(stringResource(R.string.storage_free, gb(report.free)), if (low) AttentionAmber else pal.good) @@ -292,14 +289,21 @@ private fun Overview(report: StorageReport, sessionRunning: Boolean, onCleanUp: Bucket.entries.forEach { Legend(BucketColors.getValue(it), bucketLabel(it)) } Legend(OtherApps, stringResource(R.string.storage_other_apps)) } - } + } } + val freeable: @Composable (Modifier) -> Unit = { m -> Column(m) { + Text(stringResource(R.string.storage_safe_now), fontSize = 13.sp, color = colors.onSurfaceVariant) + Text(gb(safe), fontSize = 26.sp, fontWeight = FontWeight.Bold, color = if (safe > 0) pal.good else colors.onBackground) + Text(stringResource(R.string.storage_safe_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 10.dp)) + PrimaryButton(stringResource(R.string.storage_clean_up), enabled = !sessionRunning && !StorageState.cleaning, compact = true, onClick = onCleanUp) + } } + if (wide) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = summary) { + usage(Modifier.weight(1f)) Box(Modifier.width(1.dp).height(120.dp).background(pal.line)) - Column(Modifier.widthIn(min = 170.dp, max = 210.dp)) { - Text(stringResource(R.string.storage_safe_now), fontSize = 13.sp, color = colors.onSurfaceVariant) - Text(gb(safe), fontSize = 26.sp, fontWeight = FontWeight.Bold, color = if (safe > 0) pal.good else colors.onBackground) - Text(stringResource(R.string.storage_safe_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 10.dp)) - PrimaryButton(stringResource(R.string.storage_clean_up), enabled = !sessionRunning && !StorageState.cleaning, compact = true, onClick = onCleanUp) - } + freeable(Modifier.widthIn(min = 170.dp, max = 210.dp)) + } else Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = summary) { + usage(Modifier.fillMaxWidth()) + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + freeable(Modifier.fillMaxWidth()) } if (sessionRunning) { Spacer(Modifier.height(8.dp)) @@ -846,17 +850,23 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack: val ctx = LocalContext.current val busy = sessionRunning || StorageState.cleaning var confirm by remember { mutableStateOf(null) } + var confirmPrefix by remember { mutableStateOf(null) } SubHeader(stringResource(R.string.storage_game_data), stringResource(R.string.storage_game_data_hint, gb(report.prefixes), gb(report.workshop), gb(report.shaders)), onBack) ActionStatus() if (sessionRunning) { Note(stringResource(R.string.storage_session_running)); Spacer(Modifier.height(4.dp)) } - val games = report.games.filter { it.data >= ONE_MB }.sortedByDescending { it.data } + val games = report.games.filter { it.data >= ONE_MB || it.prefixDir != null }.sortedByDescending { it.data } SectionTitle(stringResource(R.string.storage_data_installed), gb(games.sumOf { it.data })) ListCard { if (games.isEmpty()) Text(stringResource(R.string.storage_no_games), fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp)) games.forEachIndexed { i, g -> ItemRow( gameName(g), stringResource(R.string.storage_data_parts, gb(g.prefix), gb(g.shaders), gb(g.workshop)), g.data, i == games.lastIndex, - action = { SecondaryButton(stringResource(R.string.storage_clear_shaders), compact = true, enabled = !busy && g.shaders >= ONE_MB) { StorageState.clearShaders(ctx, g.appId, g.x64) } }, + action = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton(stringResource(R.string.storage_clear_shaders), compact = true, enabled = !busy && g.shaders >= ONE_MB) { StorageState.clearShaders(ctx, g.appId, g.x64) } + SecondaryButton(stringResource(R.string.storage_reset_prefix), compact = true, enabled = !busy && g.prefixDir != null) { confirmPrefix = g } + } + }, ) } } @@ -888,6 +898,13 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack: SectionTitle(stringResource(R.string.storage_data_other), null) ListCard { ItemRow(stringResource(R.string.storage_data_other_title), stringResource(R.string.storage_data_other_detail), report.otherPrefixes, true) } } + confirmPrefix?.let { g -> + ConfirmDialog( + stringResource(R.string.storage_reset_prefix_title, gameName(g)), + stringResource(R.string.storage_reset_prefix_confirm, gb(g.prefix), g.library), stringResource(R.string.storage_reset_prefix), + onConfirm = { StorageState.resetPrefix(ctx, g) }, onDismiss = { confirmPrefix = null }, + ) + } confirm?.let { l -> ConfirmDialog( stringResource(R.string.storage_remove_title, stringResource(R.string.storage_steam_app, l.appId)), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt index 5a770942a..314cb13da 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt @@ -177,6 +177,60 @@ internal fun StoreGameFacts(developer: String, publisher: String, releaseDate: S } } +/** The headline scores, small enough to sit inside the action capsule. */ +@Composable +internal fun CapsuleScores(scores: GameScores, modifier: Modifier = Modifier) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = modifier) { + Looked(scores.metacritic, exit = fadeOut()) { lookup -> + val found = (lookup as? Lookup.Found)?.value + CapsuleStat(stringResource(R.string.stores_fact_metacritic), found?.url, showLabel = false) { + if (found != null) MetacriticBadge(found.score, small = true) else Placeholder(28.dp) + } + } + Looked(scores.hltb, exit = fadeOut()) { lookup -> + val found = (lookup as? Lookup.Found)?.value + val lead = found?.let { hltbRuns(it).firstOrNull() } + CapsuleStat(lead?.second ?: stringResource(R.string.stores_hltb_main), found?.url) { + if (lead != null) Text(hltbTime(lead.first), fontSize = 17.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1) + else Placeholder(40.dp) + } + } + } +} + +@Composable +private fun CapsuleStat(label: String, link: String?, showLabel: Boolean = true, value: @Composable () -> Unit) { + val colors = MaterialTheme.colorScheme + val src = remember { MutableInteractionSource() } + val hot = link != null && rememberHot(src) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.then(linkSurface(label, link, src, hot, CapsuleStatShape)).padding(horizontal = 10.dp, vertical = 6.dp), + ) { + value() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + val tint = colors.onSurfaceVariant.copy(alpha = if (hot) 1f else 0.85f) + if (showLabel) Text(label.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = tint, maxLines = 1) + if (link != null) Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = if (showLabel) null else label, tint = tint, modifier = Modifier.size(10.dp)) + } + } +} + +/** Credits and times as label and value rows, for the details panel. */ +@Composable +internal fun StoreDetailRows(developer: String, publisher: String, releaseDate: String, scores: GameScores) { + val released = remember(releaseDate) { GameFacts.releaseLabel(releaseDate) } + val publishedBy = publisher.takeUnless { it.isBlank() || it.trim().equals(developer.trim(), ignoreCase = true) } + val credits = listOf( + stringResource(R.string.stores_fact_developer) to developer, + stringResource(R.string.stores_fact_publisher) to publishedBy.orEmpty(), + stringResource(R.string.stores_fact_released) to released.orEmpty(), + ).filter { it.second.isNotBlank() } + val runs = ((scores.hltb as? Lookup.Found)?.value)?.let { hltbRuns(it) }.orEmpty() + credits.forEach { (label, value) -> FactRow(label, value, labelWidth = 96.dp) } + runs.forEach { (secs, what) -> FactRow(what, hltbTime(secs), labelWidth = 96.dp) } +} + @Composable private fun LineLink(label: String, link: String, content: @Composable () -> Unit) { val pal = LocalPalette.current @@ -202,6 +256,7 @@ private fun Looked(lookup: Lookup, exit: ExitTransition, content: @Compos } private val StatShape = RoundedCornerShape(12.dp) +private val CapsuleStatShape = RoundedCornerShape(99.dp) private val LinkShape = RoundedCornerShape(10.dp) private val BadgeHeight = 32.dp 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 e52185681..bd6898e68 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -360,8 +360,10 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { when (d.state) { DownloadState.RUNNING, DownloadState.QUEUED -> { - RoundAction(Icons.Filled.Pause, stringResource(R.string.stores_dl_pause)) { DownloadQueue.pause(d.key) } - CancelAction { DownloadQueue.cancel(ctx, d.key) } + if (d.interruptible) { + RoundAction(Icons.Filled.Pause, stringResource(R.string.stores_dl_pause)) { DownloadQueue.pause(d.key) } + CancelAction { DownloadQueue.cancel(ctx, d.key) } + } } DownloadState.PAUSED -> { RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { DownloadQueue.resume(ctx, d.key) } 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 92b118d54..1b965ee4c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -253,13 +253,17 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier 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]) + val id = ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size] + chip = id; openGame = null; query = "" + move("item:storechip:$id") } fun openModes(st: Store) { if (chip != st.id) pickChip(st.id) else move("first") } BackHandler(enabled = openGame != null) { closeGame() } BackHandler(enabled = arranging) { arranging = false } + var contentFocused by remember { mutableStateOf(false) } + BackHandler(enabled = contentFocused && openGame == null && !arranging) { move("item:storechip:$chip") } LaunchedEffect(arranging) { val focus = ff ?: return@LaunchedEffect if (!arranging || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect @@ -348,7 +352,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier else scroll.value > 0 }, onDetailFocus = { detailFocused = it }, - modifier = Modifier.fillMaxWidth().weight(1f).onSizeChanged { viewportH = with(density) { it.height.toDp() } }, + modifier = Modifier.fillMaxWidth().weight(1f).onFocusChanged { contentFocused = it.hasFocus }.onSizeChanged { viewportH = with(density) { it.height.toDp() } }, + bleedHorizontal = padH, wrap = { key, content -> StoreTheme(Store.byId(key.substringBefore(':')) ?: store) { content() } }, grid = { AnimatedContent( @@ -851,6 +856,9 @@ private fun Storefront( var firstPlaced = false var columns by remember { mutableIntStateOf(1) } val density = androidx.compose.ui.platform.LocalDensity.current + val displayContext = LocalContext.current + remember { LibraryDisplay.load(displayContext) } + val tileWidth = libraryTileWidth() val focus = LocalFrontFocus.current val scope = rememberCoroutineScope() val searching = q.isNotEmpty() || browsing || catalog @@ -881,7 +889,7 @@ private fun Storefront( 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 { - grid.scrollToItem(targetIndex) + launch { grid.revealRow(headerItems + index, targetIndex, direction, with(density) { 10.dp.toPx() }) } focusWithinFrames({ focus.last == target }) { focus.items[target] ?: androidx.compose.ui.focus.FocusRequester.Default } } } @@ -892,9 +900,9 @@ private fun Storefront( GameCard(item, store, installedKeys, onOpen, first, navigation) } LazyVerticalGrid( - columns = GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { + columns = GridCells.Adaptive(tileWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { val gap = with(density) { 10.dp.roundToPx() } - val minimum = with(density) { CardWidth.roundToPx() } + val minimum = with(density) { tileWidth.roundToPx() } columns = ((it.width + gap) / (minimum + gap)).coerceAtLeast(1) }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -902,7 +910,7 @@ private fun Storefront( ) { item(key = "profile", span = { GridItemSpan(maxLineSpan) }) { Rise(1) { ProfileStrip(store, library.size, StoresState.library.containsKey(store), s, a) } } item(key = "search", span = { GridItemSpan(maxLineSpan) }) { - Rise(2) { ModeHeader(store.label, query, onQuery, placeholder = stringResource(R.string.stores_search_catalog, store.label), clearSearchFocus = clearSearchFocus) {} } + Rise(2) { ModeHeader(store.label, query, onQuery, placeholder = stringResource(R.string.stores_search_catalog, store.label), clearSearchFocus = clearSearchFocus, inlineWhenStacked = true) { DisplayButton() } } } 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) } } } } @@ -1058,7 +1066,7 @@ internal fun SectionLabel(title: String) { internal fun ModeHeader( title: String, query: String, onQuery: (String) -> Unit, placeholder: String, clearSearchFocus: Boolean = false, - lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit, + lead: (@Composable () -> Unit)? = null, inlineWhenStacked: Boolean = false, trailing: @Composable () -> Unit, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -1100,8 +1108,23 @@ internal fun ModeHeader( } BoxWithConstraints(Modifier.fillMaxWidth()) { val stacked = maxWidth < NarrowPaneWidth + val inline = stacked && inlineWhenStacked && maxWidth >= 440.dp Column(Modifier.fillMaxWidth()) { - if (stacked) { + if (inline && !(searching || open > 0.01f)) Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp), + ) { + heading() + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) { + searchButton() + trailing() + } + } + } else if (stacked) { Box(Modifier.padding(vertical = 4.dp)) { heading() } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -1237,7 +1260,7 @@ private fun Shelf(title: String, items: List?, card: @Composable (C if (items.isNullOrEmpty()) return Box(Modifier.padding(top = 10.dp, bottom = 8.dp)) { SectionLabel(title) } Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 2.dp)) { - for (item in items.take(24)) key(item.key) { Box(Modifier.width(CardWidth)) { card(item) } } + for (item in items.take(24)) key(item.key) { Box(Modifier.width(libraryTileWidth())) { card(item) } } } } @@ -1287,7 +1310,19 @@ private fun GameCard( .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), ) { - CardFace(item.title, item.imageUrl, item.tallImageUrl, download, hot) { CardMarker(item, installed, download) } + val chip = when { + download != null -> "${download.percent}%" + item.accessible && StoresState.isUnfinished(item) -> stringResource(R.string.stores_resume) + item.store == Store.GOG && installed && com.droiddeck.launcher.stores.gog.GogMaintenance.checks[item.id]?.state == com.droiddeck.launcher.stores.gog.GogMaintenance.State.AVAILABLE -> stringResource(R.string.common_update) + else -> null + } + val priced = installed || item.isFree || item.isDiscounted || item.hasPrice + val pill: (@Composable BoxScope.() -> Unit)? = if (priced && !LibraryDisplay.size && !hot) ({ + Box(Modifier.align(Alignment.TopStart).padding(6.dp).clip(RoundedCornerShape(6.dp)).background(Color.Black.copy(alpha = 0.82f)).padding(horizontal = 6.dp, vertical = 2.dp)) { + CardMarker(item, installed, download, compact = true, status = false) + } + }) else null + ArtCard(item.title, item.imageUrl, item.tallImageUrl, download, hot, capsule = false, chip = chip, badge = pill) { CardMarker(item, installed, download, status = false) } } } @@ -1309,8 +1344,6 @@ internal fun SyncBar(line: String) { } } -private val CardStrip = 26.dp - @Composable internal fun cardArtRequest(url: Any?, cover: Any?): coil.request.ImageRequest? { val ctx = LocalContext.current @@ -1320,66 +1353,15 @@ internal fun cardArtRequest(url: Any?, cover: Any?): coil.request.ImageRequest? } } -@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable -internal fun CardFace( - title: String, wide: Any?, tall: Any?, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false, - dim: Boolean = false, badge: (@Composable BoxScope.() -> Unit)? = null, marker: @Composable () -> Unit, -) { - val url = if (capsule) tall ?: wide else wide ?: tall - val art = cardArtRequest(url, tall) - val blurs = android.os.Build.VERSION.SDK_INT >= 31 - var landscape by remember(url) { mutableStateOf(false) } - BoxWithConstraints(Modifier.fillMaxWidth()) { - 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(title))) else Modifier)) { - 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 = if (capsule) ContentScale.Fit else ContentScale.Crop, - modifier = Modifier.matchParentSize().padding(bottom = if (capsule) CardStrip else 0.dp), - onSuccess = { r -> - val drawn = r.result.drawable - if (capsule && drawn.intrinsicWidth > 0 && drawn.intrinsicHeight > 0) landscape = drawn.intrinsicWidth > drawn.intrinsicHeight * 0.95f - }, - ) - if (dim) Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.5f))) - Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) { - 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))) - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.matchParentSize().padding(horizontal = 8.dp), - ) { - Text( - title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, - overflow = if (hot) TextOverflow.Clip else TextOverflow.Ellipsis, - modifier = Modifier.weight(1f).then(if (hot) Modifier.basicMarquee(iterations = Int.MAX_VALUE, initialDelayMillis = 700, velocity = 40.dp) else Modifier), - ) - marker() - } - } - Spacer(Modifier.align(Alignment.BottomCenter).padding(bottom = CardStrip).fillMaxWidth().height(14.dp).background(Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.35f)))) - if (download != null) Box(Modifier.align(Alignment.BottomCenter).padding(start = 6.dp, end = 6.dp, bottom = CardStrip + 5.dp)) { - ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY) - } - badge?.invoke(this) - } - } -} - -@Composable -private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?, compact: Boolean = false) { +private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?, compact: Boolean = false, status: Boolean = true) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val small = 10.sp when { + status && item.store == Store.GOG && download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + status && item.store == Store.GOG && installed && com.droiddeck.launcher.stores.gog.GogMaintenance.checks[item.id]?.state == com.droiddeck.launcher.stores.gog.GogMaintenance.State.AVAILABLE -> + Text(stringResource(R.string.common_update), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) installed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { val ctx = LocalContext.current val game = StoresState.installedGame(item.store, item.id) @@ -1387,8 +1369,8 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi 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.accessible && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + status && download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + status && 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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 7e67b352b..8ebfb5ad6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable @@ -40,10 +41,13 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.droiddeck.launcher.core.PhantomProcessLimit @@ -373,6 +377,31 @@ internal fun AdbTextField( val shape = RoundedCornerShape(8.dp) val inputHeight = if (compact) 44.dp else 52.dp val colors = MaterialTheme.colorScheme + val bottomTyping = LocalBottomTyping.current + val bottomTypingLatest = rememberUpdatedState(bottomTyping) + val typingRequest = remember { arrayOfNulls(1) } + + fun typeOnBottom(editText: EditText): Boolean { + if (!bottomTypingLatest.value || !DualScreenPanel.attached) return false + val text = editText.text.toString() + val request = TypingRequest( + TextFieldValue(text, TextRange(editText.selectionStart.coerceIn(0, text.length), editText.selectionEnd.coerceIn(0, text.length))), + ImeOptions(singleLine = true, keyboardType = keyboardType, imeAction = imeAction), + { next -> + if (editText.text.toString() != next.text) editText.setText(next.text) + editText.setSelection(next.selection.start.coerceIn(0, next.text.length), next.selection.end.coerceIn(0, next.text.length)) + }, + { action -> editText.onEditorAction(imeActionFlag(action)) }, + { editText.post { editText.clearFocus() } }, + ) + typingRequest[0] = request + DualScreenPanel.startTyping(request) + return true + } + + DisposableEffect(Unit) { + onDispose { typingRequest[0]?.let { DualScreenPanel.stopTyping(it) } } + } Column(modifier = modifier.fillMaxWidth()) { if (showLabel) Text( @@ -424,8 +453,12 @@ internal fun AdbTextField( else -> false } } + showSoftInputOnFocus = !bottomTyping + setOnClickListener { if (hasFocus() && !typeOnBottom(this) && bottomTypingLatest.value) showKeyboardHere(this) } setOnFocusChangeListener { _, hasFocus -> focused = hasFocus + if (hasFocus) { if (!typeOnBottom(this) && bottomTypingLatest.value) showKeyboardHere(this) } + else typingRequest[0]?.let { DualScreenPanel.stopTyping(it) } onFocusChangeLatest.value(hasFocus) } addTextChangedListener(object : TextWatcher { @@ -438,11 +471,12 @@ internal fun AdbTextField( } }, update = { editText -> + editText.showSoftInputOnFocus = !bottomTyping if (focusOnShow && !focusAsked[0]) { focusAsked[0] = true editText.post { editText.requestFocus() - (editText.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager) + if (!bottomTypingLatest.value) (editText.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager) ?.showSoftInput(editText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) } } @@ -548,6 +582,11 @@ private fun parseAdbAddress(value: String): AdbEndpoint? { private fun formatAddressHost(host: String): String = if (':' in host) "[$host]" else host private fun formatAdbAddress(host: String, port: Int): String = "${formatAddressHost(host)}:$port" +private fun showKeyboardHere(editText: EditText) { + (editText.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager) + ?.showSoftInput(editText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) +} + private fun imeActionFlag(action: ImeAction): Int = when (action) { ImeAction.Next -> EditorInfo.IME_ACTION_NEXT ImeAction.Done -> EditorInfo.IME_ACTION_DONE diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 222d30859..3a86e28af 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -200,18 +200,31 @@ object AppUpdates { fun message(context: Context): String = context.getString(text, *args) } - fun follow(context: Context, catalog: Catalog?): Follow { + fun follow(context: Context, catalog: Catalog?): Follow = follow(context, catalog, installed()) + + internal fun follow(context: Context, catalog: Catalog?, me: Installed): Follow { stored(context)?.let { return it } - val me = installed() - val inferred = when { - me.pr != 0 -> Follow(Channel.TEST, me.pr) - catalog == null -> return Follow(Channel.STABLE) - catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE) - me.updatable -> Follow(Channel.NIGHTLY) - else -> Follow(Channel.STABLE) - } - setFollow(context, inferred) - return inferred + val guess = guessFollow(catalog, me) + if (guess.certain) setFollow(context, guess.follow) + return guess.follow + } + + internal class Guess(val follow: Follow, val certain: Boolean) + + /** + * Stable and Preview builds of one commit are the same APK. Preview history can include older Stable releases, + * so an overlapping older-build guess keeps the Stable default for this run without persisting it. + */ + internal fun guessFollow(catalog: Catalog?, me: Installed): Guess { + if (me.pr != 0) return Guess(Follow(Channel.TEST, me.pr), true) + val stable = catalog?.stable + if (stable != null && isRunning(stable, me)) return Guess(Follow(Channel.STABLE), true) + val publishedPreview = catalog != null && me.updatable && ( + catalog.preview?.let { isRunning(it, me) } == true || + catalog.recentPreviews.any { matchesInstalledCommit(it.commit, me.commit) } + ) + if (stable?.version?.let { compareVersions(me.version, it) < 0 } == true) return Guess(Follow(Channel.STABLE), !publishedPreview) + return Guess(Follow(if (publishedPreview) Channel.NIGHTLY else Channel.STABLE), false) } fun setFollow(context: Context, follow: Follow) { diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index fcf57b951..ea06879fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -53,6 +53,7 @@ object CompositorHost { fpsLimit: Int, ): Boolean { ownVsyncThread = SessionPrefs.vsyncThread(context) + WaylandCompositor.nativeSetRenderOnCommit(SessionPrefs.renderOnCommit(context)) pace(refreshHz, fpsLimit) if (!SessionState.running) WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight) if (started) { 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 206b18280..a31847a33 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -167,6 +167,10 @@ public final class WaylandCompositor { public static native void nativeSendKey(int evdev, int state); + public static native boolean nativeFocusTakesTouch(); + + public static native boolean nativeCursorInputPos(int[] out); + public static native void nativeVsync(long frameTimeNanos); public static native int nativeCompositorTid(); @@ -284,6 +288,8 @@ public final class WaylandCompositor { public static native void nativeSetFpsLimit(int fps); + public static native void nativeSetRenderOnCommit(boolean on); + public static native void nativeSetGpuTurbo(boolean on); public static native int nativeRaisePriority(int tid, int nice); @@ -321,9 +327,17 @@ public final class WaylandCompositor { nativeSendSceneInput(6, dx * 256, dy * 256); } + private static float pendingDeltaX, pendingDeltaY; + public static void sendPointerDelta(float dx, float dy) { - int x = Math.round(dx * 256f), y = Math.round(dy * 256f); + // Round-to-zero per event would drop slow motion entirely; carry the remainder. + pendingDeltaX += dx * 256f; + pendingDeltaY += dy * 256f; + int x = (int) Math.floor(pendingDeltaX); + int y = (int) Math.floor(pendingDeltaY); if (x == 0 && y == 0) return; + pendingDeltaX -= x; + pendingDeltaY -= y; nativeSendSceneInput(6, x, y); } } diff --git a/app/src/main/res/values-ar/gestures.xml b/app/src/main/res/values-ar/gestures.xml index 6e4aa5468..3c16961dc 100644 --- a/app/src/main/res/values-ar/gestures.xml +++ b/app/src/main/res/values-ar/gestures.xml @@ -2,7 +2,6 @@ الإيماءات إعدادات إيماءات اللمس - خصّص إيماءات الفأرة ولوحة المفاتيح. اختر الإيماءات كوضع للمس لاستخدامها. نقرة سحب بإصبع واحد ضغط مطوّل @@ -15,6 +14,8 @@ ضغط مستمر بثلاثة أصابع ضمّ الإصبعين أو إبعادهما للتكبير والتصغير إبقاء الزر مضغوطًا + إبقاء الزر الأيسر مضغوطًا + إبقاء الزر الأيمن مضغوطًا النقر مرة واحدة مهلة الضغط المستمر الفاصل بين النقرتين diff --git a/app/src/main/res/values-ar/shortcuts.xml b/app/src/main/res/values-ar/shortcuts.xml new file mode 100644 index 000000000..cc08ebc31 --- /dev/null +++ b/app/src/main/res/values-ar/shortcuts.xml @@ -0,0 +1,13 @@ + + + اختصارات الأزرار الفعلية + قائمة DroidDeck + تعيين: %1$s + اضغط على زر ثم حرره. يُحفظ التعيين تلقائيًا. + أفلت الزر للمتابعة. + %1$s: %2$s + هذا الزر معيّن بالفعل إلى: %1$s + إعادة تعيين + تجربة زر آخر + قد لا يتوفر زر الشاشة الرئيسية وبعض الأزرار الإضافية. + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e07f0fade..7454ab1f6 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -615,6 +615,64 @@ كهرماني بنفسجي العتامة + الحجم + النقر على HUD يغيّر حجمه أيضًا. + كامل + مربعات + كبسولة + مصغّر + ضخم + تثبيت في مكانه + اسحب HUD لتحريكه؛ والضغط مطولًا يثبّته أو يلغي تثبيته. أثناء تثبيته، تظل عناصر التحكم على الشاشة الموجودة تحته تعمل. + المظهر + الحجم على الشاشة + هادئ + متوسط + زاهٍ + الحد الخارجي + لون الحد الخارجي + رمادي + لون المظهر + القراءات + مخطط زمن الإطار + معدل الإطارات بخانة عشرية + أدنى 0.01% + محرك الرسومات + اسم GPU + حمل GPU + درجة حرارة GPU + حمل CPU + CPU لكل نواة + درجات الحرارة + الذاكرة + ذاكرة الرسومات + ذاكرة التبديل + مستوى البطارية + استهلاك الطاقة + الشبكة + الدقة + إصدار Proton + مدة الجلسة + الساعة + درجات الحرارة + الوحدة + ألوان التحذير + تتحول درجات الحرارة إلى الكهرماني ثم الأحمر كلما اقتربت من الحد. + إيقاف + تلقائي + يدوي + CPU بالأحمر عند + GPU بالأحمر عند + البطارية بالأحمر عند + المزيد + تشخيص HUD + يحفظ تقريرًا عن كل مستشعر يقرؤه HUD في Downloads/DroidDeck. + حفظ + تم الحفظ في %1$s + تعذّر حفظ تقرير HUD. + إعادة ضبط إعدادات HUD + إعادة ضبط + FusionHUD من The412Banner، github.com/The412Banner/FusionHUD (GPL-3.0). وضع التحجيم حدة التحجيم مؤثرات الشاشة @@ -653,6 +711,8 @@ تبلغ وحدة التحكم بمعدل 1000 هرتز بدلًا من 250 هرتز. استهلاك معالج أعلى قليلًا. لمس بزمن استجابة منخفض تتجاوز اللمسات تجميع الإطارات، فيعمل اللمس في Steam بمعدل 1000 هرتز. استهلاك معالج أعلى قليلًا. + قناة فأرة خام + المؤشر لوحة مفاتيح الكمبيوتر Android الشاشة الثانية @@ -781,6 +841,17 @@ ملء الشاشة يخفي شريطي الحالة والتنقّل في Android يعرض شريطي الحالة والتنقّل في Android + الشاشة المزدوجة + يستخدم الوضع التلقائي الشاشة الثانية في الأجهزة المحمولة التي تحتوي على شاشة مدمجة. يستخدم وضع التشغيل أي شاشة ثانية. يُبقي وضع الإيقاف كل شيء على شاشة واحدة. + تلقائي + تشغيل + إيقاف + قائمة الشاشة السفلية + ينقل الأقسام وقائمة اللعبة إلى الشاشة الثانية ويتم الكتابة هناك، لتبقى الشاشة العلوية بعرضها الكامل. يبدّل L2 وR2 بين الأقسام. + عناصر تحكم الشاشة السفلية في الألعاب + يفتح لوحة اللمس ولوحة المفاتيح على الشاشة الثانية عند بدء الجلسة. + الكتابة على الشاشة العلوية + تم تطبيق الشاشة الرئيسية الافتراضي اختيار تطبيق للشاشة الرئيسية اختيار @@ -1078,7 +1149,6 @@ لم يتم تشغيلها أبدًا بيئة التشغيل مشغولة · %1$d%% بيئة التشغيل مشغولة - تُثبَّت بيئة التشغيل عند أول تشغيل يتوفر تحديث لبيئة التشغيل ● بيئة التشغيل جاهزة التخزين الداخلي @@ -3219,6 +3289,8 @@ في المكتبة عرض المجموعات + التحديثات المتاحة: %1$d + استبدل المطوّر هذا الإصدار بـ %1$s. هل تريد التحديث إليه؟ سحابية إعدادات السحابة البحث في التطبيقات @@ -3267,4 +3339,20 @@ %d تطبيقًا %d تطبيق + جارٍ استعادة برامجك + إعادة تثبيت التطبيقات التي أضفتها بعد تحديث DroidDeck. + تمت استعادة البرامج + التطبيقات التي أضفتها مثبتة مرة أخرى. + تعذّرت استعادة بعض البرامج + راجع ‎~/.cache/droiddeck/apt-restore.log ثم جرّب sudo apt install في Konsole. + اتصال USB بطيء + %1$s متصل عبر %2$s (%3$s ميغابت/ث، حوالي %4$d ميغابايت/ث)، لذا ستُحمَّل الألعاب عليه ببطء. استخدم كابل USB 3 بسرعة 5 جيجابت/ث أو أكثر، أو جرّب قلب قابس USB-C، أو استخدم محركًا أسرع. + %1$s متصل عبر %2$s (%3$s ميغابت/ث، حوالي %4$d ميغابايت/ث). لا يدعم منفذ USB في هذا الجهاز سرعة أعلى، لذا ستُحمَّل الألعاب عليه ببطء. قد تكون الذاكرة الداخلية أو بطاقة microSD أسرع. + عدم الإظهار مرة أخرى + إغلاق + هل تريد إعادة تشغيل Steam لإضافة ألعابك؟ + يمكن لـ Steam إدراج هذه الألعاب التي تملكها كألعاب مثبتة: %1$s. يجب إعادة تشغيله لإظهارها. أعد التشغيل الآن، أو ستُضاف في المرة القادمة التي يبدأ فيها Steam. + %1$s و%2$d أخرى + إعادة التشغيل الآن + لاحقًا diff --git a/app/src/main/res/values-b+es+419/gestures.xml b/app/src/main/res/values-b+es+419/gestures.xml index 4f5f49779..d65a5db54 100644 --- a/app/src/main/res/values-b+es+419/gestures.xml +++ b/app/src/main/res/values-b+es+419/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Pulsación sostenida con tres dedos Pellizcar para ampliar Mantener el botón pulsado + Mantener pulsado el botón izquierdo + Mantener pulsado el botón derecho Hacer clic una vez Tiempo de pulsación sostenida Intervalo de doble toque diff --git a/app/src/main/res/values-b+es+419/shortcuts.xml b/app/src/main/res/values-b+es+419/shortcuts.xml new file mode 100644 index 000000000..7b643864a --- /dev/null +++ b/app/src/main/res/values-b+es+419/shortcuts.xml @@ -0,0 +1,13 @@ + + + Atajos de botones físicos + Menú de DroidDeck + Asignar: %1$s + Presiona y suelta un botón. La asignación se guarda automáticamente. + Suelta el botón para continuar. + %1$s: %2$s + Este botón ya está asignado a: %1$s + Reasignar + Probar otro botón + El botón Inicio y algunos botones adicionales pueden no estar disponibles. + 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 0861e3525..cac745f0f 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -602,6 +602,64 @@ Ámbar Violeta Opacidad + Tamaño + Tocar el HUD también cambia su tamaño. + Completo + Mosaicos + Píldora + Mínimo + Mega + Fijar posición + Arrastra el HUD para moverlo; una pulsación larga lo bloquea o lo desbloquea. Mientras está bloqueado, los controles en pantalla que tiene debajo siguen funcionando. + Aspecto + Tamaño en pantalla + Suave + Medio + Intenso + Contorno + Color del contorno + Gris + Color del tema + Lecturas + Gráfico de tiempos de fotograma + Tasa de fotogramas con decimal + 0.01% más bajo + Motor gráfico + Nombre de la GPU + Carga de la GPU + Temperatura de la GPU + Carga de la CPU + CPU por núcleo + Temperaturas + Memoria + Memoria gráfica + Memoria de intercambio + Nivel de batería + Consumo de energía + Red + Resolución + Versión de Proton + Tiempo de sesión + Reloj + Temperaturas + Unidad + Colores de aviso + Las temperaturas se vuelven ámbar y luego rojas al acercarse al límite. + Desactivado + Automático + Manual + CPU en rojo a partir de + GPU en rojo a partir de + Batería en rojo a partir de + Más + Diagnóstico del HUD + Guarda en Downloads/DroidDeck un informe de cada sensor que lee el HUD. + Guardar + Guardado en %1$s + No se pudo guardar el informe del HUD. + Restablecer ajustes del HUD + Restablecer + FusionHUD de The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Modo de escalado Nitidez del escalado Efectos de pantalla @@ -664,6 +722,8 @@ El control informa a 1000 Hz en lugar de 250 Hz. Usa algo más de CPU. Táctil de baja latencia Los toques omiten la agrupación de fotogramas; los controles táctiles de Steam van a 1000 Hz. Usa algo más de CPU. + Canal de ratón sin procesar + Cursor Teclado de PC Android Segunda pantalla @@ -792,6 +852,17 @@ Pantalla completa Oculta las barras de estado y navegación de Android Muestra las barras de estado y navegación de Android + Pantalla dual + Automático usa la segunda pantalla en consolas portátiles que la traen integrada. Activado usa cualquier segunda pantalla. Desactivado deja todo en una sola pantalla. + Automático + Activado + Desactivado + Menú en la pantalla inferior + Mueve las secciones y el menú del juego a la segunda pantalla y escribe ahí, para que la pantalla superior conserve la vista completa. L2 y R2 cambian de sección. + Controles en la pantalla inferior en juegos + Abre el trackpad y el teclado en la segunda pantalla cuando empieza una sesión. + Escribiendo en la pantalla superior + Listo App de inicio predeterminada Elige una app de inicio Elegir @@ -1117,7 +1188,6 @@ Nunca jugado Runtime ocupado · %1$d%% Runtime ocupado - El runtime se instala al jugar Actualización del runtime disponible ● Runtime listo Almacenamiento interno @@ -3069,6 +3139,8 @@ En Biblioteca Mostrar Colecciones + Actualizaciones disponibles: %1$d + El desarrollador reemplazó esta versión por %1$s. ¿Actualizar a ella? Nube Configuración de nube Buscar apps @@ -3114,4 +3186,20 @@ %d apps %d apps + Restaurando tu software + Reinstalando las apps que agregaste después de una actualización de DroidDeck. + Software restaurado + Las apps que agregaste están instaladas otra vez. + No se pudo restaurar parte del software + Revisa ~/.cache/droiddeck/apt-restore.log y luego prueba sudo apt install en Konsole. + Conexión USB lenta + %1$s está conectado por %2$s (%3$s Mbps, unos %4$d MB/s), así que los juegos cargarán lento. Usa un cable USB 3 de 5 Gbps o más, prueba a voltear el conector USB-C o usa una unidad más rápida. + %1$s está conectado por %2$s (%3$s Mbps, unos %4$d MB/s). El puerto USB de este dispositivo no admite más velocidad, así que los juegos cargarán lento. El almacenamiento interno o una microSD pueden ser más rápidos. + No volver a mostrar + Descartar + ¿Reiniciar Steam para agregar tus juegos? + Steam puede mostrar como instalados estos juegos que tienes: %1$s. Tiene que reiniciarse para mostrarlos. Reinicia ahora o se agregarán la próxima vez que Steam se inicie. + %1$s y %2$d más + Reiniciar ahora + Más tarde diff --git a/app/src/main/res/values-bg/gestures.xml b/app/src/main/res/values-bg/gestures.xml index 7c05b9b60..15898ef0e 100644 --- a/app/src/main/res/values-bg/gestures.xml +++ b/app/src/main/res/values-bg/gestures.xml @@ -2,7 +2,6 @@ Жестове Настройки на сензорните жестове - Настройте жестовете за мишка и клавиатура. За да ги използвате, изберете сензорен режим „Жестове“. Докосване Плъзгане с един пръст Продължително натискане @@ -15,6 +14,8 @@ Задържане с три пръста Мащабиране със събиране или раздалечаване на пръстите Задържане на бутона натиснат + Задържане на левия бутон + Задържане на десния бутон Еднократно щракване Закъснение за задържане Интервал за двойно докосване diff --git a/app/src/main/res/values-bg/shortcuts.xml b/app/src/main/res/values-bg/shortcuts.xml new file mode 100644 index 000000000..076068c11 --- /dev/null +++ b/app/src/main/res/values-bg/shortcuts.xml @@ -0,0 +1,13 @@ + + + Преки пътища с физически бутони + Меню на DroidDeck + Задаване: %1$s + Натиснете и отпуснете бутон. Назначението се запазва автоматично. + Отпуснете бутона, за да продължите. + %1$s: %2$s + Този бутон вече е зададен за: %1$s + Преназначаване + Опитайте друг бутон + Бутонът Начало и някои допълнителни бутони може да не са налични. + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index fecfd3a04..359ca326c 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -607,6 +607,64 @@ Кехлибарено Виолетово Прозрачност + Размер + Докосването на HUD също променя размера му. + Пълен + Плочки + Хапче + Минимален + Мега + Заключване на място + Плъзнете HUD, за да го преместите; продължително натискане го заключва или отключва. Докато е заключен, екранните контроли под него продължават да работят. + Визия + Размер на екрана + Мек + Среден + Наситен + Контур + Цвят на контура + Сив + Цвят на темата + Показания + Графика на времето на кадрите + Честота на кадрите с десетичен знак + 0.01% най-ниски + Графичен двигател + Име на GPU + Натоварване на GPU + Температура на GPU + Натоварване на CPU + CPU по ядра + Температури + Памет + Графична памет + Swap памет + Ниво на батерията + Консумация на енергия + Мрежа + Разделителна способност + Версия на Proton + Продължителност на сесията + Часовник + Температури + Мерна единица + Предупредителни цветове + Температурите стават кехлибарени, а после червени, когато наближат границата. + Изкл. + Автоматично + Ръчно + CPU в червено от + GPU в червено от + Батерия в червено от + Повече + Диагностика на HUD + Записва в Downloads/DroidDeck отчет за всеки сензор, който HUD чете. + Запиши + Записано в %1$s + Отчетът за HUD не можа да бъде записан. + Нулиране на настройките на HUD + Нулиране + FusionHUD от The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Режим на мащабиране Острота на мащабиране Ефекти на екрана @@ -645,6 +703,8 @@ Контролерът докладва при 1000 Hz вместо 250 Hz. Малко повече CPU. Докосване с ниско закъснение Докосванията пропускат групирането на кадри; управлението с докосване в Steam работи при 1000 Hz. Малко повече CPU. + Суров канал за мишка + Курсор Клавиатура за компютър Android Втори екран @@ -769,6 +829,17 @@ Цял екран Скрива лентите за състояние и навигация на Android Показва лентите за състояние и навигация на Android + Двоен екран + Автоматично използва втория екран на преносими устройства с вграден такъв. Вкл. използва всеки втори дисплей. Изкл. държи всичко на един екран. + Автоматично + Вкл. + Изкл. + Меню на долния екран + Премества секциите и менюто по време на игра на втория екран и въвеждането става там, така горният екран остава с пълен изглед. L2 и R2 сменят секциите. + Контроли на долния екран в игрите + Отваря тракпада и клавиатурата на втория екран при стартиране на сесия. + Въвеждане за горния екран + Готово Приложение за начален екран по подразбиране Изберете приложение за начален екран Избор @@ -1054,7 +1125,6 @@ Никога не е играна Средата за изпълнение е заета · %1$d%% Средата за изпълнение е заета - Средата за изпълнение се инсталира при първото Play Налична актуализация на средата за изпълнение ● Средата за изпълнение е готова Вътрешна памет @@ -3083,6 +3153,8 @@ В Библиотека Показване Колекции + Налични актуализации: %1$d + Разработчикът замени тази версия с %1$s. Да се актуализира ли до нея? Облак Настройки на облака Търсене на приложения @@ -3127,4 +3199,20 @@ %d приложение %d приложения + Възстановяване на софтуера ви + Преинсталиране на приложенията, които добавихте, след обновяване на DroidDeck. + Софтуерът е възстановен + Добавените от вас приложения са инсталирани отново. + Част от софтуера не можа да бъде възстановена + Вижте ~/.cache/droiddeck/apt-restore.log, след това опитайте sudo apt install в Konsole. + Бавна USB връзка + %1$s е свързан чрез %2$s (%3$s Mbps, около %4$d MB/s), затова игрите на него ще се зареждат бавно. Използвайте USB 3 кабел за 5 Gbps или повече, обърнете USB-C щекера или използвайте по-бързо устройство. + %1$s е свързан чрез %2$s (%3$s Mbps, около %4$d MB/s). USB портът на това устройство не поддържа по-висока скорост, затова игрите ще се зареждат бавно. Вътрешната памет или microSD карта може да са по-бързи. + Не показвай отново + Затвори + Да се рестартира ли Steam, за да добави игрите ви? + Steam може да покаже тези ваши игри като инсталирани: %1$s. Трябва да се рестартира, за да ги покаже. Рестартирайте сега или те ще бъдат добавени при следващото стартиране на Steam. + %1$s и още %2$d + Рестартиране сега + По-късно diff --git a/app/src/main/res/values-cs/gestures.xml b/app/src/main/res/values-cs/gestures.xml index 4de0b6119..3b1b30f3a 100644 --- a/app/src/main/res/values-cs/gestures.xml +++ b/app/src/main/res/values-cs/gestures.xml @@ -2,7 +2,6 @@ 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í @@ -15,6 +14,8 @@ Podržení třemi prsty Přiblížení sevřením či rozevřením prstů Držet tlačítko stisknuté + Držet levé tlačítko + Držet pravé tlačítko Jednou kliknout Prodleva podržení Interval dvojitého klepnutí diff --git a/app/src/main/res/values-cs/shortcuts.xml b/app/src/main/res/values-cs/shortcuts.xml new file mode 100644 index 000000000..fdc7e7877 --- /dev/null +++ b/app/src/main/res/values-cs/shortcuts.xml @@ -0,0 +1,13 @@ + + + Zkratky fyzických tlačítek + Nabídka DroidDeck + Přiřadit: %1$s + Stiskněte a uvolněte tlačítko. Přiřazení se uloží automaticky. + Pokračujte uvolněním tlačítka. + %1$s: %2$s + Toto tlačítko je již přiřazeno k: %1$s + Přeřadit + Zkusit jiné tlačítko + Tlačítko Domů a některá další tlačítka nemusí být dostupná. + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b9694894e..f8188bb45 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -611,6 +611,64 @@ Ambrová Fialová Průhlednost + Velikost + Klepnutím na HUD se také změní jeho velikost. + Plný + Dlaždice + Pilulka + Minimální + Mega + Zamknout na místě + Přetažením HUD přesunete; dlouhým stiskem ho zamknete nebo odemknete. Když je zamknutý, ovládací prvky na obrazovce pod ním dál fungují. + Vzhled + Velikost na obrazovce + Jemná + Střední + Sytá + Obrys + Barva obrysu + Šedá + Barva tématu + Údaje + Graf časů snímků + Snímková frekvence s desetinným místem + 0.01% nejnižší + Grafický engine + Název GPU + Vytížení GPU + Teplota GPU + Vytížení CPU + CPU po jádrech + Teploty + Paměť + Grafická paměť + Odkládací paměť + Stav baterie + Příkon + Síť + Rozlišení + Verze Protonu + Doba relace + Hodiny + Teploty + Jednotka + Varovné barvy + Teploty se při přiblížení k limitu zbarví nejprve ambrově, pak červeně. + Vypnuto + Automaticky + Manuální + CPU červeně od + GPU červeně od + Baterie červeně od + Více + Diagnostika HUD + Uloží do Downloads/DroidDeck zprávu o všech senzorech, které HUD čte. + Uložit + Uloženo do %1$s + Zprávu HUD se nepodařilo uložit. + Obnovit nastavení HUD + Obnovit + FusionHUD od The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Režim škálování Ostrost škálování Efekty obrazovky @@ -649,6 +707,8 @@ Ovladač hlásí na 1000 Hz místo 250 Hz. O něco víc CPU. Dotek s nízkým zpožděním Doteky přeskakují dávkování snímků, takže ovládání dotykem ve Steamu běží na 1000 Hz. O něco víc CPU. + Surový kanál myši + Kurzor Klávesnice PC Android Druhá obrazovka @@ -775,6 +835,17 @@ Celá obrazovka Skryje stavový a navigační panel Androidu Zobrazí stavový a navigační panel Androidu + Dvě obrazovky + Automaticky používá druhou obrazovku na kapesních zařízeních, která ji mají vestavěnou. Zapnuto používá jakýkoli druhý displej. Vypnuto nechá vše na jedné obrazovce. + Automaticky + Zapnuto + Vypnuto + Nabídka na spodní obrazovce + Přesune sekce a herní nabídku na druhou obrazovku a píše se tam, takže horní obrazovka zůstane celá volná. L2 a R2 přepínají sekce. + Ovládání na spodní obrazovce ve hrách + Při spuštění relace otevře touchpad a klávesnici na druhé obrazovce. + Psaní pro horní obrazovku + Hotovo Výchozí domovská aplikace Vybrat domovskou aplikaci Vybrat @@ -1066,7 +1137,6 @@ Nikdy nehráno Runtime zaneprázdněn · %1$d%% Runtime zaneprázdněn - Runtime se nainstaluje při prvním spuštění hry K dispozici aktualizace runtime ● Runtime připraven Interní úložiště @@ -3151,6 +3221,8 @@ V knihovně Zobrazit Kolekce + Dostupné aktualizace: %1$d + Vývojář nahradil tuto verzi souborem %1$s. Aktualizovat na ni? Cloud Nastavení cloudu Hledat aplikace @@ -3197,4 +3269,20 @@ %d aplikace %d aplikací + Obnovování vašeho softwaru + Přeinstalace aplikací, které jste přidali, po aktualizaci DroidDecku. + Software obnoven + Aplikace, které jste přidali, jsou znovu nainstalovány. + Část softwaru se nepodařilo obnovit + Podívejte se do ~/.cache/droiddeck/apt-restore.log a pak zkuste sudo apt install v Konsoli. + Pomalé připojení USB + %1$s je připojen přes %2$s (%3$s Mb/s, asi %4$d MB/s), takže hry na něm se budou načítat pomalu. Použijte kabel USB 3 s rychlostí 5 Gb/s nebo vyšší, zkuste otočit konektor USB-C nebo použijte rychlejší disk. + %1$s je připojen přes %2$s (%3$s Mb/s, asi %4$d MB/s). Port USB tohoto zařízení rychleji neumí, takže hry na něm se budou načítat pomalu. Interní úložiště nebo karta microSD může být rychlejší. + Už nezobrazovat + Zavřít + Restartovat Steam a přidat vaše hry? + Steam může tyto vaše hry zobrazit jako nainstalované: %1$s. Aby je ukázal, musí se restartovat. Restartujte teď, nebo se přidají při příštím spuštění Steamu. + %1$s a %2$d dalších + Restartovat teď + Později diff --git a/app/src/main/res/values-da/gestures.xml b/app/src/main/res/values-da/gestures.xml index fdac523a6..1fd863e0d 100644 --- a/app/src/main/res/values-da/gestures.xml +++ b/app/src/main/res/values-da/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Hold med tre fingre Knib for at zoome Hold knappen nede + Hold venstre knap nede + Hold højre knap nede Klik én gang Forsinkelse før hold Interval for dobbelttryk diff --git a/app/src/main/res/values-da/shortcuts.xml b/app/src/main/res/values-da/shortcuts.xml new file mode 100644 index 000000000..d95cac5a5 --- /dev/null +++ b/app/src/main/res/values-da/shortcuts.xml @@ -0,0 +1,13 @@ + + + Genveje til fysiske knapper + DroidDeck-menu + Tildel: %1$s + Tryk på en knap, og slip den. Tildelingen gemmes automatisk. + Slip knappen for at fortsætte. + %1$s: %2$s + Denne knap er allerede tildelt: %1$s + Tildel igen + Prøv en anden knap + Hjem-knappen og visse ekstra knapper er muligvis ikke tilgængelige. + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index af13b452d..2e0196c73 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -607,6 +607,64 @@ Ravgul Violet Gennemsigtighed + Størrelse + Et tryk på HUD ændrer også størrelsen. + Fuld + Fliser + Pille + Minimal + Mega + Lås fast + Træk i HUD for at flytte det; et langt tryk låser eller frigør det. Mens det er låst, virker skærmknapperne under det stadig. + Udseende + Størrelse på skærmen + Blød + Mellem + Kraftig + Kontur + Konturfarve + Grå + Temafarve + Målinger + Billedtidsgraf + Billedhastighed med decimal + 0.01% laveste + Grafikmotor + GPU-navn + GPU-belastning + GPU-temperatur + CPU-belastning + CPU pr. kerne + Temperaturer + Hukommelse + Grafikhukommelse + Swap-hukommelse + Batteriniveau + Strømforbrug + Netværk + Opløsning + Proton-version + Sessionstid + Ur + Temperaturer + Enhed + Advarselsfarver + Temperaturer bliver ravgule og derefter røde, når de nærmer sig grænsen. + Fra + Automatisk + Manuel + CPU rød ved + GPU rød ved + Batteri rødt ved + Mere + HUD-diagnostik + Gemmer en rapport over alle sensorer, som HUD læser, i Downloads/DroidDeck. + Gem + Gemt i %1$s + HUD-rapporten kunne ikke gemmes. + Nulstil HUD-indstillinger + Nulstil + FusionHUD af The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Skaleringstilstand Skaleringsskarphed Skærmeffekter @@ -645,6 +703,8 @@ Controlleren rapporterer ved 1000 Hz i stedet for 250 Hz. Lidt mere CPU. Berøring med lav forsinkelse Berøringer springer billedsamling over; Steams berøringsstyring kører ved 1000 Hz. Lidt mere CPU. + Rå musekanal + Markør PC-tastatur Android Anden skærm @@ -769,6 +829,17 @@ Fuldskærm Skjuler Androids status- og navigationsbjælker Viser Androids status- og navigationsbjælker + Dobbeltskærm + Automatisk bruger den anden skærm på håndholdte enheder med en indbygget ekstra skærm. Til bruger enhver anden skærm. Fra holder alt på én skærm. + Automatisk + Til + Fra + Menu på nederste skærm + Flytter sektionerne og menuen i spillet til den anden skærm og skriver der, så den øverste skærm beholder hele visningen. L2 og R2 skifter sektion. + Kontroller på nederste skærm i spil + Åbner pegefeltet og tastaturet på den anden skærm, når en session starter. + Skriver til den øverste skærm + Færdig Standard Home-app Vælg en Home-app Vælg @@ -1054,7 +1125,6 @@ Aldrig spillet Runtime optaget · %1$d%% Runtime optaget - Runtime installeres ved første Spil Runtimeopdatering tilgængelig ● Runtime klar Internt lager @@ -3083,6 +3153,8 @@ I Bibliotek Vis Samlinger + Opdateringer tilgængelige: %1$d + Udvikleren har erstattet denne version med %1$s. Opdater til den? Sky Skyindstillinger Søg i apps @@ -3127,4 +3199,20 @@ %d app %d apps + Gendanner din software + Geninstallerer de apps, du tilføjede, efter en DroidDeck-opdatering. + Software gendannet + De apps, du tilføjede, er installeret igen. + Noget software kunne ikke gendannes + Se ~/.cache/droiddeck/apt-restore.log, og prøv derefter sudo apt install i Konsole. + Langsom USB-forbindelse + %1$s er tilsluttet via %2$s (%3$s Mbps, ca. %4$d MB/s), så spil på den indlæses langsomt. Brug et USB 3-kabel til 5 Gbps eller mere, prøv at vende USB-C-stikket, eller brug et hurtigere drev. + %1$s er tilsluttet via %2$s (%3$s Mbps, ca. %4$d MB/s). Denne enheds USB-port kan ikke køre hurtigere, så spil på den indlæses langsomt. Intern lagerplads eller et microSD-kort kan være hurtigere. + Vis ikke igen + Luk + Genstart Steam for at tilføje dine spil? + Steam kan vise disse spil, du ejer, som installerede: %1$s. Steam skal genstartes for at vise dem. Genstart nu, eller de bliver tilføjet næste gang Steam starter. + %1$s og %2$d mere + Genstart nu + Senere diff --git a/app/src/main/res/values-de/gestures.xml b/app/src/main/res/values-de/gestures.xml index 47532cd74..1c962d656 100644 --- a/app/src/main/res/values-de/gestures.xml +++ b/app/src/main/res/values-de/gestures.xml @@ -2,7 +2,6 @@ Gesten Touchgesten-Einstellungen - Maus- und Tastaturgesten anpassen. Wähle Gesten als Touchmodus, um sie zu verwenden. Tippen Mit einem Finger ziehen Lange drücken @@ -15,6 +14,8 @@ Mit drei Fingern halten Zum Zoomen Finger zusammen- oder auseinanderziehen Taste gedrückt halten + Linke Taste gedrückt halten + Rechte Taste gedrückt halten Einmal klicken Halteverzögerung Doppeltipp-Intervall diff --git a/app/src/main/res/values-de/shortcuts.xml b/app/src/main/res/values-de/shortcuts.xml new file mode 100644 index 000000000..aa4dca553 --- /dev/null +++ b/app/src/main/res/values-de/shortcuts.xml @@ -0,0 +1,13 @@ + + + Physische Tastenkürzel + DroidDeck-Menü + Zuweisen: %1$s + Drücke eine Taste und lasse sie los. Die Zuweisung wird automatisch gespeichert. + Lasse die Taste los, um fortzufahren. + %1$s: %2$s + Diese Taste ist bereits zugewiesen: %1$s + Neu zuweisen + Andere Taste versuchen + Die Home-Taste und einige Zusatztasten sind möglicherweise nicht verfügbar. + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9ffcfe1fd..e25b2ee75 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -607,6 +607,64 @@ Bernstein Violett Deckkraft + Größe + Tippen auf das HUD ändert auch seine Größe. + Voll + Kacheln + Kapsel + Minimal + Mega + Position sperren + Ziehe das HUD, um es zu verschieben; langes Drücken sperrt oder entsperrt es. Solange es gesperrt ist, funktionieren die Bildschirmsteuerungen darunter weiter. + Aussehen + Größe auf dem Bildschirm + Dezent + Mittel + Kräftig + Kontur + Konturfarbe + Grau + Designfarbe + Messwerte + Frametime-Diagramm + Bildrate mit Nachkommastelle + 0.01%-Tiefstwert + Grafik-Engine + GPU-Name + GPU-Auslastung + GPU-Temperatur + CPU-Auslastung + CPU pro Kern + Temperaturen + Arbeitsspeicher + Grafikspeicher + Swap-Speicher + Akkustand + Leistungsaufnahme + Netzwerk + Auflösung + Proton-Version + Sitzungsdauer + Uhrzeit + Temperaturen + Einheit + Warnfarben + Temperaturen werden erst bernsteinfarben, dann rot, wenn sie sich dem Grenzwert nähern. + Aus + Automatisch + Manuell + CPU rot ab + GPU rot ab + Akku rot ab + Mehr + HUD-Diagnose + Speichert einen Bericht zu allen Sensoren, die das HUD ausliest, unter Downloads/DroidDeck. + Speichern + Gespeichert unter %1$s + Der HUD-Bericht konnte nicht gespeichert werden. + HUD-Einstellungen zurücksetzen + Zurücksetzen + FusionHUD von The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Skalierungsmodus Skalierungsschärfe Bildschirmeffekte @@ -645,6 +703,8 @@ Der Controller meldet mit 1000 Hz statt 250 Hz. Die CPU-Last ist etwas höher. Touch mit geringer Latenz Touch überspringt das Zusammenfassen von Frames, Steam-Touch meldet mit 1000 Hz. Die CPU-Last ist etwas höher. + Roher Mauskanal + Zeiger PC-Tastatur Android Zweiter Bildschirm @@ -761,6 +821,17 @@ Vollbild Blendet die Status- und Navigationsleiste von Android aus Zeigt die Status- und Navigationsleiste von Android an + Zwei Bildschirme + Automatisch nutzt den zweiten Bildschirm auf Handhelds mit eingebautem Zweitbildschirm. Ein nutzt einen beliebigen zweiten Bildschirm. Aus lässt alles auf einem Bildschirm. + Automatisch + Ein + Aus + Menü auf dem unteren Bildschirm + Verschiebt die Bereiche und das Menü im Spiel auf den zweiten Bildschirm und tippt dort, damit der obere Bildschirm die volle Ansicht behält. L2 und R2 wechseln den Bereich. + Steuerung auf dem unteren Bildschirm in Spielen + Öffnet beim Start einer Sitzung Trackpad und Tastatur auf dem zweiten Bildschirm. + Eingabe für den oberen Bildschirm + Fertig Standard-Startbildschirm-App Startbildschirm-App auswählen Auswählen @@ -1046,7 +1117,6 @@ Nie gespielt Laufzeit beschäftigt · %1$d %% Laufzeit beschäftigt - Laufzeit wird beim ersten „Spielen“ installiert Laufzeit-Update verfügbar ● Laufzeit bereit Interner Speicher @@ -3083,6 +3153,8 @@ In der Bibliothek Anzeigen Sammlungen + Verfügbare Updates: %1$d + Der Entwickler hat diesen Build durch %1$s ersetzt. Darauf aktualisieren? Cloud Cloud-Einstellungen Apps durchsuchen @@ -3127,4 +3199,20 @@ %d App %d Apps + Deine Software wird wiederhergestellt + Die Apps, die du hinzugefügt hast, werden nach einem DroidDeck-Update neu installiert. + Software wiederhergestellt + Die Apps, die du hinzugefügt hast, sind wieder installiert. + Manche Software konnte nicht wiederhergestellt werden + Sieh in ~/.cache/droiddeck/apt-restore.log nach und versuche dann sudo apt install in Konsole. + Langsame USB-Verbindung + %1$s ist über %2$s verbunden (%3$s Mbit/s, etwa %4$d MB/s), daher laden Spiele darauf langsam. Nutze ein USB-3-Kabel mit 5 Gbit/s oder mehr, dreh den USB-C-Stecker um oder verwende ein schnelleres Laufwerk. + %1$s ist über %2$s verbunden (%3$s Mbit/s, etwa %4$d MB/s). Der USB-Anschluss dieses Geräts kann nicht schneller, daher laden Spiele darauf langsam. Interner Speicher oder eine microSD-Karte können schneller sein. + Nicht mehr anzeigen + Schließen + Steam neu starten, um deine Spiele hinzuzufügen? + Steam kann diese Spiele, die du besitzt, als installiert anzeigen: %1$s. Dafür muss Steam neu starten. Starte jetzt neu, oder sie werden beim nächsten Start von Steam hinzugefügt. + %1$s und %2$d weitere + Jetzt neu starten + Später diff --git a/app/src/main/res/values-el/gestures.xml b/app/src/main/res/values-el/gestures.xml index 22d20ba93..f0bdfe451 100644 --- a/app/src/main/res/values-el/gestures.xml +++ b/app/src/main/res/values-el/gestures.xml @@ -2,7 +2,6 @@ Χειρονομίες Ρυθμίσεις χειρονομιών αφής - Προσαρμόστε τις χειρονομίες ποντικιού και πληκτρολογίου. Επιλέξτε Χειρονομίες ως λειτουργία αφής για να τις χρησιμοποιήσετε. Πάτημα Σύρσιμο με ένα δάχτυλο Παρατεταμένο πάτημα @@ -15,6 +14,8 @@ Κράτημα με τρία δάχτυλα Μεγέθυνση με τσίμπημα Διατήρηση του κουμπιού πατημένου + Διατήρηση αριστερού κουμπιού πατημένου + Διατήρηση δεξιού κουμπιού πατημένου Ένα κλικ Καθυστέρηση κρατήματος Διάστημα διπλού πατήματος diff --git a/app/src/main/res/values-el/shortcuts.xml b/app/src/main/res/values-el/shortcuts.xml new file mode 100644 index 000000000..a09be7c53 --- /dev/null +++ b/app/src/main/res/values-el/shortcuts.xml @@ -0,0 +1,13 @@ + + + Συντομεύσεις φυσικών κουμπιών + Μενού DroidDeck + Αντιστοίχιση: %1$s + Πατήστε και αφήστε ένα κουμπί. Η αντιστοίχιση αποθηκεύεται αυτόματα. + Αφήστε το κουμπί για συνέχεια. + %1$s: %2$s + Αυτό το κουμπί έχει ήδη αντιστοιχιστεί σε: %1$s + Επαναντιστοίχιση + Δοκιμή άλλου κουμπιού + Το κουμπί Αρχική και ορισμένα επιπλέον κουμπιά ενδέχεται να μην είναι διαθέσιμα. + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index c8588ac9c..9c47bc85e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -607,6 +607,64 @@ Κεχριμπαρί Βιολετί Διαφάνεια + Μέγεθος + Το πάτημα στο HUD αλλάζει επίσης το μέγεθός του. + Πλήρες + Πλακίδια + Κάψουλα + Ελάχιστο + Μέγα + Κλείδωμα θέσης + Σύρετε το HUD για να το μετακινήσετε· ένα παρατεταμένο πάτημα το κλειδώνει ή το ξεκλειδώνει. Όσο είναι κλειδωμένο, τα χειριστήρια οθόνης από κάτω του εξακολουθούν να λειτουργούν. + Εμφάνιση + Μέγεθος στην οθόνη + Απαλό + Μεσαίο + Έντονο + Περίγραμμα + Χρώμα περιγράμματος + Γκρι + Χρώμα θέματος + Μετρήσεις + Γράφημα χρόνου καρέ + Ρυθμός καρέ με δεκαδικό + 0.01% χαμηλότερα + Μηχανή γραφικών + Όνομα GPU + Φόρτος GPU + Θερμοκρασία GPU + Φόρτος CPU + CPU ανά πυρήνα + Θερμοκρασίες + Μνήμη + Μνήμη γραφικών + Μνήμη swap + Στάθμη μπαταρίας + Κατανάλωση ισχύος + Δίκτυο + Ανάλυση + Έκδοση Proton + Χρόνος συνεδρίας + Ρολόι + Θερμοκρασίες + Μονάδα + Χρώματα προειδοποίησης + Οι θερμοκρασίες γίνονται κεχριμπαρί και μετά κόκκινες καθώς πλησιάζουν το όριο. + Ανενεργό + Αυτόματο + Μη αυτόματο + CPU κόκκινο από + GPU κόκκινο από + Μπαταρία κόκκινο από + Περισσότερα + Διαγνωστικά HUD + Αποθηκεύει στο Downloads/DroidDeck μια αναφορά για κάθε αισθητήρα που διαβάζει το HUD. + Αποθήκευση + Αποθηκεύτηκε στο %1$s + Δεν ήταν δυνατή η αποθήκευση της αναφοράς HUD. + Επαναφορά ρυθμίσεων HUD + Επαναφορά + FusionHUD από The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Λειτουργία κλιμάκωσης Οξύτητα κλιμάκωσης Εφέ οθόνης @@ -645,6 +703,8 @@ Το χειριστήριο αναφέρει στα 1000 Hz αντί για 250 Hz. Λίγο περισσότερο CPU. Αφή χαμηλής καθυστέρησης Οι αφές παρακάμπτουν την ομαδοποίηση καρέ, οπότε ο χειρισμός αφής του Steam τρέχει στα 1000 Hz. Λίγο περισσότερο CPU. + Ακατέργαστο κανάλι ποντικιού + Δείκτης Πληκτρολόγιο PC Android Δεύτερη οθόνη @@ -769,6 +829,17 @@ Πλήρης οθόνη Αποκρύπτει τις γραμμές κατάστασης και πλοήγησης του Android Εμφανίζει τις γραμμές κατάστασης και πλοήγησης του Android + Διπλή οθόνη + Το Αυτόματο χρησιμοποιεί τη δεύτερη οθόνη σε φορητές συσκευές με ενσωματωμένη. Το Ενεργό χρησιμοποιεί οποιαδήποτε δεύτερη οθόνη. Το Ανενεργό κρατά τα πάντα σε μία οθόνη. + Αυτόματο + Ενεργό + Ανενεργό + Μενού κάτω οθόνης + Μεταφέρει τις ενότητες και το μενού του παιχνιδιού στη δεύτερη οθόνη και η πληκτρολόγηση γίνεται εκεί, ώστε η πάνω οθόνη να κρατά την πλήρη προβολή. Τα L2 και R2 αλλάζουν ενότητα. + Χειριστήρια κάτω οθόνης στα παιχνίδια + Ανοίγει την επιφάνεια αφής και το πληκτρολόγιο στη δεύτερη οθόνη όταν ξεκινά μια συνεδρία. + Πληκτρολόγηση για την πάνω οθόνη + Τέλος Προεπιλεγμένη εφαρμογή αρχικής οθόνης Επιλογή εφαρμογής αρχικής οθόνης Επιλογή @@ -1054,7 +1125,6 @@ Δεν έχει παιχτεί ποτέ Το runtime είναι απασχολημένο · %1$d%% Το runtime είναι απασχολημένο - Το runtime εγκαθίσταται στο πρώτο Play Υπάρχει ενημέρωση runtime ● Το runtime είναι έτοιμο Εσωτερική αποθήκευση @@ -3083,6 +3153,8 @@ Στη Βιβλιοθήκη Εμφάνιση Συλλογές + Διαθέσιμες ενημερώσεις: %1$d + Ο προγραμματιστής αντικατέστησε αυτήν την έκδοση με %1$s. Να γίνει ενημέρωση σε αυτήν; Cloud Ρυθμίσεις cloud Αναζήτηση εφαρμογών @@ -3127,4 +3199,20 @@ %d εφαρμογή %d εφαρμογές + Επαναφορά του λογισμικού σας + Επανεγκατάσταση των εφαρμογών που προσθέσατε μετά από ενημέρωση του DroidDeck. + Το λογισμικό επαναφέρθηκε + Οι εφαρμογές που προσθέσατε εγκαταστάθηκαν ξανά. + Δεν ήταν δυνατή η επαναφορά ορισμένου λογισμικού + Δείτε το ~/.cache/droiddeck/apt-restore.log και μετά δοκιμάστε sudo apt install στο Konsole. + Αργή σύνδεση USB + Η συσκευή %1$s συνδέεται μέσω %2$s (%3$s Mbps, περίπου %4$d MB/s), οπότε τα παιχνίδια θα φορτώνουν αργά. Χρησιμοποιήστε καλώδιο USB 3 των 5 Gbps ή περισσότερο, αναποδογυρίστε το βύσμα USB-C ή χρησιμοποιήστε ταχύτερο δίσκο. + Η συσκευή %1$s συνδέεται μέσω %2$s (%3$s Mbps, περίπου %4$d MB/s). Η θύρα USB αυτής της συσκευής δεν υποστηρίζει μεγαλύτερη ταχύτητα, οπότε τα παιχνίδια θα φορτώνουν αργά. Ο εσωτερικός χώρος ή μια κάρτα microSD μπορεί να είναι ταχύτερα. + Να μην εμφανιστεί ξανά + Κλείσιμο + Επανεκκίνηση του Steam για να προστεθούν τα παιχνίδια σας; + Το Steam μπορεί να εμφανίσει αυτά τα παιχνίδια που έχετε ως εγκατεστημένα: %1$s. Πρέπει να γίνει επανεκκίνηση για να εμφανιστούν. Κάντε επανεκκίνηση τώρα ή θα προστεθούν την επόμενη φορά που θα ξεκινήσει το Steam. + %1$s και %2$d ακόμη + Επανεκκίνηση τώρα + Αργότερα diff --git a/app/src/main/res/values-es/gestures.xml b/app/src/main/res/values-es/gestures.xml index 4f5f49779..d65a5db54 100644 --- a/app/src/main/res/values-es/gestures.xml +++ b/app/src/main/res/values-es/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Pulsación sostenida con tres dedos Pellizcar para ampliar Mantener el botón pulsado + Mantener pulsado el botón izquierdo + Mantener pulsado el botón derecho Hacer clic una vez Tiempo de pulsación sostenida Intervalo de doble toque diff --git a/app/src/main/res/values-es/shortcuts.xml b/app/src/main/res/values-es/shortcuts.xml new file mode 100644 index 000000000..7db4cf53f --- /dev/null +++ b/app/src/main/res/values-es/shortcuts.xml @@ -0,0 +1,13 @@ + + + Atajos de botones físicos + Menú de DroidDeck + Asignar: %1$s + Pulsa y suelta un botón. La asignación se guarda automáticamente. + Suelta el botón para continuar. + %1$s: %2$s + Este botón ya está asignado a: %1$s + Reasignar + Probar otro botón + El botón Inicio y algunos botones adicionales pueden no estar disponibles. + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 82af91332..ed01667d5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -611,6 +611,64 @@ Ámbar Violeta Opacidad + Tamaño + Tocar el HUD también cambia su tamaño. + Completo + Mosaicos + Píldora + Mínimo + Mega + Fijar posición + Arrastra el HUD para moverlo; una pulsación larga lo bloquea o lo desbloquea. Mientras está bloqueado, los controles en pantalla que tiene debajo siguen funcionando. + Aspecto + Tamaño en pantalla + Suave + Medio + Intenso + Contorno + Color del contorno + Gris + Color del tema + Lecturas + Gráfico de tiempos de fotograma + Tasa de fotogramas con decimal + 0.01% más bajo + Motor gráfico + Nombre de la GPU + Carga de la GPU + Temperatura de la GPU + Carga de la CPU + CPU por núcleo + Temperaturas + Memoria + Memoria gráfica + Memoria de intercambio + Nivel de batería + Consumo de energía + Red + Resolución + Versión de Proton + Tiempo de sesión + Reloj + Temperaturas + Unidad + Colores de aviso + Las temperaturas se vuelven ámbar y luego rojas al acercarse al límite. + Desactivado + Automático + Manual + CPU en rojo a partir de + GPU en rojo a partir de + Batería en rojo a partir de + Más + Diagnóstico del HUD + Guarda en Downloads/DroidDeck un informe de cada sensor que lee el HUD. + Guardar + Guardado en %1$s + No se ha podido guardar el informe del HUD. + Restablecer ajustes del HUD + Restablecer + FusionHUD de The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Modo de escalado Nitidez del escalado Efectos de pantalla @@ -649,6 +707,8 @@ El mando informa a 1000 Hz en lugar de 250 Hz. Usa algo más de CPU. Táctil de baja latencia Los toques omiten la agrupación de fotogramas; los controles táctiles de Steam van a 1000 Hz. Usa algo más de CPU. + Canal de ratón sin procesar + Cursor Teclado de PC Android Segunda pantalla @@ -774,6 +834,17 @@ Pantalla completa Oculta las barras de estado y navegación de Android Muestra las barras de estado y navegación de Android + Pantalla doble + Automático usa la segunda pantalla en consolas portátiles que la llevan integrada. Activado usa cualquier segunda pantalla. Desactivado lo mantiene todo en una pantalla. + Automático + Activado + Desactivado + Menú en la pantalla inferior + Mueve las secciones y el menú del juego a la segunda pantalla y se escribe allí, para que la pantalla superior mantenga la vista completa. L2 y R2 cambian de sección. + Controles en la pantalla inferior en juegos + Abre el trackpad y el teclado en la segunda pantalla al iniciar una sesión. + Escribiendo en la pantalla superior + Hecho App de inicio predeterminada Elige una app de inicio Elegir @@ -1062,7 +1133,6 @@ Nunca jugado Runtime ocupado · %1$d%% Runtime ocupado - El runtime se instala al jugar Actualización del runtime disponible ● Runtime listo Almacenamiento interno @@ -3119,6 +3189,8 @@ En la Biblioteca Mostrar Colecciones + Actualizaciones disponibles: %1$d + El desarrollador reemplazó esta versión por %1$s. ¿Actualizar a ella? Nube Ajustes de nube Buscar aplicaciones @@ -3164,4 +3236,20 @@ %d aplicaciones %d aplicaciones + Restaurando tu software + Reinstalando las apps que añadiste tras una actualización de DroidDeck. + Software restaurado + Las apps que añadiste vuelven a estar instaladas. + No se ha podido restaurar parte del software + Consulta ~/.cache/droiddeck/apt-restore.log y después prueba sudo apt install en Konsole. + Conexión USB lenta + %1$s está conectado por %2$s (%3$s Mbps, unos %4$d MB/s), así que los juegos cargarán despacio. Usa un cable USB 3 de 5 Gbps o más, prueba a girar el conector USB-C o usa una unidad más rápida. + %1$s está conectado por %2$s (%3$s Mbps, unos %4$d MB/s). El puerto USB de este dispositivo no va más rápido, así que los juegos cargarán despacio. El almacenamiento interno o una microSD pueden ser más rápidos. + No volver a mostrar + Descartar + ¿Reiniciar Steam para añadir tus juegos? + Steam puede mostrar como instalados estos juegos que tienes: %1$s. Tiene que reiniciarse para mostrarlos. Reinicia ahora o se añadirán la próxima vez que se inicie Steam. + %1$s y %2$d más + Reiniciar ahora + Más tarde diff --git a/app/src/main/res/values-fi/gestures.xml b/app/src/main/res/values-fi/gestures.xml index c92357b99..d575b7ff7 100644 --- a/app/src/main/res/values-fi/gestures.xml +++ b/app/src/main/res/values-fi/gestures.xml @@ -2,7 +2,6 @@ Eleet Kosketuseleiden asetukset - Mukauta hiiri- ja näppäimistöeleitä. Ota ne käyttöön valitsemalla kosketustilaksi Eleet. Napautus Veto yhdellä sormella Pitkä painallus @@ -15,6 +14,8 @@ Pito kolmella sormella Zoomaa nipistämällä Pidä painike painettuna + Pidä vasen painike painettuna + Pidä oikea painike painettuna Napsauta kerran Pidon viive Kaksoisnapautuksen aikaväli diff --git a/app/src/main/res/values-fi/shortcuts.xml b/app/src/main/res/values-fi/shortcuts.xml new file mode 100644 index 000000000..b88d130f4 --- /dev/null +++ b/app/src/main/res/values-fi/shortcuts.xml @@ -0,0 +1,13 @@ + + + Fyysisten painikkeiden pikavalinnat + DroidDeck-valikko + Määritä: %1$s + Paina painiketta ja vapauta se. Määritys tallennetaan automaattisesti. + Jatka vapauttamalla painike. + %1$s: %2$s + Tämä painike on jo määritetty toimintoon: %1$s + Määritä uudelleen + Kokeile toista painiketta + Koti-painike ja jotkin lisäpainikkeet eivät ehkä ole käytettävissä. + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 3ad1e9247..1c7ae99f5 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -607,6 +607,64 @@ Meripihka Violetti Peittävyys + Koko + HUD:n napauttaminen muuttaa myös sen kokoa. + Täysi + Ruudut + Kapseli + Minimaalinen + Mega + Lukitse paikalleen + Siirrä HUD:ia vetämällä; pitkä painallus lukitsee tai vapauttaa sen. Lukittuna sen alla olevat näytön ohjaimet toimivat edelleen. + Ulkoasu + Koko näytöllä + Pehmeä + Keskitaso + Kirkas + Ääriviiva + Ääriviivan väri + Harmaa + Teeman väri + Lukemat + Kuva-aikakäyrä + Kuvataajuus desimaalilla + 0.01% alin + Grafiikkamoottori + GPU:n nimi + GPU:n kuorma + GPU:n lämpötila + CPU:n kuorma + CPU ytimittäin + Lämpötilat + Muisti + Näytönohjaimen muisti + Swap-tila + Akun varaus + Virrankulutus + Verkko + Tarkkuus + Proton-versio + Istunnon kesto + Kello + Lämpötilat + Yksikkö + Varoitusvärit + Lämpötilat muuttuvat meripihkanvärisiksi ja sitten punaisiksi, kun ne lähestyvät rajaa. + Pois + Automaattinen + Manuaalinen + CPU:n punainen raja + GPU:n punainen raja + Akun punainen raja + Lisää + HUD-diagnostiikka + Tallentaa raportin kaikista HUD:n lukemista antureista kansioon Downloads/DroidDeck. + Tallenna + Tallennettu: %1$s + HUD-raporttia ei voitu tallentaa. + Palauta HUD-asetukset + Palauta + FusionHUD, tekijä The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Skaalaustila Skaalauksen terävyys Näyttötehosteet @@ -645,6 +703,8 @@ Ohjain raportoi 1000 Hz:n taajuudella 250 Hz:n sijaan. Hieman enemmän CPU:ta. Matalan viiveen kosketus Kosketukset ohittavat ruutujen niputuksen, joten Steamin kosketusohjaus toimii 1000 Hz:llä. Hieman enemmän CPU:ta. + Raaka hiirikanava + Osoitin PC-näppäimistö Android Toinen näyttö @@ -769,6 +829,17 @@ Koko näyttö Piilottaa Androidin tila- ja navigointipalkit Näyttää Androidin tila- ja navigointipalkit + Kaksi näyttöä + Automaattinen käyttää toista näyttöä kämmenlaitteissa, joissa sellainen on sisäänrakennettuna. Päällä käyttää mitä tahansa toista näyttöä. Pois pitää kaiken yhdellä näytöllä. + Automaattinen + Päällä + Pois + Alanäytön valikko + Siirtää osiot ja pelin aikaisen valikon toiselle näytölle ja kirjoitus tehdään siellä, joten yläruutu säilyttää koko näkymän. L2 ja R2 vaihtavat osiota. + Alanäytön ohjaimet peleissä + Avaa kosketuslevyn ja näppäimistön toiselle näytölle, kun istunto alkaa. + Kirjoitus yläruudulle + Valmis Oletuskotisovellus Valitse kotisovellus Valitse @@ -1054,7 +1125,6 @@ Ei pelattu koskaan Ajoympäristö varattu · %1$d %% Ajoympäristö varattu - Ajoympäristö asentuu ensimmäisellä Pelaa-painalluksella Ajoympäristön päivitys saatavilla ● Ajoympäristö valmis Sisäinen tallennustila @@ -3083,6 +3153,8 @@ Kirjastossa Näytä Kokoelmat + Päivityksiä saatavilla: %1$d + Kehittäjä korvasi tämän version tiedostolla %1$s. Päivitetäänkö siihen? Pilvi Pilviasetukset Hae sovelluksia @@ -3127,4 +3199,20 @@ %d sovellus %d sovellusta + Ohjelmistojasi palautetaan + Lisäämiäsi sovelluksia asennetaan uudelleen DroidDeck-päivityksen jälkeen. + Ohjelmistot palautettu + Lisäämäsi sovellukset on asennettu uudelleen. + Joitakin ohjelmistoja ei voitu palauttaa + Katso ~/.cache/droiddeck/apt-restore.log ja kokeile sitten sudo apt install Konsolessa. + Hidas USB-yhteys + %1$s on yhdistetty %2$s-yhteydellä (%3$s Mbps, noin %4$d Mt/s), joten pelit latautuvat hitaasti. Käytä vähintään 5 Gbps:n USB 3 -kaapelia, kokeile kääntää USB-C-liitin tai käytä nopeampaa asemaa. + %1$s on yhdistetty %2$s-yhteydellä (%3$s Mbps, noin %4$d Mt/s). Tämän laitteen USB-portti ei ole nopeampi, joten pelit latautuvat hitaasti. Sisäinen tallennustila tai microSD-kortti voi olla nopeampi. + Älä näytä uudelleen + Sulje + Käynnistetäänkö Steam uudelleen pelien lisäämiseksi? + Steam voi näyttää nämä omistamasi pelit asennettuina: %1$s. Se täytyy käynnistää uudelleen, jotta ne näkyvät. Käynnistä uudelleen nyt, tai ne lisätään, kun Steam käynnistyy seuraavan kerran. + %1$s ja %2$d muuta + Käynnistä uudelleen nyt + Myöhemmin diff --git a/app/src/main/res/values-fr/gestures.xml b/app/src/main/res/values-fr/gestures.xml index 031725fa1..0f1870a76 100644 --- a/app/src/main/res/values-fr/gestures.xml +++ b/app/src/main/res/values-fr/gestures.xml @@ -2,7 +2,6 @@ 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é @@ -15,6 +14,8 @@ Maintien à trois doigts Pincer pour zoomer Maintenir le bouton enfoncé + Maintenir le bouton gauche enfoncé + Maintenir le bouton droit enfoncé Cliquer une fois Délai de maintien Intervalle du double appui diff --git a/app/src/main/res/values-fr/shortcuts.xml b/app/src/main/res/values-fr/shortcuts.xml new file mode 100644 index 000000000..103fc2bb5 --- /dev/null +++ b/app/src/main/res/values-fr/shortcuts.xml @@ -0,0 +1,13 @@ + + + Raccourcis des boutons physiques + Menu DroidDeck + Attribuer : %1$s + Appuyez sur un bouton puis relâchez-le. L’affectation est enregistrée automatiquement. + Relâchez le bouton pour continuer. + %1$s : %2$s + Ce bouton est déjà attribué à : %1$s + Réaffecter + Essayer un autre bouton + Le bouton Accueil et certains boutons supplémentaires peuvent être indisponibles. + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ef4fdb8f5..91447bec2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -611,6 +611,64 @@ Ambre Violet Opacité + Taille + Toucher le HUD change aussi sa taille. + Complet + Tuiles + Pilule + Minimal + Méga + Verrouiller la position + Faites glisser le HUD pour le déplacer ; un appui long le verrouille ou le déverrouille. Tant qu\'il est verrouillé, les commandes à l\'écran situées dessous restent utilisables. + Apparence + Taille à l\'écran + Douce + Moyenne + Vive + Contour + Couleur du contour + Gris + Couleur du thème + Mesures + Graphique des temps d\'image + Fréquence d\'images avec une décimale + 0.01% les plus bas + Moteur graphique + Nom du GPU + Charge du GPU + Température du GPU + Charge du CPU + CPU par cœur + Températures + Mémoire + Mémoire graphique + Mémoire d\'échange + Niveau de batterie + Consommation + Réseau + Résolution + Version de Proton + Durée de session + Horloge + Températures + Unité + Couleurs d\'alerte + Les températures passent à l\'ambre, puis au rouge, à l\'approche de la limite. + Désactivé + Automatique + Manuel + CPU en rouge à + GPU en rouge à + Batterie en rouge à + Plus + Diagnostic du HUD + Enregistre dans Downloads/DroidDeck un rapport sur chaque capteur lu par le HUD. + Enregistrer + Enregistré dans %1$s + Impossible d\'enregistrer le rapport du HUD. + Réinitialiser les paramètres du HUD + Réinitialiser + FusionHUD par The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Mode de mise à l\'échelle Netteté de la mise à l\'échelle Effets d\'écran @@ -649,6 +707,8 @@ La manette émet à 1000 Hz au lieu de 250 Hz. Un peu plus de CPU. Tactile à faible latence Les touchers ignorent le regroupement d\'images ; les commandes tactiles Steam passent à 1000 Hz. Un peu plus de CPU. + Canal souris brut + Curseur Clavier PC Android Deuxième écran @@ -774,6 +834,17 @@ Plein écran Masquer la barre d\'état et la barre de navigation Android Afficher la barre d\'état et la barre de navigation Android + Double écran + Automatique utilise le second écran sur les consoles portables qui en ont un intégré. Activé utilise n\'importe quel second écran. Désactivé garde tout sur un seul écran. + Automatique + Activé + Désactivé + Menu sur l\'écran du bas + Déplace les sections et le menu en jeu sur le second écran et la saisie s\'y fait, pour que l\'écran du haut garde toute la vue. L2 et R2 changent de section. + Commandes sur l\'écran du bas en jeu + Ouvre le pavé tactile et le clavier sur le second écran au démarrage d\'une session. + Saisie pour l\'écran du haut + Terminé Application d\'écran d\'accueil par défaut Choisir l\'application d\'écran d\'accueil Choisir @@ -1062,7 +1133,6 @@ Jamais lancé Environnement d\'exécution occupé · %1$d%% Environnement d\'exécution occupé - Installation de l\'environnement d\'exécution lors du premier lancement du jeu Mise à jour de l\'environnement d\'exécution disponible ● Environnement d\'exécution prêt Stockage interne @@ -3119,6 +3189,8 @@ Dans la Bibliothèque Afficher Collections + Mises à jour disponibles : %1$d + Le développeur a remplacé cette version par %1$s. Mettre à jour vers celle-ci ? Cloud Paramètres cloud Rechercher des applications @@ -3164,4 +3236,20 @@ %d applications %d applications + Restauration de vos logiciels + Réinstallation des applis que vous avez ajoutées après une mise à jour de DroidDeck. + Logiciels restaurés + Les applis que vous avez ajoutées sont de nouveau installées. + Certains logiciels n\'ont pas pu être restaurés + Consultez ~/.cache/droiddeck/apt-restore.log, puis essayez sudo apt install dans Konsole. + Connexion USB lente + %1$s est connecté en %2$s (%3$s Mbit/s, environ %4$d Mo/s), les jeux qu\'il contient se chargeront donc lentement. Utilisez un câble USB 3 de 5 Gbit/s ou plus, retournez la prise USB-C ou utilisez un disque plus rapide. + %1$s est connecté en %2$s (%3$s Mbit/s, environ %4$d Mo/s). Le port USB de cet appareil ne va pas plus vite, les jeux se chargeront donc lentement. Le stockage interne ou une carte microSD peut être plus rapide. + Ne plus afficher + Fermer + Redémarrer Steam pour ajouter vos jeux ? + Steam peut afficher comme installés ces jeux que vous possédez : %1$s. Il doit redémarrer pour les afficher. Redémarrez maintenant, ou ils seront ajoutés au prochain démarrage de Steam. + %1$s et %2$d de plus + Redémarrer maintenant + Plus tard diff --git a/app/src/main/res/values-hi/gestures.xml b/app/src/main/res/values-hi/gestures.xml index 12bdd6381..5eea6440b 100644 --- a/app/src/main/res/values-hi/gestures.xml +++ b/app/src/main/res/values-hi/gestures.xml @@ -2,7 +2,6 @@ जेस्चर टच जेस्चर सेटिंग - माउस और कीबोर्ड जेस्चर बदलें। इस्तेमाल करने के लिए टच मोड में जेस्चर चुनें। टैप एक उँगली से खींचना देर तक दबाना @@ -15,6 +14,8 @@ तीन उँगलियों से दबाए रखना पिंच करके ज़ूम करें बटन दबाए रखें + बायाँ बटन दबाए रखें + दायाँ बटन दबाए रखें एक बार क्लिक करें दबाए रखने का विलंब दो टैप के बीच का अंतराल diff --git a/app/src/main/res/values-hi/shortcuts.xml b/app/src/main/res/values-hi/shortcuts.xml new file mode 100644 index 000000000..70b2dcb36 --- /dev/null +++ b/app/src/main/res/values-hi/shortcuts.xml @@ -0,0 +1,13 @@ + + + भौतिक बटन शॉर्टकट + DroidDeck मेनू + असाइन करें: %1$s + कोई बटन दबाकर छोड़ें। असाइनमेंट अपने आप सहेजा जाता है। + जारी रखने के लिए बटन छोड़ें। + %1$s: %2$s + यह बटन पहले से असाइन है: %1$s + फिर से असाइन करें + दूसरा बटन आज़माएँ + होम और कुछ अतिरिक्त बटन उपलब्ध नहीं हो सकते हैं। + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ea458fa23..412b5b581 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -607,6 +607,64 @@ एम्बर वायलेट ओपैसिटी + आकार + HUD पर टैप करने से भी उसका आकार बदलता है। + फ़ुल + टाइल्स + पिल + मिनिमल + मेगा + जगह पर लॉक करें + HUD को खिसकाने के लिए उसे ड्रैग करें; देर तक दबाने से वह लॉक या अनलॉक होता है। लॉक रहने पर भी उसके नीचे के ऑन-स्क्रीन कंट्रोल काम करते रहते हैं। + लुक + स्क्रीन पर आकार + हल्का + मध्यम + चटख + आउटलाइन + आउटलाइन का रंग + ग्रे + थीम का रंग + रीडिंग + फ़्रेम टाइम ग्राफ़ + दशमलव सहित फ़्रेम दर + 0.01% लो + ग्राफ़िक्स इंजन + GPU का नाम + GPU लोड + GPU तापमान + CPU लोड + प्रति कोर CPU + तापमान + मेमोरी + ग्राफ़िक्स मेमोरी + स्वैप + बैटरी लेवल + पावर खपत + नेटवर्क + रिज़ॉल्यूशन + Proton वर्शन + सेशन का समय + घड़ी + तापमान + इकाई + चेतावनी रंग + सीमा के पास पहुंचने पर तापमान पहले एम्बर, फिर लाल हो जाता है। + बंद + ऑटोमैटिक + मैनुअल + CPU लाल सीमा + GPU लाल सीमा + बैटरी लाल सीमा + ज़्यादा + HUD डायग्नोस्टिक्स + HUD जिन सेंसर को पढ़ता है, उन सबकी रिपोर्ट Downloads/DroidDeck में सेव करता है। + सेव करें + %1$s में सहेजा गया + HUD रिपोर्ट सेव नहीं हो सकी। + HUD सेटिंग रीसेट करें + रीसेट करें + The412Banner का FusionHUD, github.com/The412Banner/FusionHUD (GPL-3.0)। स्केलिंग मोड स्केलिंग शार्पनेस स्क्रीन इफ़ेक्ट @@ -645,6 +703,8 @@ कंट्रोलर 250 Hz के बजाय 1000 Hz पर रिपोर्ट करता है। थोड़ा ज़्यादा CPU। कम लेटेंसी टच टच फ़्रेम बैचिंग छोड़ देते हैं, इसलिए Steam का टच कंट्रोल 1000 Hz पर चलता है। थोड़ा ज़्यादा CPU। + रॉ माउस चैनल + कर्सर पीसी कीबोर्ड Android दूसरी स्क्रीन @@ -769,6 +829,17 @@ फ़ुलस्क्रीन Android स्टेटस और नेविगेशन बार छिपाता है Android स्टेटस और नेविगेशन बार दिखाता है + डुअल स्क्रीन + ऑटोमैटिक, बिल्ट-इन दूसरी स्क्रीन वाले हैंडहेल्ड पर दूसरी स्क्रीन इस्तेमाल करता है। चालू किसी भी दूसरे डिस्प्ले का इस्तेमाल करता है। बंद सब कुछ एक स्क्रीन पर रखता है। + ऑटोमैटिक + चालू + बंद + नीचे की स्क्रीन पर मेनू + सेक्शन और गेम के दौरान का मेनू दूसरी स्क्रीन पर ले जाता है और टाइपिंग वहीं होती है, ताकि ऊपर की स्क्रीन पूरा दृश्य दिखाए। L2 और R2 सेक्शन बदलते हैं। + गेम में नीचे की स्क्रीन के कंट्रोल + सेशन शुरू होने पर दूसरी स्क्रीन पर ट्रैकपैड और कीबोर्ड खोलता है। + ऊपर की स्क्रीन के लिए टाइपिंग + हो गया डिफ़ॉल्ट होम ऐप होम ऐप चुनें चुनें @@ -1054,7 +1125,6 @@ कभी नहीं खेला रनटाइम व्यस्त · %1$d%% रनटाइम व्यस्त - पहली बार Play पर रनटाइम इंस्टॉल होता है रनटाइम अपडेट उपलब्ध ● रनटाइम तैयार इंटरनल स्टोरेज @@ -3083,6 +3153,8 @@ लाइब्रेरी में दिखाएँ संग्रह + उपलब्ध अपडेट: %1$d + डेवलपर ने इस बिल्ड को %1$s से बदल दिया है। क्या उस पर अपडेट करें? क्लाउड क्लाउड सेटिंग्स ऐप खोजें @@ -3127,4 +3199,20 @@ %d ऐप %d ऐप + आपका सॉफ़्टवेयर वापस लाया जा रहा है + DroidDeck अपडेट के बाद आपके जोड़े गए ऐप्स फिर से इंस्टॉल किए जा रहे हैं। + सॉफ़्टवेयर वापस आ गया + आपके जोड़े गए ऐप्स फिर से इंस्टॉल हो गए हैं। + कुछ सॉफ़्टवेयर वापस नहीं लाया जा सका + ~/.cache/droiddeck/apt-restore.log देखें, फिर Konsole में sudo apt install आज़माएँ। + धीमा USB कनेक्शन + %1$s %2$s (%3$s Mbps, लगभग %4$d MB/s) से जुड़ा है, इसलिए इस पर गेम धीरे लोड होंगे। 5 Gbps या तेज़ USB 3 केबल इस्तेमाल करें, USB-C प्लग पलटकर देखें, या तेज़ ड्राइव इस्तेमाल करें। + %1$s %2$s (%3$s Mbps, लगभग %4$d MB/s) से जुड़ा है। इस डिवाइस का USB पोर्ट इससे तेज़ नहीं चलता, इसलिए गेम धीरे लोड होंगे। आंतरिक स्टोरेज या microSD कार्ड तेज़ हो सकता है। + फिर से न दिखाएँ + बंद करें + अपने गेम जोड़ने के लिए Steam फिर से शुरू करें? + Steam आपके इन गेम को इंस्टॉल के रूप में दिखा सकता है: %1$s. इन्हें दिखाने के लिए इसे फिर से शुरू करना होगा। अभी फिर से शुरू करें, या ये अगली बार Steam शुरू होने पर जुड़ जाएंगे। + %1$s और %2$d और + अभी फिर से शुरू करें + बाद में diff --git a/app/src/main/res/values-hu/gestures.xml b/app/src/main/res/values-hu/gestures.xml index e5279c2e2..946b4351a 100644 --- a/app/src/main/res/values-hu/gestures.xml +++ b/app/src/main/res/values-hu/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Nyomva tartás három ujjal Csippentéses nagyítás Gomb nyomva tartása + Bal gomb nyomva tartása + Jobb gomb nyomva tartása Egyszeri kattintás Nyomva tartás késleltetése Dupla koppintás időköze diff --git a/app/src/main/res/values-hu/shortcuts.xml b/app/src/main/res/values-hu/shortcuts.xml new file mode 100644 index 000000000..8f8ee108f --- /dev/null +++ b/app/src/main/res/values-hu/shortcuts.xml @@ -0,0 +1,13 @@ + + + Fizikai gombok parancsikonjai + DroidDeck-menü + Hozzárendelés: %1$s + Nyomj meg és engedj fel egy gombot. A hozzárendelés automatikusan mentődik. + A folytatáshoz engedje fel a gombot. + %1$s: %2$s + Ez a gomb már ehhez van rendelve: %1$s + Új hozzárendelés + Másik gomb kipróbálása + A Kezdőlap és egyes extra gombok nem feltétlenül érhetők el. + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 8d8f2774d..4b6a264a3 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -607,6 +607,64 @@ Borostyán Lila Átlátszatlanság + Méret + A HUD-ra koppintva is változik a mérete. + Teljes + Csempék + Kapszula + Minimális + Mega + Rögzítés a helyén + Húzd a HUD-ot az áthelyezéshez; hosszú nyomással rögzítheted vagy feloldhatod. Rögzítve is működnek az alatta lévő képernyős vezérlők. + Megjelenés + Méret a képernyőn + Lágy + Közepes + Élénk + Körvonal + Körvonal színe + Szürke + Témaszín + Mért értékek + Képkockaidő-grafikon + Képkockasebesség tizedesjeggyel + 0.01% legalacsonyabb + Grafikus motor + GPU neve + GPU-terhelés + GPU-hőmérséklet + CPU-terhelés + CPU magonként + Hőmérsékletek + Memória + Grafikus memória + Swap-memória + Akkuszint + Energiafelvétel + Hálózat + Felbontás + Proton-verzió + Munkamenet ideje + Óra + Hőmérsékletek + Mértékegység + Figyelmeztető színek + A hőmérsékletek a határhoz közeledve előbb borostyánszínűre, majd pirosra váltanak. + Ki + Automatikus + Manuális + CPU piros határa + GPU piros határa + Akku piros határa + Több + HUD-diagnosztika + Jelentést ment a HUD által olvasott összes érzékelőről a Downloads/DroidDeck mappába. + Mentés + Mentve: %1$s + Nem sikerült menteni a HUD-jelentést. + HUD-beállítások visszaállítása + Visszaállítás + FusionHUD, készítette: The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Méretezési mód Méretezési élesség Képernyőeffektek @@ -645,6 +703,8 @@ A kontroller 250 Hz helyett 1000 Hz-en jelent. Kicsit több CPU. Alacsony késleltetésű érintés Az érintések kihagyják a képkockák kötegelését, így a Steam érintésvezérlése 1000 Hz-en fut. Kicsit több CPU. + Nyers egércsatorna + Kurzor PC-billentyűzet Android Második képernyő @@ -769,6 +829,17 @@ Teljes képernyő Elrejti az Android állapot- és navigációs sávokat Megjeleníti az Android állapot- és navigációs sávokat + Két képernyő + Az Automatikus a beépített második képernyővel rendelkező kézikonzolokon használja a második képernyőt. A Be bármilyen második kijelzőt használ. A Ki mindent egy képernyőn tart. + Automatikus + Be + Ki + Menü az alsó képernyőn + A szakaszokat és a játék közbeni menüt a második képernyőre helyezi, és ott történik a gépelés, így a felső képernyő teljes nézetet kap. Az L2 és R2 vált szakaszt. + Alsó képernyős vezérlők játékokban + Munkamenet indításakor megnyitja az érintőpadot és a billentyűzetet a második képernyőn. + Gépelés a felső képernyőre + Kész Alapértelmezett kezdőképernyő alkalmazás Válasszon kezdőképernyő alkalmazást Kiválasztás @@ -1054,7 +1125,6 @@ Még nem játszott Futtatókörnyezet dolgozik · %1$d%% Futtatókörnyezet dolgozik - A futtatókörnyezet az első Játék gombnyomáskor települ Elérhető futtatókörnyezet-frissítés ● A futtatókörnyezet kész Belső tárhely @@ -3083,6 +3153,8 @@ A könyvtárban Megjelenítés Gyűjtemények + Elérhető frissítések: %1$d + A fejlesztő lecserélte ezt a buildet erre: %1$s. Frissít rá? Felhő Felhőbeállítások Alkalmazások keresése @@ -3127,4 +3199,20 @@ %d alkalmazás %d alkalmazás + Szoftverek visszaállítása + A hozzáadott alkalmazások újratelepítése egy DroidDeck-frissítés után. + Szoftverek visszaállítva + A hozzáadott alkalmazások újra telepítve vannak. + Néhány szoftvert nem sikerült visszaállítani + Nézd meg a ~/.cache/droiddeck/apt-restore.log fájlt, majd próbáld a sudo apt install parancsot a Konsole-ban. + Lassú USB-kapcsolat + A(z) %1$s %2$s kapcsolaton csatlakozik (%3$s Mbps, kb. %4$d MB/s), ezért a rajta lévő játékok lassan töltődnek be. Használj legalább 5 Gbps-os USB 3 kábelt, fordítsd meg az USB-C csatlakozót, vagy használj gyorsabb meghajtót. + A(z) %1$s %2$s kapcsolaton csatlakozik (%3$s Mbps, kb. %4$d MB/s). Az eszköz USB-portja nem tud gyorsabb lenni, ezért a játékok lassan töltődnek be. A belső tárhely vagy egy microSD-kártya gyorsabb lehet. + Ne jelenjen meg többé + Bezárás + Újraindítod a Steamet a játékaid hozzáadásához? + A Steam telepítettként mutathatja ezeket a saját játékaidat: %1$s. Ehhez újra kell indulnia. Indítsd újra most, vagy a Steam következő indításakor kerülnek be. + %1$s és még %2$d + Újraindítás most + Később diff --git a/app/src/main/res/values-in/gestures.xml b/app/src/main/res/values-in/gestures.xml index 3bf4b33e5..923583c75 100644 --- a/app/src/main/res/values-in/gestures.xml +++ b/app/src/main/res/values-in/gestures.xml @@ -2,7 +2,6 @@ Gestur Setelan gestur layar sentuh - Sesuaikan gestur mouse dan keyboard. Pilih Gestur sebagai mode sentuh untuk menggunakannya. Ketuk Seret dengan satu jari Tekan lama @@ -15,6 +14,8 @@ Tahan dengan tiga jari Cubit untuk memperbesar Tetap tekan tombol + Tetap tekan tombol kiri + Tetap tekan tombol kanan Klik sekali Jeda sebelum menahan Interval ketuk dua kali diff --git a/app/src/main/res/values-in/shortcuts.xml b/app/src/main/res/values-in/shortcuts.xml new file mode 100644 index 000000000..b3e99c4de --- /dev/null +++ b/app/src/main/res/values-in/shortcuts.xml @@ -0,0 +1,13 @@ + + + Pintasan tombol fisik + Menu DroidDeck + Tetapkan: %1$s + Tekan lalu lepaskan tombol. Penetapan disimpan otomatis. + Lepaskan tombol untuk melanjutkan. + %1$s: %2$s + Tombol ini sudah ditetapkan untuk: %1$s + Tetapkan ulang + Coba tombol lain + Tombol Beranda dan beberapa tombol tambahan mungkin tidak tersedia. + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index c0c4fa21f..68d019a23 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -605,6 +605,64 @@ Ambar Ungu Opasitas + Ukuran + Mengetuk HUD juga mengubah ukurannya. + Penuh + Ubin + Pil + Minimal + Mega + Kunci posisi + Seret HUD untuk memindahkannya; tekan lama untuk mengunci atau membuka kuncinya. Saat terkunci, kontrol layar di bawahnya tetap berfungsi. + Tampilan + Ukuran di layar + Lembut + Sedang + Cerah + Garis tepi + Warna garis tepi + Abu-abu + Warna tema + Pembacaan + Grafik waktu bingkai + Laju bingkai dengan desimal + 0.01% terendah + Mesin grafis + Nama GPU + Beban GPU + Suhu GPU + Beban CPU + CPU per inti + Suhu + Memori + Memori grafis + Memori swap + Level baterai + Konsumsi daya + Jaringan + Resolusi + Versi Proton + Durasi sesi + Jam + Suhu + Satuan + Warna peringatan + Suhu berubah menjadi ambar, lalu merah, saat mendekati batas. + Nonaktif + Otomatis + Manual + CPU merah pada + GPU merah pada + Baterai merah pada + Lainnya + Diagnostik HUD + Menyimpan laporan semua sensor yang dibaca HUD ke Downloads/DroidDeck. + Simpan + Disimpan di %1$s + Tidak dapat menyimpan laporan HUD. + Setel ulang setelan HUD + Setel ulang + FusionHUD oleh The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Mode penskalaan Ketajaman penskalaan Efek layar @@ -643,6 +701,8 @@ Pengontrol melapor pada 1000 Hz, bukan 250 Hz. Sedikit lebih banyak CPU. Sentuh latensi rendah Sentuhan melewati pengelompokan frame, jadi kontrol sentuh Steam berjalan pada 1000 Hz. Sedikit lebih banyak CPU. + Kanal mouse mentah + Kursor Keyboard PC Android Layar kedua @@ -766,6 +826,17 @@ Layar penuh Menyembunyikan bilah status dan navigasi Android Menampilkan bilah status dan navigasi Android + Layar ganda + Otomatis memakai layar kedua pada perangkat genggam yang memiliki layar bawaan. Aktif memakai layar kedua apa pun. Nonaktif membuat semuanya tetap di satu layar. + Otomatis + Aktif + Nonaktif + Menu layar bawah + Memindahkan bagian dan menu dalam game ke layar kedua dan mengetik di sana, agar layar atas tetap menampilkan tampilan penuh. L2 dan R2 berpindah bagian. + Kontrol layar bawah dalam game + Membuka trackpad dan keyboard di layar kedua saat sesi dimulai. + Mengetik untuk layar atas + Selesai Aplikasi Utama default Pilih aplikasi Utama Pilih @@ -1048,7 +1119,6 @@ Belum pernah dimainkan Runtime sibuk · %1$d%% Runtime sibuk - Runtime terinstal saat Main pertama kali Pembaruan runtime tersedia ● Runtime siap Penyimpanan internal @@ -3073,6 +3143,8 @@ Di Pustaka Tampilkan Koleksi + Pembaruan tersedia: %1$d + Pengembang mengganti build ini dengan %1$s. Perbarui ke build itu? Cloud Pengaturan cloud Cari aplikasi @@ -3116,4 +3188,20 @@ %d aplikasi + Memulihkan perangkat lunak Anda + Menginstal ulang aplikasi yang Anda tambahkan setelah pembaruan DroidDeck. + Perangkat lunak dipulihkan + Aplikasi yang Anda tambahkan sudah terinstal lagi. + Beberapa perangkat lunak tidak dapat dipulihkan + Lihat ~/.cache/droiddeck/apt-restore.log, lalu coba sudo apt install di Konsole. + Koneksi USB lambat + %1$s terhubung melalui %2$s (%3$s Mbps, sekitar %4$d MB/s), jadi game di dalamnya akan dimuat lambat. Gunakan kabel USB 3 berkecepatan 5 Gbps atau lebih, coba balik colokan USB-C, atau gunakan drive yang lebih cepat. + %1$s terhubung melalui %2$s (%3$s Mbps, sekitar %4$d MB/s). Port USB perangkat ini tidak bisa lebih cepat, jadi game akan dimuat lambat. Penyimpanan internal atau kartu microSD mungkin lebih cepat. + Jangan tampilkan lagi + Tutup + Mulai ulang Steam untuk menambahkan game Anda? + Steam dapat menampilkan game milik Anda ini sebagai terpasang: %1$s. Steam harus dimulai ulang untuk menampilkannya. Mulai ulang sekarang, atau game akan ditambahkan saat Steam dimulai berikutnya. + %1$s dan %2$d lainnya + Mulai ulang sekarang + Nanti diff --git a/app/src/main/res/values-it/gestures.xml b/app/src/main/res/values-it/gestures.xml index b9a3147f4..b3a530b23 100644 --- a/app/src/main/res/values-it/gestures.xml +++ b/app/src/main/res/values-it/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Pressione con tre dita Pizzica per ingrandire Tieni premuto il pulsante + Tieni premuto il pulsante sinistro + Tieni premuto il pulsante destro Clic singolo Ritardo della pressione Intervallo del doppio tocco diff --git a/app/src/main/res/values-it/shortcuts.xml b/app/src/main/res/values-it/shortcuts.xml new file mode 100644 index 000000000..7b77d0e12 --- /dev/null +++ b/app/src/main/res/values-it/shortcuts.xml @@ -0,0 +1,13 @@ + + + Scorciatoie dei pulsanti fisici + Menu DroidDeck + Assegna: %1$s + Premi e rilascia un pulsante. L’assegnazione viene salvata automaticamente. + Rilascia il pulsante per continuare. + %1$s: %2$s + Questo pulsante è già assegnato a: %1$s + Riassegna + Prova un altro pulsante + Il pulsante Home e alcuni pulsanti aggiuntivi potrebbero non essere disponibili. + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c20bdc151..65574c352 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -609,6 +609,64 @@ Ambra Viola Opacità + Dimensione + Toccare l\'HUD ne cambia anche la dimensione. + Completo + Riquadri + Pillola + Minimo + Mega + Blocca in posizione + Trascina l\'HUD per spostarlo; una pressione prolungata lo blocca o lo sblocca. Mentre è bloccato, i controlli su schermo sotto di esso continuano a funzionare. + Aspetto + Dimensione sullo schermo + Tenue + Medio + Vivace + Contorno + Colore contorno + Grigio + Colore del tema + Valori + Grafico tempi fotogramma + Frequenza fotogrammi con decimale + 0.01% più basso + Motore grafico + Nome GPU + Carico GPU + Temperatura GPU + Carico CPU + CPU per core + Temperature + Memoria + Memoria grafica + Memoria swap + Livello batteria + Consumo energetico + Rete + Risoluzione + Versione Proton + Durata sessione + Orologio + Temperature + Unità + Colori di avviso + Le temperature diventano ambra, poi rosse, man mano che si avvicinano al limite. + Disattivato + Automatico + Manuale + CPU in rosso da + GPU in rosso da + Batteria in rosso da + Altro + Diagnostica HUD + Salva in Downloads/DroidDeck un rapporto su ogni sensore letto dall\'HUD. + Salva + Salvato in %1$s + Impossibile salvare il rapporto dell\'HUD. + Ripristina impostazioni HUD + Ripristina + FusionHUD di The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Modalità di ridimensionamento Nitidezza del ridimensionamento Effetti schermo @@ -647,6 +705,8 @@ Il controller invia dati a 1000 Hz invece di 250 Hz. Un po\' più di CPU. Touch a bassa latenza I tocchi saltano il raggruppamento dei frame; i controlli touch di Steam passano a 1000 Hz. Un po\' più di CPU. + Canale mouse grezzo + Cursore Tastiera PC Android Secondo schermo @@ -764,6 +824,17 @@ Schermo intero Nasconde la barra di stato e la barra di navigazione di Android Mostra la barra di stato e la barra di navigazione di Android + Doppio schermo + Automatico usa il secondo schermo sulle console portatili che ne hanno uno integrato. Attivo usa qualsiasi secondo schermo. Disattivo tiene tutto su un solo schermo. + Automatico + Attivo + Disattivo + Menu sullo schermo inferiore + Sposta le sezioni e il menu di gioco sul secondo schermo e si digita lì, così lo schermo superiore mantiene la vista completa. L2 e R2 cambiano sezione. + Controlli sullo schermo inferiore nei giochi + Apre il trackpad e la tastiera sul secondo schermo all\'avvio di una sessione. + Digitazione per lo schermo superiore + Fine App Home predefinita Scegli un\'app Home Scegli @@ -1052,7 +1123,6 @@ Mai giocato Runtime occupato · %1$d%% Runtime occupato - Il runtime si installa al primo Gioca Aggiornamento del runtime disponibile ● Runtime pronto Memoria interna @@ -3117,6 +3187,8 @@ Nella Libreria Mostra Raccolte + Aggiornamenti disponibili: %1$d + Lo sviluppatore ha sostituito questa build con %1$s. Aggiornare a quella? Cloud Impostazioni cloud Cerca app @@ -3162,4 +3234,20 @@ %d app %d app + Ripristino del software + Reinstallazione delle app che hai aggiunto dopo un aggiornamento di DroidDeck. + Software ripristinato + Le app che hai aggiunto sono di nuovo installate. + Non è stato possibile ripristinare alcuni software + Controlla ~/.cache/droiddeck/apt-restore.log, poi prova sudo apt install in Konsole. + Connessione USB lenta + %1$s è collegato tramite %2$s (%3$s Mbps, circa %4$d MB/s), quindi i giochi si caricheranno lentamente. Usa un cavo USB 3 da 5 Gbps o più, prova a girare il connettore USB-C o usa un\'unità più veloce. + %1$s è collegato tramite %2$s (%3$s Mbps, circa %4$d MB/s). La porta USB di questo dispositivo non va più veloce, quindi i giochi si caricheranno lentamente. La memoria interna o una microSD potrebbero essere più veloci. + Non mostrare più + Chiudi + Riavviare Steam per aggiungere i tuoi giochi? + Steam può mostrare come installati questi giochi che possiedi: %1$s. Deve riavviarsi per mostrarli. Riavvia ora, oppure verranno aggiunti al prossimo avvio di Steam. + %1$s e altri %2$d + Riavvia ora + Più tardi diff --git a/app/src/main/res/values-ja/gestures.xml b/app/src/main/res/values-ja/gestures.xml index 961a4ec21..eca0f4910 100644 --- a/app/src/main/res/values-ja/gestures.xml +++ b/app/src/main/res/values-ja/gestures.xml @@ -2,7 +2,6 @@ ジェスチャー タッチジェスチャーの設定 - マウスとキーボードのジェスチャーを設定します。使用するにはタッチモードで「ジェスチャー」を選択してください。 タップ 1本指でドラッグ 長押し @@ -15,6 +14,8 @@ 3本指で長押し ピンチで拡大・縮小 ボタンを押したままにする + 左ボタンを押したままにする + 右ボタンを押したままにする 1回クリック 長押しの待ち時間 ダブルタップの間隔 diff --git a/app/src/main/res/values-ja/shortcuts.xml b/app/src/main/res/values-ja/shortcuts.xml new file mode 100644 index 000000000..6890b83d1 --- /dev/null +++ b/app/src/main/res/values-ja/shortcuts.xml @@ -0,0 +1,13 @@ + + + 物理ボタンのショートカット + DroidDeck メニュー + 割り当て:%1$s + ボタンを押して離してください。割り当ては自動的に保存されます。 + ボタンを離して続行してください。 + %1$s:%2$s + このボタンはすでに次の操作に割り当てられています:%1$s + 再割り当て + 別のボタンを試す + ホームボタンや一部の追加ボタンは使用できない場合があります。 + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 87a910cae..188ccd545 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -607,6 +607,64 @@ アンバー バイオレット 不透明度 + サイズ + HUDをタップしてもサイズを切り替えられます。 + フル + タイル + カプセル + ミニマル + メガ + 位置を固定 + HUDはドラッグで移動できます。長押しで固定または解除します。固定中も、下にある画面上のコントロールは使えます。 + 外観 + 画面上のサイズ + ソフト + 標準 + ビビッド + アウトライン + アウトラインの色 + グレー + テーマカラー + 表示項目 + フレームタイムグラフ + フレームレートの小数表示 + 0.01%最低値 + グラフィックスエンジン + GPU名 + GPU使用率 + GPU温度 + CPU使用率 + コアごとのCPU + 温度 + メモリ + グラフィックスメモリ + スワップ + バッテリー残量 + 消費電力 + ネットワーク + 解像度 + Protonバージョン + セッション時間 + 時計 + 温度 + 単位 + 警告色 + 温度が上限に近づくと、アンバー、次に赤で表示されます。 + オフ + 自動 + 手動 + CPUを赤にする温度 + GPUを赤にする温度 + バッテリーを赤にする温度 + その他 + HUD診断 + HUDが読み取るすべてのセンサーのレポートをDownloads/DroidDeckに保存します。 + 保存 + %1$s に保存しました + HUDレポートを保存できませんでした。 + HUD設定をリセット + リセット + FusionHUD 作者:The412Banner、github.com/The412Banner/FusionHUD (GPL-3.0)。 スケーリングモード スケーリングのシャープネス 画面エフェクト @@ -645,6 +703,8 @@ コントローラーが 250 Hz ではなく 1000 Hz で報告されます。CPU 使用率がわずかに上がります。 低遅延タッチ タッチはフレームの結合を省略し、Steam のタッチは 1000 Hz で報告されます。CPU 使用率がわずかに上がります。 + 生のマウス経路 + カーソル PCキーボード Android セカンドスクリーン @@ -768,6 +828,17 @@ 全画面 Androidのステータスバーとナビゲーションバーを非表示 Androidのステータスバーとナビゲーションバーを表示 + デュアル画面 + 自動では、2画面目を内蔵した携帯機でその画面を使います。オンではどの2画面目でも使います。オフではすべてを1画面に表示します。 + 自動 + オン + オフ + 下画面メニュー + セクションとゲーム中のメニューを2画面目に移し、入力もそこで行うため、上画面は全体表示のままになります。L2とR2でセクションを切り替えます。 + ゲーム中の下画面コントロール + セッション開始時に2画面目にトラックパッドとキーボードを開きます。 + 上画面に入力中 + 完了 デフォルトのホームアプリ ホームアプリを選択 選択 @@ -1050,7 +1121,6 @@ 未プレイ 実行環境は使用中 · %1$d%% 実行環境は使用中 - 初回実行時に実行環境を導入 実行環境にアップデートがあります ● 実行環境の準備完了 内部ストレージ @@ -3060,6 +3130,8 @@ ライブラリにあります 表示 コレクション + 利用可能なアップデート: %1$d + 開発者がこのビルドを %1$s に置き換えました。更新しますか? クラウド クラウド設定 アプリを検索 @@ -3103,4 +3175,20 @@ %d 個のアプリ + ソフトウェアを復元しています + DroidDeck の更新後、追加したアプリを再インストールしています。 + ソフトウェアを復元しました + 追加したアプリが再びインストールされました。 + 一部のソフトウェアを復元できませんでした + ~/.cache/droiddeck/apt-restore.log を確認してから、Konsole で sudo apt install を試してください。 + USB 接続が低速です + %1$s は %2$s(%3$s Mbps、約 %4$d MB/s)で接続されているため、ゲームの読み込みが遅くなります。5 Gbps 以上の USB 3 ケーブルを使うか、USB-C プラグを裏返すか、より高速なドライブを使ってください。 + %1$s は %2$s(%3$s Mbps、約 %4$d MB/s)で接続されています。この端末の USB ポートはこれ以上速くならないため、ゲームの読み込みが遅くなります。内部ストレージや microSD カードの方が速い場合があります。 + 今後表示しない + 閉じる + ゲームを追加するために Steam を再起動しますか? + 所有している次のゲームを Steam でインストール済みとして表示できます: %1$s。表示するには Steam の再起動が必要です。今すぐ再起動するか、次に Steam を起動したときに追加されます。 + %1$s ほか %2$d 件 + 今すぐ再起動 + 後で diff --git a/app/src/main/res/values-ko/gestures.xml b/app/src/main/res/values-ko/gestures.xml index 9f32380c4..adae0128d 100644 --- a/app/src/main/res/values-ko/gestures.xml +++ b/app/src/main/res/values-ko/gestures.xml @@ -2,7 +2,6 @@ 제스처 터치 제스처 설정 - 마우스와 키보드 제스처를 맞춤 설정합니다. 사용하려면 터치 모드에서 제스처를 선택하세요. 탭 한 손가락 드래그 길게 누르기 @@ -15,6 +14,8 @@ 세 손가락 길게 누르기 손가락을 모으거나 벌려 확대/축소 버튼을 누른 상태로 유지 + 왼쪽 버튼을 누른 상태로 유지 + 오른쪽 버튼을 누른 상태로 유지 한 번 클릭 길게 누르기 대기 시간 두 번 탭 간격 diff --git a/app/src/main/res/values-ko/shortcuts.xml b/app/src/main/res/values-ko/shortcuts.xml new file mode 100644 index 000000000..dd0457f4a --- /dev/null +++ b/app/src/main/res/values-ko/shortcuts.xml @@ -0,0 +1,13 @@ + + + 물리 버튼 바로가기 + DroidDeck 메뉴 + 지정: %1$s + 버튼을 눌렀다 놓으세요. 할당은 자동으로 저장됩니다. + 계속하려면 버튼을 놓으세요. + %1$s: %2$s + 이 버튼은 이미 다음 동작에 지정되어 있습니다: %1$s + 다시 할당 + 다른 버튼 시도 + 홈 버튼과 일부 추가 버튼은 사용할 수 없을 수 있습니다. + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 10d6fd46d..2253d27af 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -607,6 +607,64 @@ 호박색 보라색 불투명도 + 크기 + HUD를 탭해도 크기가 바뀝니다. + 전체 + 타일 + 캡슐 + 미니멀 + 메가 + 위치 고정 + HUD를 드래그하여 이동하고, 길게 눌러 고정하거나 해제합니다. 고정된 동안에도 아래에 있는 화면 컨트롤은 계속 작동합니다. + 외관 + 화면상 크기 + 은은함 + 보통 + 선명함 + 외곽선 + 외곽선 색상 + 회색 + 테마 색상 + 측정값 + 프레임 타임 그래프 + 소수점 프레임 속도 + 0.01% 최저 + 그래픽 엔진 + GPU 이름 + GPU 사용률 + GPU 온도 + CPU 사용률 + 코어별 CPU + 온도 + 메모리 + 그래픽 메모리 + 스왑 + 배터리 잔량 + 소비 전력 + 네트워크 + 해상도 + Proton 버전 + 세션 시간 + 시계 + 온도 + 단위 + 경고 색상 + 온도가 한도에 가까워지면 호박색, 그다음 빨간색으로 바뀝니다. + 끔 + 자동 + 수동 + CPU 빨간색 기준 + GPU 빨간색 기준 + 배터리 빨간색 기준 + 기타 + HUD 진단 + HUD가 읽는 모든 센서의 보고서를 Downloads/DroidDeck에 저장합니다. + 저장 + %1$s에 저장됨 + HUD 보고서를 저장할 수 없습니다. + HUD 설정 초기화 + 초기화 + FusionHUD 제작: The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). 스케일링 모드 스케일링 선명도 화면 효과 @@ -645,6 +703,8 @@ 컨트롤러가 250 Hz 대신 1000 Hz로 보고됩니다. CPU 사용량이 약간 늘어납니다. 저지연 터치 터치가 프레임 병합을 건너뛰고 Steam 터치가 1000 Hz로 보고됩니다. CPU 사용량이 약간 늘어납니다. + 원시 마우스 채널 + 커서 PC 키보드 Android 보조 화면 @@ -768,6 +828,17 @@ 전체 화면 Android 상태 표시줄 및 탐색 표시줄 숨기기 Android 상태 표시줄 및 탐색 표시줄 표시 + 듀얼 화면 + 자동은 두 번째 화면이 내장된 휴대용 기기에서 그 화면을 사용합니다. 켜기는 모든 두 번째 디스플레이를 사용합니다. 끄기는 모든 것을 한 화면에 둡니다. + 자동 + 켜기 + 끄기 + 하단 화면 메뉴 + 섹션과 게임 중 메뉴를 두 번째 화면으로 옮기고 입력도 그곳에서 하므로 상단 화면은 전체 보기를 유지합니다. L2와 R2로 섹션을 전환합니다. + 게임 중 하단 화면 컨트롤 + 세션이 시작되면 두 번째 화면에 트랙패드와 키보드를 엽니다. + 상단 화면에 입력 중 + 완료 기본 홈 앱 홈 앱 선택 선택 @@ -1050,7 +1121,6 @@ 플레이한 적 없음 런타임 사용 중 · %1$d%% 런타임 사용 중 - 처음 \"Steam 실행\" 시 런타임 설치 런타임 업데이트 있음 ● 런타임 준비됨 내부 저장 공간 @@ -3060,6 +3130,8 @@ 라이브러리에 있음 표시 컬렉션 + 사용 가능한 업데이트: %1$d + 개발자가 이 빌드를 %1$s(으)로 교체했습니다. 업데이트할까요? 클라우드 클라우드 설정 앱 검색 @@ -3103,4 +3175,20 @@ 앱 %d개 + 소프트웨어 복원 중 + DroidDeck 업데이트 후 추가한 앱을 다시 설치하고 있습니다. + 소프트웨어 복원됨 + 추가한 앱이 다시 설치되었습니다. + 일부 소프트웨어를 복원하지 못했습니다 + ~/.cache/droiddeck/apt-restore.log를 확인한 다음 Konsole에서 sudo apt install을 시도하세요. + USB 연결이 느립니다 + %1$s이(가) %2$s(%3$s Mbps, 약 %4$d MB/s)로 연결되어 게임 로딩이 느립니다. 5 Gbps 이상의 USB 3 케이블을 사용하거나, USB-C 플러그를 뒤집어 꽂거나, 더 빠른 드라이브를 사용하세요. + %1$s이(가) %2$s(%3$s Mbps, 약 %4$d MB/s)로 연결되어 있습니다. 이 기기의 USB 포트는 더 빠르게 동작하지 않아 게임 로딩이 느립니다. 내부 저장소나 microSD 카드가 더 빠를 수 있습니다. + 다시 표시 안 함 + 닫기 + 게임을 추가하려면 Steam을 다시 시작할까요? + 보유한 다음 게임을 Steam에서 설치됨으로 표시할 수 있습니다: %1$s. 표시하려면 Steam을 다시 시작해야 합니다. 지금 다시 시작하거나, 다음에 Steam을 시작할 때 추가됩니다. + %1$s 외 %2$d개 + 지금 다시 시작 + 나중에 diff --git a/app/src/main/res/values-ms/gestures.xml b/app/src/main/res/values-ms/gestures.xml index 67f5d2091..d47bfc09e 100644 --- a/app/src/main/res/values-ms/gestures.xml +++ b/app/src/main/res/values-ms/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Tahan dengan tiga jari Cubit untuk zum Kekalkan butang ditekan + Kekalkan butang kiri ditekan + Kekalkan butang kanan ditekan Klik sekali Lengah sebelum menahan Sela ketikan dua kali diff --git a/app/src/main/res/values-ms/shortcuts.xml b/app/src/main/res/values-ms/shortcuts.xml new file mode 100644 index 000000000..63942381b --- /dev/null +++ b/app/src/main/res/values-ms/shortcuts.xml @@ -0,0 +1,13 @@ + + + Pintasan butang fizikal + Menu DroidDeck + Tetapkan: %1$s + Tekan dan lepaskan butang. Penetapan disimpan secara automatik. + Lepaskan butang untuk meneruskan. + %1$s: %2$s + Butang ini sudah ditetapkan kepada: %1$s + Tukar penetapan + Cuba butang lain + Butang Utama dan sesetengah butang tambahan mungkin tidak tersedia. + diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 098f2429a..89a2bec77 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -605,6 +605,64 @@ Ambar Ungu Kelegapan + Saiz + Mengetik HUD juga menukar saiznya. + Penuh + Jubin + Pil + Minimal + Mega + Kunci di tempat + Seret HUD untuk mengalihkannya; tekan lama untuk mengunci atau membuka kuncinya. Semasa dikunci, kawalan skrin di bawahnya masih berfungsi. + Rupa + Saiz pada skrin + Lembut + Sederhana + Terang + Garis luar + Warna garis luar + Kelabu + Warna tema + Bacaan + Graf masa bingkai + Kadar bingkai dengan perpuluhan + 0.01% terendah + Enjin grafik + Nama GPU + Beban GPU + Suhu GPU + Beban CPU + CPU setiap teras + Suhu + Memori + Memori grafik + Memori swap + Tahap bateri + Penggunaan kuasa + Rangkaian + Resolusi + Versi Proton + Tempoh sesi + Jam + Suhu + Unit + Warna amaran + Suhu bertukar ambar, kemudian merah, apabila menghampiri had. + Mati + Automatik + Manual + CPU merah pada + GPU merah pada + Bateri merah pada + Lagi + Diagnostik HUD + Menyimpan laporan setiap sensor yang dibaca HUD ke Downloads/DroidDeck. + Simpan + Disimpan di %1$s + Tidak dapat menyimpan laporan HUD. + Set semula tetapan HUD + Set semula + FusionHUD oleh The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Mod penskalaan Ketajaman penskalaan Kesan skrin @@ -643,6 +701,8 @@ Pengawal melapor pada 1000 Hz dan bukan 250 Hz. Sedikit lebih banyak CPU. Sentuh kependaman rendah Sentuhan melangkau pengumpulan bingkai, jadi kawalan sentuh Steam berjalan pada 1000 Hz. Sedikit lebih banyak CPU. + Saluran tetikus mentah + Kursor Papan kekunci PC Android Skrin kedua @@ -766,6 +826,17 @@ Skrin penuh Menyembunyikan bar status dan navigasi Android Memaparkan bar status dan navigasi Android + Dwi skrin + Automatik menggunakan skrin kedua pada peranti pegang tangan yang mempunyai skrin terbina dalam. Hidup menggunakan sebarang paparan kedua. Mati mengekalkan semuanya pada satu skrin. + Automatik + Hidup + Mati + Menu skrin bawah + Memindahkan bahagian dan menu dalam permainan ke skrin kedua dan menaip di sana, supaya skrin atas kekal dengan paparan penuh. L2 dan R2 menukar bahagian. + Kawalan skrin bawah dalam permainan + Membuka pad jejak dan papan kekunci pada skrin kedua apabila sesi bermula. + Menaip untuk skrin atas + Selesai Apl Utama lalai Pilih apl Utama Pilih @@ -1048,7 +1119,6 @@ Belum pernah dimainkan Masa jalan sibuk · %1$d%% Masa jalan sibuk - Masa jalan dipasang pada Main pertama Kemas kini masa jalan tersedia ● Masa jalan sedia Storan dalaman @@ -3058,6 +3128,8 @@ Dalam Pustaka Tunjukkan Koleksi + Kemas kini tersedia: %1$d + Pembangun telah menggantikan binaan ini dengan %1$s. Kemas kini kepadanya? Awan Tetapan awan Cari apl @@ -3101,4 +3173,20 @@ %d apl + Memulihkan perisian anda + Memasang semula apl yang anda tambah selepas kemas kini DroidDeck. + Perisian dipulihkan + Apl yang anda tambah dipasang semula. + Sesetengah perisian tidak dapat dipulihkan + Lihat ~/.cache/droiddeck/apt-restore.log, kemudian cuba sudo apt install dalam Konsole. + Sambungan USB perlahan + %1$s disambungkan melalui %2$s (%3$s Mbps, kira-kira %4$d MB/s), jadi permainan padanya akan dimuatkan perlahan. Gunakan kabel USB 3 berkadar 5 Gbps atau lebih, cuba terbalikkan palam USB-C atau gunakan pemacu yang lebih pantas. + %1$s disambungkan melalui %2$s (%3$s Mbps, kira-kira %4$d MB/s). Port USB peranti ini tidak boleh lebih pantas, jadi permainan akan dimuatkan perlahan. Storan dalaman atau kad microSD mungkin lebih pantas. + Jangan tunjukkan lagi + Tutup + Mulakan semula Steam untuk menambah permainan anda? + Steam boleh menyenaraikan permainan milik anda ini sebagai dipasang: %1$s. Steam perlu dimulakan semula untuk menunjukkannya. Mulakan semula sekarang, atau ia akan ditambah apabila Steam dimulakan seterusnya. + %1$s dan %2$d lagi + Mulakan semula sekarang + Kemudian diff --git a/app/src/main/res/values-nb/gestures.xml b/app/src/main/res/values-nb/gestures.xml index 4f4143850..ee01f0e3a 100644 --- a/app/src/main/res/values-nb/gestures.xml +++ b/app/src/main/res/values-nb/gestures.xml @@ -2,7 +2,6 @@ Bevegelser Innstillinger for berøringsbevegelser - Tilpass muse- og tastaturbevegelser. Velg Bevegelser som berøringsmodus for å bruke dem. Trykk Dra med én finger Langt trykk @@ -15,6 +14,8 @@ Hold med tre fingre Knip for å zoome Hold knappen nede + Hold venstre knapp nede + Hold høyre knapp nede Klikk én gang Forsinkelse før hold Intervall for dobbelttrykk diff --git a/app/src/main/res/values-nb/shortcuts.xml b/app/src/main/res/values-nb/shortcuts.xml new file mode 100644 index 000000000..712de7c6e --- /dev/null +++ b/app/src/main/res/values-nb/shortcuts.xml @@ -0,0 +1,13 @@ + + + Snarveier for fysiske knapper + DroidDeck-meny + Tilordne: %1$s + Trykk på en knapp og slipp den. Tilordningen lagres automatisk. + Slipp knappen for å fortsette. + %1$s: %2$s + Denne knappen er allerede tilordnet: %1$s + Tilordne på nytt + Prøv en annen knapp + Hjem-knappen og enkelte ekstraknapper er kanskje ikke tilgjengelige. + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index e95a57c22..a441c8618 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -607,6 +607,64 @@ Amber Fiolett Opasitet + Størrelse + Et trykk på HUD-en endrer også størrelsen. + Full + Fliser + Pille + Minimal + Mega + Lås på plass + Dra HUD-en for å flytte den; et langt trykk låser eller låser den opp. Mens den er låst, fungerer skjermkontrollene under den fortsatt. + Utseende + Størrelse på skjermen + Myk + Middels + Kraftig + Kontur + Konturfarge + Grå + Temafarge + Måleverdier + Bildetidsgraf + Bildefrekvens med desimal + 0.01% laveste + Grafikkmotor + GPU-navn + GPU-belastning + GPU-temperatur + CPU-belastning + CPU per kjerne + Temperaturer + Minne + Grafikkminne + Swap-minne + Batterinivå + Strømforbruk + Nettverk + Oppløsning + Proton-versjon + Sesjonstid + Klokke + Temperaturer + Enhet + Varselfarger + Temperaturer blir ravgule og så røde når de nærmer seg grensen. + Av + Automatisk + Manuell + CPU rød ved + GPU rød ved + Batteri rødt ved + Mer + HUD-diagnostikk + Lagrer en rapport over alle sensorer HUD-en leser, i Downloads/DroidDeck. + Lagre + Lagret i %1$s + Kunne ikke lagre HUD-rapporten. + Tilbakestill HUD-innstillinger + Tilbakestill + FusionHUD av The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Skaleringsmodus Skaleringsskarphet Skjermeffekter @@ -645,6 +703,8 @@ Kontrolleren rapporterer ved 1000 Hz i stedet for 250 Hz. Litt mer CPU. Berøring med lav forsinkelse Berøringer hopper over bildeinnsamling; Steams berøringsstyring går ved 1000 Hz. Litt mer CPU. + Rå musekanal + Peker PC-tastatur Android Andre skjerm @@ -769,6 +829,17 @@ Fullskjerm Skjuler Androids status- og navigasjonslinjer Viser Androids status- og navigasjonslinjer + Dobbel skjerm + Automatisk bruker den andre skjermen på håndholdte enheter med innebygd skjerm. På bruker enhver annen skjerm. Av holder alt på én skjerm. + Automatisk + På + Av + Meny på nederste skjerm + Flytter seksjonene og menyen i spillet til den andre skjermen og skriver der, så den øverste skjermen beholder hele visningen. L2 og R2 bytter seksjon. + Kontroller på nederste skjerm i spill + Åpner styreflaten og tastaturet på den andre skjermen når en økt starter. + Skriver for den øverste skjermen + Ferdig Standard Hjem-app Velg en Hjem-app Velg @@ -1054,7 +1125,6 @@ Aldri spilt Kjøretid opptatt · %1$d%% Kjøretid opptatt - Kjøretiden installeres ved første Spill Kjøretidsoppdatering tilgjengelig ● Kjøretid klar Intern lagring @@ -3083,6 +3153,8 @@ I Bibliotek Vis Samlinger + Oppdateringer tilgjengelig: %1$d + Utvikleren har erstattet denne versjonen med %1$s. Oppdatere til den? Sky Skyinnstillinger Søk i apper @@ -3127,4 +3199,20 @@ %d app %d apper + Gjenoppretter programvaren din + Installerer appene du la til på nytt etter en DroidDeck-oppdatering. + Programvare gjenopprettet + Appene du la til er installert igjen. + Noe programvare kunne ikke gjenopprettes + Se ~/.cache/droiddeck/apt-restore.log, og prøv deretter sudo apt install i Konsole. + Treg USB-tilkobling + %1$s er koblet til via %2$s (%3$s Mbps, ca. %4$d MB/s), så spill på den lastes inn tregt. Bruk en USB 3-kabel for 5 Gbps eller mer, prøv å snu USB-C-pluggen, eller bruk en raskere stasjon. + %1$s er koblet til via %2$s (%3$s Mbps, ca. %4$d MB/s). USB-porten på denne enheten kan ikke gå raskere, så spill lastes inn tregt. Intern lagring eller et microSD-kort kan være raskere. + Ikke vis igjen + Lukk + Starte Steam på nytt for å legge til spillene dine? + Steam kan vise disse spillene du eier som installert: %1$s. Steam må startes på nytt for å vise dem. Start på nytt nå, eller så legges de til neste gang Steam starter. + %1$s og %2$d til + Start på nytt nå + Senere diff --git a/app/src/main/res/values-nl/gestures.xml b/app/src/main/res/values-nl/gestures.xml index a970becd5..007027c15 100644 --- a/app/src/main/res/values-nl/gestures.xml +++ b/app/src/main/res/values-nl/gestures.xml @@ -2,7 +2,6 @@ Gebaren Instellingen voor aanraakgebaren - Pas muis- en toetsenbordgebaren aan. Kies Gebaren als aanraakmodus om ze te gebruiken. Tikken Slepen met één vinger Lang indrukken @@ -15,6 +14,8 @@ Vasthouden met drie vingers Knijpen om te zoomen Knop ingedrukt houden + Linkermuisknop ingedrukt houden + Rechtermuisknop ingedrukt houden Eenmaal klikken Wachttijd voor vasthouden Interval voor dubbeltikken diff --git a/app/src/main/res/values-nl/shortcuts.xml b/app/src/main/res/values-nl/shortcuts.xml new file mode 100644 index 000000000..b76f6c6cb --- /dev/null +++ b/app/src/main/res/values-nl/shortcuts.xml @@ -0,0 +1,13 @@ + + + Snelkoppelingen voor fysieke knoppen + DroidDeck-menu + Toewijzen: %1$s + Druk op een knop en laat deze los. De toewijzing wordt automatisch opgeslagen. + Laat de knop los om door te gaan. + %1$s: %2$s + Deze knop is al toegewezen aan: %1$s + Opnieuw toewijzen + Andere knop proberen + De Home-knop en sommige extra knoppen zijn mogelijk niet beschikbaar. + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 5cd7fdf36..5b737ed61 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -607,6 +607,64 @@ Amber Violet Transparantie + Grootte + Tikken op de HUD verandert ook de grootte. + Volledig + Tegels + Pil + Minimaal + Mega + Vastzetten + Sleep de HUD om hem te verplaatsen; lang drukken zet hem vast of los. Zolang hij vastzit, blijven de schermknoppen eronder werken. + Weergave + Grootte op scherm + Zacht + Gemiddeld + Fel + Contour + Contourkleur + Grijs + Themakleur + Meetwaarden + Frametijdgrafiek + Beeldsnelheid met decimaal + 0.01% laagste + Grafische engine + GPU-naam + GPU-belasting + GPU-temperatuur + CPU-belasting + CPU per kern + Temperaturen + Geheugen + Grafisch geheugen + Swapgeheugen + Accuniveau + Stroomverbruik + Netwerk + Resolutie + Proton-versie + Sessieduur + Klok + Temperaturen + Eenheid + Waarschuwingskleuren + Temperaturen worden eerst amber en dan rood naarmate ze de limiet naderen. + Uit + Automatisch + Handmatig + CPU rood vanaf + GPU rood vanaf + Accu rood vanaf + Meer + HUD-diagnose + Slaat een rapport van elke sensor die de HUD uitleest op in Downloads/DroidDeck. + Opslaan + Opgeslagen in %1$s + Kan het HUD-rapport niet opslaan. + HUD-instellingen resetten + Resetten + FusionHUD door The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Schaalmodus Schaalscherpte Schermeffecten @@ -645,6 +703,8 @@ De controller rapporteert op 1000 Hz in plaats van 250 Hz. Iets meer CPU. Aanraking met lage latentie Aanrakingen slaan framebundeling over; Steam-aanraakbediening op 1000 Hz. Iets meer CPU. + Ruw muiskanaal + Cursor Pc-toetsenbord Android Tweede scherm @@ -769,6 +829,17 @@ Volledig scherm Verbergt de statusbalk en navigatiebalk van Android Toont de statusbalk en navigatiebalk van Android + Dubbel scherm + Automatisch gebruikt het tweede scherm op handhelds met een ingebouwd scherm. Aan gebruikt elk tweede scherm. Uit houdt alles op één scherm. + Automatisch + Aan + Uit + Menu op het onderste scherm + Verplaatst de onderdelen en het menu in het spel naar het tweede scherm en typt daar, zodat het bovenste scherm het volledige beeld houdt. L2 en R2 wisselen van onderdeel. + Bediening op het onderste scherm in games + Opent het trackpad en toetsenbord op het tweede scherm wanneer een sessie start. + Typen voor het bovenste scherm + Klaar Standaard startschermapp Kies een startschermapp Kiezen @@ -1054,7 +1125,6 @@ Nooit gespeeld Runtime bezig · %1$d%% Runtime bezig - Runtime wordt geïnstalleerd bij de eerste keer Spelen Runtime-update beschikbaar ● Runtime gereed Interne opslag @@ -3083,6 +3153,8 @@ In Bibliotheek Weergeven Collecties + Beschikbare updates: %1$d + De ontwikkelaar heeft deze build vervangen door %1$s. Bijwerken naar die build? Cloud Cloudinstellingen Apps zoeken @@ -3127,4 +3199,20 @@ %d app %d apps + Je software wordt hersteld + De apps die je hebt toegevoegd worden na een DroidDeck-update opnieuw geïnstalleerd. + Software hersteld + De apps die je hebt toegevoegd zijn weer geïnstalleerd. + Sommige software kon niet worden hersteld + Bekijk ~/.cache/droiddeck/apt-restore.log en probeer daarna sudo apt install in Konsole. + Trage USB-verbinding + %1$s is verbonden via %2$s (%3$s Mbps, ongeveer %4$d MB/s), dus games erop laden traag. Gebruik een USB 3-kabel van 5 Gbps of sneller, draai de USB-C-stekker om of gebruik een snellere schijf. + %1$s is verbonden via %2$s (%3$s Mbps, ongeveer %4$d MB/s). De USB-poort van dit apparaat kan niet sneller, dus games laden traag. Interne opslag of een microSD-kaart kan sneller zijn. + Niet meer tonen + Sluiten + Steam opnieuw starten om je games toe te voegen? + Steam kan deze games die je bezit als geïnstalleerd tonen: %1$s. Steam moet opnieuw starten om ze te tonen. Start nu opnieuw, of ze worden toegevoegd de volgende keer dat Steam start. + %1$s en nog %2$d + Nu opnieuw starten + Later diff --git a/app/src/main/res/values-pl/gestures.xml b/app/src/main/res/values-pl/gestures.xml index 8325469e7..78fbf8cf1 100644 --- a/app/src/main/res/values-pl/gestures.xml +++ b/app/src/main/res/values-pl/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Przytrzymanie trzema palcami Uszczypnij, aby przybliżyć Trzymaj przycisk wciśnięty + Trzymaj lewy przycisk wciśnięty + Trzymaj prawy przycisk wciśnięty Kliknij raz Czas przytrzymania Odstęp podwójnego stuknięcia diff --git a/app/src/main/res/values-pl/shortcuts.xml b/app/src/main/res/values-pl/shortcuts.xml new file mode 100644 index 000000000..665ec3f84 --- /dev/null +++ b/app/src/main/res/values-pl/shortcuts.xml @@ -0,0 +1,13 @@ + + + Skróty przycisków fizycznych + Menu DroidDeck + Przypisz: %1$s + Naciśnij i zwolnij przycisk. Przypisanie zostanie zapisane automatycznie. + Zwolnij przycisk, aby kontynuować. + %1$s: %2$s + Ten przycisk jest już przypisany do: %1$s + Przypisz ponownie + Wypróbuj inny przycisk + Przycisk Home i niektóre dodatkowe przyciski mogą być niedostępne. + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4e87191fe..10e95b027 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -611,6 +611,64 @@ Bursztynowy Fioletowy Przezroczystość + Rozmiar + Dotknięcie HUD również zmienia jego rozmiar. + Pełny + Kafelki + Pigułka + Minimalny + Mega + Zablokuj w miejscu + Przeciągnij HUD, aby go przesunąć; długie naciśnięcie blokuje go lub odblokowuje. Gdy jest zablokowany, sterowanie ekranowe pod nim nadal działa. + Wygląd + Rozmiar na ekranie + Stonowany + Średni + Żywy + Obrys + Kolor obrysu + Szary + Kolor motywu + Odczyty + Wykres czasu klatek + Liczba klatek z miejscem po przecinku + 0.01% najniższych + Silnik graficzny + Nazwa GPU + Obciążenie GPU + Temperatura GPU + Obciążenie CPU + CPU na rdzeń + Temperatury + Pamięć + Pamięć graficzna + Pamięć wymiany + Poziom baterii + Pobór mocy + Sieć + Rozdzielczość + Wersja Protona + Czas sesji + Zegar + Temperatury + Jednostka + Kolory ostrzeżeń + Temperatury zmieniają kolor na bursztynowy, a potem czerwony, gdy zbliżają się do limitu. + Wył. + Automatycznie + Ręcznie + CPU na czerwono od + GPU na czerwono od + Bateria na czerwono od + Więcej + Diagnostyka HUD + Zapisuje w Downloads/DroidDeck raport ze wszystkich czujników odczytywanych przez HUD. + Zapisz + Zapisano w %1$s + Nie udało się zapisać raportu HUD. + Zresetuj ustawienia HUD + Resetuj + FusionHUD autorstwa The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Tryb skalowania Ostrość skalowania Efekty ekranowe @@ -649,6 +707,8 @@ Kontroler raportuje z częstotliwością 1000 Hz zamiast 250 Hz. Nieco więcej CPU. Dotyk o niskim opóźnieniu Dotknięcia pomijają łączenie klatek; sterowanie dotykiem Steam działa z 1000 Hz. Nieco więcej CPU. + Surowy kanał myszy + Kursor Klawiatura PC Android Drugi ekran @@ -775,6 +835,17 @@ Pełny ekran Ukrywa systemowe paski stanu i nawigacji Pokazuje systemowe paski stanu i nawigacji + Dwa ekrany + Automatycznie używa drugiego ekranu w urządzeniach przenośnych, które mają go wbudowanego. Włączone używa dowolnego drugiego wyświetlacza. Wyłączone zostawia wszystko na jednym ekranie. + Automatycznie + Włączone + Wyłączone + Menu na dolnym ekranie + Przenosi sekcje i menu w grze na drugi ekran i tam odbywa się pisanie, dzięki czemu górny ekran zachowuje pełny widok. L2 i R2 przełączają sekcje. + Sterowanie na dolnym ekranie w grach + Otwiera gładzik i klawiaturę na drugim ekranie po rozpoczęciu sesji. + Pisanie dla górnego ekranu + Gotowe Domyślna aplikacja ekranu głównego Wybierz aplikację ekranu głównego Wybierz @@ -1066,7 +1137,6 @@ Nigdy nie grano Środowisko zajęte · %1$d%% Środowisko zajęte - Środowisko instaluje się przy pierwszym uruchomieniu Dostępna aktualizacja środowiska ● Środowisko gotowe Pamięć wewnętrzna @@ -3154,6 +3224,8 @@ W bibliotece Pokaż Kolekcje + Dostępne aktualizacje: %1$d + Deweloper zastąpił tę wersję plikiem %1$s. Zaktualizować do niej? Chmura Ustawienia chmury Szukaj aplikacji @@ -3200,4 +3272,20 @@ %d aplikacji %d aplikacji + Przywracanie oprogramowania + Ponowna instalacja dodanych aplikacji po aktualizacji DroidDeck. + Oprogramowanie przywrócone + Dodane aplikacje są ponownie zainstalowane. + Nie udało się przywrócić części oprogramowania + Sprawdź ~/.cache/droiddeck/apt-restore.log, a potem spróbuj sudo apt install w Konsole. + Wolne połączenie USB + %1$s jest podłączony przez %2$s (%3$s Mb/s, około %4$d MB/s), więc gry na nim będą wczytywać się wolno. Użyj kabla USB 3 o przepustowości 5 Gb/s lub większej, odwróć wtyczkę USB-C albo użyj szybszego dysku. + %1$s jest podłączony przez %2$s (%3$s Mb/s, około %4$d MB/s). Port USB tego urządzenia nie działa szybciej, więc gry będą wczytywać się wolno. Pamięć wewnętrzna lub karta microSD mogą być szybsze. + Nie pokazuj ponownie + Zamknij + Uruchomić ponownie Steam, aby dodać Twoje gry? + Steam może pokazać te posiadane gry jako zainstalowane: %1$s. Aby je pokazać, musi się uruchomić ponownie. Uruchom ponownie teraz albo zostaną dodane przy następnym uruchomieniu Steam. + %1$s i %2$d więcej + Uruchom ponownie teraz + Później diff --git a/app/src/main/res/values-pt-rPT/gestures.xml b/app/src/main/res/values-pt-rPT/gestures.xml index 2d8f3cf8e..19b89be40 100644 --- a/app/src/main/res/values-pt-rPT/gestures.xml +++ b/app/src/main/res/values-pt-rPT/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Manter premido com três dedos Pinçar para ampliar Manter o botão premido + Manter o botão esquerdo premido + Manter o botão direito premido Clicar uma vez Tempo para manter premido Intervalo de toque duplo diff --git a/app/src/main/res/values-pt-rPT/shortcuts.xml b/app/src/main/res/values-pt-rPT/shortcuts.xml new file mode 100644 index 000000000..7e9f6cf24 --- /dev/null +++ b/app/src/main/res/values-pt-rPT/shortcuts.xml @@ -0,0 +1,13 @@ + + + Atalhos de botões físicos + Menu do DroidDeck + Atribuir: %1$s + Prima e solte um botão. A atribuição é guardada automaticamente. + Solte o botão para continuar. + %1$s: %2$s + Este botão já está atribuído a: %1$s + Reatribuir + Experimentar outro botão + O botão Início e alguns botões adicionais podem não estar disponíveis. + diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index ceac1c534..0d3b71ba9 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -609,6 +609,64 @@ Âmbar Violeta Opacidade + Tamanho + Tocar no HUD também altera o tamanho. + Completo + Mosaicos + Pílula + Mínimo + Mega + Bloquear posição + Arraste o HUD para o mover; um toque longo bloqueia-o ou desbloqueia-o. Enquanto estiver bloqueado, os controlos no ecrã por baixo dele continuam a funcionar. + Aspeto + Tamanho no ecrã + Suave + Média + Viva + Contorno + Cor do contorno + Cinzento + Cor do tema + Leituras + Gráfico de tempo de fotograma + Taxa de fotogramas com decimal + 0.01% mais baixo + Motor gráfico + Nome da GPU + Utilização da GPU + Temperatura da GPU + Utilização da CPU + CPU por núcleo + Temperaturas + Memória + Memória gráfica + Memória swap + Nível da bateria + Consumo de energia + Rede + Resolução + Versão do Proton + Tempo de sessão + Relógio + Temperaturas + Unidade + Cores de aviso + As temperaturas ficam âmbar e depois vermelhas à medida que se aproximam do limite. + Desativado + Automático + Manual + CPU a vermelho a partir de + GPU a vermelho a partir de + Bateria a vermelho a partir de + Mais + Diagnóstico do HUD + Guarda em Downloads/DroidDeck um relatório de cada sensor que o HUD lê. + Guardar + Guardado em %1$s + Não foi possível guardar o relatório do HUD. + Repor definições do HUD + Repor + FusionHUD por The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Modo de escala Nitidez da escala Efeitos de ecrã @@ -647,6 +705,8 @@ O comando comunica a 1000 Hz em vez de 250 Hz. Um pouco mais de CPU. Toque de baixa latência Os toques ignoram o agrupamento de fotogramas; os controlos táteis do Steam passam a 1000 Hz. Um pouco mais de CPU. + Canal de rato em bruto + Cursor Teclado de PC Android Segundo ecrã @@ -764,6 +824,17 @@ Ecrã inteiro Oculta as barras de estado e de navegação do Android Mostra as barras de estado e de navegação do Android + Ecrã duplo + Automático usa o segundo ecrã em consolas portáteis com ecrã secundário integrado. Ativado usa qualquer segundo ecrã. Desativado mantém tudo num só ecrã. + Automático + Ativado + Desativado + Menu no ecrã inferior + Move as secções e o menu do jogo para o segundo ecrã e a escrita é feita lá, para que o ecrã superior mantenha a vista completa. L2 e R2 mudam de secção. + Controlos no ecrã inferior nos jogos + Abre o trackpad e o teclado no segundo ecrã quando uma sessão começa. + A escrever para o ecrã superior + Concluído App de página inicial predefinida Escolher uma app de página inicial Escolher @@ -1052,7 +1123,6 @@ Nunca jogado Ambiente de execução ocupado · %1$d%% Ambiente de execução ocupado - O ambiente de execução é instalado ao tocar em Jogar pela primeira vez Atualização do ambiente de execução disponível ● Ambiente de execução pronto Armazenamento interno @@ -3117,6 +3187,8 @@ Na Biblioteca Mostrar Coleções + Atualizações disponíveis: %1$d + O programador substituiu esta versão por %1$s. Atualizar para ela? Nuvem Definições da nuvem Pesquisar aplicações @@ -3162,4 +3234,20 @@ %d aplicações %d aplicações + A restaurar o seu software + A reinstalar as apps que adicionou após uma atualização do DroidDeck. + Software restaurado + As apps que adicionou estão novamente instaladas. + Não foi possível restaurar algum software + Consulte ~/.cache/droiddeck/apt-restore.log e depois experimente sudo apt install no Konsole. + Ligação USB lenta + %1$s está ligado por %2$s (%3$s Mbps, cerca de %4$d MB/s), por isso os jogos vão carregar devagar. Use um cabo USB 3 de 5 Gbps ou mais, experimente virar a ficha USB-C ou use uma unidade mais rápida. + %1$s está ligado por %2$s (%3$s Mbps, cerca de %4$d MB/s). A porta USB deste dispositivo não é mais rápida, por isso os jogos vão carregar devagar. O armazenamento interno ou um cartão microSD pode ser mais rápido. + Não voltar a mostrar + Ignorar + Reiniciar o Steam para adicionar os seus jogos? + O Steam pode mostrar estes jogos que tem como instalados: %1$s. Tem de reiniciar para os mostrar. Reinicie agora ou serão adicionados da próxima vez que o Steam iniciar. + %1$s e mais %2$d + Reiniciar agora + Mais tarde diff --git a/app/src/main/res/values-pt/gestures.xml b/app/src/main/res/values-pt/gestures.xml index c345617df..42225990f 100644 --- a/app/src/main/res/values-pt/gestures.xml +++ b/app/src/main/res/values-pt/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Manter pressionado com três dedos Pinçar para ampliar Manter o botão pressionado + Manter o botão esquerdo pressionado + Manter o botão direito pressionado Clicar uma vez Tempo para manter pressionado Intervalo de toque duplo diff --git a/app/src/main/res/values-pt/shortcuts.xml b/app/src/main/res/values-pt/shortcuts.xml new file mode 100644 index 000000000..cd9abed5c --- /dev/null +++ b/app/src/main/res/values-pt/shortcuts.xml @@ -0,0 +1,13 @@ + + + Atalhos de botões físicos + Menu do DroidDeck + Atribuir: %1$s + Pressione e solte um botão. A atribuição é salva automaticamente. + Solte o botão para continuar. + %1$s: %2$s + Este botão já está atribuído a: %1$s + Reatribuir + Testar outro botão + O botão Início e alguns botões extras podem não estar disponíveis. + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 825ef7b95..d6a1aa08c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -609,6 +609,64 @@ Âmbar Violeta Opacidade + Tamanho + Tocar no HUD também altera o tamanho. + Completo + Blocos + Pílula + Mínimo + Mega + Travar posição + Arraste o HUD para movê-lo; um toque longo o trava ou destrava. Enquanto estiver travado, os controles na tela embaixo dele continuam funcionando. + Aparência + Tamanho na tela + Suave + Média + Viva + Contorno + Cor do contorno + Cinza + Cor do tema + Leituras + Gráfico de tempo de quadro + Taxa de quadros com decimal + 0.01% mais baixo + Motor gráfico + Nome da GPU + Uso da GPU + Temperatura da GPU + Uso da CPU + CPU por núcleo + Temperaturas + Memória + Memória gráfica + Memória swap + Nível da bateria + Consumo de energia + Rede + Resolução + Versão do Proton + Tempo de sessão + Relógio + Temperaturas + Unidade + Cores de alerta + As temperaturas ficam âmbar e depois vermelhas ao se aproximarem do limite. + Desativado + Automático + Manual + CPU em vermelho a partir de + GPU em vermelho a partir de + Bateria em vermelho a partir de + Mais + Diagnóstico do HUD + Salva em Downloads/DroidDeck um relatório de cada sensor que o HUD lê. + Salvar + Salvo em %1$s + Não foi possível salvar o relatório do HUD. + Redefinir configurações do HUD + Redefinir + FusionHUD por The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Modo de escala Nitidez da escala Efeitos de tela @@ -647,6 +705,8 @@ O controle envia dados a 1000 Hz em vez de 250 Hz. Um pouco mais de CPU. Toque de baixa latência Os toques ignoram o agrupamento de quadros; os controles de toque da Steam vão a 1000 Hz. Um pouco mais de CPU. + Canal de mouse bruto + Cursor Teclado de PC Android Segunda tela @@ -764,6 +824,17 @@ Tela cheia Oculta as barras de status e de navegação do Android Mostra as barras de status e de navegação do Android + Tela dupla + Automático usa a segunda tela em portáteis que têm uma integrada. Ativado usa qualquer segunda tela. Desativado mantém tudo em uma tela. + Automático + Ativado + Desativado + Menu na tela inferior + Move as seções e o menu do jogo para a segunda tela e a digitação acontece lá, para que a tela superior mantenha a visão completa. L2 e R2 trocam de seção. + Controles na tela inferior nos jogos + Abre o trackpad e o teclado na segunda tela quando uma sessão começa. + Digitando para a tela superior + Concluído App de início padrão Escolher um app de início Escolher @@ -1052,7 +1123,6 @@ Nunca jogado Runtime ocupado · %1$d%% Runtime ocupado - O runtime é instalado no primeiro Jogar Atualização do runtime disponível ● Runtime pronto Armazenamento interno @@ -3117,6 +3187,8 @@ Na Biblioteca Mostrar Coleções + Atualizações disponíveis: %1$d + O desenvolvedor substituiu esta versão por %1$s. Atualizar para ela? Nuvem Configurações da nuvem Pesquisar apps @@ -3162,4 +3234,20 @@ %d apps %d apps + Restaurando seus programas + Reinstalando os apps que você adicionou após uma atualização do DroidDeck. + Programas restaurados + Os apps que você adicionou estão instalados de novo. + Não foi possível restaurar alguns programas + Veja ~/.cache/droiddeck/apt-restore.log e depois tente sudo apt install no Konsole. + Conexão USB lenta + %1$s está conectado por %2$s (%3$s Mbps, cerca de %4$d MB/s), então os jogos nele vão carregar devagar. Use um cabo USB 3 de 5 Gbps ou mais, tente virar o conector USB-C ou use uma unidade mais rápida. + %1$s está conectado por %2$s (%3$s Mbps, cerca de %4$d MB/s). A porta USB deste aparelho não fica mais rápida, então os jogos vão carregar devagar. O armazenamento interno ou um cartão microSD pode ser mais rápido. + Não mostrar novamente + Dispensar + Reiniciar a Steam para adicionar seus jogos? + A Steam pode mostrar estes jogos que você tem como instalados: %1$s. Ela precisa reiniciar para mostrá-los. Reinicie agora ou eles serão adicionados na próxima vez que a Steam iniciar. + %1$s e mais %2$d + Reiniciar agora + Mais tarde diff --git a/app/src/main/res/values-ro/gestures.xml b/app/src/main/res/values-ro/gestures.xml index 6fb7b66fd..50ab4e0ab 100644 --- a/app/src/main/res/values-ro/gestures.xml +++ b/app/src/main/res/values-ro/gestures.xml @@ -2,7 +2,6 @@ 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ă @@ -15,6 +14,8 @@ Menținere cu trei degete Apropie sau depărtează degetele pentru zoom Ține butonul apăsat + Ține butonul stâng apăsat + Ține butonul drept apăsat Un singur clic Durata până la menținere Intervalul atingerii duble diff --git a/app/src/main/res/values-ro/shortcuts.xml b/app/src/main/res/values-ro/shortcuts.xml new file mode 100644 index 000000000..776013182 --- /dev/null +++ b/app/src/main/res/values-ro/shortcuts.xml @@ -0,0 +1,13 @@ + + + Scurtături pentru butoane fizice + Meniul DroidDeck + Atribuie: %1$s + Apasă și eliberează un buton. Atribuirea se salvează automat. + Eliberează butonul pentru a continua. + %1$s: %2$s + Acest buton este deja atribuit pentru: %1$s + Reatribuie + Încearcă alt buton + Butonul Acasă și unele butoane suplimentare pot fi indisponibile. + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index b95ef4857..b5be718db 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -609,6 +609,64 @@ Chihlimbariu Violet Opacitate + Dimensiune + Atingerea HUD-ului îi schimbă și dimensiunea. + Complet + Plăci + Pastilă + Minimal + Mega + Blochează pe loc + Trage HUD-ul pentru a-l muta; o apăsare lungă îl blochează sau îl deblochează. Cât timp e blocat, comenzile de pe ecran de sub el funcționează în continuare. + Aspect + Dimensiune pe ecran + Discretă + Medie + Vie + Contur + Culoare contur + Gri + Culoarea temei + Valori + Grafic timp de cadru + Rată de cadre cu zecimală + 0.01% cele mai mici + Motor grafic + Nume GPU + Încărcare GPU + Temperatură GPU + Încărcare CPU + CPU pe nucleu + Temperaturi + Memorie + Memorie grafică + Memorie swap + Nivel baterie + Consum de energie + Rețea + Rezoluție + Versiune Proton + Durata sesiunii + Ceas + Temperaturi + Unitate + Culori de avertizare + Temperaturile devin chihlimbarii, apoi roșii, pe măsură ce se apropie de limită. + Dezactivat + Automat + Manual + CPU roșu de la + GPU roșu de la + Baterie roșie de la + Mai mult + Diagnosticare HUD + Salvează în Downloads/DroidDeck un raport despre fiecare senzor citit de HUD. + Salvează + Salvat în %1$s + Raportul HUD nu a putut fi salvat. + Resetează setările HUD + Resetează + FusionHUD de The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Mod de scalare Claritatea scalării Efecte de ecran @@ -647,6 +705,8 @@ Controllerul raportează la 1000 Hz în loc de 250 Hz. Consum CPU puțin mai mare. Atingere cu latență mică Atingerile sar peste gruparea cadrelor, deci controlul tactil Steam rulează la 1000 Hz. Consum CPU puțin mai mare. + Canal brut de mouse + Cursor Tastatură de PC Android Al doilea ecran @@ -772,6 +832,17 @@ Ecran complet Ascunde barele de stare și de navigare Android Arată barele de stare și de navigare Android + Ecran dublu + Automat folosește al doilea ecran pe consolele portabile care au unul integrat. Pornit folosește orice al doilea afișaj. Oprit păstrează totul pe un singur ecran. + Automat + Pornit + Oprit + Meniu pe ecranul de jos + Mută secțiunile și meniul din joc pe al doilea ecran și scrierea se face acolo, astfel încât ecranul de sus păstrează imaginea completă. L2 și R2 schimbă secțiunea. + Comenzi pe ecranul de jos în jocuri + Deschide trackpadul și tastatura pe al doilea ecran când pornește o sesiune. + Scriere pentru ecranul de sus + Gata Aplicația Acasă implicită Alege o aplicație Acasă Alege @@ -1060,7 +1131,6 @@ Niciodată jucat Mediu de execuție ocupat · %1$d%% Mediu de execuție ocupat - Mediul de execuție se instalează la prima apăsare pe Play Actualizare disponibilă pentru mediul de execuție ● Mediu de execuție pregătit Stocare internă @@ -3117,6 +3187,8 @@ În Bibliotecă Afișează Colecții + Actualizări disponibile: %1$d + Dezvoltatorul a înlocuit această versiune cu %1$s. Actualizezi la ea? Cloud Setări cloud Caută aplicații @@ -3162,4 +3234,20 @@ %d aplicații %d de aplicații + Se restaurează software-ul + Se reinstalează aplicațiile adăugate de tine după o actualizare DroidDeck. + Software restaurat + Aplicațiile adăugate de tine sunt instalate din nou. + O parte din software nu a putut fi restaurată + Vezi ~/.cache/droiddeck/apt-restore.log, apoi încearcă sudo apt install în Konsole. + Conexiune USB lentă + %1$s este conectat prin %2$s (%3$s Mbps, aproximativ %4$d MB/s), așa că jocurile de pe el se vor încărca lent. Folosește un cablu USB 3 de 5 Gbps sau mai rapid, încearcă să întorci mufa USB-C sau folosește o unitate mai rapidă. + %1$s este conectat prin %2$s (%3$s Mbps, aproximativ %4$d MB/s). Portul USB al acestui dispozitiv nu poate merge mai repede, așa că jocurile se vor încărca lent. Memoria internă sau un card microSD pot fi mai rapide. + Nu mai afișa + Închide + Repornești Steam pentru a adăuga jocurile tale? + Steam poate afișa ca instalate aceste jocuri pe care le deții: %1$s. Trebuie să repornească pentru a le afișa. Repornește acum sau vor fi adăugate la următoarea pornire a Steam. + %1$s și încă %2$d + Repornește acum + Mai târziu diff --git a/app/src/main/res/values-ru/gestures.xml b/app/src/main/res/values-ru/gestures.xml index bb758cb1b..4c630b780 100644 --- a/app/src/main/res/values-ru/gestures.xml +++ b/app/src/main/res/values-ru/gestures.xml @@ -2,7 +2,6 @@ Жесты Настройки сенсорных жестов - Настройте жесты мыши и клавиатуры. Чтобы использовать их, выберите режим касаний «Жесты». Касание Перетаскивание одним пальцем Долгое нажатие @@ -15,6 +14,8 @@ Удержание тремя пальцами Масштабирование щипком Удерживать кнопку нажатой + Удерживать левую кнопку нажатой + Удерживать правую кнопку нажатой Один щелчок Задержка удержания Интервал двойного касания diff --git a/app/src/main/res/values-ru/shortcuts.xml b/app/src/main/res/values-ru/shortcuts.xml new file mode 100644 index 000000000..783abb4bd --- /dev/null +++ b/app/src/main/res/values-ru/shortcuts.xml @@ -0,0 +1,13 @@ + + + Действия физических кнопок + Меню DroidDeck + Назначить: %1$s + Нажмите и отпустите кнопку. Назначение сохранится автоматически. + Отпустите кнопку, чтобы продолжить. + %1$s: %2$s + Эта кнопка уже назначена для: %1$s + Переназначить + Попробовать другую кнопку + Кнопка «Домой» и некоторые дополнительные кнопки могут быть недоступны. + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 483d86ab5..2d56e1d69 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -613,6 +613,64 @@ Янтарный Фиолетовый Непрозрачность + Размер + Касание HUD тоже меняет его размер. + Полный + Плитки + Капсула + Минимальный + Мега + Закрепить на месте + Перетащите HUD, чтобы переместить его; долгое нажатие закрепляет или открепляет его. Пока он закреплён, экранные элементы управления под ним продолжают работать. + Внешний вид + Размер на экране + Мягкий + Средний + Яркий + Контур + Цвет контура + Серый + Цвет темы + Показатели + График времени кадров + Частота кадров с десятыми + 0.01% минимальных + Графический движок + Название GPU + Загрузка GPU + Температура GPU + Загрузка CPU + CPU по ядрам + Температуры + Память + Видеопамять + Подкачка + Заряд батареи + Потребляемая мощность + Сеть + Разрешение + Версия Proton + Время сессии + Часы + Температуры + Единицы + Цвета предупреждений + Температуры становятся янтарными, затем красными по мере приближения к пределу. + Выкл. + Автоматически + Вручную + CPU: красный от + GPU: красный от + Батарея: красный от + Еще + Диагностика HUD + Сохраняет в Downloads/DroidDeck отчет по всем датчикам, которые считывает HUD. + Сохранить + Сохранено: %1$s + Не удалось сохранить отчет HUD. + Сбросить настройки HUD + Сбросить + FusionHUD от The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Режим масштабирования Резкость масштабирования Экранные эффекты @@ -651,6 +709,8 @@ Контроллер опрашивается на 1000 Гц вместо 250 Гц. Немного больше нагрузки на CPU. Сенсор с низкой задержкой Касания идут без объединения кадров; сенсорное управление Steam работает на 1000 Гц. Немного больше нагрузки на CPU. + Сырой канал мыши + Курсор Клавиатура ПК Android Второй экран @@ -777,6 +837,17 @@ Полноэкранный режим Скрывает строку состояния и панель навигации Android Показывает строку состояния и панель навигации Android + Два экрана + Автоматически — использует второй экран на портативных устройствах со встроенным вторым экраном. Вкл. — использует любой второй дисплей. Выкл. — всё остаётся на одном экране. + Автоматически + Вкл. + Выкл. + Меню на нижнем экране + Переносит разделы и меню в игре на второй экран, и ввод выполняется там, поэтому верхний экран остаётся полностью свободным. L2 и R2 переключают разделы. + Управление на нижнем экране в играх + Открывает тачпад и клавиатуру на втором экране при запуске сеанса. + Ввод для верхнего экрана + Готово Главный экран по умолчанию Выбрать главный экран Выбрать @@ -1068,7 +1139,6 @@ Никогда не запускалась Среда выполнения занята · %1$d%% Среда выполнения занята - Среда выполнения установится при первом запуске игры Доступно обновление среды выполнения ● Среда выполнения готова Внутреннее хранилище @@ -3153,6 +3223,8 @@ В библиотеке Показать Коллекции + Доступные обновления: %1$d + Разработчик заменил эту сборку на %1$s. Обновиться до неё? Облако Настройки облака Поиск приложений @@ -3199,4 +3271,20 @@ %d приложений %d приложения + Восстановление программ + Повторная установка добавленных вами приложений после обновления DroidDeck. + Программы восстановлены + Добавленные вами приложения снова установлены. + Не удалось восстановить часть программ + Посмотрите ~/.cache/droiddeck/apt-restore.log, затем попробуйте sudo apt install в Konsole. + Медленное подключение USB + %1$s подключён через %2$s (%3$s Мбит/с, около %4$d МБ/с), поэтому игры на нём будут загружаться медленно. Используйте кабель USB 3 на 5 Гбит/с или быстрее, попробуйте перевернуть разъём USB-C или возьмите более быстрый накопитель. + %1$s подключён через %2$s (%3$s Мбит/с, около %4$d МБ/с). USB-порт этого устройства не работает быстрее, поэтому игры будут загружаться медленно. Внутренняя память или карта microSD могут быть быстрее. + Больше не показывать + Закрыть + Перезапустить Steam, чтобы добавить ваши игры? + Steam может показать эти ваши игры как установленные: %1$s. Для этого его нужно перезапустить. Перезапустите сейчас, или они будут добавлены при следующем запуске Steam. + %1$s и ещё %2$d + Перезапустить сейчас + Позже diff --git a/app/src/main/res/values-sv/gestures.xml b/app/src/main/res/values-sv/gestures.xml index a860712b4..e8034b112 100644 --- a/app/src/main/res/values-sv/gestures.xml +++ b/app/src/main/res/values-sv/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Håll med tre fingrar Nyp för att zooma Håll knappen nedtryckt + Håll vänster knapp nedtryckt + Håll höger knapp nedtryckt Klicka en gång Fördröjning före hållning Intervall för dubbeltryck diff --git a/app/src/main/res/values-sv/shortcuts.xml b/app/src/main/res/values-sv/shortcuts.xml new file mode 100644 index 000000000..c74ad141d --- /dev/null +++ b/app/src/main/res/values-sv/shortcuts.xml @@ -0,0 +1,13 @@ + + + Genvägar för fysiska knappar + DroidDeck-meny + Tilldela: %1$s + Tryck på en knapp och släpp den. Tilldelningen sparas automatiskt. + Släpp knappen för att fortsätta. + %1$s: %2$s + Den här knappen är redan tilldelad: %1$s + Tilldela igen + Prova en annan knapp + Hemknappen och vissa extraknappar kanske inte är tillgängliga. + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 72c27705b..deca220a0 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -607,6 +607,64 @@ Amber Lila Opacitet + Storlek + Ett tryck på HUD:en ändrar också dess storlek. + Full + Rutor + Piller + Minimal + Mega + Lås på plats + Dra HUD:en för att flytta den; ett långt tryck låser eller låser upp den. När den är låst fungerar skärmkontrollerna under den fortfarande. + Utseende + Storlek på skärmen + Mjuk + Mellan + Livfull + Kontur + Konturfärg + Grå + Temafärg + Mätvärden + Bildtidsgraf + Bildfrekvens med decimal + 0.01% lägsta + Grafikmotor + GPU-namn + GPU-belastning + GPU-temperatur + CPU-belastning + CPU per kärna + Temperaturer + Minne + Grafikminne + Växlingsminne + Batterinivå + Strömförbrukning + Nätverk + Upplösning + Proton-version + Sessionstid + Klocka + Temperaturer + Enhet + Varningsfärger + Temperaturer blir bärnstensfärgade och sedan röda när de närmar sig gränsen. + Av + Automatiskt + Manuell + CPU röd vid + GPU röd vid + Batteri rött vid + Mer + HUD-diagnostik + Sparar en rapport över alla sensorer som HUD:en läser i Downloads/DroidDeck. + Spara + Sparad i %1$s + Det gick inte att spara HUD-rapporten. + Återställ HUD-inställningar + Återställ + FusionHUD av The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Skalningsläge Skalningsskärpa Skärmeffekter @@ -645,6 +703,8 @@ Handkontrollen rapporterar i 1000 Hz i stället för 250 Hz. Något mer CPU. Pekning med låg latens Pekningar hoppar över bildrutebatchning; Steams pekstyrning går i 1000 Hz. Något mer CPU. + Rå muskanal + Markör PC-tangentbord Android Andra skärmen @@ -769,6 +829,17 @@ Helskärm Döljer Androids statusfält och navigeringsfält Visar Androids statusfält och navigeringsfält + Dubbelskärm + Automatiskt använder den andra skärmen på handhållna enheter med en inbyggd andra skärm. På använder vilken andra skärm som helst. Av håller allt på en skärm. + Automatiskt + På + Av + Meny på nedre skärmen + Flyttar avsnitten och menyn i spelet till den andra skärmen och skrivandet sker där, så att den övre skärmen behåller hela vyn. L2 och R2 byter avsnitt. + Kontroller på nedre skärmen i spel + Öppnar styrplattan och tangentbordet på den andra skärmen när en session startar. + Skriver för den övre skärmen + Klar Standardapp för startskärmen Välj en app för startskärmen Välj @@ -1054,7 +1125,6 @@ Aldrig spelat Körmiljö upptagen · %1$d %% Körmiljö upptagen - Körmiljön installeras första gången du trycker på Play Uppdatering av körmiljön tillgänglig ● Körmiljö redo Internt lagringsutrymme @@ -3083,6 +3153,8 @@ I Bibliotek Visa Samlingar + Uppdateringar tillgängliga: %1$d + Utvecklaren har ersatt den här versionen med %1$s. Uppdatera till den? Moln Molninställningar Sök appar @@ -3127,4 +3199,20 @@ %d app %d appar + Återställer din programvara + Installerar om apparna du lade till efter en DroidDeck-uppdatering. + Programvaran återställd + Apparna du lade till är installerade igen. + Viss programvara kunde inte återställas + Se ~/.cache/droiddeck/apt-restore.log och prova sedan sudo apt install i Konsole. + Långsam USB-anslutning + %1$s är ansluten via %2$s (%3$s Mbit/s, cirka %4$d MB/s), så spel på den läses in långsamt. Använd en USB 3-kabel för 5 Gbit/s eller mer, prova att vända USB-C-kontakten eller använd en snabbare enhet. + %1$s är ansluten via %2$s (%3$s Mbit/s, cirka %4$d MB/s). USB-porten på den här enheten kan inte gå snabbare, så spel läses in långsamt. Internt lagringsutrymme eller ett microSD-kort kan vara snabbare. + Visa inte igen + Stäng + Starta om Steam för att lägga till dina spel? + Steam kan visa de här spelen du äger som installerade: %1$s. Steam måste startas om för att visa dem. Starta om nu, annars läggs de till nästa gång Steam startar. + %1$s och %2$d till + Starta om nu + Senare diff --git a/app/src/main/res/values-th/gestures.xml b/app/src/main/res/values-th/gestures.xml index 0fedbbfa1..64ecde905 100644 --- a/app/src/main/res/values-th/gestures.xml +++ b/app/src/main/res/values-th/gestures.xml @@ -2,7 +2,6 @@ ท่าทาง การตั้งค่าท่าทางสัมผัส - ปรับแต่งท่าทางเมาส์และแป้นพิมพ์ เลือกท่าทางเป็นโหมดสัมผัสเพื่อใช้งาน แตะ ลากด้วยหนึ่งนิ้ว กดค้าง @@ -15,6 +14,8 @@ กดค้างด้วยสามนิ้ว จีบนิ้วเพื่อซูม กดปุ่มค้างไว้ + กดปุ่มซ้ายค้างไว้ + กดปุ่มขวาค้างไว้ คลิกหนึ่งครั้ง ระยะเวลาก่อนกดค้าง ช่วงเวลาแตะสองครั้ง diff --git a/app/src/main/res/values-th/shortcuts.xml b/app/src/main/res/values-th/shortcuts.xml new file mode 100644 index 000000000..c9c7a1077 --- /dev/null +++ b/app/src/main/res/values-th/shortcuts.xml @@ -0,0 +1,13 @@ + + + ทางลัดปุ่มจริง + เมนู DroidDeck + กำหนด: %1$s + กดปุ่มแล้วปล่อย การกำหนดปุ่มจะบันทึกโดยอัตโนมัติ + ปล่อยปุ่มเพื่อดำเนินการต่อ + %1$s: %2$s + ปุ่มนี้ถูกกำหนดไว้แล้วสำหรับ: %1$s + กำหนดใหม่ + ลองปุ่มอื่น + ปุ่มโฮมและปุ่มพิเศษบางปุ่มอาจไม่พร้อมใช้งาน + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 9cdb0a55b..566cc1a97 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -605,6 +605,64 @@ อำพัน ม่วง ความโปร่งใส + ขนาด + การแตะ HUD จะเปลี่ยนขนาดด้วย + เต็ม + ไทล์ + แคปซูล + มินิมอล + เมกะ + ล็อกตำแหน่ง + ลาก HUD เพื่อย้าย กดค้างเพื่อล็อกหรือปลดล็อก ขณะล็อก ปุ่มควบคุมบนหน้าจอที่อยู่ด้านล่างยังใช้งานได้ + รูปลักษณ์ + ขนาดบนหน้าจอ + อ่อน + ปานกลาง + สด + เส้นขอบ + สีเส้นขอบ + เทา + สีธีม + ค่าที่แสดง + กราฟเวลาเฟรม + อัตราเฟรมแบบทศนิยม + 0.01% ต่ำสุด + เอนจินกราฟิก + ชื่อ GPU + โหลด GPU + อุณหภูมิ GPU + โหลด CPU + CPU รายคอร์ + อุณหภูมิ + หน่วยความจำ + หน่วยความจำกราฟิก + พื้นที่สว็อป + ระดับแบตเตอรี่ + การใช้พลังงาน + เครือข่าย + ความละเอียด + เวอร์ชัน Proton + เวลาเซสชัน + นาฬิกา + อุณหภูมิ + หน่วย + สีเตือน + อุณหภูมิจะเปลี่ยนเป็นสีอำพันแล้วเป็นสีแดงเมื่อใกล้ถึงขีดจำกัด + ปิด + อัตโนมัติ + กำหนดเอง + CPU แดงที่ + GPU แดงที่ + แบตเตอรี่แดงที่ + เพิ่มเติม + การวินิจฉัย HUD + บันทึกรายงานของเซนเซอร์ทุกตัวที่ HUD อ่านไปที่ Downloads/DroidDeck + บันทึก + บันทึกไปที่ %1$s แล้ว + บันทึกรายงาน HUD ไม่ได้ + รีเซ็ตการตั้งค่า HUD + รีเซ็ต + FusionHUD โดย The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0) โหมดการปรับขนาด ความคมชัดของการปรับขนาด เอฟเฟกต์หน้าจอ @@ -643,6 +701,8 @@ คอนโทรลเลอร์รายงานที่ 1000 Hz แทน 250 Hz ใช้ CPU เพิ่มขึ้นเล็กน้อย การสัมผัสความหน่วงต่ำ การสัมผัสข้ามการรวมเฟรม ทำให้ระบบสัมผัสของ Steam ทำงานที่ 1000 Hz ใช้ CPU เพิ่มขึ้นเล็กน้อย + ช่องเมาส์ดิบ + เคอร์เซอร์ แป้นพิมพ์พีซี Android หน้าจอที่สอง @@ -766,6 +826,17 @@ เต็มจอ ซ่อนแถบสถานะและแถบนำทางของ Android แสดงแถบสถานะและแถบนำทางของ Android + หน้าจอคู่ + อัตโนมัติจะใช้หน้าจอที่สองบนเครื่องพกพาที่มีหน้าจอที่สองในตัว เปิดจะใช้หน้าจอที่สองใดก็ได้ ปิดจะแสดงทุกอย่างบนหน้าจอเดียว + อัตโนมัติ + เปิด + ปิด + เมนูหน้าจอล่าง + ย้ายส่วนต่าง ๆ และเมนูระหว่างเล่นเกมไปที่หน้าจอที่สองและพิมพ์ที่นั่น เพื่อให้หน้าจอบนแสดงเต็มพื้นที่ L2 และ R2 ใช้สลับส่วน + ปุ่มควบคุมหน้าจอล่างในเกม + เปิดแทร็กแพดและแป้นพิมพ์บนหน้าจอที่สองเมื่อเริ่มเซสชัน + กำลังพิมพ์ให้หน้าจอบน + เสร็จสิ้น แอปหน้าจอหลักเริ่มต้น เลือกแอปหน้าจอหลัก เลือก @@ -1048,7 +1119,6 @@ ยังไม่เคยเล่น รันไทม์กำลังทำงาน · %1$d%% รันไทม์กำลังทำงาน - รันไทม์จะติดตั้งเมื่อกด Play ครั้งแรก มีอัปเดตรันไทม์ ● รันไทม์พร้อมใช้งาน พื้นที่จัดเก็บภายใน @@ -3058,6 +3128,8 @@ อยู่ในไลบรารี แสดง คอลเลกชัน + มีอัปเดตพร้อมใช้งาน: %1$d + ผู้พัฒนาได้แทนที่บิลด์นี้ด้วย %1$s ต้องการอัปเดตเป็นบิลด์นั้นไหม คลาวด์ การตั้งค่าคลาวด์ ค้นหาแอป @@ -3101,4 +3173,20 @@ %d แอป + กำลังกู้คืนซอฟต์แวร์ของคุณ + กำลังติดตั้งแอปที่คุณเพิ่มไว้อีกครั้งหลังการอัปเดต DroidDeck + กู้คืนซอฟต์แวร์แล้ว + แอปที่คุณเพิ่มไว้ติดตั้งกลับมาแล้ว + กู้คืนซอฟต์แวร์บางส่วนไม่ได้ + ดู ~/.cache/droiddeck/apt-restore.log แล้วลองใช้ sudo apt install ใน Konsole + การเชื่อมต่อ USB ช้า + %1$s เชื่อมต่อผ่าน %2$s (%3$s Mbps ประมาณ %4$d MB/s) เกมในไดรฟ์นี้จึงโหลดช้า ใช้สาย USB 3 ที่รองรับ 5 Gbps ขึ้นไป ลองกลับด้านหัว USB-C หรือใช้ไดรฟ์ที่เร็วกว่า + %1$s เชื่อมต่อผ่าน %2$s (%3$s Mbps ประมาณ %4$d MB/s) พอร์ต USB ของอุปกรณ์นี้เร็วกว่านี้ไม่ได้ เกมจึงโหลดช้า ที่เก็บข้อมูลภายในหรือการ์ด microSD อาจเร็วกว่า + ไม่ต้องแสดงอีก + ปิด + รีสตาร์ต Steam เพื่อเพิ่มเกมของคุณหรือไม่ + Steam แสดงเกมที่คุณเป็นเจ้าของเหล่านี้ว่าติดตั้งแล้วได้: %1$s ต้องรีสตาร์ตเพื่อให้แสดง รีสตาร์ตเลย หรือเกมจะถูกเพิ่มเมื่อ Steam เริ่มครั้งถัดไป + %1$s และอีก %2$d เกม + รีสตาร์ตเลย + ภายหลัง diff --git a/app/src/main/res/values-tr/gestures.xml b/app/src/main/res/values-tr/gestures.xml index 92ca99e01..df0186b2e 100644 --- a/app/src/main/res/values-tr/gestures.xml +++ b/app/src/main/res/values-tr/gestures.xml @@ -2,7 +2,6 @@ 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 @@ -15,6 +14,8 @@ Üç parmakla basılı tutma İki parmakla yakınlaştırma Düğmeyi basılı tut + Sol düğmeyi basılı tut + Sağ düğmeyi basılı tut Bir kez tıkla Basılı tutma gecikmesi Çift dokunma aralığı diff --git a/app/src/main/res/values-tr/shortcuts.xml b/app/src/main/res/values-tr/shortcuts.xml new file mode 100644 index 000000000..b279d0a04 --- /dev/null +++ b/app/src/main/res/values-tr/shortcuts.xml @@ -0,0 +1,13 @@ + + + Fiziksel düğme kısayolları + DroidDeck menüsü + Ata: %1$s + Bir düğmeye basıp bırakın. Atama otomatik olarak kaydedilir. + Devam etmek için düğmeyi bırakın. + %1$s: %2$s + Bu düğme zaten şuna atanmış: %1$s + Yeniden ata + Başka düğme dene + Ana ekran düğmesi ve bazı ek düğmeler kullanılamayabilir. + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 3dea1e5bc..3a32c442b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -607,6 +607,64 @@ Kehribar Mor Opaklık + Boyut + HUD\'a dokunmak da boyutunu değiştirir. + Tam + Kutucuklar + Hap + Minimal + Mega + Yerinde kilitle + Taşımak için HUD\'u sürükleyin; uzun basmak onu kilitler veya kilidini açar. Kilitliyken altındaki ekran kontrolleri çalışmaya devam eder. + Görünüm + Ekrandaki boyut + Yumuşak + Orta + Canlı + Dış hat + Dış hat rengi + Gri + Tema rengi + Ölçümler + Kare süresi grafiği + Ondalıklı kare hızı + 0.01% en düşük + Grafik motoru + GPU adı + GPU yükü + GPU sıcaklığı + CPU yükü + Çekirdek başına CPU + Sıcaklıklar + Bellek + Grafik belleği + Takas alanı + Pil seviyesi + Güç tüketimi + Ağ + Çözünürlük + Proton sürümü + Oturum süresi + Saat + Sıcaklıklar + Birim + Uyarı renkleri + Sıcaklıklar sınıra yaklaştıkça önce kehribar, sonra kırmızı olur. + Kapalı + Otomatik + Manuel + CPU kırmızı eşiği + GPU kırmızı eşiği + Pil kırmızı eşiği + Daha fazla + HUD tanılaması + HUD\'un okuduğu her sensörün raporunu Downloads/DroidDeck klasörüne kaydeder. + Kaydet + %1$s konumuna kaydedildi + HUD raporu kaydedilemedi. + HUD ayarlarını sıfırla + Sıfırla + FusionHUD, yapımcı: The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Ölçekleme modu Ölçekleme keskinliği Ekran efektleri @@ -645,6 +703,8 @@ Kumanda 250 Hz yerine 1000 Hz bildirir. CPU kullanımı biraz artar. Düşük gecikmeli dokunma Dokunmalar kare gruplamasını atlar; Steam dokunmatik kontrolleri 1000 Hz çalışır. CPU kullanımı biraz artar. + Ham fare kanalı + İmleç PC klavyesi Android İkinci ekran @@ -761,6 +821,17 @@ Tam ekran Android durum ve gezinme çubuklarını gizler Android durum ve gezinme çubuklarını gösterir + Çift ekran + Otomatik, dahili ikinci ekranı olan el konsollarında ikinci ekranı kullanır. Açık, herhangi bir ikinci ekranı kullanır. Kapalı, her şeyi tek ekranda tutar. + Otomatik + Açık + Kapalı + Alt ekran menüsü + Bölümleri ve oyun içi menüyü ikinci ekrana taşır ve yazma orada yapılır, böylece üst ekran tam görünümü korur. L2 ve R2 bölümler arasında geçiş yapar. + Oyunlarda alt ekran kontrolleri + Bir oturum başladığında ikinci ekranda dokunmatik yüzeyi ve klavyeyi açar. + Üst ekran için yazılıyor + Bitti Varsayılan ana ekran uygulaması Ana ekran uygulaması seçin Seç @@ -1046,7 +1117,6 @@ Hiç oynanmadı Çalışma ortamı meşgul · %1$d%% Çalışma ortamı meşgul - Çalışma ortamı ilk Oyna\'da yüklenir Çalışma ortamı güncellemesi var ● Çalışma ortamı hazır Dahili depolama @@ -3085,6 +3155,8 @@ Kütüphane içinde Göster Koleksiyonlar + Mevcut güncellemeler: %1$d + Geliştirici bu sürümü %1$s ile değiştirdi. Ona güncellensin mi? Bulut Bulut ayarları Uygulama ara @@ -3129,4 +3201,20 @@ %d uygulama %d uygulama + Yazılımınız geri yükleniyor + Eklediğiniz uygulamalar DroidDeck güncellemesinden sonra yeniden yükleniyor. + Yazılım geri yüklendi + Eklediğiniz uygulamalar yeniden yüklendi. + Bazı yazılımlar geri yüklenemedi + ~/.cache/droiddeck/apt-restore.log dosyasına bakın, ardından Konsole\'da sudo apt install deneyin. + Yavaş USB bağlantısı + %1$s, %2$s üzerinden bağlı (%3$s Mbps, yaklaşık %4$d MB/sn), bu yüzden üzerindeki oyunlar yavaş yüklenecek. 5 Gbps veya daha hızlı bir USB 3 kablo kullanın, USB-C fişini ters çevirmeyi deneyin ya da daha hızlı bir sürücü kullanın. + %1$s, %2$s üzerinden bağlı (%3$s Mbps, yaklaşık %4$d MB/sn). Bu cihazın USB bağlantı noktası daha hızlı çalışamıyor, bu yüzden oyunlar yavaş yüklenecek. Dahili depolama veya bir microSD kart daha hızlı olabilir. + Bir daha gösterme + Kapat + Oyunlarınızı eklemek için Steam yeniden başlatılsın mı? + Steam sahip olduğunuz bu oyunları yüklü olarak gösterebilir: %1$s. Bunları göstermek için yeniden başlaması gerekir. Şimdi yeniden başlatın ya da Steam bir sonraki açılışta onları ekleyecek. + %1$s ve %2$d tane daha + Şimdi yeniden başlat + Daha sonra diff --git a/app/src/main/res/values-uk/gestures.xml b/app/src/main/res/values-uk/gestures.xml index 7ed3663c6..6ed58ef52 100644 --- a/app/src/main/res/values-uk/gestures.xml +++ b/app/src/main/res/values-uk/gestures.xml @@ -2,7 +2,6 @@ Жести Налаштування сенсорних жестів - Налаштуйте жести миші та клавіатури. Щоб використовувати їх, виберіть режим дотику «Жести». Дотик Перетягування одним пальцем Тривале натискання @@ -15,6 +14,8 @@ Утримання трьома пальцями Масштабування щипком Утримувати кнопку натиснутою + Утримувати ліву кнопку натиснутою + Утримувати праву кнопку натиснутою Одне клацання Затримка утримання Інтервал подвійного дотику diff --git a/app/src/main/res/values-uk/shortcuts.xml b/app/src/main/res/values-uk/shortcuts.xml new file mode 100644 index 000000000..f1085d070 --- /dev/null +++ b/app/src/main/res/values-uk/shortcuts.xml @@ -0,0 +1,13 @@ + + + Дії фізичних кнопок + Меню DroidDeck + Призначити: %1$s + Натисніть і відпустіть кнопку. Призначення збережеться автоматично. + Відпустіть кнопку, щоб продовжити. + %1$s: %2$s + Ця кнопка вже призначена для: %1$s + Перепризначити + Спробувати іншу кнопку + Кнопка «Додому» та деякі додаткові кнопки можуть бути недоступні. + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 3084c36ba..af3946a26 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -611,6 +611,64 @@ Бурштиновий Фіолетовий Непрозорість + Розмір + Дотик до HUD також змінює його розмір. + Повний + Плитки + Капсула + Мінімальний + Мега + Закріпити на місці + Перетягніть HUD, щоб перемістити його; довге натискання закріплює або відкріплює його. Поки його закріплено, екранні елементи керування під ним працюють і далі. + Вигляд + Розмір на екрані + М\'який + Середній + Яскравий + Контур + Колір контуру + Сірий + Колір теми + Показники + Графік часу кадрів + Частота кадрів із десятими + 0.01% мінімальних + Графічний рушій + Назва GPU + Навантаження GPU + Температура GPU + Навантаження CPU + CPU за ядрами + Температури + Пам\'ять + Відеопам\'ять + Підкачка + Заряд батареї + Споживана потужність + Мережа + Роздільна здатність + Версія Proton + Час сеансу + Годинник + Температури + Одиниці + Кольори попередження + Температури стають бурштиновими, а потім червоними, коли наближаються до межі. + Вимк. + Автоматично + Вручну + CPU: червоний від + GPU: червоний від + Батарея: червоний від + Більше + Діагностика HUD + Зберігає в Downloads/DroidDeck звіт про всі датчики, які зчитує HUD. + Зберегти + Збережено: %1$s + Не вдалося зберегти звіт HUD. + Скинути налаштування HUD + Скинути + FusionHUD від The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Режим масштабування Чіткість масштабування Ефекти екрана @@ -649,6 +707,8 @@ Контролер звітує на 1000 Hz замість 250 Hz. Трохи більше навантаження на CPU. Дотик із низькою затримкою Дотики обходять групування кадрів, тож сенсорне керування Steam працює на 1000 Hz. Трохи більше навантаження на CPU. + Сирий канал миші + Курсор Клавіатура ПК Android Другий екран @@ -775,6 +835,17 @@ Повний екран Приховує рядок стану та панель навігації Android Показує рядок стану та панель навігації Android + Два екрани + Автоматично — використовує другий екран на портативних пристроях із вбудованим другим екраном. Увімк. — використовує будь-який другий дисплей. Вимк. — усе лишається на одному екрані. + Автоматично + Увімк. + Вимк. + Меню на нижньому екрані + Переносить розділи та меню в грі на другий екран, і введення відбувається там, тож верхній екран лишається повністю вільним. L2 і R2 перемикають розділи. + Керування на нижньому екрані в іграх + Відкриває тачпад і клавіатуру на другому екрані під час запуску сеансу. + Введення для верхнього екрана + Готово Стандартний додаток головного екрана Виберіть додаток головного екрана Вибрати @@ -1066,7 +1137,6 @@ Ще не грали Середовище зайняте · %1$d%% Середовище зайняте - Середовище встановлюється під час першого натискання «Грати» Є оновлення середовища ● Середовище готове Внутрішнє сховище @@ -3151,6 +3221,8 @@ У бібліотеці Показати Колекції + Доступні оновлення: %1$d + Розробник замінив цю збірку на %1$s. Оновити до неї? Хмара Налаштування хмари Пошук застосунків @@ -3197,4 +3269,20 @@ %d застосунків %d застосунку + Відновлення програм + Повторне встановлення доданих вами застосунків після оновлення DroidDeck. + Програми відновлено + Додані вами застосунки знову встановлено. + Не вдалося відновити частину програм + Перегляньте ~/.cache/droiddeck/apt-restore.log, потім спробуйте sudo apt install у Konsole. + Повільне з’єднання USB + %1$s під’єднано через %2$s (%3$s Мбіт/с, близько %4$d МБ/с), тому ігри на ньому завантажуватимуться повільно. Використайте кабель USB 3 на 5 Гбіт/с або швидший, спробуйте перевернути роз’єм USB-C або візьміть швидший накопичувач. + %1$s під’єднано через %2$s (%3$s Мбіт/с, близько %4$d МБ/с). USB-порт цього пристрою не працює швидше, тому ігри завантажуватимуться повільно. Внутрішня пам’ять або карта microSD можуть бути швидшими. + Більше не показувати + Закрити + Перезапустити Steam, щоб додати ваші ігри? + Steam може показати ці ваші ігри як встановлені: %1$s. Для цього його треба перезапустити. Перезапустіть зараз, або їх буде додано під час наступного запуску Steam. + %1$s і ще %2$d + Перезапустити зараз + Пізніше diff --git a/app/src/main/res/values-vi/gestures.xml b/app/src/main/res/values-vi/gestures.xml index 90196d5ae..40bd8c9b7 100644 --- a/app/src/main/res/values-vi/gestures.xml +++ b/app/src/main/res/values-vi/gestures.xml @@ -2,7 +2,6 @@ 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ữ @@ -15,6 +14,8 @@ 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 + Giữ nút trái được nhấn + Giữ nút phải được nhấn Nhấp một lần Độ trễ nhấn giữ Khoảng thời gian chạm hai lần diff --git a/app/src/main/res/values-vi/shortcuts.xml b/app/src/main/res/values-vi/shortcuts.xml new file mode 100644 index 000000000..fccc48168 --- /dev/null +++ b/app/src/main/res/values-vi/shortcuts.xml @@ -0,0 +1,13 @@ + + + Phím tắt nút vật lý + Menu DroidDeck + Gán: %1$s + Nhấn và thả một nút. Gán nút được lưu tự động. + Thả nút để tiếp tục. + %1$s: %2$s + Nút này đã được gán cho: %1$s + Gán lại + Thử nút khác + Nút Home và một số nút bổ sung có thể không khả dụng. + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 0cfe20866..d682418a1 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -605,6 +605,64 @@ Hổ phách Tím Độ mờ + Kích thước + Chạm vào HUD cũng thay đổi kích thước của nó. + Đầy đủ + Ô + Dạng viên + Tối giản + Mega + Khóa vị trí + Kéo HUD để di chuyển; nhấn giữ để khóa hoặc mở khóa. Khi đã khóa, các nút điều khiển trên màn hình bên dưới vẫn hoạt động. + Giao diện + Kích thước trên màn hình + Dịu + Vừa + Rực rỡ + Viền + Màu viền + Xám + Màu giao diện + Chỉ số + Biểu đồ thời gian khung hình + Tốc độ khung hình có số thập phân + 0.01% thấp nhất + Engine đồ họa + Tên GPU + Tải GPU + Nhiệt độ GPU + Tải CPU + CPU theo từng lõi + Nhiệt độ + Bộ nhớ + Bộ nhớ đồ họa + Bộ nhớ swap + Mức pin + Công suất tiêu thụ + Mạng + Độ phân giải + Phiên bản Proton + Thời gian phiên + Đồng hồ + Nhiệt độ + Đơn vị + Màu cảnh báo + Nhiệt độ chuyển sang màu hổ phách, rồi đỏ khi gần đến giới hạn. + Tắt + Tự động + Thủ công + CPU đỏ từ + GPU đỏ từ + Pin đỏ từ + Thêm + Chẩn đoán HUD + Lưu báo cáo về mọi cảm biến mà HUD đọc vào Downloads/DroidDeck. + Lưu + Đã lưu vào %1$s + Không thể lưu báo cáo HUD. + Đặt lại thiết lập HUD + Đặt lại + FusionHUD của The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Chế độ chia tỷ lệ Độ sắc nét khi chia tỷ lệ Hiệu ứng màn hình @@ -643,6 +701,8 @@ Tay cầm báo cáo ở 1000 Hz thay vì 250 Hz. Tốn thêm một chút CPU. Cảm ứng độ trễ thấp Cảm ứng bỏ qua việc gộp khung hình, nên điều khiển cảm ứng của Steam chạy ở 1000 Hz. Tốn thêm một chút CPU. + Kênh chuột thô + Con trỏ Bàn phím PC Android Màn hình thứ hai @@ -766,6 +826,17 @@ Toàn màn hình Ẩn thanh trạng thái và thanh điều hướng của Android Hiện thanh trạng thái và thanh điều hướng của Android + Màn hình kép + Tự động dùng màn hình thứ hai trên máy cầm tay có màn hình thứ hai tích hợp. Bật dùng bất kỳ màn hình thứ hai nào. Tắt giữ mọi thứ trên một màn hình. + Tự động + Bật + Tắt + Menu ở màn hình dưới + Chuyển các mục và menu trong trò chơi sang màn hình thứ hai và nhập liệu ở đó, để màn hình trên giữ toàn bộ khung nhìn. L2 và R2 chuyển mục. + Điều khiển màn hình dưới khi chơi game + Mở bàn di chuột và bàn phím trên màn hình thứ hai khi phiên bắt đầu. + Đang nhập cho màn hình trên + Xong Ứng dụng Màn hình chính mặc định Chọn một ứng dụng Màn hình chính Chọn @@ -1048,7 +1119,6 @@ Chưa chơi bao giờ Runtime đang bận · %1$d%% Runtime đang bận - Runtime sẽ cài ở lần Play đầu tiên Có cập nhật runtime ● Runtime đã sẵn sàng Bộ nhớ trong @@ -3058,6 +3128,8 @@ Trong Thư viện Hiển thị Bộ sưu tập + Có bản cập nhật: %1$d + Nhà phát triển đã thay bản dựng này bằng %1$s. Cập nhật lên bản đó? Đám mây Cài đặt đám mây Tìm ứng dụng @@ -3101,4 +3173,20 @@ %d ứng dụng + Đang khôi phục phần mềm của bạn + Đang cài lại các ứng dụng bạn đã thêm sau khi cập nhật DroidDeck. + Đã khôi phục phần mềm + Các ứng dụng bạn đã thêm đã được cài lại. + Không thể khôi phục một số phần mềm + Xem ~/.cache/droiddeck/apt-restore.log, rồi thử sudo apt install trong Konsole. + Kết nối USB chậm + %1$s đang kết nối qua %2$s (%3$s Mbps, khoảng %4$d MB/s), nên trò chơi trên đó sẽ tải chậm. Hãy dùng cáp USB 3 tốc độ 5 Gbps trở lên, thử lật đầu cắm USB-C hoặc dùng ổ nhanh hơn. + %1$s đang kết nối qua %2$s (%3$s Mbps, khoảng %4$d MB/s). Cổng USB của thiết bị này không thể nhanh hơn, nên trò chơi sẽ tải chậm. Bộ nhớ trong hoặc thẻ microSD có thể nhanh hơn. + Không hiển thị lại + Đóng + Khởi động lại Steam để thêm trò chơi của bạn? + Steam có thể hiển thị các trò chơi bạn sở hữu này là đã cài đặt: %1$s. Steam cần khởi động lại để hiển thị chúng. Khởi động lại ngay, hoặc chúng sẽ được thêm vào lần tới Steam khởi động. + %1$s và %2$d trò chơi khác + Khởi động lại ngay + Để sau diff --git a/app/src/main/res/values-zh-rCN/gestures.xml b/app/src/main/res/values-zh-rCN/gestures.xml index 18f6c00ce..69954db01 100644 --- a/app/src/main/res/values-zh-rCN/gestures.xml +++ b/app/src/main/res/values-zh-rCN/gestures.xml @@ -2,7 +2,6 @@ 手势 触屏手势设置 - 自定义鼠标和键盘手势。请选择“手势”触控模式以使用这些功能。 点按 单指拖动 长按 @@ -15,6 +14,8 @@ 三指按住 双指缩放 持续按住按钮 + 按住左键 + 按住右键 单击一次 按住延迟 双击间隔 diff --git a/app/src/main/res/values-zh-rCN/shortcuts.xml b/app/src/main/res/values-zh-rCN/shortcuts.xml new file mode 100644 index 000000000..53fa9d766 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/shortcuts.xml @@ -0,0 +1,13 @@ + + + 实体按键快捷操作 + DroidDeck 菜单 + 分配:%1$s + 按下并松开一个按钮,分配将自动保存。 + 松开按键以继续。 + %1$s:%2$s + 此按键已分配给:%1$s + 重新分配 + 尝试其他按键 + 主屏幕按钮和部分额外按钮可能无法使用。 + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 416839104..29fb0c0ea 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -607,6 +607,64 @@ 琥珀 紫罗兰 不透明度 + 大小 + 轻点 HUD 也可以切换大小。 + 完整 + 磁贴 + 胶囊 + 极简 + 超大 + 锁定位置 + 拖动 HUD 可移动位置;长按可锁定或解锁。锁定时,下方的屏幕控件仍可使用。 + 外观 + 屏幕显示大小 + 柔和 + 适中 + 鲜艳 + 描边 + 描边颜色 + 灰色 + 主题色 + 读数 + 帧时间图表 + 帧率显示小数 + 0.01% 最低帧 + 图形引擎 + GPU 名称 + GPU 负载 + GPU 温度 + CPU 负载 + CPU 各核心 + 温度 + 内存 + 显存 + 交换空间 + 电池电量 + 功耗 + 网络 + 分辨率 + Proton 版本 + 会话时长 + 时钟 + 温度 + 单位 + 警告颜色 + 温度接近上限时会先变为琥珀色,再变为红色。 + 关 + 自动 + 手动 + CPU 变红温度 + GPU 变红温度 + 电池变红温度 + 更多 + HUD 诊断 + 将 HUD 读取的所有传感器报告保存到 Downloads/DroidDeck。 + 保存 + 已保存到 %1$s + 无法保存 HUD 报告。 + 重置 HUD 设置 + 重置 + FusionHUD,作者 The412Banner,github.com/The412Banner/FusionHUD (GPL-3.0)。 缩放模式 缩放锐度 屏幕效果 @@ -645,6 +703,8 @@ 手柄以 1000 Hz 而非 250 Hz 上报。CPU 占用略高。 低延迟触控 触控跳过帧合并,Steam 触控以 1000 Hz 上报。CPU 占用略高。 + 原生鼠标通道 + 光标 PC 键盘 Android 副屏 @@ -768,6 +828,17 @@ 全屏 隐藏 Android 状态栏和导航栏 显示 Android 状态栏和导航栏 + 双屏 + 自动:在内置第二块屏幕的掌机上使用该屏幕。开启:使用任何第二块显示器。关闭:所有内容只显示在一块屏幕上。 + 自动 + 开启 + 关闭 + 下屏菜单 + 将各个分区和游戏内菜单移到第二块屏幕并在那里输入,让上屏保持完整画面。L2 和 R2 切换分区。 + 游戏中的下屏控制 + 会话开始时在第二块屏幕上打开触控板和键盘。 + 正在为上屏输入 + 完成 默认主屏幕应用 选择主屏幕应用 选择 @@ -1133,7 +1204,6 @@ 从未游玩 运行时繁忙 · %1$d%% 运行时繁忙 - 运行时在首次“游玩”时安装 运行时有可用更新 ● 运行时就绪 内部存储 @@ -3010,6 +3080,10 @@ 6DoF 虚拟屏幕 在光线充足的房间内缓慢环顾四周。\n不要遮挡追踪摄像头,并面向家具等有明显细节的物体。 DroidDeck ChArUco 30 mm + Steam 因非法指令(SIGILL)停止。这可能是 CPU 指令兼容问题,也可能是客户端崩溃。请分享日志并附上设备信息。 + 可尝试“Steam 设置 → Steam 客户端 → x86-64(实验)”。此选项需要额外下载,运行速度也可能更慢。 + Linux 图形驱动未找到可用的 Vulkan 设备。请确认所选 Linux GPU 驱动支持这颗 GPU。 + 当前配置缺少兼容这颗 GPU 的 Linux Vulkan 驱动,会话无法启动。Adreno Turnip 驱动不能为联发科/Mali GPU 增加支持。 内存 会话前关闭缓存的应用 会话开始时关闭 Android 留在后台的应用,让游戏有更多内存可用。需要开启无线调试。 @@ -3062,6 +3136,8 @@ 已在游戏库 显示 收藏集 + 可用更新:%1$d + 开发者已将此版本替换为 %1$s。要更新到该版本吗? 云 云设置 搜索应用 @@ -3105,4 +3181,20 @@ %d 个应用 + 正在恢复你的软件 + DroidDeck 更新后正在重新安装你添加的应用。 + 软件已恢复 + 你添加的应用已重新安装。 + 部分软件无法恢复 + 请查看 ~/.cache/droiddeck/apt-restore.log,然后在 Konsole 中尝试 sudo apt install。 + USB 连接速度慢 + %1$s 通过 %2$s 连接(%3$s Mbps,约 %4$d MB/s),其中的游戏加载会很慢。请使用 5 Gbps 或更快的 USB 3 数据线,尝试翻转 USB-C 插头,或使用更快的硬盘。 + %1$s 通过 %2$s 连接(%3$s Mbps,约 %4$d MB/s)。此设备的 USB 接口无法更快,游戏加载会很慢。内部存储或 microSD 卡可能更快。 + 不再显示 + 关闭 + 重启 Steam 以添加你的游戏? + Steam 可以将你拥有的这些游戏显示为已安装:%1$s。需要重启 Steam 才能显示。现在重启,或在下次启动 Steam 时添加。 + %1$s 等另外 %2$d 款 + 立即重启 + 稍后 diff --git a/app/src/main/res/values-zh-rHK/gestures.xml b/app/src/main/res/values-zh-rHK/gestures.xml index 45bbcc062..7cd3314bc 100644 --- a/app/src/main/res/values-zh-rHK/gestures.xml +++ b/app/src/main/res/values-zh-rHK/gestures.xml @@ -2,7 +2,6 @@ 手勢 觸控手勢設定 - 自訂滑鼠與鍵盤手勢。請選擇「手勢」觸控模式以使用這些功能。 點按 單指拖曳 長按 @@ -15,6 +14,8 @@ 三指按住 雙指縮放 持續按住按鈕 + 按住左鍵 + 按住右鍵 按一下 按住延遲 連點兩下間隔 diff --git a/app/src/main/res/values-zh-rHK/shortcuts.xml b/app/src/main/res/values-zh-rHK/shortcuts.xml new file mode 100644 index 000000000..4a4d7f170 --- /dev/null +++ b/app/src/main/res/values-zh-rHK/shortcuts.xml @@ -0,0 +1,13 @@ + + + 實體按鍵快捷操作 + DroidDeck 選單 + 指派:%1$s + 按下並放開一個按鈕,指派會自動儲存。 + 放開按鍵以繼續。 + %1$s:%2$s + 此按鍵已指派給:%1$s + 重新指派 + 嘗試其他按鍵 + 主畫面按鈕及部分額外按鈕可能無法使用。 + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index ee0daff39..e85e6dd66 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -607,6 +607,64 @@ 琥珀色 紫羅蘭 不透明度 + 大小 + 輕按 HUD 亦可切換大小。 + 完整 + 方塊 + 膠囊 + 極簡 + 超大 + 鎖定位置 + 拖曳 HUD 可移動位置;長按可鎖定或解鎖。鎖定時,下方的螢幕控制項仍可使用。 + 外觀 + 螢幕顯示大小 + 柔和 + 適中 + 鮮豔 + 描邊 + 描邊顏色 + 灰色 + 主題色 + 讀數 + 幀時間圖表 + 幀率顯示小數 + 0.01% 最低幀 + 圖形引擎 + GPU 名稱 + GPU 負載 + GPU 溫度 + CPU 負載 + CPU 各核心 + 溫度 + 記憶體 + 顯示記憶體 + 交換空間 + 電池電量 + 功耗 + 網絡 + 解像度 + Proton 版本 + 會話時長 + 時鐘 + 溫度 + 單位 + 警告顏色 + 溫度接近上限時會先變成琥珀色,再變成紅色。 + 關閉 + 自動 + 手動 + CPU 變紅溫度 + GPU 變紅溫度 + 電池變紅溫度 + 更多 + HUD 診斷 + 將 HUD 讀取的所有感應器報告儲存到 Downloads/DroidDeck。 + 儲存 + 已儲存到 %1$s + 無法儲存 HUD 報告。 + 重設 HUD 設定 + 重設 + FusionHUD,作者 The412Banner,github.com/The412Banner/FusionHUD (GPL-3.0)。 縮放模式 縮放銳利度 畫面效果 @@ -645,6 +703,8 @@ 控制器以 1000 Hz 而非 250 Hz 回報。CPU 使用率略高。 低延遲觸控 觸控略過幀合併,Steam 觸控以 1000 Hz 回報。CPU 使用率略高。 + 原生滑鼠通道 + 游標 電腦鍵盤 Android 第二螢幕 @@ -768,6 +828,17 @@ 全螢幕 隱藏 Android 狀態列和導覽列 顯示 Android 狀態列和導覽列 + 雙屏 + 自動:在內置第二塊螢幕的掌機上使用該螢幕。開啟:使用任何第二塊顯示器。關閉:所有內容只顯示在一塊螢幕上。 + 自動 + 開啟 + 關閉 + 下屏選單 + 將各個分區和遊戲內選單移到第二塊螢幕並在那裡輸入,讓上屏保持完整畫面。L2 和 R2 切換分區。 + 遊戲中的下屏控制 + 工作階段開始時在第二塊螢幕上開啟觸控板和鍵盤。 + 正在為上屏輸入 + 完成 預設主畫面應用程式 選擇主畫面應用程式 選擇 @@ -1050,7 +1121,6 @@ 從未玩過 運行環境繁忙 · %1$d%% 運行環境繁忙 - 首次開啟 Steam 時安裝運行環境 運行環境有可用更新 ● 運行環境已就緒 內部儲存空間 @@ -3009,6 +3079,10 @@ 6DoF 虛擬螢幕 在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。 DroidDeck ChArUco 30 mm + Steam 因非法指令(SIGILL)停止。這可能是 CPU 指令兼容問題,也可能是客戶端崩潰。請分享記錄並附上裝置資訊。 + 可嘗試「Steam 設定 → Steam 客戶端 → x86-64(實驗)」。此選項需要額外下載,執行速度也可能更慢。 + Linux 圖形驅動程式未找到可用的 Vulkan 裝置。請確認所選 Linux GPU 驅動程式支援這顆 GPU。 + 目前設定缺少兼容這顆 GPU 的 Linux Vulkan 驅動程式,工作階段無法啟動。Adreno Turnip 驅動程式無法為聯發科/Mali GPU 增加支援。 記憶體 工作階段前關閉快取的應用程式 工作階段開始時關閉 Android 留在背景的應用程式,讓遊戲有更多記憶體可用。需要開啟無線偵錯。 @@ -3061,6 +3135,8 @@ 已在收藏庫 顯示 收藏集 + 可用更新:%1$d + 開發者已將此版本替換為 %1$s。要更新到該版本嗎? 雲端 雲端設定 搜尋應用程式 @@ -3104,4 +3180,20 @@ %d 個應用程式 + 正在還原你的軟件 + DroidDeck 更新後正在重新安裝你加入的應用程式。 + 軟件已還原 + 你加入的應用程式已重新安裝。 + 部分軟件無法還原 + 請查看 ~/.cache/droiddeck/apt-restore.log,然後在 Konsole 中嘗試 sudo apt install。 + USB 連線速度慢 + %1$s 透過 %2$s 連接(%3$s Mbps,約 %4$d MB/s),當中的遊戲載入會很慢。請使用 5 Gbps 或更快的 USB 3 線,嘗試反轉 USB-C 插頭,或使用更快的硬碟。 + %1$s 透過 %2$s 連接(%3$s Mbps,約 %4$d MB/s)。此裝置的 USB 埠無法更快,遊戲載入會很慢。內部儲存空間或 microSD 卡可能更快。 + 不再顯示 + 關閉 + 重新啟動 Steam 以加入你的遊戲? + Steam 可將你擁有的這些遊戲顯示為已安裝:%1$s。需要重新啟動 Steam 才會顯示。立即重新啟動,或在下次啟動 Steam 時加入。 + %1$s 及另外 %2$d 款 + 立即重新啟動 + 稍後 diff --git a/app/src/main/res/values-zh-rTW/gestures.xml b/app/src/main/res/values-zh-rTW/gestures.xml index 464f94d88..7841fe7b4 100644 --- a/app/src/main/res/values-zh-rTW/gestures.xml +++ b/app/src/main/res/values-zh-rTW/gestures.xml @@ -2,7 +2,6 @@ 手勢 觸控手勢設定 - 自訂滑鼠與鍵盤手勢。請選擇「手勢」觸控模式以使用這些功能。 點按 單指拖曳 長按 @@ -15,6 +14,8 @@ 三指按住 雙指縮放 持續按住按鈕 + 按住左鍵 + 按住右鍵 按一下 按住延遲 連點兩下間隔 diff --git a/app/src/main/res/values-zh-rTW/shortcuts.xml b/app/src/main/res/values-zh-rTW/shortcuts.xml new file mode 100644 index 000000000..4a4d7f170 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/shortcuts.xml @@ -0,0 +1,13 @@ + + + 實體按鍵快捷操作 + DroidDeck 選單 + 指派:%1$s + 按下並放開一個按鈕,指派會自動儲存。 + 放開按鍵以繼續。 + %1$s:%2$s + 此按鍵已指派給:%1$s + 重新指派 + 嘗試其他按鍵 + 主畫面按鈕及部分額外按鈕可能無法使用。 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 27effeacb..f174dc20b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -607,6 +607,64 @@ 琥珀 紫羅蘭 不透明度 + 大小 + 輕觸 HUD 也可以切換大小。 + 完整 + 圖塊 + 膠囊 + 極簡 + 超大 + 鎖定位置 + 拖曳 HUD 可移動位置;長按可鎖定或解鎖。鎖定時,下方的螢幕控制項仍可使用。 + 外觀 + 螢幕顯示大小 + 柔和 + 適中 + 鮮豔 + 描邊 + 描邊顏色 + 灰色 + 主題色 + 讀數 + 幀時間圖表 + 幀率顯示小數 + 0.01% 最低幀 + 圖形引擎 + GPU 名稱 + GPU 負載 + GPU 溫度 + CPU 負載 + CPU 各核心 + 溫度 + 記憶體 + 顯示記憶體 + 交換空間 + 電池電量 + 功耗 + 網路 + 解析度 + Proton 版本 + 工作階段時間 + 時鐘 + 溫度 + 單位 + 警告顏色 + 溫度接近上限時會先變成琥珀色,再變成紅色。 + 關閉 + 自動 + 手動 + CPU 變紅溫度 + GPU 變紅溫度 + 電池變紅溫度 + 更多 + HUD 診斷 + 將 HUD 讀取的所有感測器報告儲存到 Downloads/DroidDeck。 + 儲存 + 已儲存到 %1$s + 無法儲存 HUD 報告。 + 重設 HUD 設定 + 重設 + FusionHUD,作者 The412Banner,github.com/The412Banner/FusionHUD (GPL-3.0)。 縮放模式 縮放銳利度 畫面效果 @@ -645,6 +703,8 @@ 控制器以 1000 Hz 而非 250 Hz 回報。CPU 使用率略高。 低延遲觸控 觸控略過影格合併,Steam 觸控以 1000 Hz 回報。CPU 使用率略高。 + 原生滑鼠通道 + 游標 電腦鍵盤 Android 第二螢幕 @@ -768,6 +828,17 @@ 全螢幕 隱藏 Android 狀態列和導覽列 顯示 Android 狀態列和導覽列 + 雙螢幕 + 自動:在內建第二個螢幕的掌機上使用該螢幕。開啟:使用任何第二個顯示器。關閉:所有內容只顯示在一個螢幕上。 + 自動 + 開啟 + 關閉 + 下螢幕選單 + 將各個區塊和遊戲內選單移到第二個螢幕並在那裡輸入,讓上螢幕保持完整畫面。L2 和 R2 切換區塊。 + 遊戲中的下螢幕控制 + 工作階段開始時在第二個螢幕上開啟觸控板和鍵盤。 + 正在為上螢幕輸入 + 完成 預設主螢幕應用程式 選擇主螢幕應用程式 選擇 @@ -1050,7 +1121,6 @@ 從未執行 執行環境繁忙 · %1$d%% 執行環境繁忙 - 首次點擊「執行」時安裝執行環境 執行環境有可用更新 ● 執行環境已就緒 內部儲存空間 @@ -3009,6 +3079,10 @@ 6DoF 虛擬螢幕 在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。 DroidDeck ChArUco 30 mm + Steam 因非法指令(SIGILL)停止。這可能是 CPU 指令相容問題,也可能是用戶端當機。請分享記錄並附上裝置資訊。 + 可嘗試「Steam 設定 → Steam 用戶端 → x86-64(實驗)」。此選項需要額外下載,執行速度也可能更慢。 + Linux 圖形驅動程式未找到可用的 Vulkan 裝置。請確認所選 Linux GPU 驅動程式支援這顆 GPU。 + 目前設定缺少相容這顆 GPU 的 Linux Vulkan 驅動程式,工作階段無法啟動。Adreno Turnip 驅動程式無法為聯發科/Mali GPU 增加支援。 記憶體 工作階段前關閉快取的應用程式 工作階段開始時關閉 Android 留在背景的應用程式,讓遊戲有更多記憶體可用。需要開啟無線偵錯。 @@ -3061,6 +3135,8 @@ 已在收藏庫 顯示 收藏集 + 可用更新:%1$d + 開發者已將此版本替換為 %1$s。要更新到該版本嗎? 雲端 雲端設定 搜尋應用程式 @@ -3104,4 +3180,20 @@ %d 個應用程式 + 正在還原你的軟體 + DroidDeck 更新後正在重新安裝你加入的應用程式。 + 軟體已還原 + 你加入的應用程式已重新安裝。 + 部分軟體無法還原 + 請查看 ~/.cache/droiddeck/apt-restore.log,然後在 Konsole 中嘗試 sudo apt install。 + USB 連線速度慢 + %1$s 透過 %2$s 連接(%3$s Mbps,約 %4$d MB/s),其中的遊戲載入會很慢。請使用 5 Gbps 或更快的 USB 3 傳輸線,試著翻轉 USB-C 插頭,或使用更快的硬碟。 + %1$s 透過 %2$s 連接(%3$s Mbps,約 %4$d MB/s)。此裝置的 USB 連接埠無法更快,遊戲載入會很慢。內部儲存空間或 microSD 卡可能更快。 + 不再顯示 + 關閉 + 重新啟動 Steam 以加入你的遊戲? + Steam 可以將你擁有的這些遊戲顯示為已安裝:%1$s。需要重新啟動 Steam 才會顯示。立即重新啟動,或在下次啟動 Steam 時加入。 + %1$s 及另外 %2$d 款 + 立即重新啟動 + 稍後 diff --git a/app/src/main/res/values/gestures.xml b/app/src/main/res/values/gestures.xml index 7c9132422..65f3a25b7 100644 --- a/app/src/main/res/values/gestures.xml +++ b/app/src/main/res/values/gestures.xml @@ -2,7 +2,6 @@ Gestures Touch gesture settings - Customize mouse and keyboard gestures. Select Gestures as the touch mode to use them. Tap One-finger drag Long press @@ -15,6 +14,8 @@ Three-finger hold Pinch to zoom Keep button held + Hold left button + Hold right button Click once Hold delay Double-tap interval diff --git a/app/src/main/res/values/shortcuts.xml b/app/src/main/res/values/shortcuts.xml new file mode 100644 index 000000000..d7e7a26d5 --- /dev/null +++ b/app/src/main/res/values/shortcuts.xml @@ -0,0 +1,13 @@ + + + Physical button shortcuts + DroidDeck menu + Assign: %1$s + Press and release a button. Your assignment saves automatically. + Release the button to continue. + %1$s: %2$s + This button is already assigned to: %1$s + Reassign + Try another button + Home and some extra buttons may be unavailable. + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8979e00b6..421dd380c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,24 @@ + Manage GOG installation + Check for updates + Verify/Repair + Offline patches + Download full offline installer + Update to %1$s + Resume GOG maintenance + Open patch installer + Finish or cancel the patch installer in the Linux session. + Choose a patch for your installed version and language. Supported source versions may not be listed. + GOG has no standalone Windows patches for this game. + Modified game files may be replaced. + The installer opens in your Linux session. + Download + Extra space + Space estimate + Repair files + Refresh installation + Select Luma Ultra cameras — Experimental Joy-Con position tracking Use the glasses cameras to position your paired Joy-Cons. Rotation and button inputs come from the Joy-Cons. Position is estimated when your wrists are hidden. Replaces bare-hand controllers and pauses phone-camera tracking. Point the Joy-Cons forward when connecting or recentering. @@ -623,6 +642,8 @@ Its files are removed from the app. Its files are removed. It\'s in use, so the default takes over. Display + Frame limit + Caps this game. The display switches to a refresh rate the cap divides evenly when it has one; other caps can look uneven. Steam games use Steam\'s Frame Limit in the Quick Access menu. Effects Controls Components @@ -647,6 +668,64 @@ Amber Violet Opacity + Size + Tapping the HUD also changes its size. + Full + Tiles + Pill + Minimal + Mega + Lock in place + Drag the HUD to move it; a long press locks or unlocks it. While it is locked, on-screen controls under it still work. + Look + Size on screen + Soft + Medium + Vivid + Outline + Outline color + Gray + Theme color + Readings + Frame time graph + Frame rate with a decimal + 0.01% low + Graphics engine + GPU name + GPU load + GPU temperature + CPU load + CPU per core + Temperatures + Memory + Graphics memory + Swap + Battery level + Power draw + Network + Resolution + Proton version + Session time + Clock + Temperatures + Unit + Warning colors + Temperatures turn amber, then red, as they near the limit. + Off + Automatic + Manual + CPU red at + GPU red at + Battery red at + More + HUD diagnostics + Saves a report of every sensor the HUD reads to Downloads/DroidDeck. + Save + Saved to %1$s + Couldn\'t save the HUD report. + Reset HUD settings + Reset + FusionHUD by The412Banner, github.com/The412Banner/FusionHUD (GPL-3.0). Scaling mode Scaling sharpness Screen effects @@ -711,6 +790,8 @@ Pad reports at 1000 Hz instead of 250 Hz. Slightly more CPU. Low-latency touch Touches skip frame batching; Steam touch controls at 1000 Hz. Slightly more CPU. + Raw mouse channel + Cursor PC keyboard Android Second screen @@ -800,6 +881,10 @@ Session Session logs Saved after each session + Game debug logs + Include Proton, Wine and graphics diagnostics on the next game launch. Can reduce performance and use more storage. + Restart this session to save game debug logs + Couldn\'t update logging. Check the switch and try again. Debugging tools Latest session logs Saved session logs @@ -843,6 +928,17 @@ Fullscreen Hides the Android status and navigation bars Shows the Android status and navigation bars + Dual screen + Automatic uses the second screen on handhelds with a built-in one. On uses any second display. Off keeps the launcher and games on one screen; you can still pick a second screen from the session menu. + Automatic + On + Off + Bottom screen menu + Moves the sections and the in-game menu to the second screen and types there, so the top screen keeps the full view. L2 and R2 switch sections. + Bottom screen controls in games + Opens the trackpad and keyboard on the second screen when a session starts. + Typing on the top screen + Done Default Home app Choose a Home app Choose @@ -993,6 +1089,8 @@ In About phone, tap Build number 7 times Turn on Wireless debugging Open Wireless debugging, then tap “Pair device with pairing code” + Turn on “Disable child process restrictions” + Disable child process restrictions Build number Wireless debugging Pair device with pairing code @@ -1176,7 +1274,6 @@ Never played Runtime busy · %1$d%% Runtime busy - Runtime installs on first Play Runtime update available ● Runtime ready Internal storage @@ -1310,6 +1407,9 @@ Picture in picture is available once the session is ready. Allow picture in picture for DroidDeck in Android settings. Android could not open picture in picture. + More + Edit art + In Steam Shortcut Add to home screen Choose game @@ -2221,6 +2321,13 @@ Resume Refresh Sort + Sidebar labels + Show names under the sidebar icons. Turn off for a slimmer sidebar. + Display + Larger cards + Smaller cards + Show title + Show size Recently played Recently installed Title A–Z @@ -2955,6 +3062,9 @@ Installed games Prefix %1$s · shaders %2$s · workshop %3$s Clear shaders + Reset prefix + Reset prefix for %1$s? + Deletes its Wine prefix (%1$s) in %2$s and recreates it on the next launch. Saves, settings and installed redistributables inside it are lost; Steam Cloud saves stay in your account. Uninstalled games Steam app %1$d Its prefix still holds files, which can include local saves @@ -3215,6 +3325,10 @@ Saved views can be recalculated. Start a new calibration to capture in the current tracking frame. Hand model checksum mismatch OpenCV could not load + Steam stopped with an illegal instruction (SIGILL). This can be a CPU instruction compatibility problem or a client crash. Share the logs with your device details. + You can try Steam settings → Steam client → x86-64 (experimental). It needs an additional download and may run more slowly. + The Linux graphics driver could not find a usable Vulkan device. Check that the selected Linux GPU driver supports this GPU. + This session cannot start because this GPU has no compatible Linux Vulkan driver in the current setup. Adreno Turnip drivers cannot enable support for MediaTek/Mali GPUs. Memory Clear cached apps before a session When a session starts, DroidDeck closes the apps Android keeps parked in the background, so the game has more memory. Wireless debugging must be on. @@ -3283,6 +3397,8 @@ In Library Show Collections + Updates available: %1$d + The developer replaced this build with %1$s. Update to it? Cloud Cloud settings Xbox Cloud Gaming @@ -3290,7 +3406,6 @@ Steam integration %1$d of %2$d on Set up - Not now Location App info Wi-Fi names @@ -3310,4 +3425,22 @@ DroidDeck Home button Pass Home button presses to Steam Guide while a Steam session is focused and DroidDeck is the default Home app. Does not read screen content. Turn on Brightness in Setup → Steam integration + Restoring your software + Reinstalling the apps you added after a DroidDeck update. + Software restored + The apps you added are installed again. + Some software couldn\'t be restored + See ~/.cache/droiddeck/apt-restore.log, then try sudo apt install in Konsole. + Native optimizations are unavailable. The download includes complete replacement files. + Verify patch result + Slow USB connection + %1$s is connected over %2$s (%3$s Mbps, about %4$d MB/s), so games on it will load slowly. Use a USB 3 cable rated 5 Gbps or faster, try flipping the USB-C plug, or use a faster drive. + %1$s is connected over %2$s (%3$s Mbps, about %4$d MB/s). This device\'s USB port can\'t go faster, so games on it will load slowly. Internal storage or a microSD card may be quicker. + Don\'t show this again + Dismiss + Restart Steam to add your games? + Steam can list these games you own as installed: %1$s. It has to restart to show them. Restart now, or they will be added the next time Steam starts. + %1$s and %2$d more + Restart now + Later diff --git a/app/src/main/rust/stores/Cargo.lock b/app/src/main/rust/stores/Cargo.lock index 90055ee4c..2fac37c5a 100644 --- a/app/src/main/rust/stores/Cargo.lock +++ b/app/src/main/rust/stores/Cargo.lock @@ -1,3 +1,5 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. version = 4 [[package]] @@ -186,6 +188,7 @@ dependencies = [ name = "droiddeckstores" version = "0.1.0" dependencies = [ + "cc", "flate2", "futures-util", "jni", diff --git a/app/src/main/rust/stores/Cargo.toml b/app/src/main/rust/stores/Cargo.toml index d21d5132b..61d121a22 100644 --- a/app/src/main/rust/stores/Cargo.toml +++ b/app/src/main/rust/stores/Cargo.toml @@ -21,6 +21,9 @@ serde_json = { version = "1", features = ["preserve_order"] } sha1 = "0.10" sha2 = "0.10" +[build-dependencies] +cc = "1" + [profile.release] codegen-units = 1 lto = "thin" diff --git a/app/src/main/rust/stores/JNI.md b/app/src/main/rust/stores/JNI.md index ee7e5a827..b87390ac8 100644 --- a/app/src/main/rust/stores/JNI.md +++ b/app/src/main/rust/stores/JNI.md @@ -77,14 +77,14 @@ after it (marker files, exe resolution, DLC markers, redist orchestration). | `nativeCancel(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_gog_GogNative_nativeCancel` | | `nativeRelease(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_gog_GogNative_nativeRelease` | -`nativeProbe()` always returns `1`; it exists so `isAvailable()` can catch `UnsatisfiedLinkError` +`nativeProbe()` returns `2`; it exists so `isAvailable()` can catch `UnsatisfiedLinkError` once at startup instead of mid-download. `nativeStart` parameters, in order: | # | Parameter | Meaning | |---|---|---| -| 1 | `kind: Int` | `0` = `KIND_GEN2_CHUNKS`, `1` = `KIND_GEN1_RANGES` (anything else → gen2) | +| 1 | `kind: Int` | `0` = `KIND_GEN2_CHUNKS`, `1` = `KIND_GEN1_RANGES`, `2` = GOG maintenance (anything else → gen2) | | 2 | `depotManifests: Array` | gen2: the inflated depot-manifest JSON strings, in fetch order, already filtered by product + language; gen1: the inflated build manifest (one element) | | 3 | `cdnBases: Array` | gen2: **every** CDN base of the secure-link answer — one per `urls[]` entry, each resolved the way `parseCdnUrl` resolves `urls[0]` today (query string intact), in GOG's order — or the one unauthenticated dependency store base (an array of one); gen1: `emptyArray()` (file URLs are in the manifest). Blank entries are ignored and a base whose host already appeared is dropped (the first wins). The fetch core spreads the window across the distinct hosts with a per-host cap of `max(6, ceil(maxWorkers / hosts))`, rotating a failed chunk to another CDN; one base behaves exactly like the old single `cdnBase`. The start log line lists them as `hosts=a,b,c`. | | 4 | `installDir: String` | absolute install directory | @@ -113,6 +113,15 @@ On-disk protocol (what a resumed run, by either engine, relies on): a file is as `.bhtmp`, then renamed over `` once its size and MD5 match the manifest; an existing `` whose size+MD5 match is skipped. The engine writes nothing else. +Maintenance (`kind=2`) instead takes a single normalized plan in `depotManifests[0]`: +`files`, `oldFiles`, optional exact-build `patches`, per-product `links` and `patchLinks`, +and estimated `downloadBytes`. Credentials and these temporary links stay in memory. +It stages replacements under `.droiddeck-gog/stage`, revalidates completed chunks on +every run, and verifies each file before atomic replacement. `onBytes` counts actual +network bytes, including failed attempts. `onMaintenance(networkBytes, reusedBytes, +appliedBytes)` (`(JJJ)V`) reports the three separate cumulative counters. Existing +gen1/gen2 first-install progress semantics are unchanged. + ## `com.droiddeck.launcher.stores.epic.EpicNative` Everything Epic runs in the Rust `epic` module: the web-service calls (sign-in, refresh, exchange diff --git a/app/src/main/rust/stores/build.rs b/app/src/main/rust/stores/build.rs new file mode 100644 index 000000000..f80559c87 --- /dev/null +++ b/app/src/main/rust/stores/build.rs @@ -0,0 +1,21 @@ +fn main() { + cc::Build::new() + .include("vendor/xdelta3") + .file("vendor/xdelta3/xdelta3.c") + .file("src/gog/delta.c") + .define("SIZEOF_SIZE_T", "8") + .define("SIZEOF_UNSIGNED_INT", "4") + .define("SIZEOF_UNSIGNED_LONG", "8") + .define("SIZEOF_UNSIGNED_LONG_LONG", "8") + .define("XD3_USE_LARGEFILE64", "1") + .define("XD3_ENCODER", "0") + .define("SECONDARY_DJW", "0") + .define("SECONDARY_FGK", "0") + .define("SECONDARY_LZMA", "0") + .define("SHELL_TESTS", "0") + .define("XD3_MAIN", "0") + .warnings(false) + .compile("gogdelta"); + println!("cargo:rerun-if-changed=vendor/xdelta3"); + println!("cargo:rerun-if-changed=src/gog/delta.c"); +} diff --git a/app/src/main/rust/stores/src/gog.rs b/app/src/main/rust/stores/src/gog.rs index 3aed66074..e3a049c4c 100644 --- a/app/src/main/rust/stores/src/gog.rs +++ b/app/src/main/rust/stores/src/gog.rs @@ -2,6 +2,7 @@ pub mod engine; pub mod jni; pub mod plan; +pub mod maintenance; use std::fs::File; use std::io::Read; diff --git a/app/src/main/rust/stores/src/gog/delta.c b/app/src/main/rust/stores/src/gog/delta.c new file mode 100644 index 000000000..fd64ea924 --- /dev/null +++ b/app/src/main/rust/stores/src/gog/delta.c @@ -0,0 +1,78 @@ +#include "xdelta3.h" + +typedef struct { size_t used; } dd_budget; +typedef struct { size_t size; } dd_allocation; + +static void *dd_alloc(void *opaque, size_t count, usize_t size) { + dd_budget *budget = opaque; + if (size && count > (62u * 1024u * 1024u) / size) return NULL; + size_t n = count * size; + if (n > 62u * 1024u * 1024u - budget->used) return NULL; + dd_allocation *allocation = malloc(sizeof(*allocation) + n); + if (!allocation) return NULL; + allocation->size = n; + budget->used += n; + return allocation + 1; +} + +static void dd_free(void *opaque, void *ptr) { + if (!ptr) return; + dd_allocation *allocation = (dd_allocation *)ptr - 1; + ((dd_budget *)opaque)->used -= allocation->size; + free(allocation); +} + +int dd_gog_delta(const char *source, const char *patch, const char *target, + int (*cancel)(void *), void *opaque) { + FILE *old = NULL, *delta = NULL, *out = NULL; + xd3_stream stream = {0}; + xd3_source src = {0}; + xd3_config config = {0}; + dd_budget budget = {0}; + unsigned char *input = NULL, *block = NULL; + int result = -1, configured = 0; + old = fopen(source, "rb"); delta = fopen(patch, "rb"); out = fopen(target, "wb"); + if (!old || !delta || !out) goto done; + input = malloc(64 * 1024); block = malloc(1024 * 1024); + if (!input || !block) goto done; + config.winsize = 64 * 1024; + config.alloc = dd_alloc; config.freef = dd_free; config.opaque = &budget; + if (xd3_config_stream(&stream, &config)) goto done; + configured = 1; + src.blksize = 1024 * 1024; src.curblk = block; src.curblkno = 0; + src.onblk = fread(block, 1, src.blksize, old); + if (xd3_set_source(&stream, &src)) goto done; + for (;;) { + size_t n = fread(input, 1, 64 * 1024, delta); + if (ferror(delta)) goto done; + if (feof(delta)) xd3_set_flags(&stream, XD3_FLUSH); + xd3_avail_input(&stream, input, n); + int code; + while ((code = xd3_decode_input(&stream)) != XD3_INPUT) { + if (cancel && cancel(opaque)) { result = 130; goto done; } + switch (code) { + case XD3_OUTPUT: + if (fwrite(stream.next_out, 1, stream.avail_out, out) != stream.avail_out) goto done; + xd3_consume_output(&stream); break; + case XD3_GETSRCBLK: + if (fseeko(old, (off_t)(src.getblkno * src.blksize), SEEK_SET)) goto done; + src.curblkno = src.getblkno; + src.onblk = fread(block, 1, src.blksize, old); + if (ferror(old)) goto done; + break; + case XD3_GOTHEADER: case XD3_WINSTART: case XD3_WINFINISH: break; + default: goto done; + } + } + if (feof(delta)) break; + } + if (xd3_close_stream(&stream) || fflush(out)) goto done; + result = 0; +done: + if (configured) xd3_free_stream(&stream); + if (old) fclose(old); + if (delta) fclose(delta); + if (out && fclose(out)) result = -1; + free(input); free(block); + return result; +} diff --git a/app/src/main/rust/stores/src/gog/engine.rs b/app/src/main/rust/stores/src/gog/engine.rs index 9f2f59428..c1c1a8b11 100644 --- a/app/src/main/rust/stores/src/gog/engine.rs +++ b/app/src/main/rust/stores/src/gog/engine.rs @@ -32,11 +32,14 @@ pub enum PlanKind { #[default] Gen2Chunks, Gen1Ranges, + Maintenance, } impl PlanKind { pub fn from_i32(v: i32) -> Self { - if v == 1 { + if v == 2 { + PlanKind::Maintenance + } else if v == 1 { PlanKind::Gen1Ranges } else { PlanKind::Gen2Chunks @@ -72,6 +75,7 @@ pub trait GogEvents: Sync { ); fn on_log(&self, line: &str); fn on_bytes(&self, _bytes_done: u64, _bytes_total: u64) {} + fn on_maintenance(&self, _network_bytes: u64, _reused_bytes: u64, _applied_bytes: u64) {} } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -672,6 +676,7 @@ pub fn run(req: &GogRequest, cancel: &AtomicBool, events: &dyn GogEvents) -> Gog match req.kind { PlanKind::Gen2Chunks => run_gen2(req, cancel, events), PlanKind::Gen1Ranges => run_gen1(req, cancel, events), + PlanKind::Maintenance => super::maintenance::run(req, cancel, events), } } diff --git a/app/src/main/rust/stores/src/gog/fixtures/update.vcdiff b/app/src/main/rust/stores/src/gog/fixtures/update.vcdiff new file mode 100644 index 000000000..f053eb540 Binary files /dev/null and b/app/src/main/rust/stores/src/gog/fixtures/update.vcdiff differ diff --git a/app/src/main/rust/stores/src/gog/jni.rs b/app/src/main/rust/stores/src/gog/jni.rs index 9fa1fbf01..c2e1b0bd5 100644 --- a/app/src/main/rust/stores/src/gog/jni.rs +++ b/app/src/main/rust/stores/src/gog/jni.rs @@ -153,6 +153,15 @@ impl GogEvents for JniEvents { let _ = env.delete_local_ref(text); }); } + + fn on_maintenance(&self, network_bytes: u64, reused_bytes: u64, applied_bytes: u64) { + self.with_env(|env| { + let _ = env.call_method(self.listener.as_obj(), "onMaintenance", "(JJJ)V", &[ + JValue::Long(network_bytes as jlong), JValue::Long(reused_bytes as jlong), JValue::Long(applied_bytes as jlong), + ]); + Self::clear_exception(env); + }); + } } fn jstring_to_string(env: &mut JNIEnv, value: &JString) -> Option { @@ -197,7 +206,7 @@ pub extern "system" fn Java_com_droiddeck_launcher_stores_gog_GogNative_nativePr _env: JNIEnv, _class: JClass, ) -> jint { - 1 + 2 } #[no_mangle] diff --git a/app/src/main/rust/stores/src/gog/maintenance.rs b/app/src/main/rust/stores/src/gog/maintenance.rs new file mode 100644 index 000000000..b2a9338c2 --- /dev/null +++ b/app/src/main/rust/stores/src/gog/maintenance.rs @@ -0,0 +1,523 @@ +use std::collections::{HashMap, HashSet}; +use std::ffi::CString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use serde_json::Value; +use super::engine::{GogEvents, GogRequest, GogRunResult, http_status_in, is_link_expiry_status}; +use super::{md5_hex, md5_hex_file}; + +static DELTA_LOCK: Mutex<()> = Mutex::new(()); +unsafe extern "C" { + fn dd_gog_delta(source: *const libc::c_char, patch: *const libc::c_char, target: *const libc::c_char, + cancel: extern "C" fn(*mut libc::c_void) -> libc::c_int, opaque: *mut libc::c_void) -> libc::c_int; +} +extern "C" fn cancelled(opaque: *mut libc::c_void) -> libc::c_int { + unsafe { (*(opaque as *const AtomicBool)).load(Ordering::Relaxed) as libc::c_int } +} + +fn http_client(read_timeout: std::time::Duration) -> Result { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(20)) + .read_timeout(read_timeout) + .build().map_err(|e| e.to_string()) +} + +pub fn inside(root: &Path, relative: &str) -> Result { + if relative.is_empty() || relative.contains(['\\', ':', '\0']) || relative.split('/').any(|p| p.is_empty() || p == "." || p == "..") { return Err("invalid GOG path".into()); } + let mut out = root.to_path_buf(); + for component in Path::new(relative).components() { + let Component::Normal(part) = component else { return Err("GOG path leaves installation".into()) }; + out.push(part); + if fs::symlink_metadata(&out).map(|m| m.file_type().is_symlink()).unwrap_or(false) { + return Err("GOG path is a symbolic link".into()); + } + } + Ok(out) +} + +fn text<'a>(v: &'a Value, key: &str) -> &'a str { v.get(key).and_then(Value::as_str).unwrap_or("") } +fn num(v: &Value, key: &str) -> u64 { v.get(key).and_then(Value::as_u64).unwrap_or(0) } +fn array<'a>(v: &'a Value, key: &str) -> &'a [Value] { v.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) } +fn valid_md5(s: &str) -> bool { s.len() == 32 && s.bytes().all(|c| c.is_ascii_hexdigit()) } + +fn matches(path: &Path, file: &Value) -> bool { + if !fs::metadata(path).map(|m| m.is_file() && m.len() == num(file, "size")).unwrap_or(false) { return false; } + let md5 = text(file, "md5"); + if valid_md5(md5) { return md5_hex_file(path).map(|h| h.eq_ignore_ascii_case(md5)).unwrap_or(false); } + let sha = text(file, "sha256"); + if sha.len() == 64 { + use sha2::{Digest, Sha256}; + let Ok(mut input) = File::open(path) else { return false; }; + let mut digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; + loop { let Ok(n) = input.read(&mut buffer) else { return false; }; if n == 0 { break; } digest.update(&buffer[..n]); } + return super::to_hex(&digest.finalize()).eq_ignore_ascii_case(sha); + } + let chunks = array(file, "chunks"); + if chunks.is_empty() { return num(file, "size") == 0; } + let Ok(mut input) = File::open(path) else { return false; }; + chunks.iter().all(|chunk| { + let size = num(chunk, "size"); + valid_md5(text(chunk, "md5")) && hash_range(&mut input, size).as_deref() == Some(text(chunk, "md5")) + }) +} + +fn hash_range(input: &mut File, size: u64) -> Option { + let mut digest = crate::md5_small::Md5::new(); + let mut remaining = size; + let mut buffer = [0u8; 64 * 1024]; + while remaining > 0 { + let want = remaining.min(buffer.len() as u64) as usize; + input.read_exact(&mut buffer[..want]).ok()?; + digest.update(&buffer[..want]); remaining -= want as u64; + } + Some(super::to_hex(&digest.finalize())) +} + +#[derive(Clone)] +struct Reuse { path: PathBuf, offset: u64, size: u64 } +type ReuseMap = HashMap<(String, u64), Vec>; + +fn reuse_map(root: &Path, files: &[Value]) -> Result { + let mut result: ReuseMap = HashMap::new(); + for file in files { + if text(file, "path").to_lowercase().starts_with(".droiddeck-") { return Err("GOG content overlaps launcher metadata".into()); } + let path = inside(root, text(file, "path"))?; + let mut offset: u64 = 0; + for chunk in array(file, "chunks") { + let size = num(chunk, "size"); + if valid_md5(text(chunk, "md5")) { + result.entry((text(chunk, "md5").to_ascii_lowercase(), size)).or_default() + .push(Reuse { path: path.clone(), offset, size }); + } + offset = offset.checked_add(size).ok_or("GOG file size overflow")?; + } + } + Ok(result) +} + +fn reusable(chunk: &Value, map: &ReuseMap) -> Option { + let key = (text(chunk, "md5").to_ascii_lowercase(), num(chunk, "size")); + map.get(&key)?.iter().find(|old| { + let Ok(mut file) = File::open(&old.path) else { return false; }; + file.seek(SeekFrom::Start(old.offset)).is_ok() && hash_range(&mut file, old.size).map(|h| h.eq_ignore_ascii_case(&key.0)).unwrap_or(false) + }).cloned() +} + +struct Transfer<'a> { + runtime: tokio::runtime::Runtime, + client: reqwest::Client, + cancel: &'a AtomicBool, + events: &'a dyn GogEvents, + wire: u64, + total: u64, +} + +impl Transfer<'_> { + fn fetch(&mut self, urls: &[String], range: Option<(u64, u64)>, dest: &Path, limit: u64) -> Result<(), String> { + let mut failure = "GOG download has no URLs".to_string(); + for _ in 0..3 { + for url in urls { + if self.cancel.load(Ordering::Relaxed) { return Err("cancelled".into()); } + let result = self.runtime.block_on(async { + let mut request = self.client.get(url).header("User-Agent", "GOG Galaxy"); + if let Some((start, end)) = range { request = request.header("Range", format!("bytes={start}-{end}")); } + let mut response = request.send().await.map_err(|_| "GOG connection failed".to_string())?; + let status = response.status().as_u16(); + if status != if range.is_some() { 206 } else { 200 } { return Err(format!("HTTP {status}")); } + let mut output = File::create(dest).map_err(|e| e.to_string())?; + let mut size = 0u64; + while let Some(bytes) = response.chunk().await.map_err(|_| "GOG connection interrupted".to_string())? { + if self.cancel.load(Ordering::Relaxed) { return Err("cancelled".into()); } + size += bytes.len() as u64; + if limit > 0 && size > limit { return Err("GOG response exceeds expected size".into()); } + self.wire += bytes.len() as u64; + self.events.on_bytes(self.wire, self.total.max(self.wire)); + output.write_all(&bytes).map_err(|e| e.to_string())?; + } + if limit > 0 && size != limit { return Err("GOG download is incomplete".into()); } + output.sync_all().map_err(|e| e.to_string()) + }); + match result { Ok(()) => return Ok(()), Err(e) => failure = e } + if http_status_in(&failure).map(is_link_expiry_status).unwrap_or(false) { return Err(failure); } + } + } + Err(failure) + } +} + +fn urls(links: &Value, product: &str, suffix: &str) -> Vec { + links.get(product).and_then(Value::as_array).map(|bases| bases.iter().filter_map(Value::as_str).map(|base| { + if base.contains("{GALAXY_PATH}") { base.replace("{GALAXY_PATH}", suffix) } + else if let Some((path, query)) = base.split_once('?') { format!("{}{suffix}?{query}", path.trim_end_matches('/').to_string() + "/") } + else { format!("{}{suffix}", base.trim_end_matches('/').to_string() + "/") } + }).collect()).unwrap_or_default() +} + +fn chunk_path(chunk: &Value) -> Result { + let hash = text(chunk, "compressedMd5"); + if !valid_md5(hash) { return Err("GOG chunk has no valid compressed hash".into()); } + Ok(super::plan::build_cdn_path(hash)) +} + +fn download_chunk(transfer: &mut Transfer, links: &Value, product: &str, chunk: &Value, scratch: &Path, output: &mut File) -> Result<(), String> { + transfer.fetch(&urls(links, product, &chunk_path(chunk)?), None, scratch, num(chunk, "compressedSize"))?; + if !md5_hex_file(scratch).map(|h| h.eq_ignore_ascii_case(text(chunk, "compressedMd5"))).unwrap_or(false) { return Err("GOG compressed chunk checksum mismatch".into()); } + let mut raw = File::open(scratch).map_err(|e| e.to_string())?; + let mut head = [0u8; 1]; raw.read_exact(&mut head).map_err(|e| e.to_string())?; + raw.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?; + let stored = num(chunk, "size") == num(chunk, "compressedSize") && text(chunk, "md5").eq_ignore_ascii_case(text(chunk, "compressedMd5")); + let input: Box = if !stored && head[0] == 0x78 { Box::new(flate2::read::ZlibDecoder::new(raw)) } else { Box::new(raw) }; + let mut input = input.take(num(chunk, "size") + 1); + let mut digest = crate::md5_small::Md5::new(); + let mut size = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let n = input.read(&mut buffer).map_err(|_| "GOG chunk decompression failed")?; + if n == 0 { break; } + if transfer.cancel.load(Ordering::Relaxed) { return Err("cancelled".into()); } + digest.update(&buffer[..n]); size += n as u64; + output.write_all(&buffer[..n]).map_err(|e| e.to_string())?; + } + if size != num(chunk, "size") || !super::to_hex(&digest.finalize()).eq_ignore_ascii_case(text(chunk, "md5")) { return Err("GOG chunk checksum mismatch".into()); } + Ok(()) +} + +fn apply_delta(root: &Path, file: &Value, patch: &Value, plan: &Value, stage: &Path, transfer: &mut Transfer) -> Result<(), String> { + let _guard = DELTA_LOCK.lock().map_err(|_| "GOG delta lock poisoned")?; + let source = inside(root, &text(patch, "path_source").replace('\\', "/"))?; + if !md5_hex_file(&source).map(|h| h.eq_ignore_ascii_case(text(patch, "md5_source"))).unwrap_or(false) { return Err("GOG delta source changed".into()); } + let payload = stage.with_extension("delta"); + let packed = stage.with_extension("packed"); + let mut output = File::create(&payload).map_err(|e| e.to_string())?; + for chunk in array(patch, "chunks") { download_chunk(transfer, &plan["patchLinks"], text(patch, "product"), chunk, &packed, &mut output)?; } + drop(output); + if !md5_hex_file(&payload).map(|h| h.eq_ignore_ascii_case(text(patch, "md5"))).unwrap_or(false) { return Err("GOG delta checksum mismatch".into()); } + let cpath = |p: &Path| CString::new(p.as_os_str().as_encoded_bytes()).map_err(|_| "invalid path"); + let (s, p, t) = (cpath(&source)?, cpath(&payload)?, cpath(stage)?); + let code = unsafe { dd_gog_delta(s.as_ptr(), p.as_ptr(), t.as_ptr(), cancelled, transfer.cancel as *const AtomicBool as *mut libc::c_void) }; + let _ = fs::remove_file(payload); let _ = fs::remove_file(packed); + if code != 0 || !matches(stage, file) || !md5_hex_file(stage).map(|h| h.eq_ignore_ascii_case(text(patch, "md5_target"))).unwrap_or(false) { return Err("GOG delta reconstruction failed".into()); } + Ok(()) +} + +pub fn run(req: &GogRequest, cancel: &AtomicBool, events: &dyn GogEvents) -> GogRunResult { + let mut result = GogRunResult::default(); + let work = || -> Result<(u64, u32), String> { + let plan: Value = serde_json::from_str(req.depot_manifests.first().ok_or("missing GOG maintenance plan")?).map_err(|_| "invalid GOG maintenance plan")?; + let root = fs::canonicalize(&req.install_dir).map_err(|e| e.to_string())?; + let files = array(&plan, "files"); + let mut seen = HashSet::new(); + let mut components = HashMap::new(); + for file in files { + let name = text(file, "path"); inside(&root, name)?; + if name.to_lowercase().starts_with(".droiddeck-") || !seen.insert(name.to_lowercase()) { return Err("ambiguous GOG path".into()); } + let mut parent = root.clone(); + let mut relative = PathBuf::new(); + for part in name.split('/') { + relative.push(part); + let key = relative.to_string_lossy().to_lowercase(); + if let Some(previous) = components.insert(key, relative.clone()) { + if previous != relative { return Err("ambiguous GOG directory".into()); } + } + if let Ok(entries) = fs::read_dir(&parent) { + for entry in entries.flatten() { + let actual = entry.file_name().to_string_lossy().into_owned(); + if actual != part && actual.to_lowercase() == part.to_lowercase() { return Err("existing path differs only by case".into()); } + } + } + parent.push(part); + } + } + let map = reuse_map(&root, array(&plan, "oldFiles"))?; + let stages = inside(&root, ".droiddeck-gog/stage")?; + fs::create_dir_all(&stages).map_err(|e| e.to_string())?; + let mut transfer = Transfer { + runtime: tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?, + client: http_client(std::time::Duration::from_secs(60))?, + cancel, events, wire: 0, total: num(&plan, "downloadBytes"), + }; + let mut done = 0u32; let mut reused = 0u64; let mut applied = 0u64; + for file in files { + if cancel.load(Ordering::Relaxed) { return Err("cancelled".into()); } + let path = inside(&root, text(file, "path"))?; + if file.get("directory").and_then(Value::as_bool).unwrap_or(false) { + fs::create_dir_all(&path).map_err(|e| e.to_string())?; done += 1; continue; + } + if matches(&path, file) { done += 1; events.on_file_done(text(file, "path"), num(file, "size"), true, done, files.len() as u32, transfer.wire, transfer.total); continue; } + let stage = stages.join(md5_hex(text(file, "path").as_bytes())); + if !matches(&stage, file) { + let mut start = 0; + let missing_bytes: u64 = array(file, "chunks").iter().map(|chunk| { + let valid = File::open(&stage).ok().map(|mut input| input.seek(SeekFrom::Start(start)).is_ok() && + hash_range(&mut input, num(chunk, "size")).map(|h| h.eq_ignore_ascii_case(text(chunk, "md5"))).unwrap_or(false)).unwrap_or(false); + start += num(chunk, "size"); + if valid || reusable(chunk, &map).is_some() { 0 } else { num(chunk, "compressedSize") } + }).sum(); + let patch = array(&plan, "patches").iter().find(|p| text(p, "path_target").replace('\\', "/") == text(file, "path") && + array(p, "chunks").iter().map(|c| num(c, "compressedSize")).sum::() < missing_bytes); + let delta_ok = if let Some(patch) = patch { + match apply_delta(&root, file, patch, &plan, &stage, &mut transfer) { + Ok(()) => true, + Err(e) => { if cancel.load(Ordering::Relaxed) || http_status_in(&e).map(is_link_expiry_status).unwrap_or(false) { return Err(e); } events.on_log(&format!("gog: delta fallback file={} reason={e}", text(file, "path"))); false } + } + } else { false }; + if !delta_ok { + let chunks = array(file, "chunks"); + if chunks.is_empty() && num(file, "size") > 0 { + let offset = num(file, "offset"); + let remote = text(file, "remotePath"); + let endpoints = if remote.starts_with("https://") { vec![remote.to_string()] } else { urls(&plan["links"], text(file, "product"), remote.trim_start_matches('/')) }; + transfer.fetch(&endpoints, Some((offset, offset + num(file, "size") - 1)), &stage, num(file, "size"))?; + } else { + let mut output = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(&stage).map_err(|e| e.to_string())?; + let mut offset = 0u64; + for chunk in chunks { + if cancel.load(Ordering::Relaxed) { return Err("cancelled".into()); } + output.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?; + let valid_stage = hash_range(&mut output, num(chunk, "size")).map(|h| h.eq_ignore_ascii_case(text(chunk, "md5"))).unwrap_or(false); + output.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?; + if valid_stage { reused += num(chunk, "size"); } + else if let Some(old) = reusable(chunk, &map) { + let mut source = File::open(old.path).map_err(|e| e.to_string())?; + source.seek(SeekFrom::Start(old.offset)).map_err(|e| e.to_string())?; + std::io::copy(&mut source.take(old.size), &mut output).map_err(|e| e.to_string())?; + reused += old.size; + } else { + download_chunk(&mut transfer, &plan["links"], text(file, "product"), chunk, &stage.with_extension("packed"), &mut output)?; + } + offset += num(chunk, "size"); + } + output.set_len(num(file, "size")).map_err(|e| e.to_string())?; + output.sync_all().map_err(|e| e.to_string())?; + } + } + } + let unhashed_range = array(file, "chunks").is_empty() && !valid_md5(text(file, "md5")) && text(file, "sha256").is_empty(); + if !matches(&stage, file) && !(unhashed_range && fs::metadata(&stage).map(|m| m.len() == num(file, "size")).unwrap_or(false)) { return Err(format!("GOG target verification failed: {}", text(file, "path"))); } + if cancel.load(Ordering::Relaxed) { return Err("cancelled".into()); } + inside(&root, text(file, "path"))?; + fs::create_dir_all(path.parent().ok_or("GOG file has no parent")?).map_err(|e| e.to_string())?; + File::open(&stage).and_then(|f| f.sync_all()).map_err(|e| e.to_string())?; + fs::rename(&stage, &path).map_err(|e| e.to_string())?; + applied += num(file, "size"); done += 1; + events.on_file_done(text(file, "path"), num(file, "size"), false, done, files.len() as u32, transfer.wire, transfer.total); + events.on_maintenance(transfer.wire, reused, applied); + events.on_log(&format!("gog: maintenance wire={} reused={reused} applied={applied} files={done}", transfer.wire)); + } + for old in array(&plan, "oldFiles") { + if cancel.load(Ordering::Relaxed) { return Err("cancelled".into()); } + if !seen.contains(&text(old, "path").to_lowercase()) { + let path = inside(&root, text(old, "path"))?; + if matches(&path, old) { fs::remove_file(path).map_err(|e| e.to_string())?; } + } + } + events.on_log(&format!("gog: maintenance complete wire={} reused={reused} applied={applied}", transfer.wire)); + Ok((transfer.wire, done)) + }; + match work() { + Ok((bytes, files)) => { result.success = true; result.bytes_written = bytes; result.files_done = files; } + Err(error) => { result.cancelled = cancel.load(Ordering::Relaxed); result.link_expiry = http_status_in(&error).map(is_link_expiry_status).unwrap_or(false); result.error = error; } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::{write::ZlibEncoder, Compression}; + use serde_json::json; + use std::net::TcpListener; + use std::sync::{Arc, atomic::AtomicU64}; + + struct Events; + impl GogEvents for Events { + fn on_file_done(&self, _: &str, _: u64, _: bool, _: u32, _: u32, _: u64, _: u64) {} + fn on_log(&self, _: &str) {} + } + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("gog-maintenance-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + fs::create_dir_all(&path).unwrap(); Self(path) + } + } + impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } + + struct Server { base: String, bytes: Arc, stop: Arc, thread: Option> } + impl Server { + fn new(payloads: HashMap>, range: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); let bytes = Arc::new(AtomicU64::new(0)); + let (stopped, count) = (stop.clone(), bytes.clone()); + let thread = std::thread::spawn(move || { + while !stopped.load(Ordering::Relaxed) { + if let Ok((mut stream, _)) = listener.accept() { + stream.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + let mut request = Vec::new(); let mut one = [0u8]; + while stream.read(&mut one).unwrap_or(0) > 0 { request.push(one[0]); if request.ends_with(b"\r\n\r\n") { break; } } + let request = String::from_utf8_lossy(&request); + let path = request.split_whitespace().nth(1).unwrap_or(""); + if let Some(body) = payloads.get(path) { + let status = if range { "206 Partial Content" } else { "200 OK" }; + let _ = write!(stream, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + if stream.write_all(body).is_ok() { count.fetch_add(body.len() as u64, Ordering::Relaxed); } + } else { let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); } + } else { std::thread::sleep(std::time::Duration::from_millis(2)); } + } + }); + Self { base, bytes, stop, thread: Some(thread) } + } + } + impl Drop for Server { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); if let Some(thread) = self.thread.take() { thread.join().unwrap(); } } } + + #[test] + fn continuing_download_may_exceed_the_read_timeout_in_total() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(3))).unwrap(); + let mut request = Vec::new(); let mut byte = [0u8]; + while stream.read(&mut byte).unwrap() > 0 { request.push(byte[0]); if request.ends_with(b"\r\n\r\n") { break; } } + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\n").unwrap(); + for byte in b"abcd" { + stream.write_all(&[*byte]).unwrap(); stream.flush().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(400)); + } + }); + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + let client = http_client(std::time::Duration::from_secs(1)).unwrap(); + let body = runtime.block_on(async { client.get(url).send().await.unwrap().bytes().await.unwrap() }); + assert_eq!(&body[..], b"abcd"); + server.join().unwrap(); + } + + fn chunk(data: &[u8]) -> (Value, String, Vec) { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); encoder.write_all(data).unwrap(); + let packed = encoder.finish().unwrap(); let hash = md5_hex(&packed); + (json!({"md5": md5_hex(data), "size": data.len(), "compressedMd5": hash, "compressedSize": packed.len()}), + format!("/{}", super::super::plan::build_cdn_path(&hash)), packed) + } + fn execute(root: &Path, files: Value, old: Value, server: &Server) -> GogRunResult { + let plan = json!({"files": files, "oldFiles": old, "patches": [], "links": {"1": [server.base]}, "downloadBytes": 4096}); + run(&GogRequest { install_dir: root.to_string_lossy().into_owned(), depot_manifests: vec![plan.to_string()], ..Default::default() }, &AtomicBool::new(false), &Events) + } + + #[test] + fn reuses_moved_and_repeated_chunks_and_downloads_only_missing_bytes() { + let temp = Temp::new(); + let (a, _, _) = chunk(b"aaaa"); let (b, path, packed) = chunk(b"bbbb"); + fs::write(temp.0.join("archive"), b"aaaaaaaa").unwrap(); + let server = Server::new(HashMap::from([(path, packed.clone())]), false); + let old = json!([{"path":"archive", "size":8, "md5":md5_hex(b"aaaaaaaa"), "chunks":[a,a]}]); + let files = json!([{"path":"archive", "size":12, "md5":md5_hex(b"bbbbaaaaaaaa"), "chunks":[b,a,a], "product":"1"}]); + let result = execute(&temp.0, files, old, &server); + assert!(result.success, "{}", result.error); + assert_eq!(fs::read(temp.0.join("archive")).unwrap(), b"bbbbaaaaaaaa"); + assert_eq!(server.bytes.load(Ordering::Relaxed), packed.len() as u64); + assert_eq!(result.bytes_written, packed.len() as u64); + } + + #[test] + fn revalidates_partial_staging_and_preserves_original_until_verified() { + let temp = Temp::new(); let (a, _, _) = chunk(b"aaaa"); let (b, path, packed) = chunk(b"bbbb"); + let stages = temp.0.join(".droiddeck-gog/stage"); fs::create_dir_all(&stages).unwrap(); + fs::write(temp.0.join("archive"), b"original").unwrap(); + fs::write(stages.join(md5_hex(b"archive")), b"aaaabadd").unwrap(); + let server = Server::new(HashMap::from([(path, packed.clone())]), false); + let files = json!([{"path":"archive", "size":8, "md5":md5_hex(b"aaaabbbb"), "chunks":[a,b], "product":"1"}]); + let result = execute(&temp.0, files, json!([]), &server); + assert!(result.success, "{}", result.error); + assert_eq!(server.bytes.load(Ordering::Relaxed), packed.len() as u64); + assert_eq!(fs::read(temp.0.join("archive")).unwrap(), b"aaaabbbb"); + } + + #[test] + fn unchanged_files_need_no_network_and_obsolete_modified_files_survive() { + let temp = Temp::new(); let (a, _, _) = chunk(b"aaaa"); + fs::write(temp.0.join("current"), b"aaaa").unwrap(); + fs::write(temp.0.join("obsolete"), b"old").unwrap(); fs::write(temp.0.join("modded"), b"mod").unwrap(); + let server = Server::new(HashMap::new(), false); + let files = json!([{"path":"current", "size":4, "md5":md5_hex(b"aaaa"), "chunks":[a], "product":"1"}, {"path":"empty", "size":0, "chunks":[], "product":"1"}]); + let old = json!([{"path":"obsolete", "size":3, "md5":md5_hex(b"old")}, {"path":"modded", "size":3, "md5":md5_hex(b"old")}]); + let result = execute(&temp.0, files, old, &server); + assert!(result.success, "{}", result.error); + assert_eq!(server.bytes.load(Ordering::Relaxed), 0); + assert!(!temp.0.join("obsolete").exists()); assert!(temp.0.join("modded").exists()); + assert_eq!(fs::metadata(temp.0.join("empty")).unwrap().len(), 0); + } + + #[test] + fn gen1_same_size_corruption_is_replaced_using_hash_and_range() { + let temp = Temp::new(); fs::write(temp.0.join("game"), b"bad!").unwrap(); + let server = Server::new(HashMap::from([("/range".to_string(), b"good".to_vec())]), true); + let files = json!([{"path":"game", "size":4, "md5":md5_hex(b"good"), "chunks":[], "product":"1", "remotePath":"range", "offset":10}]); + let result = execute(&temp.0, files, json!([]), &server); + assert!(result.success, "{}", result.error); assert_eq!(fs::read(temp.0.join("game")).unwrap(), b"good"); + assert_eq!(server.bytes.load(Ordering::Relaxed), 4); + } + + #[test] + fn bad_chunks_never_replace_original_and_expired_links_are_reported() { + let temp = Temp::new(); fs::write(temp.0.join("game"), b"old!").unwrap(); + let (a, path, mut packed) = chunk(b"new!"); packed[0] ^= 1; + let server = Server::new(HashMap::from([(path, packed)]), false); + let files = json!([{"path":"game", "size":4, "md5":md5_hex(b"new!"), "chunks":[a], "product":"1"}]); + let result = execute(&temp.0, files.clone(), json!([]), &server); + assert!(!result.success); assert_eq!(fs::read(temp.0.join("game")).unwrap(), b"old!"); + let missing = Server::new(HashMap::new(), false); + let result = execute(&temp.0, files, json!([]), &missing); + assert!(result.link_expiry, "{}", result.error); + } + + #[test] + fn rejects_traversal_symlinks_and_case_collisions_before_fetching() { + let temp = Temp::new(); let outside = Temp::new(); + std::os::unix::fs::symlink(&outside.0, temp.0.join("linked")).unwrap(); + for path in ["../escape", "/absolute", "a/./b", "a//b", "linked/game"] { assert!(inside(&temp.0, path).is_err(), "{path}"); } + let server = Server::new(HashMap::new(), false); + let result = execute(&temp.0, json!([{"path":"Game"}, {"path":"game"}]), json!([]), &server); + assert!(!result.success); assert_eq!(server.bytes.load(Ordering::Relaxed), 0); + } + + #[test] + fn valid_xdelta_downloads_only_delta_and_corrupt_source_falls_back() { + let old = b"the old archive contents\n".repeat(4096); + let mut new = b"the new archive contents\n".to_vec(); new.extend_from_slice(&old); new.extend_from_slice(b"END\n"); + let delta = include_bytes!("fixtures/update.vcdiff"); + let (patch_chunk, patch_url, packed_delta) = chunk(delta); + let (target_chunk, target_url, packed_target) = chunk(&new); + for mode in 0..3 { + let corrupt = mode == 1; + let temp = Temp::new(); + fs::write(temp.0.join("game"), if corrupt { vec![b'x'; old.len()] } else { old.clone() }).unwrap(); + let (delta_chunk, delta_url, delta_body) = if mode == 2 { chunk(b"unsupported VCDIFF") } else { (patch_chunk.clone(), patch_url.clone(), packed_delta.clone()) }; + let server = Server::new(HashMap::from([(delta_url, delta_body.clone()), (target_url.clone(), packed_target.clone())]), false); + let plan = json!({"files":[{"path":"game", "product":"1", "size":new.len(), "md5":md5_hex(&new), "chunks":[target_chunk]}], + "oldFiles":[{"path":"game", "size":old.len(), "md5":md5_hex(&old), "chunks":[]}], + "patches":[{"path_source":"game", "path_target":"game", "product":"1", "md5_source":md5_hex(&old), "md5_target":md5_hex(&new), "md5":if mode == 2 { md5_hex(b"unsupported VCDIFF") } else { md5_hex(delta) }, "chunks":[delta_chunk]}], + "links":{"1":[server.base]}, "patchLinks":{"1":[server.base]}, "downloadBytes":packed_target.len()}); + let result = run(&GogRequest {install_dir:temp.0.to_string_lossy().into_owned(), depot_manifests:vec![plan.to_string()], ..Default::default()}, &AtomicBool::new(false), &Events); + assert!(result.success, "{}", result.error); + assert_eq!(fs::read(temp.0.join("game")).unwrap(), new); + assert_eq!(server.bytes.load(Ordering::Relaxed), match mode { 1 => packed_target.len(), 2 => delta_body.len() + packed_target.len(), _ => packed_delta.len() } as u64); + } + } + + #[test] + fn stored_chunks_starting_with_x_are_not_mistaken_for_zlib() { + let temp = Temp::new(); let raw = b"xyz stored data"; + let hash = md5_hex(raw); + let path = format!("/{}", super::super::plan::build_cdn_path(&hash)); + let server = Server::new(HashMap::from([(path, raw.to_vec())]), false); + let result = execute(&temp.0, json!([{"path":"game", "product":"1", "size":raw.len(), "md5":hash, + "chunks":[{"md5":hash,"compressedMd5":hash,"size":raw.len(),"compressedSize":raw.len()}]}]), json!([]), &server); + assert!(result.success, "{}", result.error); + assert_eq!(fs::read(temp.0.join("game")).unwrap(), raw); + } +} diff --git a/app/src/main/rust/stores/vendor/xdelta3/LICENSE b/app/src/main/rust/stores/vendor/xdelta3/LICENSE new file mode 100644 index 000000000..7a774156a --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/LICENSE @@ -0,0 +1,176 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/app/src/main/rust/stores/vendor/xdelta3/README.droiddeck.md b/app/src/main/rust/stores/vendor/xdelta3/README.droiddeck.md new file mode 100644 index 000000000..696f3afa7 --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/README.droiddeck.md @@ -0,0 +1,7 @@ +Xdelta3 decoder from jmacd/xdelta revision 0525275fe4b553a10f38e455d30c60dc6ed9b45d (release3_0_apl), the +Apache-2.0 revision used by heroic-gogdl. Upstream sources are unmodified. +https://github.com/jmacd/xdelta/tree/0525275/xdelta3 + +DroidDeck's streaming wrapper is src/gog/delta.c. Encoding and secondary +compression are disabled, matching heroic-gogdl's decoder baseline. Unsupported +streams fall back to verified Galaxy chunks. No Python runtime is required. diff --git a/app/src/main/rust/stores/vendor/xdelta3/xdelta3-cfgs.h b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-cfgs.h new file mode 100644 index 000000000..84a222143 --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-cfgs.h @@ -0,0 +1,171 @@ +/* xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/****************************************************************** + SOFT string matcher + ******************************************************************/ + +#if XD3_BUILD_SOFT + +#define TEMPLATE soft +#define LLOOK stream->smatcher.large_look +#define LSTEP stream->smatcher.large_step +#define SLOOK stream->smatcher.small_look +#define SCHAIN stream->smatcher.small_chain +#define SLCHAIN stream->smatcher.small_lchain +#define MAXLAZY stream->smatcher.max_lazy +#define LONGENOUGH stream->smatcher.long_enough + +#define SOFTCFG 1 +#include "xdelta3.c" +#undef SOFTCFG + +#undef TEMPLATE +#undef LLOOK +#undef SLOOK +#undef LSTEP +#undef SCHAIN +#undef SLCHAIN +#undef MAXLAZY +#undef LONGENOUGH +#endif + +#define SOFTCFG 0 + +/************************************************************ + FASTEST string matcher + **********************************************************/ +#if XD3_BUILD_FASTEST +#define TEMPLATE fastest +#define LLOOK 9 +#define LSTEP 26 +#define SLOOK 4U +#define SCHAIN 1 +#define SLCHAIN 1 +#define MAXLAZY 6 +#define LONGENOUGH 6 + +#include "xdelta3.c" + +#undef TEMPLATE +#undef LLOOK +#undef SLOOK +#undef LSTEP +#undef SCHAIN +#undef SLCHAIN +#undef MAXLAZY +#undef LONGENOUGH +#endif + +/************************************************************ + FASTER string matcher + **********************************************************/ +#if XD3_BUILD_FASTER +#define TEMPLATE faster +#define LLOOK 9 +#define LSTEP 15 +#define SLOOK 4U +#define SCHAIN 1 +#define SLCHAIN 1 +#define MAXLAZY 18 +#define LONGENOUGH 18 + +#include "xdelta3.c" + +#undef TEMPLATE +#undef LLOOK +#undef SLOOK +#undef LSTEP +#undef SCHAIN +#undef SLCHAIN +#undef MAXLAZY +#undef LONGENOUGH +#endif + +/****************************************************** + FAST string matcher + ********************************************************/ +#if XD3_BUILD_FAST +#define TEMPLATE fast +#define LLOOK 9 +#define LSTEP 8 +#define SLOOK 4U +#define SCHAIN 4 +#define SLCHAIN 1 +#define MAXLAZY 18 +#define LONGENOUGH 35 + +#include "xdelta3.c" + +#undef TEMPLATE +#undef LLOOK +#undef SLOOK +#undef LSTEP +#undef SCHAIN +#undef SLCHAIN +#undef MAXLAZY +#undef LONGENOUGH +#endif + +/************************************************** + SLOW string matcher + **************************************************************/ +#if XD3_BUILD_SLOW +#define TEMPLATE slow +#define LLOOK 9 +#define LSTEP 2 +#define SLOOK 4U +#define SCHAIN 44 +#define SLCHAIN 13 +#define MAXLAZY 90 +#define LONGENOUGH 70 + +#include "xdelta3.c" + +#undef TEMPLATE +#undef LLOOK +#undef SLOOK +#undef LSTEP +#undef SCHAIN +#undef SLCHAIN +#undef MAXLAZY +#undef LONGENOUGH +#endif + +/******************************************************** + DEFAULT string matcher + ************************************************************/ +#if XD3_BUILD_DEFAULT +#define TEMPLATE default +#define LLOOK 9 +#define LSTEP 3 +#define SLOOK 4U +#define SCHAIN 8 +#define SLCHAIN 2 +#define MAXLAZY 36 +#define LONGENOUGH 70 + +#include "xdelta3.c" + +#undef TEMPLATE +#undef LLOOK +#undef SLOOK +#undef LSTEP +#undef SCHAIN +#undef SLCHAIN +#undef MAXLAZY +#undef LONGENOUGH +#endif diff --git a/app/src/main/rust/stores/vendor/xdelta3/xdelta3-decode.h b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-decode.h new file mode 100644 index 000000000..3933b0157 --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-decode.h @@ -0,0 +1,1218 @@ +/* xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +#ifndef _XDELTA3_DECODE_H_ +#define _XDELTA3_DECODE_H_ + +#include "xdelta3-internal.h" + +#define SRCORTGT(x) ((((x) & VCD_SRCORTGT) == VCD_SOURCE) ? \ + VCD_SOURCE : ((((x) & VCD_SRCORTGT) == \ + VCD_TARGET) ? VCD_TARGET : 0)) + +static inline int +xd3_decode_byte (xd3_stream *stream, usize_t *val) +{ + if (stream->avail_in == 0) + { + stream->msg = "further input required"; + return XD3_INPUT; + } + + (*val) = stream->next_in[0]; + + DECODE_INPUT (1); + return 0; +} + +static inline int +xd3_decode_bytes (xd3_stream *stream, uint8_t *buf, usize_t *pos, usize_t size) +{ + usize_t want; + usize_t take; + + /* Note: The case where (*pos == size) happens when a zero-length + * appheader or code table is transmitted, but there is nothing in + * the standard against that. */ + while (*pos < size) + { + if (stream->avail_in == 0) + { + stream->msg = "further input required"; + return XD3_INPUT; + } + + want = size - *pos; + take = xd3_min (want, stream->avail_in); + + memcpy (buf + *pos, stream->next_in, (size_t) take); + + DECODE_INPUT (take); + (*pos) += take; + } + + return 0; +} + +/* Initialize the decoder for a new window. The dec_tgtlen value is + * preserved across successive window decodings, and the update to + * dec_winstart is delayed until a new window actually starts. This + * is to avoid throwing an error due to overflow until the last + * possible moment. This makes it possible to encode exactly 4GB + * through a 32-bit encoder. */ +static int +xd3_decode_init_window (xd3_stream *stream) +{ + stream->dec_cpylen = 0; + stream->dec_cpyoff = 0; + stream->dec_cksumbytes = 0; + + xd3_init_cache (& stream->acache); + + return 0; +} + +/* Allocates buffer space for the target window and possibly the + * VCD_TARGET copy-window. Also sets the base of the two copy + * segments. */ +static int +xd3_decode_setup_buffers (xd3_stream *stream) +{ + /* If VCD_TARGET is set then the previous buffer may be reused. */ + if (stream->dec_win_ind & VCD_TARGET) + { + /* Note: this implementation is untested, since Xdelta3 itself + * does not implement an encoder for VCD_TARGET mode. Thus, mark + * unimplemented until needed. */ + if (1) + { + stream->msg = "VCD_TARGET not implemented"; + return XD3_UNIMPLEMENTED; + } + + /* But this implementation only supports copying from the last + * target window. If the offset is outside that range, it can't + * be done. */ + if (stream->dec_cpyoff < stream->dec_laststart) + { + stream->msg = "unsupported VCD_TARGET offset"; + return XD3_INVALID_INPUT; + } + + /* See if the two windows are the same. This indicates the + * first time VCD_TARGET is used. This causes a second buffer + * to be allocated, after that the two are swapped in the + * DEC_FINISH case. */ + if (stream->dec_lastwin == stream->next_out) + { + stream->next_out = NULL; + stream->space_out = 0; + } + + /* TODO: (See note above, this looks incorrect) */ + stream->dec_cpyaddrbase = stream->dec_lastwin + + (usize_t) (stream->dec_cpyoff - stream->dec_laststart); + } + + /* See if the current output window is large enough. */ + if (stream->space_out < stream->dec_tgtlen) + { + xd3_free (stream, stream->dec_buffer); + + stream->space_out = + xd3_round_blksize (stream->dec_tgtlen, XD3_ALLOCSIZE); + + if ((stream->dec_buffer = + (uint8_t*) xd3_alloc (stream, stream->space_out, 1)) == NULL) + { + return ENOMEM; + } + + stream->next_out = stream->dec_buffer; + } + + /* dec_tgtaddrbase refers to an invalid base address, but it is + * always used with a sufficiently large instruction offset (i.e., + * beyond the copy window). This condition is enforced by + * xd3_decode_output_halfinst. */ + stream->dec_tgtaddrbase = stream->next_out - stream->dec_cpylen; + + return 0; +} + +static int +xd3_decode_allocate (xd3_stream *stream, + usize_t size, + uint8_t **buf_ptr, + usize_t *buf_alloc) +{ + IF_DEBUG2 (DP(RINT "[xd3_decode_allocate] size %u alloc %u\n", + size, *buf_alloc)); + + if (*buf_ptr != NULL && *buf_alloc < size) + { + xd3_free (stream, *buf_ptr); + *buf_ptr = NULL; + } + + if (*buf_ptr == NULL) + { + *buf_alloc = xd3_round_blksize (size, XD3_ALLOCSIZE); + + if ((*buf_ptr = (uint8_t*) xd3_alloc (stream, *buf_alloc, 1)) == NULL) + { + return ENOMEM; + } + } + + return 0; +} + +static int +xd3_decode_section (xd3_stream *stream, + xd3_desect *section, + xd3_decode_state nstate, + int copy) +{ + XD3_ASSERT (section->pos <= section->size); + XD3_ASSERT (stream->dec_state != nstate); + + if (section->pos < section->size) + { + usize_t sect_take; + + if (stream->avail_in == 0) + { + return XD3_INPUT; + } + + if ((copy == 0) && (section->pos == 0)) + { + /* No allocation/copy needed */ + section->buf = stream->next_in; + sect_take = section->size; + IF_DEBUG1 (DP(RINT "[xd3_decode_section] zerocopy %u @ %u avail %u\n", + sect_take, section->pos, stream->avail_in)); + } + else + { + usize_t sect_need = section->size - section->pos; + + /* Allocate and copy */ + sect_take = xd3_min (sect_need, stream->avail_in); + + if (section->pos == 0) + { + int ret; + + if ((ret = xd3_decode_allocate (stream, + section->size, + & section->copied1, + & section->alloc1))) + { + return ret; + } + + section->buf = section->copied1; + } + + IF_DEBUG2 (DP(RINT "[xd3_decode_section] take %u @ %u [need %u] avail %u\n", + sect_take, section->pos, sect_need, stream->avail_in)); + XD3_ASSERT (section->pos + sect_take <= section->alloc1); + + memcpy (section->copied1 + section->pos, + stream->next_in, + sect_take); + } + + section->pos += sect_take; + + stream->dec_winbytes += sect_take; + + DECODE_INPUT (sect_take); + } + + if (section->pos < section->size) + { + IF_DEBUG1 (DP(RINT "[xd3_decode_section] further input required %u\n", section->size - section->pos)); + stream->msg = "further input required"; + return XD3_INPUT; + } + + XD3_ASSERT (section->pos == section->size); + + stream->dec_state = nstate; + section->buf_max = section->buf + section->size; + section->pos = 0; + return 0; +} + +/* Decode the size and address for half of an instruction (i.e., a + * single opcode). This updates the stream->dec_position, which are + * bytes already output prior to processing this instruction. Perform + * bounds checking for sizes and copy addresses, which uses the + * dec_position (which is why these checks are done here). */ +static int +xd3_decode_parse_halfinst (xd3_stream *stream, xd3_hinst *inst) +{ + int ret; + + /* If the size from the instruction table is zero then read a size value. */ + if ((inst->size == 0) && + (ret = xd3_read_size (stream, + & stream->inst_sect.buf, + stream->inst_sect.buf_max, + & inst->size))) + { + return XD3_INVALID_INPUT; + } + + /* For copy instructions, read address. */ + if (inst->type >= XD3_CPY) + { + IF_DEBUG2 ({ + static int cnt = 0; + XPR(NT "DECODE:%u: COPY at %"Q"u (winoffset %u) size %u winaddr %u\n", + cnt++, + stream->total_out + (stream->dec_position - + stream->dec_cpylen), + (stream->dec_position - stream->dec_cpylen), + inst->size, + inst->addr); + }); + + if ((ret = xd3_decode_address (stream, + stream->dec_position, + inst->type - XD3_CPY, + & stream->addr_sect.buf, + stream->addr_sect.buf_max, + & inst->addr))) + { + return ret; + } + + /* Cannot copy an address before it is filled-in. */ + if (inst->addr >= stream->dec_position) + { + stream->msg = "address too large"; + return XD3_INVALID_INPUT; + } + + /* Check: a VCD_TARGET or VCD_SOURCE copy cannot exceed the remaining + * buffer space in its own segment. */ + if (inst->addr < stream->dec_cpylen && + inst->addr + inst->size > stream->dec_cpylen) + { + stream->msg = "size too large"; + return XD3_INVALID_INPUT; + } + } + else + { + IF_DEBUG2 ({ + if (inst->type == XD3_ADD) + { + static int cnt; + XPR(NT "DECODE:%d: ADD at %"Q"u (winoffset %u) size %u\n", + cnt++, + (stream->total_out + stream->dec_position - stream->dec_cpylen), + stream->dec_position - stream->dec_cpylen, + inst->size); + } + else + { + static int cnt; + XD3_ASSERT (inst->type == XD3_RUN); + XPR(NT "DECODE:%d: RUN at %"Q"u (winoffset %u) size %u\n", + cnt++, + stream->total_out + stream->dec_position - stream->dec_cpylen, + stream->dec_position - stream->dec_cpylen, + inst->size); + } + }); + } + + /* Check: The instruction will not overflow the output buffer. */ + if (stream->dec_position + inst->size > stream->dec_maxpos) + { + stream->msg = "size too large"; + return XD3_INVALID_INPUT; + } + + stream->dec_position += inst->size; + return 0; +} + +/* Decode a single opcode and then decode the two half-instructions. */ +static int +xd3_decode_instruction (xd3_stream *stream) +{ + int ret; + const xd3_dinst *inst; + + if (stream->inst_sect.buf == stream->inst_sect.buf_max) + { + stream->msg = "instruction underflow"; + return XD3_INVALID_INPUT; + } + + inst = &stream->code_table[*stream->inst_sect.buf++]; + + stream->dec_current1.type = inst->type1; + stream->dec_current2.type = inst->type2; + stream->dec_current1.size = inst->size1; + stream->dec_current2.size = inst->size2; + + /* For each instruction with a real operation, decode the + * corresponding size and addresses if necessary. Assume a + * code-table may have NOOP in either position, although this is + * unlikely. */ + if (inst->type1 != XD3_NOOP && + (ret = xd3_decode_parse_halfinst (stream, & stream->dec_current1))) + { + return ret; + } + if (inst->type2 != XD3_NOOP && + (ret = xd3_decode_parse_halfinst (stream, & stream->dec_current2))) + { + return ret; + } + return 0; +} + +/* Output the result of a single half-instruction. OPT: This the + decoder hotspot. Modifies "hinst", see below. */ +static int +xd3_decode_output_halfinst (xd3_stream *stream, xd3_hinst *inst) +{ + /* This method is reentrant for copy instructions which may return + * XD3_GETSRCBLK to the caller. Each time through a copy takes the + * minimum of inst->size and the available space on whichever block + * supplies the data */ + usize_t take = inst->size; + + if (USIZE_T_OVERFLOW (stream->avail_out, take) || + stream->avail_out + take > stream->space_out) + { + stream->msg = "overflow while decoding"; + return XD3_INVALID_INPUT; + } + + XD3_ASSERT (inst->type != XD3_NOOP); + + switch (inst->type) + { + case XD3_RUN: + { + /* Only require a single data byte. */ + if (stream->data_sect.buf == stream->data_sect.buf_max) + { + stream->msg = "data underflow"; + return XD3_INVALID_INPUT; + } + + memset (stream->next_out + stream->avail_out, + stream->data_sect.buf[0], + take); + + stream->data_sect.buf += 1; + stream->avail_out += take; + inst->type = XD3_NOOP; + break; + } + case XD3_ADD: + { + /* Require at least TAKE data bytes. */ + if (stream->data_sect.buf + take > stream->data_sect.buf_max) + { + stream->msg = "data underflow"; + return XD3_INVALID_INPUT; + } + + memcpy (stream->next_out + stream->avail_out, + stream->data_sect.buf, + take); + + stream->data_sect.buf += take; + stream->avail_out += take; + inst->type = XD3_NOOP; + break; + } + default: + { + usize_t i; + const uint8_t *src; + uint8_t *dst; + int overlap; + + /* See if it copies from the VCD_TARGET/VCD_SOURCE window or + * the target window. Out-of-bounds checks for the addresses + * and sizes are performed in xd3_decode_parse_halfinst. This + * if/else must set "overlap", "src", and "dst". */ + if (inst->addr < stream->dec_cpylen) + { + /* In both branches we are copying from outside the + * current decoder window, the first (VCD_TARGET) is + * unimplemented. */ + overlap = 0; + + /* This branch sets "src". As a side-effect, we modify + * "inst" so that if we reenter this method after a + * XD3_GETSRCBLK response the state is correct. So if the + * instruction can be fulfilled by a contiguous block of + * memory then we will set: + * + * inst->type = XD3_NOOP; + * inst->size = 0; + */ + if (stream->dec_win_ind & VCD_TARGET) + { + /* TODO: Users have requested long-distance copies of + * similar material within a target (e.g., for dup + * supression in backups). This code path is probably + * dead due to XD3_UNIMPLEMENTED in xd3_decode_setup_buffers */ + inst->size = 0; + inst->type = XD3_NOOP; + stream->msg = "VCD_TARGET not implemented"; + return XD3_UNIMPLEMENTED; + } + else + { + /* In this case we have to read a source block, which + * could return control to the caller. We need to + * know the first block number needed for this + * copy. */ + xd3_source *source = stream->src; + xoff_t block = source->cpyoff_blocks; + usize_t blkoff = source->cpyoff_blkoff; + const usize_t blksize = source->blksize; + int ret; + + xd3_blksize_add (&block, &blkoff, source, inst->addr); + XD3_ASSERT (blkoff < blksize); + + if ((ret = xd3_getblk (stream, block))) + { + /* could be a XD3_GETSRCBLK failure. */ + if (ret == XD3_TOOFARBACK) + { + stream->msg = "non-seekable source in decode"; + ret = XD3_INTERNAL; + } + return ret; + } + + src = source->curblk + blkoff; + + /* This block is either full, or a partial block that + * must contain enough bytes. */ + if ((source->onblk != blksize) && + (blkoff + take > source->onblk)) + { + IF_DEBUG1 (XPR(NT "[srcfile] short at blkno %"Q"u onblk " + "%u blksize %u blkoff %u take %u\n", + block, + source->onblk, + blksize, + blkoff, + take)); + stream->msg = "source file too short"; + return XD3_INVALID_INPUT; + } + + XD3_ASSERT (blkoff != blksize); + + /* Check if we have enough data on this block to + * finish the instruction. */ + if (blkoff + take <= blksize) + { + inst->type = XD3_NOOP; + inst->size = 0; + } + else + { + take = blksize - blkoff; + inst->size -= take; + inst->addr += take; + + /* because (blkoff + take > blksize), above */ + XD3_ASSERT (inst->size != 0); + } + } + } + else + { + /* TODO: the memcpy/overlap optimization, etc. Overlap + * here could be more specific, it's whether (inst->addr - + * srclen) + inst->size > input_pos ? And is the system + * memcpy really any good? */ + overlap = 1; + + /* For a target-window copy, we know the entire range is + * in-memory. The dec_tgtaddrbase is negatively offset by + * dec_cpylen because the addresses start beyond that + * point. */ + src = stream->dec_tgtaddrbase + inst->addr; + inst->type = XD3_NOOP; + inst->size = 0; + } + + dst = stream->next_out + stream->avail_out; + + stream->avail_out += take; + + if (overlap) + { + /* Can't just memcpy here due to possible overlap. */ + for (i = take; i != 0; i -= 1) + { + *dst++ = *src++; + } + } + else + { + memcpy (dst, src, take); + } + } + } + + return 0; +} + +static int +xd3_decode_finish_window (xd3_stream *stream) +{ + stream->dec_winbytes = 0; + stream->dec_state = DEC_FINISH; + + stream->data_sect.pos = 0; + stream->inst_sect.pos = 0; + stream->addr_sect.pos = 0; + + return XD3_OUTPUT; +} + +static int +xd3_decode_secondary_sections (xd3_stream *secondary_stream) +{ +#if SECONDARY_ANY + int ret; +#define DECODE_SECONDARY_SECTION(UPPER,LOWER) \ + ((secondary_stream->dec_del_ind & VCD_ ## UPPER ## COMP) && \ + (ret = xd3_decode_secondary (secondary_stream, \ + & secondary_stream-> LOWER ## _sect, \ + & xd3_sec_ ## LOWER (secondary_stream)))) + + if (DECODE_SECONDARY_SECTION (DATA, data) || + DECODE_SECONDARY_SECTION (INST, inst) || + DECODE_SECONDARY_SECTION (ADDR, addr)) + { + return ret; + } +#undef DECODE_SECONDARY_SECTION +#endif + return 0; +} + +static int +xd3_decode_sections (xd3_stream *stream) +{ + usize_t need, more, take; + int copy, ret; + + if ((stream->flags & XD3_JUST_HDR) != 0) + { + /* Nothing left to do. */ + return xd3_decode_finish_window (stream); + } + + /* To avoid extra copying, allocate three sections at once (but + * check for overflow). */ + need = stream->inst_sect.size; + + if (USIZE_T_OVERFLOW (need, stream->addr_sect.size)) + { + stream->msg = "decoder section size overflow"; + return XD3_INTERNAL; + } + need += stream->addr_sect.size; + + if (USIZE_T_OVERFLOW (need, stream->data_sect.size)) + { + stream->msg = "decoder section size overflow"; + return XD3_INTERNAL; + } + need += stream->data_sect.size; + + /* The window may be entirely processed. */ + XD3_ASSERT (stream->dec_winbytes <= need); + + /* Compute how much more input is needed. */ + more = (need - stream->dec_winbytes); + + /* How much to consume. */ + take = xd3_min (more, stream->avail_in); + + /* See if the input is completely available, to avoid copy. */ + copy = (take != more); + + /* If the window is skipped... */ + if ((stream->flags & XD3_SKIP_WINDOW) != 0) + { + /* Skip the available input. */ + DECODE_INPUT (take); + + stream->dec_winbytes += take; + + if (copy) + { + stream->msg = "further input required"; + return XD3_INPUT; + } + + return xd3_decode_finish_window (stream); + } + + /* Process all but the DATA section. */ + switch (stream->dec_state) + { + default: + stream->msg = "internal error"; + return XD3_INVALID_INPUT; + + case DEC_DATA: + if ((ret = xd3_decode_section (stream, & stream->data_sect, + DEC_INST, copy))) { return ret; } + case DEC_INST: + if ((ret = xd3_decode_section (stream, & stream->inst_sect, + DEC_ADDR, copy))) { return ret; } + case DEC_ADDR: + if ((ret = xd3_decode_section (stream, & stream->addr_sect, + DEC_EMIT, copy))) { return ret; } + } + + XD3_ASSERT (stream->dec_winbytes == need); + + if ((ret = xd3_decode_secondary_sections (stream))) { return ret; } + + if (stream->flags & XD3_SKIP_EMIT) + { + return xd3_decode_finish_window (stream); + } + + /* OPT: A possible optimization is to avoid allocating memory in + * decode_setup_buffers and to avoid a large memcpy when the window + * consists of a single VCD_SOURCE copy instruction. */ + if ((ret = xd3_decode_setup_buffers (stream))) { return ret; } + + return 0; +} + +static int +xd3_decode_emit (xd3_stream *stream) +{ + int ret; + + /* Produce output: originally structured to allow reentrant code + * that fills as much of the output buffer as possible, but VCDIFF + * semantics allows to copy from anywhere from the target window, so + * instead allocate a sufficiently sized buffer after the target + * window length is decoded. + * + * This code still needs to be reentrant to allow XD3_GETSRCBLK to + * return control. This is handled by setting the + * stream->dec_currentN instruction types to XD3_NOOP after they + * have been processed. */ + XD3_ASSERT (! (stream->flags & XD3_SKIP_EMIT)); + XD3_ASSERT (stream->dec_tgtlen <= stream->space_out); + + while (stream->inst_sect.buf != stream->inst_sect.buf_max || + stream->dec_current1.type != XD3_NOOP || + stream->dec_current2.type != XD3_NOOP) + { + /* Decode next instruction pair. */ + if ((stream->dec_current1.type == XD3_NOOP) && + (stream->dec_current2.type == XD3_NOOP) && + (ret = xd3_decode_instruction (stream))) { return ret; } + + /* Output dec_current1 */ + while ((stream->dec_current1.type != XD3_NOOP)) + { + if ((ret = xd3_decode_output_halfinst (stream, & stream->dec_current1))) + { + return ret; + } + } + /* Output dec_current2 */ + while (stream->dec_current2.type != XD3_NOOP) + { + if ((ret = xd3_decode_output_halfinst (stream, & stream->dec_current2))) + { + return ret; + } + } + } + + if (stream->avail_out != stream->dec_tgtlen) + { + IF_DEBUG2 (DP(RINT "AVAIL_OUT(%d) != DEC_TGTLEN(%d)\n", + stream->avail_out, stream->dec_tgtlen)); + stream->msg = "wrong window length"; + return XD3_INVALID_INPUT; + } + + if (stream->data_sect.buf != stream->data_sect.buf_max) + { + stream->msg = "extra data section"; + return XD3_INVALID_INPUT; + } + + if (stream->addr_sect.buf != stream->addr_sect.buf_max) + { + stream->msg = "extra address section"; + return XD3_INVALID_INPUT; + } + + /* OPT: Should cksum computation be combined with the above loop? */ + if ((stream->dec_win_ind & VCD_ADLER32) != 0 && + (stream->flags & XD3_ADLER32_NOVER) == 0) + { + uint32_t a32 = adler32 (1L, stream->next_out, stream->avail_out); + + if (a32 != stream->dec_adler32) + { + stream->msg = "target window checksum mismatch"; + return XD3_INVALID_INPUT; + } + } + + /* Finished with a window. */ + return xd3_decode_finish_window (stream); +} + +int +xd3_decode_input (xd3_stream *stream) +{ + int ret; + + if (stream->enc_state != 0) + { + stream->msg = "encoder/decoder transition"; + return XD3_INVALID_INPUT; + } + +#define BYTE_CASE(expr,x,nstate) \ + do { \ + if ( (expr) && \ + ((ret = xd3_decode_byte (stream, & (x))) != 0) ) { return ret; } \ + stream->dec_state = (nstate); \ + } while (0) + +#define OFFSET_CASE(expr,x,nstate) \ + do { \ + if ( (expr) && \ + ((ret = xd3_decode_offset (stream, & (x))) != 0) ) { return ret; } \ + stream->dec_state = (nstate); \ + } while (0) + +#define SIZE_CASE(expr,x,nstate) \ + do { \ + if ( (expr) && \ + ((ret = xd3_decode_size (stream, & (x))) != 0) ) { return ret; } \ + stream->dec_state = (nstate); \ + } while (0) + + switch (stream->dec_state) + { + case DEC_VCHEAD: + { + if ((ret = xd3_decode_bytes (stream, stream->dec_magic, + & stream->dec_magicbytes, 4))) + { + return ret; + } + + if (stream->dec_magic[0] != VCDIFF_MAGIC1 || + stream->dec_magic[1] != VCDIFF_MAGIC2 || + stream->dec_magic[2] != VCDIFF_MAGIC3) + { + stream->msg = "not a VCDIFF input"; + return XD3_INVALID_INPUT; + } + + if (stream->dec_magic[3] != 0) + { + stream->msg = "VCDIFF input version > 0 is not supported"; + return XD3_INVALID_INPUT; + } + + stream->dec_state = DEC_HDRIND; + } + case DEC_HDRIND: + { + if ((ret = xd3_decode_byte (stream, & stream->dec_hdr_ind))) + { + return ret; + } + + if ((stream->dec_hdr_ind & VCD_INVHDR) != 0) + { + stream->msg = "unrecognized header indicator bits set"; + return XD3_INVALID_INPUT; + } + + stream->dec_state = DEC_SECONDID; + } + + case DEC_SECONDID: + /* Secondary compressor ID: only if VCD_SECONDARY is set */ + if ((stream->dec_hdr_ind & VCD_SECONDARY) != 0) + { + BYTE_CASE (1, stream->dec_secondid, DEC_TABLEN); + + switch (stream->dec_secondid) + { + case VCD_FGK_ID: + FGK_CASE (stream); + case VCD_DJW_ID: + DJW_CASE (stream); + case VCD_LZMA_ID: + LZMA_CASE (stream); + default: + stream->msg = "unknown secondary compressor ID"; + return XD3_INVALID_INPUT; + } + } + + case DEC_TABLEN: + /* Length of code table data: only if VCD_CODETABLE is set */ + SIZE_CASE ((stream->dec_hdr_ind & VCD_CODETABLE) != 0, + stream->dec_codetblsz, DEC_NEAR); + + /* The codetblsz counts the two NEAR/SAME bytes */ + if ((stream->dec_hdr_ind & VCD_CODETABLE) != 0) { + if (stream->dec_codetblsz <= 2) { + stream->msg = "invalid code table size"; + return ENOMEM; + } + stream->dec_codetblsz -= 2; + } + case DEC_NEAR: + /* Near modes: only if VCD_CODETABLE is set */ + BYTE_CASE((stream->dec_hdr_ind & VCD_CODETABLE) != 0, + stream->acache.s_near, DEC_SAME); + case DEC_SAME: + /* Same modes: only if VCD_CODETABLE is set */ + BYTE_CASE((stream->dec_hdr_ind & VCD_CODETABLE) != 0, + stream->acache.s_same, DEC_TABDAT); + case DEC_TABDAT: + /* Compressed code table data */ + + if ((stream->dec_hdr_ind & VCD_CODETABLE) != 0) + { + stream->msg = "VCD_CODETABLE support was removed"; + return XD3_UNIMPLEMENTED; + } + else + { + /* Use the default table. */ + stream->acache.s_near = __rfc3284_code_table_desc.near_modes; + stream->acache.s_same = __rfc3284_code_table_desc.same_modes; + stream->code_table = xd3_rfc3284_code_table (); + } + + if ((ret = xd3_alloc_cache (stream))) { return ret; } + + stream->dec_state = DEC_APPLEN; + + case DEC_APPLEN: + /* Length of application data */ + SIZE_CASE((stream->dec_hdr_ind & VCD_APPHEADER) != 0, + stream->dec_appheadsz, DEC_APPDAT); + + case DEC_APPDAT: + /* Application data */ + if (stream->dec_hdr_ind & VCD_APPHEADER) + { + /* Note: we add an additional byte for padding, to allow + 0-termination. Check for overflow: */ + if (USIZE_T_OVERFLOW(stream->dec_appheadsz, 1)) + { + stream->msg = "exceptional appheader size"; + return XD3_INVALID_INPUT; + } + + if ((stream->dec_appheader == NULL) && + (stream->dec_appheader = + (uint8_t*) xd3_alloc (stream, + stream->dec_appheadsz+1, 1)) == NULL) + { + return ENOMEM; + } + + stream->dec_appheader[stream->dec_appheadsz] = 0; + + if ((ret = xd3_decode_bytes (stream, stream->dec_appheader, + & stream->dec_appheadbytes, + stream->dec_appheadsz))) + { + return ret; + } + } + + /* xoff_t -> usize_t is safe because this is the first block. */ + stream->dec_hdrsize = (usize_t) stream->total_in; + stream->dec_state = DEC_WININD; + + case DEC_WININD: + { + /* Start of a window: the window indicator */ + if ((ret = xd3_decode_byte (stream, & stream->dec_win_ind))) + { + return ret; + } + + stream->current_window = stream->dec_window_count; + + if (XOFF_T_OVERFLOW (stream->dec_winstart, stream->dec_tgtlen)) + { + stream->msg = "decoder file offset overflow"; + return XD3_INVALID_INPUT; + } + + stream->dec_winstart += stream->dec_tgtlen; + + if ((stream->dec_win_ind & VCD_INVWIN) != 0) + { + stream->msg = "unrecognized window indicator bits set"; + return XD3_INVALID_INPUT; + } + + if ((ret = xd3_decode_init_window (stream))) { return ret; } + + stream->dec_state = DEC_CPYLEN; + + IF_DEBUG2 (DP(RINT "--------- TARGET WINDOW %"Q"u -----------\n", + stream->current_window)); + } + + case DEC_CPYLEN: + /* Copy window length: only if VCD_SOURCE or VCD_TARGET is set */ + SIZE_CASE(SRCORTGT (stream->dec_win_ind), stream->dec_cpylen, + DEC_CPYOFF); + + /* Set the initial, logical decoder position (HERE address) in + * dec_position. This is set to just after the source/copy + * window, as we are just about to output the first byte of + * target window. */ + stream->dec_position = stream->dec_cpylen; + + case DEC_CPYOFF: + /* Copy window offset: only if VCD_SOURCE or VCD_TARGET is set */ + + OFFSET_CASE(SRCORTGT (stream->dec_win_ind), stream->dec_cpyoff, + DEC_ENCLEN); + + /* Copy offset and copy length may not overflow. */ + if (XOFF_T_OVERFLOW (stream->dec_cpyoff, stream->dec_cpylen)) + { + stream->msg = "decoder copy window overflows a file offset"; + return XD3_INVALID_INPUT; + } + + /* Check copy window bounds: VCD_TARGET window may not exceed + current position. */ + if ((stream->dec_win_ind & VCD_TARGET) && + (stream->dec_cpyoff + (xoff_t) stream->dec_cpylen > + stream->dec_winstart)) + { + stream->msg = "VCD_TARGET window out of bounds"; + return XD3_INVALID_INPUT; + } + + case DEC_ENCLEN: + /* Length of the delta encoding */ + SIZE_CASE(1, stream->dec_enclen, DEC_TGTLEN); + case DEC_TGTLEN: + /* Length of target window */ + SIZE_CASE(1, stream->dec_tgtlen, DEC_DELIND); + + /* Set the maximum decoder position, beyond which we should not + * decode any data. This is the maximum value for dec_position. + * This may not exceed the size of a usize_t. */ + if (USIZE_T_OVERFLOW (stream->dec_cpylen, stream->dec_tgtlen)) + { + stream->msg = "decoder target window overflows a usize_t"; + return XD3_INVALID_INPUT; + } + + /* Check for malicious files. */ + if (stream->dec_tgtlen > XD3_HARDMAXWINSIZE) + { + stream->msg = "hard window size exceeded"; + return XD3_INVALID_INPUT; + } + + stream->dec_maxpos = stream->dec_cpylen + stream->dec_tgtlen; + + case DEC_DELIND: + /* Delta indicator */ + BYTE_CASE(1, stream->dec_del_ind, DEC_DATALEN); + + if ((stream->dec_del_ind & VCD_INVDEL) != 0) + { + stream->msg = "unrecognized delta indicator bits set"; + return XD3_INVALID_INPUT; + } + + /* Delta indicator is only used with secondary compression. */ + if ((stream->dec_del_ind != 0) && (stream->sec_type == NULL)) + { + stream->msg = "invalid delta indicator bits set"; + return XD3_INVALID_INPUT; + } + + /* Section lengths */ + case DEC_DATALEN: + SIZE_CASE(1, stream->data_sect.size, DEC_INSTLEN); + case DEC_INSTLEN: + SIZE_CASE(1, stream->inst_sect.size, DEC_ADDRLEN); + case DEC_ADDRLEN: + SIZE_CASE(1, stream->addr_sect.size, DEC_CKSUM); + + case DEC_CKSUM: + /* Window checksum. */ + if ((stream->dec_win_ind & VCD_ADLER32) != 0) + { + int i; + + if ((ret = xd3_decode_bytes (stream, stream->dec_cksum, + & stream->dec_cksumbytes, 4))) + { + return ret; + } + + for (i = 0; i < 4; i += 1) + { + stream->dec_adler32 = + (stream->dec_adler32 << 8) | stream->dec_cksum[i]; + } + } + + stream->dec_state = DEC_DATA; + + /* Check dec_enclen for redundency, otherwise it is not really used. */ + { + usize_t enclen_check = + (1 + (xd3_sizeof_size (stream->dec_tgtlen) + + xd3_sizeof_size (stream->data_sect.size) + + xd3_sizeof_size (stream->inst_sect.size) + + xd3_sizeof_size (stream->addr_sect.size)) + + stream->data_sect.size + + stream->inst_sect.size + + stream->addr_sect.size + + ((stream->dec_win_ind & VCD_ADLER32) ? 4 : 0)); + + if (stream->dec_enclen != enclen_check) + { + stream->msg = "incorrect encoding length (redundent)"; + return XD3_INVALID_INPUT; + } + } + + /* Returning here gives the application a chance to inspect the + * header, skip the window, etc. */ + if (stream->current_window == 0) { return XD3_GOTHEADER; } + else { return XD3_WINSTART; } + + case DEC_DATA: + case DEC_INST: + case DEC_ADDR: + /* Next read the three sections. */ + if ((ret = xd3_decode_sections (stream))) { return ret; } + + case DEC_EMIT: + + /* To speed VCD_SOURCE block-address calculations, the source + * cpyoff_blocks and cpyoff_blkoff are pre-computed. */ + if (stream->dec_win_ind & VCD_SOURCE) + { + xd3_source *src = stream->src; + + if (src == NULL) + { + stream->msg = "source input required"; + return XD3_INVALID_INPUT; + } + + xd3_blksize_div(stream->dec_cpyoff, src, + &src->cpyoff_blocks, + &src->cpyoff_blkoff); + + IF_DEBUG2(DP(RINT + "[decode_cpyoff] %"Q"u " + "cpyblkno %"Q"u " + "cpyblkoff %u " + "blksize %u\n", + stream->dec_cpyoff, + src->cpyoff_blocks, + src->cpyoff_blkoff, + src->blksize)); + } + + /* xd3_decode_emit returns XD3_OUTPUT on every success. */ + if ((ret = xd3_decode_emit (stream)) == XD3_OUTPUT) + { + stream->total_out += (xoff_t) stream->avail_out; + } + + return ret; + + case DEC_FINISH: + { + if (stream->dec_win_ind & VCD_TARGET) + { + if (stream->dec_lastwin == NULL) + { + stream->dec_lastwin = stream->next_out; + stream->dec_lastspace = stream->space_out; + } + else + { + xd3_swap_uint8p (& stream->dec_lastwin, + & stream->next_out); + xd3_swap_usize_t (& stream->dec_lastspace, + & stream->space_out); + } + } + + stream->dec_lastlen = stream->dec_tgtlen; + stream->dec_laststart = stream->dec_winstart; + stream->dec_window_count += 1; + + /* Note: the updates to dec_winstart & current_window are + * deferred until after the next DEC_WININD byte is read. */ + stream->dec_state = DEC_WININD; + return XD3_WINFINISH; + } + + default: + stream->msg = "invalid state"; + return XD3_INVALID_INPUT; + } +} + +#endif // _XDELTA3_DECODE_H_ diff --git a/app/src/main/rust/stores/vendor/xdelta3/xdelta3-hash.h b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-hash.h new file mode 100644 index 000000000..a20017a31 --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-hash.h @@ -0,0 +1,167 @@ +/* xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#ifndef _XDELTA3_HASH_H_ +#define _XDELTA3_HASH_H_ + +#if XD3_DEBUG +#define SMALL_HASH_DEBUG1(s,inp) \ + uint32_t debug_state; \ + uint32_t debug_hval = xd3_checksum_hash (& (s)->small_hash, \ + xd3_scksum (&debug_state, (inp), (s)->smatcher.small_look)) +#define SMALL_HASH_DEBUG2(s,inp) \ + XD3_ASSERT (debug_hval == xd3_checksum_hash (& (s)->small_hash, \ + xd3_scksum (&debug_state, (inp), (s)->smatcher.small_look))) +#else +#define SMALL_HASH_DEBUG1(s,inp) +#define SMALL_HASH_DEBUG2(s,inp) +#endif /* XD3_DEBUG */ + +/* This is a good hash multiplier for 32-bit LCGs: see "linear + * congruential generators of different sizes and good lattice + * structure" */ +static const uint32_t hash_multiplier = 1597334677U; + +/*********************************************************************** + Permute stuff + ***********************************************************************/ + +#if HASH_PERMUTE == 0 +#define PERMUTE(x) (x) +#else +#define PERMUTE(x) (__single_hash[(uint32_t)x]) + +extern const uint16_t __single_hash[256]; +#endif + +/* Update the checksum state. */ +#if ADLER_LARGE_CKSUM +inline uint32_t +xd3_large_cksum_update (uint32_t cksum, + const uint8_t *base, + usize_t look) { + uint32_t old_c = PERMUTE(base[0]); + uint32_t new_c = PERMUTE(base[look]); + uint32_t low = ((cksum & 0xffff) - old_c + new_c) & 0xffff; + uint32_t high = ((cksum >> 16) - (old_c * look) + low) & 0xffff; + return (high << 16) | low; +} +#else +/* TODO: revisit this topic */ +#endif + +#if UNALIGNED_OK +#define UNALIGNED_READ32(dest,src) (*(dest)) = (*(uint32_t*)(src)) +#else +#define UNALIGNED_READ32(dest,src) memcpy((dest), (src), 4); +#endif + +/* TODO: small cksum is hard-coded for 4 bytes (i.e., "look" is unused) */ +static inline uint32_t +xd3_scksum (uint32_t *state, + const uint8_t *base, + const usize_t look) +{ + UNALIGNED_READ32(state, base); + return (*state) * hash_multiplier; +} +static inline uint32_t +xd3_small_cksum_update (uint32_t *state, + const uint8_t *base, + usize_t look) +{ + UNALIGNED_READ32(state, base+1); + return (*state) * hash_multiplier; +} + +/*********************************************************************** + Ctable stuff + ***********************************************************************/ + +static inline usize_t +xd3_checksum_hash (const xd3_hash_cfg *cfg, const usize_t cksum) +{ + return (cksum >> cfg->shift) ^ (cksum & cfg->mask); +} + +/*********************************************************************** + Cksum function + ***********************************************************************/ + +#if ADLER_LARGE_CKSUM +static inline uint32_t +xd3_lcksum (const uint8_t *seg, const usize_t ln) +{ + usize_t i = 0; + uint32_t low = 0; + uint32_t high = 0; + + for (; i < ln; i += 1) + { + low += PERMUTE(*seg++); + high += low; + } + + return ((high & 0xffff) << 16) | (low & 0xffff); +} +#else +static inline uint32_t +xd3_lcksum (const uint8_t *seg, const usize_t ln) +{ + usize_t i, j; + uint32_t h = 0; + for (i = 0, j = ln - 1; i < ln; ++i, --j) { + h += PERMUTE(seg[i]) * hash_multiplier_powers[j]; + } + return h; +} +#endif + +#if XD3_ENCODER +static usize_t +xd3_size_log2 (usize_t slots) +{ + int bits = 28; /* This should not be an unreasonable limit. */ + int i; + + for (i = 3; i <= bits; i += 1) + { + if (slots < (1U << i)) + { + /* TODO: this is compaction=1 in checksum_test.cc and maybe should + * not be fixed at -1. */ + bits = i - 1; + break; + } + } + + return bits; +} + +static void +xd3_size_hashtable (xd3_stream *stream, + usize_t slots, + xd3_hash_cfg *cfg) +{ + int bits = xd3_size_log2 (slots); + + /* TODO: there's a 32-bit assumption here */ + cfg->size = (1 << bits); + cfg->mask = (cfg->size - 1); + cfg->shift = 32 - bits; +} +#endif + +#endif diff --git a/app/src/main/rust/stores/vendor/xdelta3/xdelta3-internal.h b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-internal.h new file mode 100644 index 000000000..661ce4155 --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-internal.h @@ -0,0 +1,370 @@ +/* xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#ifndef XDELTA3_INTERNAL_H__ +#define XDELTA3_INTERNAL_H__ + +#include "xdelta3.h" + +typedef struct _main_file main_file; +typedef struct _main_extcomp main_extcomp; + +void main_buffree (void *ptr); +void* main_bufalloc (size_t size); +void main_file_init (main_file *xfile); +int main_file_close (main_file *xfile); +void main_file_cleanup (main_file *xfile); +int main_file_isopen (main_file *xfile); +int main_file_open (main_file *xfile, const char* name, int mode); +int main_file_exists (main_file *xfile); +int xd3_whole_append_window (xd3_stream *stream); +int xd3_main_cmdline (int argc, char **argv); +int main_file_read (main_file *ifile, + uint8_t *buf, + size_t size, + size_t *nread, + const char *msg); +int main_file_write (main_file *ofile, uint8_t *buf, + usize_t size, const char *msg); +int test_compare_files (const char* f0, const char* f1); +usize_t xd3_bytes_on_srcblk (xd3_source *src, xoff_t blkno); +xoff_t xd3_source_eof(const xd3_source *src); +uint32_t xd3_large_cksum_update (uint32_t cksum, + const uint8_t *base, + usize_t look); +int xd3_emit_byte (xd3_stream *stream, + xd3_output **outputp, + uint8_t code); + +int xd3_emit_bytes (xd3_stream *stream, + xd3_output **outputp, + const uint8_t *base, + usize_t size); +xd3_output* xd3_alloc_output (xd3_stream *stream, + xd3_output *old_output); + +int xd3_encode_init_full (xd3_stream *stream); +size_t xd3_pow2_roundup (size_t x); +int xd3_process_stream (int is_encode, + xd3_stream *stream, + int (*func) (xd3_stream *), + int close_stream, + const uint8_t *input, + usize_t input_size, + uint8_t *output, + usize_t *output_size, + usize_t output_size_max); + +#if PYTHON_MODULE || SWIG_MODULE || NOT_MAIN +int xd3_main_cmdline (int argc, char **argv); +#endif + +/* main_file->mode values */ +typedef enum +{ + XO_READ = 0, + XO_WRITE = 1 +} main_file_modes; + +#ifndef XD3_POSIX +#define XD3_POSIX 0 +#endif +#ifndef XD3_STDIO +#define XD3_STDIO 0 +#endif +#ifndef XD3_WIN32 +#define XD3_WIN32 0 +#endif +#ifndef NOT_MAIN +#define NOT_MAIN 0 +#endif + +/* If none are set, default to posix. */ +#if (XD3_POSIX + XD3_STDIO + XD3_WIN32) == 0 +#undef XD3_POSIX +#define XD3_POSIX 1 +#endif + +struct _main_file +{ +#if XD3_WIN32 + HANDLE file; +#elif XD3_STDIO + FILE *file; +#elif XD3_POSIX + int file; +#endif + + int mode; /* XO_READ and XO_WRITE */ + const char *filename; /* File name or /dev/stdin, + * /dev/stdout, /dev/stderr. */ + char *filename_copy; /* File name or /dev/stdin, + * /dev/stdout, /dev/stderr. */ + const char *realname; /* File name or /dev/stdin, + * /dev/stdout, /dev/stderr. */ + const main_extcomp *compressor; /* External compression struct. */ + int flags; /* RD_FIRST, RD_NONEXTERNAL, ... */ + xoff_t nread; /* for input position */ + xoff_t nwrite; /* for output position */ + uint8_t *snprintf_buf; /* internal snprintf() use */ + int size_known; /* Set by main_set_souze */ + xoff_t source_position; /* for avoiding seek in getblk_func */ + int seek_failed; /* after seek fails once, try FIFO */ +}; + +#ifdef _WIN32 +#define vsnprintf_func _vsnprintf +#define snprintf_func _snprintf +#else +#define vsnprintf_func vsnprintf +#define snprintf_func snprintf +#endif +#define short_sprintf(sb,fmt,...) \ + snprintf_func((sb).buf,sizeof((sb).buf),fmt,__VA_ARGS__) + +/* Type used for short snprintf calls. */ +typedef struct { + char buf[48]; +} shortbuf; + +/* Prior to SVN 303 this function was only defined in DJGPP and WIN32 + * environments and other platforms would use the builtin snprintf() + * with an arrangement of macros below. In OS X 10.6, Apply made + * snprintf() a macro, which defeated those macros (since snprintf + * would be evaluated before its argument macros were expanded, + * therefore always define xsnprintf_func. */ +#undef PRINTF_ATTRIBUTE +#ifdef __GNUC__ +/* Let's just assume no one uses gcc 2.x! */ +#define PRINTF_ATTRIBUTE(x,y) __attribute__ ((__format__ (__printf__, x, y))) +#else +#define PRINTF_ATTRIBUTE(x,y) +#endif + +/* Underlying xprintf() */ +int xsnprintf_func (char *str, int n, const char *fmt, ...) + PRINTF_ATTRIBUTE(3,4); + +/* XPR(NT "", ...) (used by main) prefixes an "xdelta3: " to the output. */ +void xprintf(const char *fmt, ...) PRINTF_ATTRIBUTE(1,2); +#define XPR xprintf +#define NT "xdelta3: " +#define NTR "" + +#ifndef UINT32_MAX +#define UINT32_MAX 4294967295U +#endif + +#ifndef UINT64_MAX +#define UINT64_MAX 18446744073709551615ULL +#endif + +#define UINT32_OFLOW_MASK 0xfe000000U +#define UINT64_OFLOW_MASK 0xfe00000000000000ULL + +/********************************************************************* + Integer encoder/decoder functions + **********************************************************************/ + +/* Consume N bytes of input, only used by the decoder. */ +#define DECODE_INPUT(n) \ + do { \ + stream->total_in += (xoff_t) (n); \ + stream->avail_in -= (n); \ + stream->next_in += (n); \ + } while (0) + +#define DECODE_INTEGER_TYPE(PART,OFLOW) \ + while (stream->avail_in != 0) \ + { \ + usize_t next = stream->next_in[0]; \ + \ + DECODE_INPUT(1); \ + \ + if (PART & OFLOW) \ + { \ + stream->msg = "overflow in decode_integer"; \ + return XD3_INVALID_INPUT; \ + } \ + \ + PART = (PART << 7) | (next & 127); \ + \ + if ((next & 128) == 0) \ + { \ + (*val) = PART; \ + PART = 0; \ + return 0; \ + } \ + } \ + \ + stream->msg = "further input required"; \ + return XD3_INPUT + +#define READ_INTEGER_TYPE(TYPE, OFLOW) \ + TYPE val = 0; \ + const uint8_t *inp = (*inpp); \ + usize_t next; \ + \ + do \ + { \ + if (inp == maxp) \ + { \ + stream->msg = "end-of-input in read_integer"; \ + return XD3_INVALID_INPUT; \ + } \ + \ + if (val & OFLOW) \ + { \ + stream->msg = "overflow in read_intger"; \ + return XD3_INVALID_INPUT; \ + } \ + \ + next = (*inp++); \ + val = (val << 7) | (next & 127); \ + } \ + while (next & 128); \ + \ + (*valp) = val; \ + (*inpp) = inp; \ + \ + return 0 + +#define EMIT_INTEGER_TYPE() \ + /* max 64-bit value in base-7 encoding is 9.1 bytes */ \ + uint8_t buf[10]; \ + usize_t bufi = 10; \ + \ + /* This loop performs division and turns on all MSBs. */ \ + do \ + { \ + buf[--bufi] = (num & 127) | 128; \ + num >>= 7U; \ + } \ + while (num != 0); \ + \ + /* Turn off MSB of the last byte. */ \ + buf[9] &= 127; \ + \ + return xd3_emit_bytes (stream, output, buf + bufi, 10 - bufi) + +#define IF_SIZEOF32(x) if (num < (1U << (7 * (x)))) return (x); +#define IF_SIZEOF64(x) if (num < (1ULL << (7 * (x)))) return (x); + +#if USE_UINT32 +static inline uint32_t +xd3_sizeof_uint32_t (uint32_t num) +{ + IF_SIZEOF32(1); + IF_SIZEOF32(2); + IF_SIZEOF32(3); + IF_SIZEOF32(4); + return 5; +} + +static inline int +xd3_decode_uint32_t (xd3_stream *stream, uint32_t *val) +{ DECODE_INTEGER_TYPE (stream->dec_32part, UINT32_OFLOW_MASK); } + +static inline int +xd3_read_uint32_t (xd3_stream *stream, const uint8_t **inpp, + const uint8_t *maxp, uint32_t *valp) +{ READ_INTEGER_TYPE (uint32_t, UINT32_OFLOW_MASK); } + +#if XD3_ENCODER +static inline int +xd3_emit_uint32_t (xd3_stream *stream, xd3_output **output, uint32_t num) +{ EMIT_INTEGER_TYPE (); } +#endif +#endif + +#if USE_UINT64 +static inline int +xd3_decode_uint64_t (xd3_stream *stream, uint64_t *val) +{ DECODE_INTEGER_TYPE (stream->dec_64part, UINT64_OFLOW_MASK); } + +#if XD3_ENCODER +static inline int +xd3_emit_uint64_t (xd3_stream *stream, xd3_output **output, uint64_t num) +{ EMIT_INTEGER_TYPE (); } +#endif + +/* These are tested but not used */ +#if REGRESSION_TEST +static int +xd3_read_uint64_t (xd3_stream *stream, const uint8_t **inpp, + const uint8_t *maxp, uint64_t *valp) +{ READ_INTEGER_TYPE (uint64_t, UINT64_OFLOW_MASK); } + +static uint32_t +xd3_sizeof_uint64_t (uint64_t num) +{ + IF_SIZEOF64(1); + IF_SIZEOF64(2); + IF_SIZEOF64(3); + IF_SIZEOF64(4); + IF_SIZEOF64(5); + IF_SIZEOF64(6); + IF_SIZEOF64(7); + IF_SIZEOF64(8); + IF_SIZEOF64(9); + + return 10; +} +#endif + +#endif + +#if SIZEOF_USIZE_T == 4 +#define USIZE_T_MAX UINT32_MAX +#define USIZE_T_MAXBLKSZ 0x80000000U +#define xd3_decode_size xd3_decode_uint32_t +#define xd3_emit_size xd3_emit_uint32_t +#define xd3_sizeof_size xd3_sizeof_uint32_t +#define xd3_read_size xd3_read_uint32_t +#elif SIZEOF_USIZE_T == 8 +#define USIZE_T_MAX UINT64_MAX +#define USIZE_T_MAXBLKSZ 0x8000000000000000ULL +#define xd3_decode_size xd3_decode_uint64_t +#define xd3_emit_size xd3_emit_uint64_t +#define xd3_sizeof_size xd3_sizeof_uint64_t +#define xd3_read_size xd3_read_uint64_t +#endif + +#if SIZEOF_XOFF_T == 4 +#define XOFF_T_MAX UINT32_MAX +#define xd3_emit_offset xd3_emit_uint32_t +static inline int +xd3_decode_offset (xd3_stream *stream, xoff_t *val) +{ + return xd3_decode_uint32_t (stream, (uint32_t*) val); +} +#elif SIZEOF_XOFF_T == 8 +#define XOFF_T_MAX UINT64_MAX +#define xd3_emit_offset xd3_emit_uint64_t +static inline int +xd3_decode_offset (xd3_stream *stream, xoff_t *val) +{ + return xd3_decode_uint64_t (stream, (uint64_t*) val); +} +#endif + +#define USIZE_T_OVERFLOW(a,b) ((USIZE_T_MAX - (usize_t) (a)) < (usize_t) (b)) +#define XOFF_T_OVERFLOW(a,b) ((XOFF_T_MAX - (xoff_t) (a)) < (xoff_t) (b)) + +#define MAX_LRU_SIZE 32U +#define XD3_MINSRCWINSZ (XD3_ALLOCSIZE * MAX_LRU_SIZE) +#define XD3_MAXSRCWINSZ (1ULL << 31) + +#endif // XDELTA3_INTERNAL_H__ diff --git a/app/src/main/rust/stores/vendor/xdelta3/xdelta3-list.h b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-list.h new file mode 100644 index 000000000..23c925eaa --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/xdelta3-list.h @@ -0,0 +1,127 @@ +/* xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#ifndef __XDELTA3_LIST__ +#define __XDELTA3_LIST__ + +#define XD3_MAKELIST(LTYPE,ETYPE,LNAME) \ + \ +static inline ETYPE* \ +LTYPE ## _entry (LTYPE* l) \ +{ \ + return (ETYPE*) ((char*) l - (ptrdiff_t) &((ETYPE*) 0)->LNAME); \ +} \ + \ +static inline void \ +LTYPE ## _init (LTYPE *l) \ +{ \ + l->next = l; \ + l->prev = l; \ +} \ + \ +static inline void \ +LTYPE ## _add (LTYPE *prev, LTYPE *next, LTYPE *ins) \ +{ \ + next->prev = ins; \ + prev->next = ins; \ + ins->next = next; \ + ins->prev = prev; \ +} \ + \ +static inline void \ +LTYPE ## _push_back (LTYPE *l, ETYPE *i) \ +{ \ + LTYPE ## _add (l->prev, l, & i->LNAME); \ +} \ + \ +static inline void \ +LTYPE ## _del (LTYPE *next, \ + LTYPE *prev) \ +{ \ + next->prev = prev; \ + prev->next = next; \ +} \ + \ +static inline ETYPE* \ +LTYPE ## _remove (ETYPE *f) \ +{ \ + LTYPE *i = f->LNAME.next; \ + LTYPE ## _del (f->LNAME.next, f->LNAME.prev); \ + return LTYPE ## _entry (i); \ +} \ + \ +static inline ETYPE* \ +LTYPE ## _pop_back (LTYPE *l) \ +{ \ + LTYPE *i = l->prev; \ + LTYPE ## _del (i->next, i->prev); \ + return LTYPE ## _entry (i); \ +} \ + \ +static inline ETYPE* \ +LTYPE ## _pop_front (LTYPE *l) \ +{ \ + LTYPE *i = l->next; \ + LTYPE ## _del (i->next, i->prev); \ + return LTYPE ## _entry (i); \ +} \ + \ +static inline int \ +LTYPE ## _empty (LTYPE *l) \ +{ \ + return l == l->next; \ +} \ + \ +static inline ETYPE* \ +LTYPE ## _front (LTYPE *f) \ +{ \ + return LTYPE ## _entry (f->next); \ +} \ + \ +static inline ETYPE* \ +LTYPE ## _back (LTYPE *f) \ +{ \ + return LTYPE ## _entry (f->prev); \ +} \ + \ +static inline int \ +LTYPE ## _end (LTYPE *f, ETYPE *i) \ +{ \ + return f == & i->LNAME; \ +} \ + \ +static inline ETYPE* \ +LTYPE ## _next (ETYPE *f) \ +{ \ + return LTYPE ## _entry (f->LNAME.next); \ +} \ + \ +static inline usize_t \ +LTYPE ## _length (LTYPE *l) \ +{ \ + LTYPE *p; \ + int c = 0; \ + \ + for (p = l->next; p != l; p = p->next) \ + { \ + c += 1; \ + } \ + \ + return c; \ +} \ + \ +typedef int unused_ ## LTYPE + +#endif diff --git a/app/src/main/rust/stores/vendor/xdelta3/xdelta3.c b/app/src/main/rust/stores/vendor/xdelta3/xdelta3.c new file mode 100644 index 000000000..ce4dadae7 --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/xdelta3.c @@ -0,0 +1,4835 @@ +/* xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ------------------------------------------------------------------- + + Xdelta 3 + + The goal of this library is to to implement both the (stand-alone) + data-compression and delta-compression aspects of VCDIFF encoding, and + to support a programming interface that works like Zlib + (http://www.gzip.org/zlib.html). See RFC3284: The VCDIFF Generic + Differencing and Compression Data Format. + + VCDIFF is a unified encoding that combines data-compression and + delta-encoding ("differencing"). + + VCDIFF has a detailed byte-code instruction set with many features. + The instruction format supports an immediate size operand for small + COPYs and ADDs (e.g., under 18 bytes). There are also instruction + "modes", which are used to compress COPY addresses by using two + address caches. An instruction mode refers to slots in the NEAR + and SAME caches for recent addresses. NEAR remembers the + previous 4 (by default) COPY addresses, and SAME catches + frequent re-uses of the same address using a 3-way (by default) + 256-entry associative cache of [ADDR mod 256], the encoded byte. + A hit in the NEAR/SAME cache requires 0/1 ADDR bytes. + + VCDIFF has a default instruction table, but an alternate + instruction tables may themselves be be delta-compressed and + included in the encoding header. This allows even more freedom. + There are 9 instruction modes in the default code table, 4 near, 3 + same, VCD_SELF (absolute encoding) and VCD_HERE (relative to the + current position). + + ---------------------------------------------------------------------- + + Algorithms + + Aside from the details of encoding and decoding, there are a bunch + of algorithms needed. + + 1. STRING-MATCH. A two-level fingerprinting approach is used. A + single loop computes the two checksums -- small and large -- at + successive offsets in the TARGET file. The large checksum is more + accurate and is used to discover SOURCE matches, which are + potentially very long. The small checksum is used to discover + copies within the TARGET. Small matching, which is more expensive, + usually dominates the large STRING-MATCH costs in this code - the + more exhaustive the search, the better the results. Either of the + two string-matching mechanisms may be disabled. + + 2. INSTRUCTION SELECTION. The IOPT buffer here represents a queue + used to store overlapping copy instructions. There are two possible + optimizations that go beyond a greedy search. Both of these fall + into the category of "non-greedy matching" optimizations. + + The first optimization stems from backward SOURCE-COPY matching. + When a new SOURCE-COPY instruction covers a previous instruction in + the target completely, it is erased from the queue. Randal Burns + originally analyzed these algorithms and did a lot of related work + (\cite the 1.5-pass algorithm). + + The second optimization comes by the encoding of common very-small + COPY and ADD instructions, for which there are special DOUBLE-code + instructions, which code two instructions in a single byte. + + The cost of bad instruction-selection overhead is relatively high + for data-compression, relative to delta-compression, so this second + optimization is fairly important. With "lazy" matching (the name + used in Zlib for a similar optimization), the string-match + algorithm searches after a match for potential overlapping copy + instructions. In Xdelta and by default, VCDIFF, the minimum match + size is 4 bytes, whereas Zlib searches with a 3-byte minimum. This + feature, combined with double instructions, provides a nice + challenge. Search in this file for "black magic", a heuristic. + + 3. STREAM ALIGNMENT. Stream alignment is needed to compress large + inputs in constant space. See xd3_srcwin_move_point(). + + 4. WINDOW SELECTION. When the IOPT buffer flushes, in the first call + to xd3_iopt_finish_encoding containing any kind of copy instruction, + the parameters of the source window must be decided: the offset into + the source and the length of the window. Since the IOPT buffer is + finite, the program may be forced to fix these values before knowing + the best offset/length. + + 5. SECONDARY COMPRESSION. VCDIFF supports a secondary encoding to + be applied to the individual sections of the data format, which are + ADDRess, INSTruction, and DATA. Several secondary compressor + variations are implemented here, although none is standardized yet. + + One is an adaptive huffman algorithm -- the FGK algorithm (Faller, + Gallager, and Knuth, 1985). This compressor is extremely slow. + + The other is a simple static Huffman routine, which is the base + case of a semi-adaptive scheme published by D.J. Wheeler and first + widely used in bzip2 (by Julian Seward). This is a very + interesting algorithm, originally published in nearly cryptic form + by D.J. Wheeler. !!!NOTE!!! Because these are not standardized, + secondary compression remains off by default. + ftp://ftp.cl.cam.ac.uk/users/djw3/bred3.{c,ps} + -------------------------------------------------------------------- + + Other Features + + 1. USER CONVENIENCE + + For user convenience, it is essential to recognize Gzip-compressed + files and automatically Gzip-decompress them prior to + delta-compression (or else no delta-compression will be achieved + unless the user manually decompresses the inputs). The compressed + represention competes with Xdelta, and this must be hidden from the + command-line user interface. The Xdelta-1.x encoding was simple, not + compressed itself, so Xdelta-1.x uses Zlib internally to compress the + representation. + + This implementation supports external compression, which implements + the necessary fork() and pipe() mechanics. There is a tricky step + involved to support automatic detection of a compressed input in a + non-seekable input. First you read a bit of the input to detect + magic headers. When a compressed format is recognized, exec() the + external compression program and create a second child process to + copy the original input stream. [Footnote: There is a difficulty + related to using Gzip externally. It is not possible to decompress + and recompress a Gzip file transparently. If FILE.GZ had a + cryptographic signature, then, after: (1) Gzip-decompression, (2) + Xdelta-encoding, (3) Gzip-compression the signature could be + broken. The only way to solve this problem is to guess at Gzip's + compression level or control it by other means. I recommend that + specific implementations of any compression scheme store + information needed to exactly re-compress the input, that way + external compression is transparent - however, this won't happen + here until it has stabilized.] + + 2. APPLICATION-HEADER + + This feature was introduced in RFC3284. It allows any application + to include a header within the VCDIFF file format. This allows + general inter-application data exchange with support for + application-specific extensions to communicate metadata. + + 3. VCDIFF CHECKSUM + + An optional checksum value is included with each window, which can + be used to validate the final result. This verifies the correct source + file was used for decompression as well as the obvious advantage: + checking the implementation (and underlying) correctness. + + 4. LIGHT WEIGHT + + The code makes efforts to avoid copying data more than necessary. + The code delays many initialization tasks until the first use, it + optimizes for identical (perfectly matching) inputs. It does not + compute any checksums until the first lookup misses. Memory usage + is reduced. String-matching is templatized (by slightly gross use + of CPP) to hard-code alternative compile-time defaults. The code + has few outside dependencies. + ---------------------------------------------------------------------- + + The default rfc3284 instruction table: + (see RFC for the explanation) + + TYPE SIZE MODE TYPE SIZE MODE INDEX + -------------------------------------------------------------------- + 1. Run 0 0 Noop 0 0 0 + 2. Add 0, [1,17] 0 Noop 0 0 [1,18] + 3. Copy 0, [4,18] 0 Noop 0 0 [19,34] + 4. Copy 0, [4,18] 1 Noop 0 0 [35,50] + 5. Copy 0, [4,18] 2 Noop 0 0 [51,66] + 6. Copy 0, [4,18] 3 Noop 0 0 [67,82] + 7. Copy 0, [4,18] 4 Noop 0 0 [83,98] + 8. Copy 0, [4,18] 5 Noop 0 0 [99,114] + 9. Copy 0, [4,18] 6 Noop 0 0 [115,130] + 10. Copy 0, [4,18] 7 Noop 0 0 [131,146] + 11. Copy 0, [4,18] 8 Noop 0 0 [147,162] + 12. Add [1,4] 0 Copy [4,6] 0 [163,174] + 13. Add [1,4] 0 Copy [4,6] 1 [175,186] + 14. Add [1,4] 0 Copy [4,6] 2 [187,198] + 15. Add [1,4] 0 Copy [4,6] 3 [199,210] + 16. Add [1,4] 0 Copy [4,6] 4 [211,222] + 17. Add [1,4] 0 Copy [4,6] 5 [223,234] + 18. Add [1,4] 0 Copy 4 6 [235,238] + 19. Add [1,4] 0 Copy 4 7 [239,242] + 20. Add [1,4] 0 Copy 4 8 [243,246] + 21. Copy 4 [0,8] Add 1 0 [247,255] + -------------------------------------------------------------------- + + Reading the source: Overview + + This file includes itself in several passes to macro-expand certain + sections with variable forms. Just read ahead, there's only a + little confusion. I know this sounds ugly, but hard-coding some of + the string-matching parameters results in a 10-15% increase in + string-match performance. The only time this hurts is when you have + unbalanced #if/endifs. + + A single compilation unit tames the Makefile. In short, this is to + allow the above-described hack without an explodingMakefile. The + single compilation unit includes the core library features, + configurable string-match templates, optional main() command-line + tool, misc optional features, and a regression test. Features are + controled with CPP #defines, see Makefile.am. + + The initial __XDELTA3_C_HEADER_PASS__ starts first, the _INLINE_ and + _TEMPLATE_ sections follow. Easy stuff first, hard stuff last. + + Optional features include: + + xdelta3-main.h The command-line interface, external compression + support, POSIX-specific, info & VCDIFF-debug tools. + xdelta3-second.h The common secondary compression routines. + xdelta3-decoder.h All decoding routines. + xdelta3-djw.h The semi-adaptive huffman secondary encoder. + xdelta3-fgk.h The adaptive huffman secondary encoder. + xdelta3-test.h The unit test covers major algorithms, + encoding and decoding. There are single-bit + error decoding tests. There are 32/64-bit file size + boundary tests. There are command-line tests. + There are compression tests. There are external + compression tests. There are string-matching tests. + There should be more tests... + + Additional headers include: + + xdelta3.h The public header file. + xdelta3-cfgs.h The default settings for default, built-in + encoders. These are hard-coded at + compile-time. There is also a single + soft-coded string matcher for experimenting + with arbitrary values. + xdelta3-list.h A cyclic list template + + Misc little debug utilities: + + badcopy.c Randomly modifies an input file based on two + parameters: (1) the probability that a byte in + the file is replaced with a pseudo-random value, + and (2) the mean change size. Changes are + generated using an expoential distribution + which approximates the expected error_prob + distribution. + -------------------------------------------------------------------- + + This file itself is unusually large. I hope to defend this layout + with lots of comments. Everything in this file is related to + encoding and decoding. I like it all together - the template stuff + is just a hack. */ + +#ifndef __XDELTA3_C_HEADER_PASS__ +#define __XDELTA3_C_HEADER_PASS__ + +#include "xdelta3.h" +#include "xdelta3-internal.h" + +/*********************************************************************** + STATIC CONFIGURATION + ***********************************************************************/ + +#ifndef XD3_MAIN /* the main application */ +#define XD3_MAIN 0 +#endif + +#ifndef VCDIFF_TOOLS +#define VCDIFF_TOOLS XD3_MAIN +#endif + +#ifndef SECONDARY_FGK /* one from the algorithm preservation department: */ +#define SECONDARY_FGK 0 /* adaptive Huffman routines */ +#endif + +#ifndef SECONDARY_DJW /* semi-adaptive/static Huffman for the eventual */ +#define SECONDARY_DJW 0 /* standardization, off by default until such time. */ +#endif + +#ifndef SECONDARY_LZMA +#ifdef HAVE_LZMA_H +#define SECONDARY_LZMA 1 +#else +#define SECONDARY_LZMA 0 +#endif +#endif + +#if XD3_ENCODER +#define IF_ENCODER(x) x +#else +#define IF_ENCODER(x) +#endif + +/***********************************************************************/ + + /* header indicator bits */ +#define VCD_SECONDARY (1U << 0) /* uses secondary compressor */ +#define VCD_CODETABLE (1U << 1) /* supplies code table data */ +#define VCD_APPHEADER (1U << 2) /* supplies application data */ +#define VCD_INVHDR (~0x7U) + + /* window indicator bits */ +#define VCD_SOURCE (1U << 0) /* copy window in source file */ +#define VCD_TARGET (1U << 1) /* copy window in target file */ +#define VCD_ADLER32 (1U << 2) /* has adler32 checksum */ +#define VCD_INVWIN (~0x7U) + +#define VCD_SRCORTGT (VCD_SOURCE | VCD_TARGET) + + /* delta indicator bits */ +#define VCD_DATACOMP (1U << 0) +#define VCD_INSTCOMP (1U << 1) +#define VCD_ADDRCOMP (1U << 2) +#define VCD_INVDEL (~0x7U) + +typedef enum { + VCD_DJW_ID = 1, + VCD_LZMA_ID = 2, + VCD_FGK_ID = 16 /* Note: these are not standard IANA-allocated IDs! */ +} xd3_secondary_ids; + +typedef enum { + SEC_NOFLAGS = 0, + + /* Note: SEC_COUNT_FREQS Not implemented (to eliminate 1st Huffman pass) */ + SEC_COUNT_FREQS = (1 << 0) +} xd3_secondary_flags; + +typedef enum { + DATA_SECTION, /* These indicate which section to the secondary + * compressor. */ + INST_SECTION, /* The header section is not compressed, therefore not + * listed here. */ + ADDR_SECTION +} xd3_section_type; + +typedef unsigned int xd3_rtype; + +/***********************************************************************/ + +#include "xdelta3-list.h" + +XD3_MAKELIST(xd3_rlist, xd3_rinst, link); + +/***********************************************************************/ + +#define SECONDARY_MIN_SAVINGS 2 /* Secondary compression has to save + at least this many bytes. */ +#define SECONDARY_MIN_INPUT 10 /* Secondary compression needs at + least this many bytes. */ + +#define VCDIFF_MAGIC1 0xd6 /* 1st file byte */ +#define VCDIFF_MAGIC2 0xc3 /* 2nd file byte */ +#define VCDIFF_MAGIC3 0xc4 /* 3rd file byte */ +#define VCDIFF_VERSION 0x00 /* 4th file byte */ + +#define VCD_SELF 0 /* 1st address mode */ +#define VCD_HERE 1 /* 2nd address mode */ + +#define CODE_TABLE_STRING_SIZE (6 * 256) /* Should fit a code table string. */ +#define CODE_TABLE_VCDIFF_SIZE (6 * 256) /* Should fit a compressed code + * table string */ + +#define SECONDARY_ANY (SECONDARY_DJW || SECONDARY_FGK || SECONDARY_LZMA) + +#define ALPHABET_SIZE 256 /* Used in test code--size of the secondary + * compressor alphabet. */ + +#define HASH_PERMUTE 1 /* The input is permuted by random nums */ +#define ADLER_LARGE_CKSUM 1 /* Adler checksum vs. RK checksum */ + +#define HASH_CKOFFSET 1U /* Table entries distinguish "no-entry" from + * offset 0 using this offset. */ + +#define MIN_SMALL_LOOK 2U /* Match-optimization stuff. */ +#define MIN_LARGE_LOOK 2U +#define MIN_MATCH_OFFSET 1U +#define MAX_MATCH_SPLIT 18U /* VCDIFF code table: 18 is the default limit + * for direct-coded ADD sizes */ + +#define LEAST_MATCH_INCR 0 /* The least number of bytes an overlapping + * match must beat the preceding match by. This + * is a bias for the lazy match optimization. A + * non-zero value means that an adjacent match + * has to be better by more than the step + * between them. 0. */ + +#define MIN_MATCH 4U /* VCDIFF code table: MIN_MATCH=4 */ +#define MIN_ADD 1U /* 1 */ +#define MIN_RUN 8U /* The shortest run, if it is shorter than this + * an immediate add/copy will be just as good. + * ADD1/COPY6 = 1I+1D+1A bytes, RUN18 = + * 1I+1D+1A. */ + +#define MAX_MODES 9 /* Maximum number of nodes used for + * compression--does not limit decompression. */ + +#define ENC_SECTS 4 /* Number of separate output sections. */ + +#define HDR_TAIL(s) ((s)->enc_tails[0]) +#define DATA_TAIL(s) ((s)->enc_tails[1]) +#define INST_TAIL(s) ((s)->enc_tails[2]) +#define ADDR_TAIL(s) ((s)->enc_tails[3]) + +#define HDR_HEAD(s) ((s)->enc_heads[0]) +#define DATA_HEAD(s) ((s)->enc_heads[1]) +#define INST_HEAD(s) ((s)->enc_heads[2]) +#define ADDR_HEAD(s) ((s)->enc_heads[3]) + +#define TOTAL_MODES(x) (2+(x)->acache.s_same+(x)->acache.s_near) + +/* Template instances. */ +#if XD3_BUILD_SLOW +#define IF_BUILD_SLOW(x) x +#else +#define IF_BUILD_SLOW(x) +#endif +#if XD3_BUILD_FAST +#define IF_BUILD_FAST(x) x +#else +#define IF_BUILD_FAST(x) +#endif +#if XD3_BUILD_FASTER +#define IF_BUILD_FASTER(x) x +#else +#define IF_BUILD_FASTER(x) +#endif +#if XD3_BUILD_FASTEST +#define IF_BUILD_FASTEST(x) x +#else +#define IF_BUILD_FASTEST(x) +#endif +#if XD3_BUILD_SOFT +#define IF_BUILD_SOFT(x) x +#else +#define IF_BUILD_SOFT(x) +#endif +#if XD3_BUILD_DEFAULT +#define IF_BUILD_DEFAULT(x) x +#else +#define IF_BUILD_DEFAULT(x) +#endif + +/* Update the run-length state */ +#define NEXTRUN(c) do { if ((c) == run_c) { run_l += 1; } \ + else { run_c = (c); run_l = 1; } } while (0) + +/* This CPP-conditional stuff can be cleaned up... */ +#if REGRESSION_TEST +#define IF_REGRESSION(x) x +#else +#define IF_REGRESSION(x) +#endif + +/***********************************************************************/ + +#if XD3_ENCODER +static void* xd3_alloc0 (xd3_stream *stream, + usize_t elts, + usize_t size); + + +static int xd3_alloc_iopt (xd3_stream *stream, usize_t elts); + +static void xd3_free_output (xd3_stream *stream, + xd3_output *output); + +static int xd3_emit_double (xd3_stream *stream, xd3_rinst *first, + xd3_rinst *second, usize_t code); +static int xd3_emit_single (xd3_stream *stream, xd3_rinst *single, + usize_t code); + +static usize_t xd3_sizeof_output (xd3_output *output); +static void xd3_encode_reset (xd3_stream *stream); + +static int xd3_source_match_setup (xd3_stream *stream, xoff_t srcpos); +static int xd3_source_extend_match (xd3_stream *stream); +static int xd3_srcwin_setup (xd3_stream *stream); +static usize_t xd3_iopt_last_matched (xd3_stream *stream); +static int xd3_emit_uint32_t (xd3_stream *stream, xd3_output **output, + uint32_t num); + +static usize_t xd3_smatch (xd3_stream *stream, + usize_t base, + usize_t scksum, + usize_t *match_offset); +static int xd3_string_match_init (xd3_stream *stream); +static uint32_t xd3_scksum (uint32_t *state, const uint8_t *seg, + const usize_t ln); +static usize_t xd3_comprun (const uint8_t *seg, usize_t slook, uint8_t *run_cp); +static int xd3_srcwin_move_point (xd3_stream *stream, + usize_t *next_move_point); + +static int xd3_emit_run (xd3_stream *stream, usize_t pos, + usize_t size, uint8_t *run_c); +static usize_t xd3_checksum_hash (const xd3_hash_cfg *cfg, + const usize_t cksum); +static xoff_t xd3_source_cksum_offset(xd3_stream *stream, usize_t low); +static void xd3_scksum_insert (xd3_stream *stream, + usize_t inx, + usize_t scksum, + usize_t pos); + + +#if XD3_DEBUG +static void xd3_verify_run_state (xd3_stream *stream, + const uint8_t *inp, + usize_t x_run_l, + uint8_t *x_run_c); +static void xd3_verify_large_state (xd3_stream *stream, + const uint8_t *inp, + uint32_t x_cksum); +static void xd3_verify_small_state (xd3_stream *stream, + const uint8_t *inp, + uint32_t x_cksum); + +#endif /* XD3_DEBUG */ +#endif /* XD3_ENCODER */ + +static int xd3_decode_allocate (xd3_stream *stream, usize_t size, + uint8_t **copied1, usize_t *alloc1); + +static void* xd3_alloc (xd3_stream *stream, usize_t elts, usize_t size); +static void xd3_free (xd3_stream *stream, void *ptr); + +static int xd3_read_uint32_t (xd3_stream *stream, const uint8_t **inpp, + const uint8_t *max, uint32_t *valp); + +#if REGRESSION_TEST +static int xd3_selftest (void); +#endif + +/***********************************************************************/ + +const char* xd3_strerror (int ret) +{ + switch (ret) + { + case XD3_INPUT: return "XD3_INPUT"; + case XD3_OUTPUT: return "XD3_OUTPUT"; + case XD3_GETSRCBLK: return "XD3_GETSRCBLK"; + case XD3_GOTHEADER: return "XD3_GOTHEADER"; + case XD3_WINSTART: return "XD3_WINSTART"; + case XD3_WINFINISH: return "XD3_WINFINISH"; + case XD3_TOOFARBACK: return "XD3_TOOFARBACK"; + case XD3_INTERNAL: return "XD3_INTERNAL"; + case XD3_INVALID: return "XD3_INVALID"; + case XD3_INVALID_INPUT: return "XD3_INVALID_INPUT"; + case XD3_NOSECOND: return "XD3_NOSECOND"; + case XD3_UNIMPLEMENTED: return "XD3_UNIMPLEMENTED"; + } + return NULL; +} + +/***********************************************************************/ + +#define xd3_sec_data(s) ((s)->sec_stream_d) +#define xd3_sec_inst(s) ((s)->sec_stream_i) +#define xd3_sec_addr(s) ((s)->sec_stream_a) + +struct _xd3_sec_type +{ + int id; + const char *name; + xd3_secondary_flags flags; + + /* xd3_sec_stream is opaque to the generic code */ + xd3_sec_stream* (*alloc) (xd3_stream *stream); + void (*destroy) (xd3_stream *stream, + xd3_sec_stream *sec); + int (*init) (xd3_stream *stream, + xd3_sec_stream *sec_stream, + int is_encode); + int (*decode) (xd3_stream *stream, + xd3_sec_stream *sec_stream, + const uint8_t **input, + const uint8_t *input_end, + uint8_t **output, + const uint8_t *output_end); +#if XD3_ENCODER + int (*encode) (xd3_stream *stream, + xd3_sec_stream *sec_stream, + xd3_output *input, + xd3_output *output, + xd3_sec_cfg *cfg); +#endif +}; + +#define BIT_STATE_ENCODE_INIT { 0, 1 } +#define BIT_STATE_DECODE_INIT { 0, 0x100 } + +typedef struct _bit_state bit_state; +struct _bit_state +{ + usize_t cur_byte; + usize_t cur_mask; +}; + +#if SECONDARY_ANY == 0 +#define IF_SEC(x) +#define IF_NSEC(x) x +#else /* yuck */ +#define IF_SEC(x) x +#define IF_NSEC(x) +static int +xd3_decode_secondary (xd3_stream *stream, + xd3_desect *sect, + xd3_sec_stream **sec_streamp); +#if XD3_ENCODER +static int +xd3_encode_secondary (xd3_stream *stream, + xd3_output **head, + xd3_output **tail, + xd3_sec_stream **sec_streamp, + xd3_sec_cfg *cfg, + int *did_it); +#endif +#endif /* SECONDARY_ANY */ + +#if SECONDARY_FGK +extern const xd3_sec_type fgk_sec_type; +#define IF_FGK(x) x +#define FGK_CASE(s) \ + s->sec_type = & fgk_sec_type; \ + break; +#else +#define IF_FGK(x) +#define FGK_CASE(s) \ + s->msg = "unavailable secondary compressor: FGK Adaptive Huffman"; \ + return XD3_INTERNAL; +#endif + +#if SECONDARY_DJW +extern const xd3_sec_type djw_sec_type; +#define IF_DJW(x) x +#define DJW_CASE(s) \ + s->sec_type = & djw_sec_type; \ + break; +#else +#define IF_DJW(x) +#define DJW_CASE(s) \ + s->msg = "unavailable secondary compressor: DJW Static Huffman"; \ + return XD3_INTERNAL; +#endif + +#if SECONDARY_LZMA +extern const xd3_sec_type lzma_sec_type; +#define IF_LZMA(x) x +#define LZMA_CASE(s) \ + s->sec_type = & lzma_sec_type; \ + break; +#else +#define IF_LZMA(x) +#define LZMA_CASE(s) \ + s->msg = "unavailable secondary compressor: LZMA"; \ + return XD3_INTERNAL; +#endif + +/***********************************************************************/ + +#include "xdelta3-hash.h" + +/* Process template passes - this includes xdelta3.c several times. */ +#define __XDELTA3_C_TEMPLATE_PASS__ +#include "xdelta3-cfgs.h" +#undef __XDELTA3_C_TEMPLATE_PASS__ + +/* Process the inline pass. */ +#define __XDELTA3_C_INLINE_PASS__ +#include "xdelta3.c" +#undef __XDELTA3_C_INLINE_PASS__ + +/* Secondary compression */ +#if SECONDARY_ANY +#include "xdelta3-second.h" +#endif + +#if SECONDARY_FGK +#include "xdelta3-fgk.h" +const xd3_sec_type fgk_sec_type = +{ + VCD_FGK_ID, + "FGK Adaptive Huffman", + SEC_NOFLAGS, + (xd3_sec_stream* (*)(xd3_stream*)) fgk_alloc, + (void (*)(xd3_stream*, xd3_sec_stream*)) fgk_destroy, + (int (*)(xd3_stream*, xd3_sec_stream*, int)) fgk_init, + (int (*)(xd3_stream*, xd3_sec_stream*, const uint8_t**, const uint8_t*, + uint8_t**, const uint8_t*)) xd3_decode_fgk, + IF_ENCODER((int (*)(xd3_stream*, xd3_sec_stream*, xd3_output*, + xd3_output*, xd3_sec_cfg*)) xd3_encode_fgk) +}; +#endif + +#if SECONDARY_DJW +#include "xdelta3-djw.h" +const xd3_sec_type djw_sec_type = +{ + VCD_DJW_ID, + "Static Huffman", + SEC_COUNT_FREQS, + (xd3_sec_stream* (*)(xd3_stream*)) djw_alloc, + (void (*)(xd3_stream*, xd3_sec_stream*)) djw_destroy, + (int (*)(xd3_stream*, xd3_sec_stream*, int)) djw_init, + (int (*)(xd3_stream*, xd3_sec_stream*, const uint8_t**, const uint8_t*, + uint8_t**, const uint8_t*)) xd3_decode_huff, + IF_ENCODER((int (*)(xd3_stream*, xd3_sec_stream*, xd3_output*, + xd3_output*, xd3_sec_cfg*)) xd3_encode_huff) +}; +#endif + +#if SECONDARY_LZMA +#include "xdelta3-lzma.h" +const xd3_sec_type lzma_sec_type = +{ + VCD_LZMA_ID, + "lzma", + SEC_NOFLAGS, + (xd3_sec_stream* (*)(xd3_stream*)) xd3_lzma_alloc, + (void (*)(xd3_stream*, xd3_sec_stream*)) xd3_lzma_destroy, + (int (*)(xd3_stream*, xd3_sec_stream*, int)) xd3_lzma_init, + (int (*)(xd3_stream*, xd3_sec_stream*, const uint8_t**, const uint8_t*, + uint8_t**, const uint8_t*)) xd3_decode_lzma, + IF_ENCODER((int (*)(xd3_stream*, xd3_sec_stream*, xd3_output*, + xd3_output*, xd3_sec_cfg*)) xd3_encode_lzma) +}; +#endif + +#if XD3_MAIN || PYTHON_MODULE || SWIG_MODULE || NOT_MAIN +#include "xdelta3-main.h" +#endif + +#if REGRESSION_TEST +#include "xdelta3-test.h" +#endif + +#endif /* __XDELTA3_C_HEADER_PASS__ */ +#ifdef __XDELTA3_C_INLINE_PASS__ + +const uint16_t __single_hash[256] = +{ + /* Random numbers generated using SLIB's pseudo-random number generator. + * This hashes the input alphabet. */ + 0xbcd1, 0xbb65, 0x42c2, 0xdffe, 0x9666, 0x431b, 0x8504, 0xeb46, + 0x6379, 0xd460, 0xcf14, 0x53cf, 0xdb51, 0xdb08, 0x12c8, 0xf602, + 0xe766, 0x2394, 0x250d, 0xdcbb, 0xa678, 0x02af, 0xa5c6, 0x7ea6, + 0xb645, 0xcb4d, 0xc44b, 0xe5dc, 0x9fe6, 0x5b5c, 0x35f5, 0x701a, + 0x220f, 0x6c38, 0x1a56, 0x4ca3, 0xffc6, 0xb152, 0x8d61, 0x7a58, + 0x9025, 0x8b3d, 0xbf0f, 0x95a3, 0xe5f4, 0xc127, 0x3bed, 0x320b, + 0xb7f3, 0x6054, 0x333c, 0xd383, 0x8154, 0x5242, 0x4e0d, 0x0a94, + 0x7028, 0x8689, 0x3a22, 0x0980, 0x1847, 0xb0f1, 0x9b5c, 0x4176, + 0xb858, 0xd542, 0x1f6c, 0x2497, 0x6a5a, 0x9fa9, 0x8c5a, 0x7743, + 0xa8a9, 0x9a02, 0x4918, 0x438c, 0xc388, 0x9e2b, 0x4cad, 0x01b6, + 0xab19, 0xf777, 0x365f, 0x1eb2, 0x091e, 0x7bf8, 0x7a8e, 0x5227, + 0xeab1, 0x2074, 0x4523, 0xe781, 0x01a3, 0x163d, 0x3b2e, 0x287d, + 0x5e7f, 0xa063, 0xb134, 0x8fae, 0x5e8e, 0xb7b7, 0x4548, 0x1f5a, + 0xfa56, 0x7a24, 0x900f, 0x42dc, 0xcc69, 0x02a0, 0x0b22, 0xdb31, + 0x71fe, 0x0c7d, 0x1732, 0x1159, 0xcb09, 0xe1d2, 0x1351, 0x52e9, + 0xf536, 0x5a4f, 0xc316, 0x6bf9, 0x8994, 0xb774, 0x5f3e, 0xf6d6, + 0x3a61, 0xf82c, 0xcc22, 0x9d06, 0x299c, 0x09e5, 0x1eec, 0x514f, + 0x8d53, 0xa650, 0x5c6e, 0xc577, 0x7958, 0x71ac, 0x8916, 0x9b4f, + 0x2c09, 0x5211, 0xf6d8, 0xcaaa, 0xf7ef, 0x287f, 0x7a94, 0xab49, + 0xfa2c, 0x7222, 0xe457, 0xd71a, 0x00c3, 0x1a76, 0xe98c, 0xc037, + 0x8208, 0x5c2d, 0xdfda, 0xe5f5, 0x0b45, 0x15ce, 0x8a7e, 0xfcad, + 0xaa2d, 0x4b5c, 0xd42e, 0xb251, 0x907e, 0x9a47, 0xc9a6, 0xd93f, + 0x085e, 0x35ce, 0xa153, 0x7e7b, 0x9f0b, 0x25aa, 0x5d9f, 0xc04d, + 0x8a0e, 0x2875, 0x4a1c, 0x295f, 0x1393, 0xf760, 0x9178, 0x0f5b, + 0xfa7d, 0x83b4, 0x2082, 0x721d, 0x6462, 0x0368, 0x67e2, 0x8624, + 0x194d, 0x22f6, 0x78fb, 0x6791, 0xb238, 0xb332, 0x7276, 0xf272, + 0x47ec, 0x4504, 0xa961, 0x9fc8, 0x3fdc, 0xb413, 0x007a, 0x0806, + 0x7458, 0x95c6, 0xccaa, 0x18d6, 0xe2ae, 0x1b06, 0xf3f6, 0x5050, + 0xc8e8, 0xf4ac, 0xc04c, 0xf41c, 0x992f, 0xae44, 0x5f1b, 0x1113, + 0x1738, 0xd9a8, 0x19ea, 0x2d33, 0x9698, 0x2fe9, 0x323f, 0xcde2, + 0x6d71, 0xe37d, 0xb697, 0x2c4f, 0x4373, 0x9102, 0x075d, 0x8e25, + 0x1672, 0xec28, 0x6acb, 0x86cc, 0x186e, 0x9414, 0xd674, 0xd1a5 +}; + +/**************************************************************** + Instruction tables + *****************************************************************/ + +/* The following code implements a parametrized description of the + * code table given above for a few reasons. It is not necessary for + * implementing the standard, to support compression with variable + * tables, so an implementation is only required to know the default + * code table to begin decompression. (If the encoder uses an + * alternate table, the table is included in compressed form inside + * the VCDIFF file.) + * + * Before adding variable-table support there were two functions which + * were hard-coded to the default table above. + * xd3_compute_default_table() would create the default table by + * filling a 256-elt array of xd3_dinst values. The corresponding + * function, xd3_choose_instruction(), would choose an instruction + * based on the hard-coded parameters of the default code table. + * + * Notes: The parametrized code table description here only generates + * tables of a certain regularity similar to the default table by + * allowing to vary the distribution of single- and + * double-instructions and change the number of near and same copy + * modes. More exotic tables are only possible by extending this + * code. + * + * For performance reasons, both the parametrized and non-parametrized + * versions of xd3_choose_instruction remain. The parametrized + * version is only needed for testing multi-table decoding support. + * If ever multi-table encoding is required, this can be optimized by + * compiling static functions for each table. + */ + +/* The XD3_CHOOSE_INSTRUCTION calls xd3_choose_instruction with the + * table description when GENERIC_ENCODE_TABLES are in use. The + * IF_GENCODETBL macro enables generic-code-table specific code + * (removed 10/2014). */ +#define XD3_CHOOSE_INSTRUCTION(stream,prev,inst) \ + xd3_choose_instruction (prev, inst) + +/* This structure maintains information needed by + * xd3_choose_instruction to compute the code for a double instruction + * by first indexing an array of code_table_sizes by copy mode, then + * using (offset + (muliplier * X)) */ +struct _xd3_code_table_sizes { + uint8_t cpy_max; + uint8_t offset; + uint8_t mult; +}; + +/* This contains a complete description of a code table. */ +struct _xd3_code_table_desc +{ + /* Assumes a single RUN instruction */ + /* Assumes that MIN_MATCH is 4 */ + + uint8_t add_sizes; /* Number of immediate-size single adds (default 17) */ + uint8_t near_modes; /* Number of near copy modes (default 4) */ + uint8_t same_modes; /* Number of same copy modes (default 3) */ + uint8_t cpy_sizes; /* Number of immediate-size single copies (default 15) */ + + uint8_t addcopy_add_max; /* Maximum add size for an add-copy double instruction, + all modes (default 4) */ + uint8_t addcopy_near_cpy_max; /* Maximum cpy size for an add-copy double instruction, + up through VCD_NEAR modes (default 6) */ + uint8_t addcopy_same_cpy_max; /* Maximum cpy size for an add-copy double instruction, + VCD_SAME modes (default 4) */ + + uint8_t copyadd_add_max; /* Maximum add size for a copy-add double instruction, + all modes (default 1) */ + uint8_t copyadd_near_cpy_max; /* Maximum cpy size for a copy-add double instruction, + up through VCD_NEAR modes (default 4) */ + uint8_t copyadd_same_cpy_max; /* Maximum cpy size for a copy-add double instruction, + VCD_SAME modes (default 4) */ + + xd3_code_table_sizes addcopy_max_sizes[MAX_MODES]; + xd3_code_table_sizes copyadd_max_sizes[MAX_MODES]; +}; + +/* The rfc3284 code table is represented: */ +static const xd3_code_table_desc __rfc3284_code_table_desc = { + 17, /* add sizes */ + 4, /* near modes */ + 3, /* same modes */ + 15, /* copy sizes */ + + 4, /* add-copy max add */ + 6, /* add-copy max cpy, near */ + 4, /* add-copy max cpy, same */ + + 1, /* copy-add max add */ + 4, /* copy-add max cpy, near */ + 4, /* copy-add max cpy, same */ + + /* addcopy */ + { {6,163,3},{6,175,3},{6,187,3},{6,199,3},{6,211,3},{6,223,3},{4,235,1},{4,239,1},{4,243,1} }, + /* copyadd */ + { {4,247,1},{4,248,1},{4,249,1},{4,250,1},{4,251,1},{4,252,1},{4,253,1},{4,254,1},{4,255,1} }, +}; + +/* Computes code table entries of TBL using the specified description. */ +static void +xd3_build_code_table (const xd3_code_table_desc *desc, xd3_dinst *tbl) +{ + usize_t size1, size2, mode; + usize_t cpy_modes = 2 + desc->near_modes + desc->same_modes; + xd3_dinst *d = tbl; + + (d++)->type1 = XD3_RUN; + (d++)->type1 = XD3_ADD; + + for (size1 = 1; size1 <= desc->add_sizes; size1 += 1, d += 1) + { + d->type1 = XD3_ADD; + d->size1 = size1; + } + + for (mode = 0; mode < cpy_modes; mode += 1) + { + (d++)->type1 = XD3_CPY + mode; + + for (size1 = MIN_MATCH; size1 < MIN_MATCH + desc->cpy_sizes; size1 += 1, d += 1) + { + d->type1 = XD3_CPY + mode; + d->size1 = size1; + } + } + + for (mode = 0; mode < cpy_modes; mode += 1) + { + for (size1 = 1; size1 <= desc->addcopy_add_max; size1 += 1) + { + usize_t max = (mode < 2U + desc->near_modes) ? + desc->addcopy_near_cpy_max : + desc->addcopy_same_cpy_max; + + for (size2 = MIN_MATCH; size2 <= max; size2 += 1, d += 1) + { + d->type1 = XD3_ADD; + d->size1 = size1; + d->type2 = XD3_CPY + mode; + d->size2 = size2; + } + } + } + + for (mode = 0; mode < cpy_modes; mode += 1) + { + usize_t max = (mode < 2U + desc->near_modes) ? + desc->copyadd_near_cpy_max : + desc->copyadd_same_cpy_max; + + for (size1 = MIN_MATCH; size1 <= max; size1 += 1) + { + for (size2 = 1; size2 <= desc->copyadd_add_max; size2 += 1, d += 1) + { + d->type1 = XD3_CPY + mode; + d->size1 = size1; + d->type2 = XD3_ADD; + d->size2 = size2; + } + } + } + + XD3_ASSERT (d - tbl == 256); +} + +/* This function generates the static default code table. */ +static const xd3_dinst* +xd3_rfc3284_code_table (void) +{ + static xd3_dinst __rfc3284_code_table[256]; + + if (__rfc3284_code_table[0].type1 != XD3_RUN) + { + xd3_build_code_table (& __rfc3284_code_table_desc, __rfc3284_code_table); + } + + return __rfc3284_code_table; +} + +#if XD3_ENCODER +/* This version of xd3_choose_instruction is hard-coded for the default + table. */ +static void +xd3_choose_instruction (xd3_rinst *prev, xd3_rinst *inst) +{ + switch (inst->type) + { + case XD3_RUN: + inst->code1 = 0; + break; + + case XD3_ADD: + inst->code1 = 1; + + if (inst->size <= 17) + { + inst->code1 += inst->size; + + if ( (inst->size == 1) && + (prev != NULL) && + (prev->size == 4) && + (prev->type >= XD3_CPY) ) + { + prev->code2 = 247 + (prev->type - XD3_CPY); + } + } + + break; + + default: + { + int mode = inst->type - XD3_CPY; + + XD3_ASSERT (inst->type >= XD3_CPY && inst->type < 12); + + inst->code1 = 19 + 16 * mode; + + if (inst->size <= 18 && inst->size >= 4) + { + inst->code1 += inst->size - 3; + + if ( (prev != NULL) && + (prev->type == XD3_ADD) && + (prev->size <= 4) ) + { + if ( (inst->size <= 6) && + (mode <= 5) ) + { + prev->code2 = 163 + (mode * 12) + (3 * (prev->size - 1)) + (inst->size - 4); + + XD3_ASSERT (prev->code2 <= 234); + } + else if ( (inst->size == 4) && + (mode >= 6) ) + { + prev->code2 = 235 + ((mode - 6) * 4) + (prev->size - 1); + + XD3_ASSERT (prev->code2 <= 246); + } + } + } + + XD3_ASSERT (inst->code1 <= 162); + } + break; + } +} +#endif /* XD3_ENCODER */ + +/***********************************************************************/ + +static inline void +xd3_swap_uint8p (uint8_t** p1, uint8_t** p2) +{ + uint8_t *t = (*p1); + (*p1) = (*p2); + (*p2) = t; +} + +static inline void +xd3_swap_usize_t (usize_t* p1, usize_t* p2) +{ + usize_t t = (*p1); + (*p1) = (*p2); + (*p2) = t; +} + +/* It's not constant time, but it computes the log. */ +static int +xd3_check_pow2 (xoff_t value, usize_t *logof) +{ + xoff_t x = 1; + usize_t nolog; + if (logof == NULL) { + logof = &nolog; + } + + *logof = 0; + + for (; x != 0; x <<= 1, *logof += 1) + { + if (x == value) + { + return 0; + } + } + + return XD3_INTERNAL; +} + +size_t +xd3_pow2_roundup (size_t x) +{ + size_t i = 1; + while (x > i) { + i <<= 1U; + } + return i; +} + +static xoff_t +xd3_xoff_roundup (xoff_t x) +{ + xoff_t i = 1; + while (x > i) { + i <<= 1U; + } + return i; +} + +static usize_t +xd3_round_blksize (usize_t sz, usize_t blksz) +{ + usize_t mod = sz & (blksz-1); + + XD3_ASSERT (xd3_check_pow2 (blksz, NULL) == 0); + + if (mod == 0) + { + return sz; + } + + if (sz > USIZE_T_MAXBLKSZ) + { + return USIZE_T_MAXBLKSZ; + } + + return sz + (blksz - mod); +} + +/*********************************************************************** + Adler32 stream function: code copied from Zlib, defined in RFC1950 + ***********************************************************************/ + +#define A32_BASE 65521L /* Largest prime smaller than 2^16 */ +#define A32_NMAX 5552 /* NMAX is the largest n such that 255n(n+1)/2 + (n+1)(BASE-1) <= 2^32-1 */ + +#define A32_DO1(buf,i) {s1 += buf[i]; s2 += s1;} +#define A32_DO2(buf,i) A32_DO1(buf,i); A32_DO1(buf,i+1); +#define A32_DO4(buf,i) A32_DO2(buf,i); A32_DO2(buf,i+2); +#define A32_DO8(buf,i) A32_DO4(buf,i); A32_DO4(buf,i+4); +#define A32_DO16(buf) A32_DO8(buf,0); A32_DO8(buf,8); + +static unsigned long adler32 (unsigned long adler, const uint8_t *buf, + usize_t len) +{ + unsigned long s1 = adler & 0xffff; + unsigned long s2 = (adler >> 16) & 0xffff; + int k; + + while (len > 0) + { + k = (len < A32_NMAX) ? len : A32_NMAX; + len -= k; + + while (k >= 16) + { + A32_DO16(buf); + buf += 16; + k -= 16; + } + + if (k != 0) + { + do + { + s1 += *buf++; + s2 += s1; + } + while (--k); + } + + s1 %= A32_BASE; + s2 %= A32_BASE; + } + + return (s2 << 16) | s1; +} + +/*********************************************************************** + Run-length function + ***********************************************************************/ + +#if XD3_ENCODER +static usize_t +xd3_comprun (const uint8_t *seg, usize_t slook, uint8_t *run_cp) +{ + usize_t i; + usize_t run_l = 0; + uint8_t run_c = 0; + + for (i = 0; i < slook; i += 1) + { + NEXTRUN(seg[i]); + } + + (*run_cp) = run_c; + + return run_l; +} +#endif + +/*********************************************************************** + Basic encoder/decoder functions + ***********************************************************************/ + +#if XD3_ENCODER +inline int +xd3_emit_byte (xd3_stream *stream, + xd3_output **outputp, + uint8_t code) +{ + xd3_output *output = (*outputp); + + if (output->next == output->avail) + { + xd3_output *aoutput; + + if ((aoutput = xd3_alloc_output (stream, output)) == NULL) + { + return ENOMEM; + } + + output = (*outputp) = aoutput; + } + + output->base[output->next++] = code; + + return 0; +} + +inline int +xd3_emit_bytes (xd3_stream *stream, + xd3_output **outputp, + const uint8_t *base, + usize_t size) +{ + xd3_output *output = (*outputp); + + do + { + usize_t take; + + if (output->next == output->avail) + { + xd3_output *aoutput; + + if ((aoutput = xd3_alloc_output (stream, output)) == NULL) + { + return ENOMEM; + } + + output = (*outputp) = aoutput; + } + + take = xd3_min (output->avail - output->next, size); + + memcpy (output->base + output->next, base, (size_t) take); + + output->next += take; + size -= take; + base += take; + } + while (size > 0); + + return 0; +} +#endif /* XD3_ENCODER */ + +/*********************************************************************** + Address cache stuff + ***********************************************************************/ + +static int +xd3_alloc_cache (xd3_stream *stream) +{ + if (stream->acache.near_array != NULL) + { + xd3_free (stream, stream->acache.near_array); + } + + if (stream->acache.same_array != NULL) + { + xd3_free (stream, stream->acache.same_array); + } + + if (((stream->acache.s_near > 0) && + (stream->acache.near_array = (usize_t*) + xd3_alloc (stream, stream->acache.s_near, + (usize_t) sizeof (usize_t))) + == NULL) || + ((stream->acache.s_same > 0) && + (stream->acache.same_array = (usize_t*) + xd3_alloc (stream, stream->acache.s_same * 256, + (usize_t) sizeof (usize_t))) + == NULL)) + { + return ENOMEM; + } + + return 0; +} + +void +xd3_init_cache (xd3_addr_cache* acache) +{ + if (acache->s_near > 0) + { + memset (acache->near_array, 0, acache->s_near * sizeof (usize_t)); + acache->next_slot = 0; + } + + if (acache->s_same > 0) + { + memset (acache->same_array, 0, acache->s_same * 256 * sizeof (usize_t)); + } +} + +static void +xd3_update_cache (xd3_addr_cache* acache, usize_t addr) +{ + if (acache->s_near > 0) + { + acache->near_array[acache->next_slot] = addr; + acache->next_slot = (acache->next_slot + 1) % acache->s_near; + } + + if (acache->s_same > 0) + { + acache->same_array[addr % (acache->s_same*256)] = addr; + } +} + +#if XD3_ENCODER +/* OPT: this gets called a lot, can it be optimized? */ +static int +xd3_encode_address (xd3_stream *stream, + usize_t addr, + usize_t here, + uint8_t* mode) +{ + usize_t d, bestd; + usize_t i, bestm, ret; + xd3_addr_cache* acache = & stream->acache; + +#define SMALLEST_INT(x) do { if (((x) & ~127U) == 0) { goto good; } } while (0) + + /* Attempt to find the address mode that yields the smallest integer value + * for "d", the encoded address value, thereby minimizing the encoded size + * of the address. */ + bestd = addr; + bestm = VCD_SELF; + + XD3_ASSERT (addr < here); + + SMALLEST_INT (bestd); + + if ((d = here-addr) < bestd) + { + bestd = d; + bestm = VCD_HERE; + + SMALLEST_INT (bestd); + } + + for (i = 0; i < acache->s_near; i += 1) + { + /* Note: If we used signed computation here, we'd could compte d + * and then check (d >= 0 && d < bestd). */ + if (addr >= acache->near_array[i]) + { + d = addr - acache->near_array[i]; + + if (d < bestd) + { + bestd = d; + bestm = i+2; /* 2 counts the VCD_SELF, VCD_HERE modes */ + + SMALLEST_INT (bestd); + } + } + } + + if (acache->s_same > 0 && + acache->same_array[d = addr%(acache->s_same*256)] == addr) + { + bestd = d%256; + /* 2 + s_near offsets past the VCD_NEAR modes */ + bestm = acache->s_near + 2 + d/256; + + if ((ret = xd3_emit_byte (stream, & ADDR_TAIL (stream), bestd))) + { + return ret; + } + } + else + { + good: + + if ((ret = xd3_emit_size (stream, & ADDR_TAIL (stream), bestd))) + { + return ret; + } + } + + xd3_update_cache (acache, addr); + + (*mode) += bestm; + + return 0; +} +#endif + +static int +xd3_decode_address (xd3_stream *stream, usize_t here, + usize_t mode, const uint8_t **inpp, + const uint8_t *max, uint32_t *valp) +{ + int ret; + usize_t same_start = 2 + stream->acache.s_near; + + if (mode < same_start) + { + if ((ret = xd3_read_size (stream, inpp, max, valp))) { return ret; } + + switch (mode) + { + case VCD_SELF: + break; + case VCD_HERE: + (*valp) = here - (*valp); + break; + default: + (*valp) += stream->acache.near_array[mode - 2]; + break; + } + } + else + { + if (*inpp == max) + { + stream->msg = "address underflow"; + return XD3_INVALID_INPUT; + } + + mode -= same_start; + + (*valp) = stream->acache.same_array[mode*256 + (**inpp)]; + + (*inpp) += 1; + } + + xd3_update_cache (& stream->acache, *valp); + + return 0; +} + +/*********************************************************************** + Alloc/free +***********************************************************************/ + +static void* +__xd3_alloc_func (void* opaque, size_t items, usize_t size) +{ + return malloc (items * (size_t) size); +} + +static void +__xd3_free_func (void* opaque, void* address) +{ + free (address); +} + +static void* +xd3_alloc (xd3_stream *stream, + usize_t elts, + usize_t size) +{ + void *a = stream->alloc (stream->opaque, elts, size); + + if (a != NULL) + { + IF_DEBUG (stream->alloc_cnt += 1); + IF_DEBUG2 (DP(RINT "[stream %p malloc] size %u ptr %p\n", + stream, elts * size, a)); + } + else + { + stream->msg = "out of memory"; + } + + return a; +} + +static void +xd3_free (xd3_stream *stream, + void *ptr) +{ + if (ptr != NULL) + { + IF_DEBUG (stream->free_cnt += 1); + XD3_ASSERT (stream->free_cnt <= stream->alloc_cnt); + IF_DEBUG2 (DP(RINT "[stream %p free] %p\n", + stream, ptr)); + stream->free (stream->opaque, ptr); + } +} + +#if XD3_ENCODER +static void* +xd3_alloc0 (xd3_stream *stream, + usize_t elts, + usize_t size) +{ + void *a = xd3_alloc (stream, elts, size); + + if (a != NULL) + { + memset (a, 0, (size_t) (elts * size)); + } + + return a; +} + +xd3_output* +xd3_alloc_output (xd3_stream *stream, + xd3_output *old_output) +{ + xd3_output *output; + uint8_t *base; + + if (stream->enc_free != NULL) + { + output = stream->enc_free; + stream->enc_free = output->next_page; + } + else + { + if ((output = (xd3_output*) xd3_alloc (stream, 1, + (usize_t) sizeof (xd3_output))) + == NULL) + { + return NULL; + } + + if ((base = (uint8_t*) xd3_alloc (stream, XD3_ALLOCSIZE, + sizeof (uint8_t))) == NULL) + { + xd3_free (stream, output); + return NULL; + } + + output->base = base; + output->avail = XD3_ALLOCSIZE; + } + + output->next = 0; + + if (old_output) + { + old_output->next_page = output; + } + + output->next_page = NULL; + + return output; +} + +static usize_t +xd3_sizeof_output (xd3_output *output) +{ + usize_t s = 0; + + for (; output; output = output->next_page) + { + s += output->next; + } + + return s; +} + +static void +xd3_freelist_output (xd3_stream *stream, + xd3_output *output) +{ + xd3_output *tmp; + + while (output) + { + tmp = output; + output = output->next_page; + + tmp->next = 0; + tmp->next_page = stream->enc_free; + stream->enc_free = tmp; + } +} + +static void +xd3_free_output (xd3_stream *stream, + xd3_output *output) +{ + xd3_output *next; + + again: + if (output == NULL) + { + return; + } + + next = output->next_page; + + xd3_free (stream, output->base); + xd3_free (stream, output); + + output = next; + goto again; +} +#endif /* XD3_ENCODER */ + +void +xd3_free_stream (xd3_stream *stream) +{ + xd3_iopt_buflist *blist = stream->iopt_alloc; + + while (blist != NULL) + { + xd3_iopt_buflist *tmp = blist; + blist = blist->next; + xd3_free (stream, tmp->buffer); + xd3_free (stream, tmp); + } + + xd3_free (stream, stream->large_table); + xd3_free (stream, stream->small_table); + xd3_free (stream, stream->small_prev); + +#if XD3_ENCODER + { + int i; + for (i = 0; i < ENC_SECTS; i += 1) + { + xd3_free_output (stream, stream->enc_heads[i]); + } + xd3_free_output (stream, stream->enc_free); + } +#endif + + xd3_free (stream, stream->acache.near_array); + xd3_free (stream, stream->acache.same_array); + + xd3_free (stream, stream->inst_sect.copied1); + xd3_free (stream, stream->addr_sect.copied1); + xd3_free (stream, stream->data_sect.copied1); + + if (stream->dec_lastwin != stream->dec_buffer) + { + xd3_free (stream, (uint8_t*) stream->dec_lastwin); + } + xd3_free (stream, stream->dec_buffer); + + xd3_free (stream, stream->buf_in); + xd3_free (stream, stream->dec_appheader); + xd3_free (stream, stream->dec_codetbl); + xd3_free (stream, stream->code_table_alloc); + +#if SECONDARY_ANY + xd3_free (stream, stream->inst_sect.copied2); + xd3_free (stream, stream->addr_sect.copied2); + xd3_free (stream, stream->data_sect.copied2); + + if (stream->sec_type != NULL) + { + stream->sec_type->destroy (stream, stream->sec_stream_d); + stream->sec_type->destroy (stream, stream->sec_stream_i); + stream->sec_type->destroy (stream, stream->sec_stream_a); + } +#endif + + xd3_free (stream, stream->whole_target.adds); + xd3_free (stream, stream->whole_target.inst); + xd3_free (stream, stream->whole_target.wininfo); + + XD3_ASSERT (stream->alloc_cnt == stream->free_cnt); + + memset (stream, 0, sizeof (xd3_stream)); +} + +#if (XD3_DEBUG > 1 || VCDIFF_TOOLS) +static const char* +xd3_rtype_to_string (xd3_rtype type, int print_mode) +{ + switch (type) + { + case XD3_NOOP: + return "NOOP "; + case XD3_RUN: + return "RUN "; + case XD3_ADD: + return "ADD "; + default: break; + } + if (! print_mode) + { + return "CPY "; + } + switch (type) + { + case XD3_CPY + 0: return "CPY_0"; + case XD3_CPY + 1: return "CPY_1"; + case XD3_CPY + 2: return "CPY_2"; + case XD3_CPY + 3: return "CPY_3"; + case XD3_CPY + 4: return "CPY_4"; + case XD3_CPY + 5: return "CPY_5"; + case XD3_CPY + 6: return "CPY_6"; + case XD3_CPY + 7: return "CPY_7"; + case XD3_CPY + 8: return "CPY_8"; + case XD3_CPY + 9: return "CPY_9"; + default: return "CPY>9"; + } +} +#endif + +/**************************************************************** + Stream configuration + ******************************************************************/ + +int +xd3_config_stream(xd3_stream *stream, + xd3_config *config) +{ + int ret; + xd3_config defcfg; + xd3_smatcher *smatcher = &stream->smatcher; + + if (config == NULL) + { + config = & defcfg; + memset (config, 0, sizeof (*config)); + } + + /* Initial setup: no error checks yet */ + memset (stream, 0, sizeof (*stream)); + + stream->winsize = config->winsize ? config->winsize : XD3_DEFAULT_WINSIZE; + stream->sprevsz = config->sprevsz ? config->sprevsz : XD3_DEFAULT_SPREVSZ; + + if (config->iopt_size == 0) + { + stream->iopt_size = XD3_ALLOCSIZE / sizeof(xd3_rinst); + stream->iopt_unlimited = 1; + } + else + { + stream->iopt_size = config->iopt_size; + } + + stream->getblk = config->getblk; + stream->alloc = config->alloc ? config->alloc : __xd3_alloc_func; + stream->free = config->freef ? config->freef : __xd3_free_func; + stream->opaque = config->opaque; + stream->flags = config->flags; + + /* Secondary setup. */ + stream->sec_data = config->sec_data; + stream->sec_inst = config->sec_inst; + stream->sec_addr = config->sec_addr; + + stream->sec_data.data_type = DATA_SECTION; + stream->sec_inst.data_type = INST_SECTION; + stream->sec_addr.data_type = ADDR_SECTION; + + /* Check static sizes. */ + if (sizeof (usize_t) != SIZEOF_USIZE_T || + sizeof (xoff_t) != SIZEOF_XOFF_T || + (ret = xd3_check_pow2(XD3_ALLOCSIZE, NULL))) + { + stream->msg = "incorrect compilation: wrong integer sizes"; + return XD3_INTERNAL; + } + + /* Check/set secondary compressor. */ + switch (stream->flags & XD3_SEC_TYPE) + { + case 0: + if (stream->flags & XD3_SEC_NOALL) + { + stream->msg = "XD3_SEC flags require a secondary compressor type"; + return XD3_INTERNAL; + } + break; + case XD3_SEC_FGK: + FGK_CASE (stream); + case XD3_SEC_DJW: + DJW_CASE (stream); + case XD3_SEC_LZMA: + LZMA_CASE (stream); + default: + stream->msg = "too many secondary compressor types set"; + return XD3_INTERNAL; + } + + stream->code_table_desc = & __rfc3284_code_table_desc; + stream->code_table_func = xd3_rfc3284_code_table; + + /* Check sprevsz */ + if (smatcher->small_chain == 1 && + smatcher->small_lchain == 1) + { + stream->sprevsz = 0; + } + else + { + if ((ret = xd3_check_pow2 (stream->sprevsz, NULL))) + { + stream->msg = "sprevsz is required to be a power of two"; + return XD3_INTERNAL; + } + + stream->sprevmask = stream->sprevsz - 1; + } + + /* Default scanner settings. */ +#if XD3_ENCODER + switch (config->smatch_cfg) + { + IF_BUILD_SOFT(case XD3_SMATCH_SOFT: + { + *smatcher = config->smatcher_soft; + smatcher->string_match = __smatcher_soft.string_match; + smatcher->name = __smatcher_soft.name; + if (smatcher->large_look < MIN_MATCH || + smatcher->large_step < 1 || + smatcher->small_look < MIN_MATCH) + { + stream->msg = "invalid soft string-match config"; + return XD3_INVALID; + } + break; + }) + + IF_BUILD_DEFAULT(case XD3_SMATCH_DEFAULT: + *smatcher = __smatcher_default; + break;) + IF_BUILD_SLOW(case XD3_SMATCH_SLOW: + *smatcher = __smatcher_slow; + break;) + IF_BUILD_FASTEST(case XD3_SMATCH_FASTEST: + *smatcher = __smatcher_fastest; + break;) + IF_BUILD_FASTER(case XD3_SMATCH_FASTER: + *smatcher = __smatcher_faster; + break;) + IF_BUILD_FAST(case XD3_SMATCH_FAST: + *smatcher = __smatcher_fast; + break;) + default: + stream->msg = "invalid string match config type"; + return XD3_INTERNAL; + } + + if (config->smatch_cfg == XD3_SMATCH_DEFAULT && + (stream->flags & XD3_COMPLEVEL_MASK) != 0) + { + int level = (stream->flags & XD3_COMPLEVEL_MASK) >> XD3_COMPLEVEL_SHIFT; + + switch (level) + { + case 1: + IF_BUILD_FASTEST(*smatcher = __smatcher_fastest; + break;) + case 2: + IF_BUILD_FASTER(*smatcher = __smatcher_faster; + break;) + case 3: case 4: case 5: + IF_BUILD_FAST(*smatcher = __smatcher_fast; + break;) + case 6: + IF_BUILD_DEFAULT(*smatcher = __smatcher_default; + break;) + default: + IF_BUILD_SLOW(*smatcher = __smatcher_slow; + break;) + IF_BUILD_DEFAULT(*smatcher = __smatcher_default; + break;) + IF_BUILD_FAST(*smatcher = __smatcher_fast; + break;) + IF_BUILD_FASTER(*smatcher = __smatcher_faster; + break;) + IF_BUILD_FASTEST(*smatcher = __smatcher_fastest; + break;) + } + } +#endif + + return 0; +} + +/*********************************************************** + Getblk interface + ***********************************************************/ + +inline +xoff_t xd3_source_eof(const xd3_source *src) +{ + xoff_t r = (src->max_blkno << src->shiftby) + (xoff_t)src->onlastblk; + return r; +} + +inline +usize_t xd3_bytes_on_srcblk (xd3_source *src, xoff_t blkno) +{ + usize_t r = (blkno == src->max_blkno ? + src->onlastblk : + src->blksize); + return r; +} + +/* This function interfaces with the client getblk function, checks + * its results, updates max_blkno, onlastblk, eof_known. */ +static int +xd3_getblk (xd3_stream *stream, xoff_t blkno) +{ + int ret; + xd3_source *source = stream->src; + + if (source->curblk == NULL || blkno != source->curblkno) + { + source->getblkno = blkno; + + if (stream->getblk == NULL) + { + IF_DEBUG2 (DP(RINT "[getblk] XD3_GETSRCBLK %"Q"u\n", blkno)); + stream->msg = "getblk source input"; + return XD3_GETSRCBLK; + } + + ret = stream->getblk (stream, source, blkno); + if (ret != 0) + { + IF_DEBUG2 (DP(RINT "[getblk] app error blkno %"Q"u: %s\n", + blkno, xd3_strerror (ret))); + return ret; + } + + IF_DEBUG2 (DP(RINT "[getblk] read source block %"Q"u onblk " + "%u blksize %u max_blkno %"Q"u\n", blkno, source->onblk, + source->blksize, source->max_blkno)); + } + + if (blkno > source->max_blkno) + { + source->max_blkno = blkno; + + if (source->onblk == source->blksize) + { + IF_DEBUG1 (DP(RINT "[getblk] full source blkno %"Q"u: " + "source length unknown %"Q"u\n", + blkno, + xd3_source_eof (source))); + } + else if (!source->eof_known) + { + IF_DEBUG1 (DP(RINT "[getblk] eof block has %d bytes; " + "source length known %"Q"u\n", + xd3_bytes_on_srcblk (source, blkno), + xd3_source_eof (source))); + source->eof_known = 1; + } + } + + XD3_ASSERT (source->curblk != NULL); + + if (blkno == source->max_blkno) + { + /* In case the application sets the source as 1 block w/ a + * preset buffer. */ + source->onlastblk = source->onblk; + } + return 0; +} + +/*********************************************************** + Stream open/close + ***************************************************************/ + +int +xd3_set_source (xd3_stream *stream, + xd3_source *src) +{ + usize_t shiftby; + + stream->src = src; + src->srclen = 0; + src->srcbase = 0; + + /* Enforce power-of-two blocksize so that source-block number + * calculations are cheap. */ + if (xd3_check_pow2 (src->blksize, &shiftby) != 0) + { + src->blksize = xd3_pow2_roundup(src->blksize); + xd3_check_pow2 (src->blksize, &shiftby); + IF_DEBUG1 (DP(RINT "raising src_blksz to %u\n", src->blksize)); + } + + src->shiftby = shiftby; + src->maskby = (1 << shiftby) - 1; + + if (xd3_check_pow2 (src->max_winsize, NULL) != 0) + { + src->max_winsize = xd3_xoff_roundup(src->max_winsize); + IF_DEBUG1 (DP(RINT "raising src_maxsize to %u\n", src->blksize)); + } + src->max_winsize = xd3_max (src->max_winsize, XD3_ALLOCSIZE); + return 0; +} + +int +xd3_set_source_and_size (xd3_stream *stream, + xd3_source *user_source, + xoff_t source_size) { + int ret = xd3_set_source (stream, user_source); + if (ret == 0) + { + stream->src->eof_known = 1; + + xd3_blksize_div(source_size, + stream->src, + &stream->src->max_blkno, + &stream->src->onlastblk); + + IF_DEBUG1 (DP(RINT "[set source] size known %"Q"u max_blkno %"Q"u\n", + source_size, stream->src->max_blkno)); + } + return ret; +} + +void +xd3_abort_stream (xd3_stream *stream) +{ + stream->dec_state = DEC_ABORTED; + stream->enc_state = ENC_ABORTED; +} + +int +xd3_close_stream (xd3_stream *stream) +{ + if (stream->enc_state != 0 && stream->enc_state != ENC_ABORTED) + { + if (stream->buf_leftover != NULL) + { + stream->msg = "encoding is incomplete"; + return XD3_INTERNAL; + } + + if (stream->enc_state == ENC_POSTWIN) + { +#if XD3_ENCODER + xd3_encode_reset (stream); +#endif + stream->current_window += 1; + stream->enc_state = ENC_INPUT; + } + + /* If encoding, should be ready for more input but not actually + have any. */ + if (stream->enc_state != ENC_INPUT || stream->avail_in != 0) + { + stream->msg = "encoding is incomplete"; + return XD3_INTERNAL; + } + } + else + { + switch (stream->dec_state) + { + case DEC_VCHEAD: + case DEC_WININD: + /* TODO: Address the zero-byte ambiguity. Does the encoder + * emit a window or not? If so, then catch an error here. + * If not, need another routine to say + * decode_at_least_one_if_empty. */ + case DEC_ABORTED: + break; + default: + /* If decoding, should be ready for the next window. */ + stream->msg = "eof in decode"; + return XD3_INVALID_INPUT; + } + } + + return 0; +} + +/************************************************************** + Application header + ****************************************************************/ + +int +xd3_get_appheader (xd3_stream *stream, + uint8_t **data, + usize_t *size) +{ + if (stream->dec_state < DEC_WININD) + { + stream->msg = "application header not available"; + return XD3_INTERNAL; + } + + (*data) = stream->dec_appheader; + (*size) = stream->dec_appheadsz; + return 0; +} + +/********************************************************** + Decoder stuff + *************************************************/ + +#include "xdelta3-decode.h" + +/**************************************************************** + Encoder stuff + *****************************************************************/ + +#if XD3_ENCODER +void +xd3_set_appheader (xd3_stream *stream, + const uint8_t *data, + usize_t size) +{ + stream->enc_appheader = data; + stream->enc_appheadsz = size; +} + +#if XD3_DEBUG +static int +xd3_iopt_check (xd3_stream *stream) +{ + usize_t ul = xd3_rlist_length (& stream->iopt_used); + usize_t fl = xd3_rlist_length (& stream->iopt_free); + + return (ul + fl + (stream->iout ? 1 : 0)) == stream->iopt_size; +} +#endif + +static xd3_rinst* +xd3_iopt_free (xd3_stream *stream, xd3_rinst *i) +{ + xd3_rinst *n = xd3_rlist_remove (i); + xd3_rlist_push_back (& stream->iopt_free, i); + return n; +} + +static void +xd3_iopt_free_nonadd (xd3_stream *stream, xd3_rinst *i) +{ + if (i->type != XD3_ADD) + { + xd3_rlist_push_back (& stream->iopt_free, i); + } +} + +/* When an instruction is ready to flush from the iopt buffer, this + * function is called to produce an encoding. It writes the + * instruction plus size, address, and data to the various encoding + * sections. */ +static int +xd3_iopt_finish_encoding (xd3_stream *stream, xd3_rinst *inst) +{ + int ret; + + /* Check for input overflow. */ + XD3_ASSERT (inst->pos + inst->size <= stream->avail_in); + + switch (inst->type) + { + case XD3_CPY: + { + /* the address may have an offset if there is a source window. */ + usize_t addr; + xd3_source *src = stream->src; + + if (src != NULL) + { + /* If there is a source copy, the source must have its + * source window decided before we can encode. This can + * be bad -- we have to make this decision even if no + * source matches have been found. */ + if (stream->srcwin_decided == 0) + { + if ((ret = xd3_srcwin_setup (stream))) { return ret; } + } + else + { + stream->srcwin_decided_early = (!stream->src->eof_known || + (stream->srcwin_cksum_pos < + xd3_source_eof (stream->src))); + } + + /* xtra field indicates the copy is from the source */ + if (inst->xtra) + { + XD3_ASSERT (inst->addr >= src->srcbase); + XD3_ASSERT (inst->addr + inst->size <= + src->srcbase + src->srclen); + addr = (usize_t)(inst->addr - src->srcbase); + stream->n_scpy += 1; + stream->l_scpy += (xoff_t) inst->size; + } + else + { + /* with source window: target copy address is offset + * by taroff. */ + addr = stream->taroff + (usize_t) inst->addr; + stream->n_tcpy += 1; + stream->l_tcpy += (xoff_t) inst->size; + } + } + else + { + addr = (usize_t) inst->addr; + stream->n_tcpy += 1; + stream->l_tcpy += inst->size; + } + + /* Note: used to assert inst->size >= MIN_MATCH, but not true + * for merge operations & identical match heuristics. */ + /* the "here" position is always offset by taroff */ + if ((ret = xd3_encode_address (stream, addr, inst->pos + stream->taroff, + & inst->type))) + { + return ret; + } + + IF_DEBUG2 ({ + static int cnt; + DP(RINT "[iopt copy:%d] pos %"Q"u-%"Q"u addr %"Q"u-%"Q"u size %u\n", + cnt++, + stream->total_in + inst->pos, + stream->total_in + inst->pos + inst->size, + inst->addr, inst->addr + inst->size, inst->size); + }); + break; + } + case XD3_RUN: + { + if ((ret = xd3_emit_byte (stream, & DATA_TAIL (stream), inst->xtra))) { return ret; } + + stream->n_run += 1; + stream->l_run += inst->size; + + IF_DEBUG2 ({ + static int cnt; + DP(RINT "[iopt run:%d] pos %"Q"u size %u\n", cnt++, stream->total_in + inst->pos, inst->size); + }); + break; + } + case XD3_ADD: + { + if ((ret = xd3_emit_bytes (stream, & DATA_TAIL (stream), + stream->next_in + inst->pos, inst->size))) { return ret; } + + stream->n_add += 1; + stream->l_add += inst->size; + + IF_DEBUG2 ({ + static int cnt; + DP(RINT "[iopt add:%d] pos %"Q"u size %u\n", cnt++, stream->total_in + inst->pos, inst->size); + }); + + break; + } + } + + /* This is the only place stream->unencoded_offset is incremented. */ + XD3_ASSERT (stream->unencoded_offset == inst->pos); + stream->unencoded_offset += inst->size; + + inst->code2 = 0; + + XD3_CHOOSE_INSTRUCTION (stream, stream->iout, inst); + + if (stream->iout != NULL) + { + if (stream->iout->code2 != 0) + { + if ((ret = xd3_emit_double (stream, stream->iout, inst, stream->iout->code2))) { return ret; } + + xd3_iopt_free_nonadd (stream, stream->iout); + xd3_iopt_free_nonadd (stream, inst); + stream->iout = NULL; + return 0; + } + else + { + if ((ret = xd3_emit_single (stream, stream->iout, stream->iout->code1))) { return ret; } + + xd3_iopt_free_nonadd (stream, stream->iout); + } + } + + stream->iout = inst; + + return 0; +} + +/* This possibly encodes an add instruction, iadd, which must remain + * on the stack until the following call to + * xd3_iopt_finish_encoding. */ +static int +xd3_iopt_add (xd3_stream *stream, usize_t pos, xd3_rinst *iadd) +{ + int ret; + usize_t off = stream->unencoded_offset; + + if (pos > off) + { + iadd->type = XD3_ADD; + iadd->pos = off; + iadd->size = pos - off; + + if ((ret = xd3_iopt_finish_encoding (stream, iadd))) { return ret; } + } + + return 0; +} + +/* This function calls xd3_iopt_finish_encoding to finish encoding an + * instruction, and it may also produce an add instruction for an + * unmatched region. */ +static int +xd3_iopt_add_encoding (xd3_stream *stream, xd3_rinst *inst) +{ + int ret; + xd3_rinst iadd; + + if ((ret = xd3_iopt_add (stream, inst->pos, & iadd))) { return ret; } + + if ((ret = xd3_iopt_finish_encoding (stream, inst))) { return ret; } + + return 0; +} + +/* Generates a final add instruction to encode the remaining input. */ +static int +xd3_iopt_add_finalize (xd3_stream *stream) +{ + int ret; + xd3_rinst iadd; + + if ((ret = xd3_iopt_add (stream, stream->avail_in, & iadd))) { return ret; } + + if (stream->iout) + { + if ((ret = xd3_emit_single (stream, stream->iout, stream->iout->code1))) { return ret; } + + xd3_iopt_free_nonadd (stream, stream->iout); + stream->iout = NULL; + } + + return 0; +} + +/* Compact the instruction buffer by choosing the best non-overlapping + * instructions when lazy string-matching. There are no ADDs in the + * iopt buffer because those are synthesized in xd3_iopt_add_encoding + * and during xd3_iopt_add_finalize. */ +static int +xd3_iopt_flush_instructions (xd3_stream *stream, int force) +{ + xd3_rinst *r1 = xd3_rlist_front (& stream->iopt_used); + xd3_rinst *r2; + xd3_rinst *r3; + usize_t r1end; + usize_t r2end; + usize_t r2off; + usize_t r2moff; + usize_t gap; + usize_t flushed; + int ret; + + XD3_ASSERT (xd3_iopt_check (stream)); + + /* Note: once tried to skip this step if it's possible to assert + * there are no overlapping instructions. Doesn't work because + * xd3_opt_erase leaves overlapping instructions. */ + while (! xd3_rlist_end (& stream->iopt_used, r1) && + ! xd3_rlist_end (& stream->iopt_used, r2 = xd3_rlist_next (r1))) + { + r1end = r1->pos + r1->size; + + /* If the instructions do not overlap, continue. */ + if (r1end <= r2->pos) + { + r1 = r2; + continue; + } + + r2end = r2->pos + r2->size; + + /* The min_match adjustments prevent this. */ + XD3_ASSERT (r2end > (r1end + LEAST_MATCH_INCR)); + + /* If r3 is available... */ + if (! xd3_rlist_end (& stream->iopt_used, r3 = xd3_rlist_next (r2))) + { + /* If r3 starts before r1 finishes or just about, r2 is irrelevant */ + if (r3->pos <= r1end + 1) + { + xd3_iopt_free (stream, r2); + continue; + } + } + else if (! force) + { + /* Unless force, end the loop when r3 is not available. */ + break; + } + + r2off = r2->pos - r1->pos; + r2moff = r2end - r1end; + gap = r2end - r1->pos; + + /* If the two matches overlap almost entirely, choose the better match + * and discard the other. The else branch can still create inefficient + * copies, e.g., a 4-byte copy that takes 4 bytes to encode, which + * xd3_smatch() wouldn't allow by its crude efficiency check. However, + * in this case there are adjacent copies which mean the add would cost + * one extra byte. Allow the inefficiency here. */ + if (gap < 2*MIN_MATCH || r2moff <= 2 || r2off <= 2) + { + /* Only one match should be used, choose the longer one. */ + if (r1->size < r2->size) + { + xd3_iopt_free (stream, r1); + r1 = r2; + } + else + { + /* We are guaranteed that r1 does not overlap now, so advance past r2 */ + r1 = xd3_iopt_free (stream, r2); + } + continue; + } + else + { + /* Shorten one of the instructions -- could be optimized + * based on the address cache. */ + usize_t average; + usize_t newsize; + usize_t adjust1; + + XD3_ASSERT (r1end > r2->pos && r2end > r1->pos); + + /* Try to balance the length of both instructions, but avoid + * making both longer than MAX_MATCH_SPLIT . */ + average = gap / 2; + newsize = xd3_min (MAX_MATCH_SPLIT, gap - average); + + /* Should be possible to simplify this code. */ + if (newsize > r1->size) + { + /* shorten r2 */ + adjust1 = r1end - r2->pos; + } + else if (newsize > r2->size) + { + /* shorten r1 */ + adjust1 = r1end - r2->pos; + + XD3_ASSERT (r1->size > adjust1); + + r1->size -= adjust1; + + /* don't shorten r2 */ + adjust1 = 0; + } + else + { + /* shorten r1 */ + adjust1 = r1->size - newsize; + + if (r2->pos > r1end - adjust1) + { + adjust1 -= r2->pos - (r1end - adjust1); + } + + XD3_ASSERT (r1->size > adjust1); + + r1->size -= adjust1; + + /* shorten r2 */ + XD3_ASSERT (r1->pos + r1->size >= r2->pos); + + adjust1 = r1->pos + r1->size - r2->pos; + } + + /* Fallthrough above if-else, shorten r2 */ + XD3_ASSERT (r2->size > adjust1); + + r2->size -= adjust1; + r2->pos += adjust1; + r2->addr += adjust1; + + XD3_ASSERT (r1->size >= MIN_MATCH); + XD3_ASSERT (r2->size >= MIN_MATCH); + + r1 = r2; + } + } + + XD3_ASSERT (xd3_iopt_check (stream)); + + /* If forcing, pick instructions until the list is empty, otherwise + * this empties 50% of the queue. */ + for (flushed = 0; ! xd3_rlist_empty (& stream->iopt_used); ) + { + xd3_rinst *renc = xd3_rlist_pop_front (& stream->iopt_used); + if ((ret = xd3_iopt_add_encoding (stream, renc))) + { + return ret; + } + + if (! force) + { + if (++flushed > stream->iopt_size / 2) + { + break; + } + + /* If there are only two instructions remaining, break, + * because they were not optimized. This means there were + * more than 50% eliminated by the loop above. */ + r1 = xd3_rlist_front (& stream->iopt_used); + if (xd3_rlist_end(& stream->iopt_used, r1) || + xd3_rlist_end(& stream->iopt_used, r2 = xd3_rlist_next (r1)) || + xd3_rlist_end(& stream->iopt_used, r3 = xd3_rlist_next (r2))) + { + break; + } + } + } + + XD3_ASSERT (xd3_iopt_check (stream)); + + XD3_ASSERT (!force || xd3_rlist_length (& stream->iopt_used) == 0); + + return 0; +} + +static int +xd3_iopt_get_slot (xd3_stream *stream, xd3_rinst** iptr) +{ + xd3_rinst *i; + int ret; + + if (xd3_rlist_empty (& stream->iopt_free)) + { + if (stream->iopt_unlimited) + { + usize_t elts = XD3_ALLOCSIZE / sizeof(xd3_rinst); + + if ((ret = xd3_alloc_iopt (stream, elts))) + { + return ret; + } + + stream->iopt_size += elts; + } + else + { + if ((ret = xd3_iopt_flush_instructions (stream, 0))) { return ret; } + + XD3_ASSERT (! xd3_rlist_empty (& stream->iopt_free)); + } + } + + i = xd3_rlist_pop_back (& stream->iopt_free); + + xd3_rlist_push_back (& stream->iopt_used, i); + + (*iptr) = i; + + ++stream->i_slots_used; + + return 0; +} + +/* A copy is about to be emitted that extends backwards to POS, + * therefore it may completely cover some existing instructions in the + * buffer. If an instruction is completely covered by this new match, + * erase it. If the new instruction is covered by the previous one, + * return 1 to skip it. */ +static void +xd3_iopt_erase (xd3_stream *stream, usize_t pos, usize_t size) +{ + while (! xd3_rlist_empty (& stream->iopt_used)) + { + xd3_rinst *r = xd3_rlist_back (& stream->iopt_used); + + /* Verify that greedy is working. The previous instruction + * should end before the new one begins. */ + XD3_ASSERT ((stream->flags & XD3_BEGREEDY) == 0 || (r->pos + r->size <= pos)); + /* Verify that min_match is working. The previous instruction + * should end before the new one ends. */ + XD3_ASSERT ((stream->flags & XD3_BEGREEDY) != 0 || (r->pos + r->size < pos + size)); + + /* See if the last instruction starts before the new + * instruction. If so, there is nothing to erase. */ + if (r->pos < pos) + { + return; + } + + /* Otherwise, the new instruction covers the old one, delete it + and repeat. */ + xd3_rlist_remove (r); + xd3_rlist_push_back (& stream->iopt_free, r); + --stream->i_slots_used; + } +} + +/* This function tells the last matched input position. */ +static usize_t +xd3_iopt_last_matched (xd3_stream *stream) +{ + xd3_rinst *r; + + if (xd3_rlist_empty (& stream->iopt_used)) + { + return 0; + } + + r = xd3_rlist_back (& stream->iopt_used); + + return r->pos + r->size; +} + +/********************************************************* + Emit routines + ***********************************************************/ + +static int +xd3_emit_single (xd3_stream *stream, xd3_rinst *single, usize_t code) +{ + int has_size = stream->code_table[code].size1 == 0; + int ret; + + IF_DEBUG2 (DP(RINT "[emit1] %u %s (%u) code %u\n", + single->pos, + xd3_rtype_to_string ((xd3_rtype) single->type, 0), + single->size, + code)); + + if ((ret = xd3_emit_byte (stream, & INST_TAIL (stream), code))) + { + return ret; + } + + if (has_size) + { + if ((ret = xd3_emit_size (stream, & INST_TAIL (stream), single->size))) + { + return ret; + } + } + + return 0; +} + +static int +xd3_emit_double (xd3_stream *stream, xd3_rinst *first, + xd3_rinst *second, usize_t code) +{ + int ret; + + /* All double instructions use fixed sizes, so all we need to do is + * output the instruction code, no sizes. */ + XD3_ASSERT (stream->code_table[code].size1 != 0 && + stream->code_table[code].size2 != 0); + + if ((ret = xd3_emit_byte (stream, & INST_TAIL (stream), code))) + { + return ret; + } + + IF_DEBUG2 (DP(RINT "[emit2]: %u %s (%u) %s (%u) code %u\n", + first->pos, + xd3_rtype_to_string ((xd3_rtype) first->type, 0), + first->size, + xd3_rtype_to_string ((xd3_rtype) second->type, 0), + second->size, + code)); + + return 0; +} + +/* This enters a potential run instruction into the iopt buffer. The + * position argument is relative to the target window. */ +static int +xd3_emit_run (xd3_stream *stream, usize_t pos, usize_t size, uint8_t *run_c) +{ + xd3_rinst* ri; + int ret; + + if ((ret = xd3_iopt_get_slot (stream, & ri))) { return ret; } + + ri->type = XD3_RUN; + ri->xtra = *run_c; + ri->pos = pos; + ri->size = size; + + return 0; +} + +/* This enters a potential copy instruction into the iopt buffer. The + * position argument is relative to the target window.. */ +int +xd3_found_match (xd3_stream *stream, usize_t pos, + usize_t size, xoff_t addr, int is_source) +{ + xd3_rinst* ri; + int ret; + + if ((ret = xd3_iopt_get_slot (stream, & ri))) { return ret; } + + ri->type = XD3_CPY; + ri->xtra = is_source; + ri->pos = pos; + ri->size = size; + ri->addr = addr; + + return 0; +} + +static int +xd3_emit_hdr (xd3_stream *stream) +{ + int ret; + int use_secondary = stream->sec_type != NULL; + int use_adler32 = stream->flags & (XD3_ADLER32 | XD3_ADLER32_RECODE); + int vcd_source = xd3_encoder_used_source (stream); + usize_t win_ind = 0; + usize_t del_ind = 0; + usize_t enc_len; + usize_t tgt_len; + usize_t data_len; + usize_t inst_len; + usize_t addr_len; + + if (stream->current_window == 0) + { + usize_t hdr_ind = 0; + int use_appheader = stream->enc_appheader != NULL; + + if (use_secondary) { hdr_ind |= VCD_SECONDARY; } + if (use_appheader) { hdr_ind |= VCD_APPHEADER; } + + if ((ret = xd3_emit_byte (stream, & HDR_TAIL (stream), + VCDIFF_MAGIC1)) != 0 || + (ret = xd3_emit_byte (stream, & HDR_TAIL (stream), + VCDIFF_MAGIC2)) != 0 || + (ret = xd3_emit_byte (stream, & HDR_TAIL (stream), + VCDIFF_MAGIC3)) != 0 || + (ret = xd3_emit_byte (stream, & HDR_TAIL (stream), + VCDIFF_VERSION)) != 0 || + (ret = xd3_emit_byte (stream, & HDR_TAIL (stream), hdr_ind)) != 0) + { + return ret; + } + + /* Secondary compressor ID */ +#if SECONDARY_ANY + if (use_secondary && + (ret = xd3_emit_byte (stream, & HDR_TAIL (stream), + stream->sec_type->id))) + { + return ret; + } +#endif + + /* Application header */ + if (use_appheader) + { + if ((ret = xd3_emit_size (stream, & HDR_TAIL (stream), + stream->enc_appheadsz)) || + (ret = xd3_emit_bytes (stream, & HDR_TAIL (stream), + stream->enc_appheader, + stream->enc_appheadsz))) + { + return ret; + } + } + } + + /* try to compress this window */ +#if SECONDARY_ANY + if (use_secondary) + { + int data_sec = 0; + int inst_sec = 0; + int addr_sec = 0; + +# define ENCODE_SECONDARY_SECTION(UPPER,LOWER) \ + ((stream->flags & XD3_SEC_NO ## UPPER) == 0 && \ + (ret = xd3_encode_secondary (stream, \ + & UPPER ## _HEAD (stream), \ + & UPPER ## _TAIL (stream), \ + & xd3_sec_ ## LOWER (stream), \ + & stream->sec_ ## LOWER, \ + & LOWER ## _sec))) + + if (ENCODE_SECONDARY_SECTION (DATA, data) || + ENCODE_SECONDARY_SECTION (INST, inst) || + ENCODE_SECONDARY_SECTION (ADDR, addr)) + { + return ret; + } + + del_ind |= (data_sec ? VCD_DATACOMP : 0); + del_ind |= (inst_sec ? VCD_INSTCOMP : 0); + del_ind |= (addr_sec ? VCD_ADDRCOMP : 0); + } +#endif + + /* if (vcd_target) { win_ind |= VCD_TARGET; } */ + if (vcd_source) { win_ind |= VCD_SOURCE; } + if (use_adler32) { win_ind |= VCD_ADLER32; } + + /* window indicator */ + if ((ret = xd3_emit_byte (stream, & HDR_TAIL (stream), win_ind))) + { + return ret; + } + + /* source window */ + if (vcd_source) + { + /* or (vcd_target) { ... } */ + if ((ret = xd3_emit_size (stream, & HDR_TAIL (stream), + stream->src->srclen)) || + (ret = xd3_emit_offset (stream, & HDR_TAIL (stream), + stream->src->srcbase))) { return ret; } + } + + tgt_len = stream->avail_in; + data_len = xd3_sizeof_output (DATA_HEAD (stream)); + inst_len = xd3_sizeof_output (INST_HEAD (stream)); + addr_len = xd3_sizeof_output (ADDR_HEAD (stream)); + + /* The enc_len field is a redundency for future extensions.*/ + enc_len = (1 + (xd3_sizeof_size (tgt_len) + + xd3_sizeof_size (data_len) + + xd3_sizeof_size (inst_len) + + xd3_sizeof_size (addr_len)) + + data_len + + inst_len + + addr_len + + (use_adler32 ? 4 : 0)); + + if ((ret = xd3_emit_size (stream, & HDR_TAIL (stream), enc_len)) || + (ret = xd3_emit_size (stream, & HDR_TAIL (stream), tgt_len)) || + (ret = xd3_emit_byte (stream, & HDR_TAIL (stream), del_ind)) || + (ret = xd3_emit_size (stream, & HDR_TAIL (stream), data_len)) || + (ret = xd3_emit_size (stream, & HDR_TAIL (stream), inst_len)) || + (ret = xd3_emit_size (stream, & HDR_TAIL (stream), addr_len))) + { + return ret; + } + + if (use_adler32) + { + uint8_t send[4]; + uint32_t a32; + + if (stream->flags & XD3_ADLER32) + { + a32 = adler32 (1L, stream->next_in, stream->avail_in); + } + else + { + a32 = stream->recode_adler32; + } + + /* Four bytes. */ + send[0] = (uint8_t) (a32 >> 24); + send[1] = (uint8_t) (a32 >> 16); + send[2] = (uint8_t) (a32 >> 8); + send[3] = (uint8_t) (a32 & 0x000000FFU); + + if ((ret = xd3_emit_bytes (stream, & HDR_TAIL (stream), send, 4))) + { + return ret; + } + } + + return 0; +} + +/**************************************************************** + Encode routines + ****************************************************************/ + +static int +xd3_encode_buffer_leftover (xd3_stream *stream) +{ + usize_t take; + usize_t room; + + /* Allocate the buffer. */ + if (stream->buf_in == NULL && + (stream->buf_in = (uint8_t*) xd3_alloc (stream, stream->winsize, 1)) == NULL) + { + return ENOMEM; + } + + IF_DEBUG2 (DP(RINT "[leftover] flush?=%s\n", (stream->flags & XD3_FLUSH) ? "yes" : "no")); + + /* Take leftover input first. */ + if (stream->buf_leftover != NULL) + { + XD3_ASSERT (stream->buf_avail == 0); + XD3_ASSERT (stream->buf_leftavail < stream->winsize); + + IF_DEBUG2 (DP(RINT "[leftover] previous %u avail %u\n", stream->buf_leftavail, stream->avail_in)); + + memcpy (stream->buf_in, stream->buf_leftover, stream->buf_leftavail); + + stream->buf_leftover = NULL; + stream->buf_avail = stream->buf_leftavail; + } + + /* Copy into the buffer. */ + room = stream->winsize - stream->buf_avail; + take = xd3_min (room, stream->avail_in); + + memcpy (stream->buf_in + stream->buf_avail, stream->next_in, take); + + stream->buf_avail += take; + + if (take < stream->avail_in) + { + /* Buffer is full */ + stream->buf_leftover = stream->next_in + take; + stream->buf_leftavail = stream->avail_in - take; + } + else if ((stream->buf_avail < stream->winsize) && !(stream->flags & XD3_FLUSH)) + { + /* Buffer has space */ + IF_DEBUG2 (DP(RINT "[leftover] emptied %u\n", take)); + return XD3_INPUT; + } + + /* Use the buffer: */ + IF_DEBUG2 (DP(RINT "[leftover] take %u remaining %u\n", take, stream->buf_leftavail)); + stream->next_in = stream->buf_in; + stream->avail_in = stream->buf_avail; + stream->buf_avail = 0; + + return 0; +} + +/* Allocates one block of xd3_rlist elements */ +static int +xd3_alloc_iopt (xd3_stream *stream, usize_t elts) +{ + usize_t i; + xd3_iopt_buflist* last = + (xd3_iopt_buflist*) xd3_alloc (stream, sizeof (xd3_iopt_buflist), 1); + + if (last == NULL || + (last->buffer = (xd3_rinst*) xd3_alloc (stream, sizeof (xd3_rinst), elts)) == NULL) + { + return ENOMEM; + } + + last->next = stream->iopt_alloc; + stream->iopt_alloc = last; + + for (i = 0; i < elts; i += 1) + { + xd3_rlist_push_back (& stream->iopt_free, & last->buffer[i]); + } + + return 0; +} + +/* This function allocates all memory initially used by the encoder. */ +static int +xd3_encode_init (xd3_stream *stream, int full_init) +{ + int i; + + if (full_init) + { + int large_comp = (stream->src != NULL); + int small_comp = ! (stream->flags & XD3_NOCOMPRESS); + + /* Memory allocations for checksum tables are delayed until + * xd3_string_match_init in the first call to string_match--that way + * identical or short inputs require no table allocation. */ + if (large_comp) + { + usize_t hash_values = stream->src->max_winsize / + stream->smatcher.large_step; + + xd3_size_hashtable (stream, + hash_values, + & stream->large_hash); + } + + if (small_comp) + { + /* TODO: This is under devel: used to have min (sprevsz) here, which sort + * of makes sense, but observed fast performance w/ larger tables, which + * also sort of makes sense. @@@ */ + usize_t hash_values = stream->winsize; + + xd3_size_hashtable (stream, + hash_values, + & stream->small_hash); + } + } + + /* data buffers */ + for (i = 0; i < ENC_SECTS; i += 1) + { + if ((stream->enc_heads[i] = + stream->enc_tails[i] = + xd3_alloc_output (stream, NULL)) == NULL) + { + return ENOMEM; + } + } + + /* iopt buffer */ + xd3_rlist_init (& stream->iopt_used); + xd3_rlist_init (& stream->iopt_free); + + if (xd3_alloc_iopt (stream, stream->iopt_size) != 0) { goto fail; } + + XD3_ASSERT (xd3_rlist_length (& stream->iopt_free) == stream->iopt_size); + XD3_ASSERT (xd3_rlist_length (& stream->iopt_used) == 0); + + /* address cache, code table */ + stream->acache.s_near = stream->code_table_desc->near_modes; + stream->acache.s_same = stream->code_table_desc->same_modes; + stream->code_table = stream->code_table_func (); + + return xd3_alloc_cache (stream); + + fail: + + return ENOMEM; +} + +int +xd3_encode_init_full (xd3_stream *stream) +{ + return xd3_encode_init (stream, 1); +} + +int +xd3_encode_init_partial (xd3_stream *stream) +{ + return xd3_encode_init (stream, 0); +} + +/* Called after the ENC_POSTOUT state, this puts the output buffers + * back into separate lists and re-initializes some variables. (The + * output lists were spliced together during the ENC_FLUSH state.) */ +static void +xd3_encode_reset (xd3_stream *stream) +{ + int i; + xd3_output *olist; + + stream->avail_in = 0; + stream->small_reset = 1; + stream->i_slots_used = 0; + + if (stream->src != NULL) + { + stream->src->srcbase = 0; + stream->src->srclen = 0; + stream->srcwin_decided = 0; + stream->srcwin_decided_early = 0; + stream->match_minaddr = 0; + stream->match_maxaddr = 0; + stream->taroff = 0; + } + + /* Reset output chains. */ + olist = stream->enc_heads[0]; + + for (i = 0; i < ENC_SECTS; i += 1) + { + XD3_ASSERT (olist != NULL); + + stream->enc_heads[i] = olist; + stream->enc_tails[i] = olist; + olist = olist->next_page; + + stream->enc_heads[i]->next = 0; + stream->enc_heads[i]->next_page = NULL; + + stream->enc_tails[i]->next_page = NULL; + stream->enc_tails[i] = stream->enc_heads[i]; + } + + xd3_freelist_output (stream, olist); +} + +/* The main encoding routine. */ +int +xd3_encode_input (xd3_stream *stream) +{ + int ret, i; + + if (stream->dec_state != 0) + { + stream->msg = "encoder/decoder transition"; + return XD3_INTERNAL; + } + + switch (stream->enc_state) + { + case ENC_INIT: + /* Only reached on first time through: memory setup. */ + if ((ret = xd3_encode_init_full (stream))) { return ret; } + + stream->enc_state = ENC_INPUT; + + case ENC_INPUT: + + /* If there is no input yet, just return. This checks for + * next_in == NULL, not avail_in == 0 since zero bytes is a + * valid input. There is an assertion in xd3_avail_input() that + * next_in != NULL for this reason. By returning right away we + * avoid creating an input buffer before the caller has supplied + * its first data. It is possible for xd3_avail_input to be + * called both before and after the first call to + * xd3_encode_input(). */ + if (stream->next_in == NULL) + { + return XD3_INPUT; + } + + enc_flush: + /* See if we should buffer the input: either if there is already + * a leftover buffer, or if the input is short of winsize + * without flush. The label at this point is reached by a goto + * below, when there is leftover input after postout. */ + if ((stream->buf_leftover != NULL) || + (stream->buf_avail != 0) || + (stream->avail_in < stream->winsize && ! (stream->flags & XD3_FLUSH))) + { + if ((ret = xd3_encode_buffer_leftover (stream))) { return ret; } + } + + /* Initalize the address cache before each window. */ + xd3_init_cache (& stream->acache); + + stream->input_position = 0; + stream->min_match = MIN_MATCH; + stream->unencoded_offset = 0; + + stream->enc_state = ENC_SEARCH; + + IF_DEBUG2 (DP(RINT "[WINSTART:%"Q"u] input bytes %u offset %"Q"u\n", + stream->current_window, stream->avail_in, + stream->total_in)); + return XD3_WINSTART; + + case ENC_SEARCH: + IF_DEBUG2 (DP(RINT "[SEARCH] match_state %d avail_in %u %s\n", + stream->match_state, stream->avail_in, + stream->src ? "source" : "no source")); + + /* Reentrant matching. */ + if (stream->src != NULL) + { + switch (stream->match_state) + { + case MATCH_TARGET: + /* Try matching forward at the start of the target. + * This is entered the first time through, to check for + * a perfect match, and whenever there is a source match + * that extends to the end of the previous window. The + * match_srcpos field is initially zero and later set + * during xd3_source_extend_match. */ + + if (stream->avail_in > 0) + { + /* This call can't fail because the source window is + * unrestricted. */ + ret = xd3_source_match_setup (stream, stream->match_srcpos); + XD3_ASSERT (ret == 0); + stream->match_state = MATCH_FORWARD; + } + else + { + stream->match_state = MATCH_SEARCHING; + stream->match_fwd = 0; + } + XD3_ASSERT (stream->match_fwd == 0); + + case MATCH_FORWARD: + case MATCH_BACKWARD: + if (stream->avail_in != 0) + { + if ((ret = xd3_source_extend_match (stream)) != 0) + { + return ret; + } + + /* The search has to make forward progress here + * or else it can get stuck in a match-backward + * (getsrcblk) then match-forward (getsrcblk), + * find insufficient match length, then repeat + + * exactly the same search. + */ + stream->input_position += stream->match_fwd; + } + + case MATCH_SEARCHING: + /* Continue string matching. (It's possible that the + * initial match continued through the entire input, in + * which case we're still in MATCH_FORWARD and should + * remain so for the next input window.) */ + break; + } + } + + /* String matching... */ + if (stream->avail_in != 0 && + (ret = stream->smatcher.string_match (stream))) + { + return ret; + } + + stream->enc_state = ENC_INSTR; + + case ENC_INSTR: + /* Note: Jump here to encode VCDIFF deltas w/o using this + * string-matching code. Merging code enters here. */ + + /* Flush the instrution buffer, then possibly add one more + * instruction, then emit the header. */ + if ((ret = xd3_iopt_flush_instructions (stream, 1)) || + (ret = xd3_iopt_add_finalize (stream))) + { + return ret; + } + + stream->enc_state = ENC_FLUSH; + + case ENC_FLUSH: + /* Note: main_recode_func() bypasses string-matching by setting + * ENC_FLUSH. */ + if ((ret = xd3_emit_hdr (stream))) + { + return ret; + } + + /* Begin output. */ + stream->enc_current = HDR_HEAD (stream); + + /* Chain all the outputs together. After doing this, it looks + * as if there is only one section. The other enc_heads are set + * to NULL to avoid freeing them more than once. */ + for (i = 1; i < ENC_SECTS; i += 1) + { + stream->enc_tails[i-1]->next_page = stream->enc_heads[i]; + stream->enc_heads[i] = NULL; + } + + enc_output: + + stream->enc_state = ENC_POSTOUT; + stream->next_out = stream->enc_current->base; + stream->avail_out = stream->enc_current->next; + stream->total_out += (xoff_t) stream->avail_out; + + /* If there is any output in this buffer, return it, otherwise + * fall through to handle the next buffer or finish the window + * after all buffers have been output. */ + if (stream->avail_out > 0) + { + /* This is the only place xd3_encode returns XD3_OUTPUT */ + return XD3_OUTPUT; + } + + case ENC_POSTOUT: + + if (stream->avail_out != 0) + { + stream->msg = "missed call to consume output"; + return XD3_INTERNAL; + } + + /* Continue outputting one buffer at a time, until the next is NULL. */ + if ((stream->enc_current = stream->enc_current->next_page) != NULL) + { + goto enc_output; + } + + stream->total_in += (xoff_t) stream->avail_in; + stream->enc_state = ENC_POSTWIN; + + IF_DEBUG2 (DP(RINT "[WINFINISH:%"Q"u] in=%"Q"u\n", + stream->current_window, + stream->total_in)); + return XD3_WINFINISH; + + case ENC_POSTWIN: + + xd3_encode_reset (stream); + + stream->current_window += 1; + stream->enc_state = ENC_INPUT; + + /* If there is leftover input to flush, repeat. */ + if (stream->buf_leftover != NULL) + { + goto enc_flush; + } + + /* Ready for more input. */ + return XD3_INPUT; + + default: + stream->msg = "invalid state"; + return XD3_INTERNAL; + } +} +#endif /* XD3_ENCODER */ + +/***************************************************************** + Client convenience functions + ******************************************************************/ + +int +xd3_process_stream (int is_encode, + xd3_stream *stream, + int (*func) (xd3_stream *), + int close_stream, + const uint8_t *input, + usize_t input_size, + uint8_t *output, + usize_t *output_size, + usize_t output_size_max) +{ + usize_t ipos = 0; + usize_t n = xd3_min (stream->winsize, input_size); + + (*output_size) = 0; + + stream->flags |= XD3_FLUSH; + + xd3_avail_input (stream, input + ipos, n); + ipos += n; + + for (;;) + { + int ret; + switch ((ret = func (stream))) + { + case XD3_OUTPUT: { /* memcpy below */ break; } + case XD3_INPUT: { + n = xd3_min(stream->winsize, input_size - ipos); + if (n == 0) + { + goto done; + } + xd3_avail_input (stream, input + ipos, n); + ipos += n; + continue; + } + case XD3_GOTHEADER: { /* ignore */ continue; } + case XD3_WINSTART: { /* ignore */ continue; } + case XD3_WINFINISH: { /* ignore */ continue; } + case XD3_GETSRCBLK: + { + /* When the getblk function is NULL, it is necessary to + * provide the complete source as a single block using + * xd3_set_source_and_size, otherwise this error. The + * library should never ask for another source block. */ + stream->msg = "library requested source block"; + return XD3_INTERNAL; + } + case 0: + { + /* xd3_encode_input/xd3_decode_input never return 0 */ + stream->msg = "invalid return: 0"; + return XD3_INTERNAL; + } + default: + return ret; + } + + if (*output_size + stream->avail_out > output_size_max) + { + stream->msg = "insufficient output space"; + return ENOSPC; + } + + memcpy (output + *output_size, stream->next_out, stream->avail_out); + + *output_size += stream->avail_out; + + xd3_consume_output (stream); + } + done: + return (close_stream == 0) ? 0 : xd3_close_stream (stream); +} + +static int +xd3_process_memory (int is_encode, + int (*func) (xd3_stream *), + const uint8_t *input, + usize_t input_size, + const uint8_t *source, + usize_t source_size, + uint8_t *output, + usize_t *output_size, + usize_t output_size_max, + int flags) { + xd3_stream stream; + xd3_config config; + xd3_source src; + int ret; + + memset (& stream, 0, sizeof (stream)); + memset (& config, 0, sizeof (config)); + + if (input == NULL || output == NULL) { + stream.msg = "invalid input/output buffer"; + ret = XD3_INTERNAL; + goto exit; + } + + config.flags = flags; + + if (is_encode) + { + config.winsize = xd3_min(input_size, (usize_t) XD3_DEFAULT_WINSIZE); + config.sprevsz = xd3_pow2_roundup (config.winsize); + } + + if ((ret = xd3_config_stream (&stream, &config)) != 0) + { + goto exit; + } + + if (source != NULL) + { + memset (& src, 0, sizeof (src)); + + src.blksize = source_size; + src.onblk = source_size; + src.curblk = source; + src.curblkno = 0; + src.max_winsize = source_size; + + if ((ret = xd3_set_source_and_size (&stream, &src, source_size)) != 0) + { + goto exit; + } + } + + if ((ret = xd3_process_stream (is_encode, + & stream, + func, 1, + input, input_size, + output, + output_size, + output_size_max)) != 0) + { + goto exit; + } + + exit: + if (ret != 0) + { + IF_DEBUG2 (DP(RINT "process_memory: %d: %s\n", ret, stream.msg)); + } + xd3_free_stream(&stream); + return ret; +} + +int +xd3_decode_stream (xd3_stream *stream, + const uint8_t *input, + usize_t input_size, + uint8_t *output, + usize_t *output_size, + usize_t output_size_max) +{ + return xd3_process_stream (0, stream, & xd3_decode_input, 1, + input, input_size, + output, output_size, output_size_max); +} + +int +xd3_decode_memory (const uint8_t *input, + usize_t input_size, + const uint8_t *source, + usize_t source_size, + uint8_t *output, + usize_t *output_size, + usize_t output_size_max, + int flags) { + return xd3_process_memory (0, & xd3_decode_input, + input, input_size, + source, source_size, + output, output_size, output_size_max, + flags); +} + + +#if XD3_ENCODER +int +xd3_encode_stream (xd3_stream *stream, + const uint8_t *input, + usize_t input_size, + uint8_t *output, + usize_t *output_size, + usize_t output_size_max) +{ + return xd3_process_stream (1, stream, & xd3_encode_input, 1, + input, input_size, + output, output_size, output_size_max); +} + +int +xd3_encode_memory (const uint8_t *input, + usize_t input_size, + const uint8_t *source, + usize_t source_size, + uint8_t *output, + usize_t *output_size, + usize_t output_size_max, + int flags) { + return xd3_process_memory (1, & xd3_encode_input, + input, input_size, + source, source_size, + output, output_size, output_size_max, + flags); +} +#endif + + +/************************************************************* + String matching helpers + *************************************************************/ + +#if XD3_ENCODER +/* Do the initial xd3_string_match() checksum table setup. + * Allocations are delayed until first use to avoid allocation + * sometimes (e.g., perfect matches, zero-length inputs). */ +static int +xd3_string_match_init (xd3_stream *stream) +{ + const int DO_SMALL = ! (stream->flags & XD3_NOCOMPRESS); + const int DO_LARGE = (stream->src != NULL); + + if (DO_LARGE && stream->large_table == NULL) + { + if ((stream->large_table = + (usize_t*) xd3_alloc0 (stream, stream->large_hash.size, sizeof (usize_t))) == NULL) + { + return ENOMEM; + } + } + + if (DO_SMALL) + { + /* Subsequent calls can return immediately after checking reset. */ + if (stream->small_table != NULL) + { + /* The target hash table is reinitialized once per window. */ + /* TODO: This would not have to be reinitialized if absolute + * offsets were being stored. */ + if (stream->small_reset) + { + stream->small_reset = 0; + memset (stream->small_table, 0, + sizeof (usize_t) * stream->small_hash.size); + } + + return 0; + } + + if ((stream->small_table = + (usize_t*) xd3_alloc0 (stream, + stream->small_hash.size, + sizeof (usize_t))) == NULL) + { + return ENOMEM; + } + + /* If there is a previous table needed. */ + if (stream->smatcher.small_lchain > 1 || + stream->smatcher.small_chain > 1) + { + if ((stream->small_prev = + (xd3_slist*) xd3_alloc (stream, + stream->sprevsz, + sizeof (xd3_slist))) == NULL) + { + return ENOMEM; + } + } + } + + return 0; +} + +#if XD3_USE_LARGEFILE64 +/* This function handles the 32/64bit ambiguity -- file positions are 64bit + * but the hash table for source-offsets is 32bit. */ +static xoff_t +xd3_source_cksum_offset(xd3_stream *stream, usize_t low) +{ + xoff_t scp = stream->srcwin_cksum_pos; + xoff_t s0 = scp >> 32; + + usize_t sr = (usize_t) scp; + + if (s0 == 0) { + return low; + } + + /* This should not be >= because srcwin_cksum_pos is the next + * position to index. */ + if (low > sr) { + return (--s0 << 32) | low; + } + + return (s0 << 32) | low; +} +#else +static xoff_t +xd3_source_cksum_offset(xd3_stream *stream, usize_t low) +{ + return (xoff_t) low; +} +#endif + +/* This function sets up the stream->src fields srcbase, srclen. The + * call is delayed until these values are needed to encode a copy + * address. At this point the decision has to be made. */ +static int +xd3_srcwin_setup (xd3_stream *stream) +{ + xd3_source *src = stream->src; + xoff_t length, x; + + /* Check the undecided state. */ + XD3_ASSERT (src->srclen == 0 && src->srcbase == 0); + + /* Avoid repeating this call. */ + stream->srcwin_decided = 1; + + /* If the stream is flushing, then the iopt buffer was able to + * contain the complete encoding. If no copies were issued no + * source window is actually needed. This prevents the VCDIFF + * header from including source base/len. xd3_emit_hdr checks for + * srclen == 0. */ + if (stream->enc_state == ENC_INSTR && stream->match_maxaddr == 0) + { + goto done; + } + + /* Check for overflow, srclen is usize_t - this can't happen unless + * XD3_DEFAULT_SRCBACK and related parameters are extreme - should + * use smaller windows. */ + length = stream->match_maxaddr - stream->match_minaddr; + + x = (xoff_t) USIZE_T_MAX; + if (length > x) + { + stream->msg = "source window length overflow (not 64bit)"; + return XD3_INTERNAL; + } + + /* If ENC_INSTR, then we know the exact source window to use because + * no more copies can be issued. */ + if (stream->enc_state == ENC_INSTR) + { + src->srcbase = stream->match_minaddr; + src->srclen = (usize_t) length; + XD3_ASSERT (src->srclen); + goto done; + } + + /* Otherwise, we have to make a guess. More copies may still be + * issued, but we have to decide the source window base and length + * now. */ + /* TODO: This may not working well in practice, more testing needed. */ + src->srcbase = stream->match_minaddr; + src->srclen = xd3_max ((usize_t) length, + stream->avail_in + (stream->avail_in >> 2)); + + if (src->eof_known) + { + /* Note: if the source size is known, we must reduce srclen or + * code that expects to pass a single block w/ getblk == NULL + * will not function, as the code will return GETSRCBLK asking + * for the second block. */ + src->srclen = xd3_min (src->srclen, xd3_source_eof(src) - src->srcbase); + } + + IF_DEBUG1 (DP(RINT "[srcwin_setup_constrained] base %"Q"u len %u\n", + src->srcbase, src->srclen)); + + XD3_ASSERT (src->srclen); + done: + /* Set the taroff. This convenience variable is used even when + stream->src == NULL. */ + stream->taroff = src->srclen; + return 0; +} + +/* Sets the bounding region for a newly discovered source match, prior + * to calling xd3_source_extend_match(). This sets the match_maxfwd, + * match_maxback variables. Note: srcpos is an absolute position + * (xoff_t) but the match_maxfwd, match_maxback variables are usize_t. + * Returns 0 if the setup succeeds, or 1 if the source position lies + * outside an already-decided srcbase/srclen window. */ +static int +xd3_source_match_setup (xd3_stream *stream, xoff_t srcpos) +{ + xd3_source *const src = stream->src; + usize_t greedy_or_not; + + stream->match_maxback = 0; + stream->match_maxfwd = 0; + stream->match_back = 0; + stream->match_fwd = 0; + + /* This avoids a non-blocking endless loop caused by scanning + * backwards across a block boundary, only to find not enough + * matching bytes to beat the current min_match due to a better lazy + * target match: the re-entry to xd3_string_match() repeats the same + * long match because the input position hasn't changed. TODO: if + * ever duplicates are added to the source hash table, this logic + * won't suffice to avoid loops. See testing/regtest.cc's + * TestNonBlockingProgress test! */ + if (srcpos != 0 && srcpos == stream->match_last_srcpos) + { + IF_DEBUG2(DP(RINT "[match_setup] looping failure\n")); + goto bad; + } + + /* Implement src->max_winsize, which prevents the encoder from seeking + * back further than the LRU cache maintaining FIFO discipline, (to + * avoid seeking). */ + if (srcpos < stream->srcwin_cksum_pos && + stream->srcwin_cksum_pos - srcpos > src->max_winsize) + { + IF_DEBUG2(DP(RINT "[match_setup] rejected due to src->max_winsize " + "distance eof=%"Q"u srcpos=%"Q"u max_winsz=%"Q"u\n", + xd3_source_eof (src), + srcpos, src->max_winsize)); + goto bad; + } + + /* There are cases where the above test does not reject a match that + * will experience XD3_TOOFARBACK at the first xd3_getblk call + * because the input may have advanced up to one block beyond the + * actual EOF. */ + IF_DEBUG2(DP(RINT "[match_setup] %"Q"u srcpos %"Q"u, " + "src->max_winsize %"Q"u\n", + stream->total_in + stream->input_position, + srcpos, src->max_winsize)); + + /* Going backwards, the 1.5-pass algorithm allows some + * already-matched input may be covered by a longer source match. + * The greedy algorithm does not allow this. */ + if (stream->flags & XD3_BEGREEDY) + { + /* The greedy algorithm allows backward matching to the last + matched position. */ + greedy_or_not = xd3_iopt_last_matched (stream); + } + else + { + /* The 1.5-pass algorithm allows backward matching to go back as + * far as the unencoded offset, which is updated as instructions + * pass out of the iopt buffer. If this (default) is chosen, it + * means xd3_iopt_erase may be called to eliminate instructions + * when a covering source match is found. */ + greedy_or_not = stream->unencoded_offset; + } + + /* Backward target match limit. */ + XD3_ASSERT (stream->input_position >= greedy_or_not); + stream->match_maxback = stream->input_position - greedy_or_not; + + /* Forward target match limit. */ + XD3_ASSERT (stream->avail_in > stream->input_position); + stream->match_maxfwd = stream->avail_in - stream->input_position; + + /* Now we take the source position into account. It depends whether + * the srclen/srcbase have been decided yet. */ + if (stream->srcwin_decided == 0) + { + /* Unrestricted case: the match can cover the entire source, + * 0--src->size. We compare the usize_t + * match_maxfwd/match_maxback against the xoff_t + * src->size/srcpos values and take the min. */ + if (srcpos < (xoff_t) stream->match_maxback) + { + stream->match_maxback = (usize_t) srcpos; + } + + if (src->eof_known) + { + xoff_t srcavail = xd3_source_eof (src) - srcpos; + + if (srcavail < (xoff_t) stream->match_maxfwd) + { + stream->match_maxfwd = (usize_t) srcavail; + } + } + + IF_DEBUG2(DP(RINT + "[match_setup] srcpos %"Q"u (tgtpos %"Q"u) " + "unrestricted maxback %u maxfwd %u\n", + srcpos, + stream->total_in + stream->input_position, + stream->match_maxback, + stream->match_maxfwd)); + goto good; + } + + /* Decided some source window. */ + XD3_ASSERT (src->srclen > 0); + + /* Restricted case: fail if the srcpos lies outside the source window */ + if ((srcpos < src->srcbase) || + (srcpos > (src->srcbase + (xoff_t) src->srclen))) + { + IF_DEBUG1(DP(RINT "[match_setup] restricted source window failure\n")); + goto bad; + } + else + { + usize_t srcavail; + + srcavail = (usize_t) (srcpos - src->srcbase); + if (srcavail < stream->match_maxback) + { + stream->match_maxback = srcavail; + } + + srcavail = (usize_t) (src->srcbase + (xoff_t) src->srclen - srcpos); + if (srcavail < stream->match_maxfwd) + { + stream->match_maxfwd = srcavail; + } + + IF_DEBUG2(DP(RINT + "[match_setup] srcpos %"Q"u (tgtpos %"Q"u) " + "restricted maxback %u maxfwd %u\n", + srcpos, + stream->total_in + stream->input_position, + stream->match_maxback, + stream->match_maxfwd)); + goto good; + } + + good: + stream->match_state = MATCH_BACKWARD; + stream->match_srcpos = srcpos; + stream->match_last_srcpos = srcpos; + return 0; + + bad: + stream->match_state = MATCH_SEARCHING; + stream->match_last_srcpos = srcpos; + return 1; +} + +static inline int +xd3_forward_match(const uint8_t *s1c, const uint8_t *s2c, int n) +{ + int i = 0; +#if UNALIGNED_OK + int nint = n / sizeof(int); + + if (nint >> 3) + { + int j = 0; + const int *s1 = (const int*)s1c; + const int *s2 = (const int*)s2c; + int nint_8 = nint - 8; + + while (i <= nint_8 && + s1[i++] == s2[j++] && + s1[i++] == s2[j++] && + s1[i++] == s2[j++] && + s1[i++] == s2[j++] && + s1[i++] == s2[j++] && + s1[i++] == s2[j++] && + s1[i++] == s2[j++] && + s1[i++] == s2[j++]) { } + + i = (i - 1) * sizeof(int); + } +#endif + + while (i < n && s1c[i] == s2c[i]) + { + i++; + } + return i; +} + +/* This function expands the source match backward and forward. It is + * reentrant, since xd3_getblk may return XD3_GETSRCBLK, so most + * variables are kept in xd3_stream. There are two callers of this + * function, the string_matching routine when a checksum match is + * discovered, and xd3_encode_input whenever a continuing (or initial) + * match is suspected. The two callers do different things with the + * input_position, thus this function leaves that variable untouched. + * If a match is taken the resulting stream->match_fwd is left + * non-zero. */ +static int +xd3_source_extend_match (xd3_stream *stream) +{ + int ret; + xd3_source *const src = stream->src; + xoff_t matchoff; /* matchoff is the current right/left-boundary of + the source match being tested. */ + usize_t streamoff; /* streamoff is the current right/left-boundary + of the input match being tested. */ + xoff_t tryblk; /* tryblk, tryoff are the block, offset position + of matchoff */ + usize_t tryoff; + usize_t tryrem; /* tryrem is the number of matchable bytes */ + usize_t matched; + + XD3_ASSERT (src != NULL); + + /* Does it make sense to compute backward match AFTER forward match? */ + if (stream->match_state == MATCH_BACKWARD) + { + /* Note: this code is practically duplicated below, substituting + * match_fwd/match_back and direction. */ + matchoff = stream->match_srcpos - stream->match_back; + streamoff = stream->input_position - stream->match_back; + xd3_blksize_div (matchoff, src, &tryblk, &tryoff); + + /* this loops backward over source blocks */ + while (stream->match_back < stream->match_maxback) + { + /* see if we're backing across a source block boundary */ + if (tryoff == 0) + { + tryoff = src->blksize; + tryblk -= 1; + } + + if ((ret = xd3_getblk (stream, tryblk))) + { + if (ret == XD3_TOOFARBACK) + { + IF_DEBUG2(DP(RINT "[maxback] %"Q"u TOOFARBACK: %u INP %"Q"u CKSUM %"Q"u\n", + tryblk, stream->match_back, + stream->total_in + stream->input_position, + stream->srcwin_cksum_pos)); + + /* the starting position is too far back. */ + if (stream->match_back == 0) + { + XD3_ASSERT(stream->match_fwd == 0); + goto donefwd; + } + + /* search went too far back, continue forward. */ + goto doneback; + } + + /* could be a XD3_GETSRCBLK failure. */ + return ret; + } + + tryrem = xd3_min (tryoff, stream->match_maxback - stream->match_back); + + IF_DEBUG2(DP(RINT "[maxback] maxback %u trysrc %"Q"u/%u tgt %u tryrem %u\n", + stream->match_maxback, tryblk, tryoff, streamoff, tryrem)); + + /* TODO: This code can be optimized similar to xd3_match_forward() */ + for (; tryrem != 0; tryrem -= 1, stream->match_back += 1) + { + if (src->curblk[tryoff-1] != stream->next_in[streamoff-1]) + { + goto doneback; + } + + tryoff -= 1; + streamoff -= 1; + } + } + + doneback: + stream->match_state = MATCH_FORWARD; + } + + XD3_ASSERT (stream->match_state == MATCH_FORWARD); + + matchoff = stream->match_srcpos + stream->match_fwd; + streamoff = stream->input_position + stream->match_fwd; + xd3_blksize_div (matchoff, src, & tryblk, & tryoff); + + /* Note: practically the same code as backwards case above: same comments */ + while (stream->match_fwd < stream->match_maxfwd) + { + if (tryoff == src->blksize) + { + tryoff = 0; + tryblk += 1; + } + + if ((ret = xd3_getblk (stream, tryblk))) + { + if (ret == XD3_TOOFARBACK) + { + IF_DEBUG2(DP(RINT "[maxfwd] %"Q"u TOOFARBACK: %u INP %"Q"u CKSUM %"Q"u\n", + tryblk, stream->match_fwd, + stream->total_in + stream->input_position, + stream->srcwin_cksum_pos)); + goto donefwd; + } + + /* could be a XD3_GETSRCBLK failure. */ + return ret; + } + + tryrem = xd3_min(stream->match_maxfwd - stream->match_fwd, + src->onblk - tryoff); + + if (tryrem == 0) + { + /* Generally, this means we have a power-of-two size source + * and we just found the end-of-file, in this case it's an + * empty block. */ + XD3_ASSERT (src->onblk < src->blksize); + break; + } + + matched = xd3_forward_match(src->curblk + tryoff, + stream->next_in + streamoff, + tryrem); + tryoff += matched; + streamoff += matched; + stream->match_fwd += matched; + + if (tryrem != matched) + { + break; + } + } + + donefwd: + stream->match_state = MATCH_SEARCHING; + + IF_DEBUG2(DP(RINT "[extend match] input %"Q"u srcpos %"Q"u len %u\n", + stream->input_position + stream->total_in, + stream->match_srcpos, + stream->match_fwd)); + + /* If the match ends short of the last instruction end, we probably + * don't want it. There is the possibility that a copy ends short + * of the last copy but also goes further back, in which case we + * might want it. This code does not implement such: if so we would + * need more complicated xd3_iopt_erase logic. */ + if (stream->match_fwd < stream->min_match) + { + stream->match_fwd = 0; + } + else + { + usize_t total = stream->match_fwd + stream->match_back; + + /* Correct the variables to remove match_back from the equation. */ + usize_t target_position = stream->input_position - stream->match_back; + usize_t match_length = stream->match_back + stream->match_fwd; + xoff_t match_position = stream->match_srcpos - stream->match_back; + xoff_t match_end = stream->match_srcpos + stream->match_fwd; + + /* At this point we may have to erase any iopt-buffer + * instructions that are fully covered by a backward-extending + * copy. */ + if (stream->match_back > 0) + { + xd3_iopt_erase (stream, target_position, total); + } + + stream->match_back = 0; + + /* Update ranges. The first source match occurs with both + values set to 0. */ + if (stream->match_maxaddr == 0 || + match_position < stream->match_minaddr) + { + stream->match_minaddr = match_position; + } + + if (match_end > stream->match_maxaddr) + { + /* Note: per-window */ + stream->match_maxaddr = match_end; + } + + if (match_end > stream->maxsrcaddr) + { + /* Note: across windows */ + stream->maxsrcaddr = match_end; + } + + IF_DEBUG2 ({ + static int x = 0; + DP(RINT "[source match:%d] length %u (%s) [ %u bytes ]\n", + x++, + match_length, + stream->total_in + target_position, + stream->total_in + target_position + match_length, + match_position, + match_position + match_length, + (stream->total_in + target_position == match_position) ? "same" : "diff", + match_length); + }); + + if ((ret = xd3_found_match (stream, + /* decoder position */ target_position, + /* length */ match_length, + /* address */ match_position, + /* is_source */ 1))) + { + return ret; + } + + /* If the match ends with the available input: */ + if (target_position + match_length == stream->avail_in) + { + /* Setup continuing match for the next window. */ + stream->match_state = MATCH_TARGET; + stream->match_srcpos = match_end; + } + } + + return 0; +} + +/* Update the small hash. Values in the small_table are offset by + * HASH_CKOFFSET (1) to distinguish empty buckets from real offsets. */ +static void +xd3_scksum_insert (xd3_stream *stream, + usize_t inx, + usize_t scksum, + usize_t pos) +{ + /* If we are maintaining previous duplicates. */ + if (stream->small_prev) + { + usize_t last_pos = stream->small_table[inx]; + xd3_slist *pos_list = & stream->small_prev[pos & stream->sprevmask]; + + /* Note last_pos is offset by HASH_CKOFFSET. */ + pos_list->last_pos = last_pos; + } + + /* Enter the new position into the hash bucket. */ + stream->small_table[inx] = pos + HASH_CKOFFSET; +} + +#if XD3_DEBUG +static int +xd3_check_smatch (const uint8_t *ref0, const uint8_t *inp0, + const uint8_t *inp_max, usize_t cmp_len) +{ + usize_t i; + + for (i = 0; i < cmp_len; i += 1) + { + XD3_ASSERT (ref0[i] == inp0[i]); + } + + if (inp0 + cmp_len < inp_max) + { + XD3_ASSERT (inp0[i] != ref0[i]); + } + + return 1; +} +#endif /* XD3_DEBUG */ + +/* When the hash table indicates a possible small string match, it + * calls this routine to find the best match. The first matching + * position is taken from the small_table, HASH_CKOFFSET is subtracted + * to get the actual position. After checking that match, if previous + * linked lists are in use (because stream->smatcher.small_chain > 1), + * previous matches are tested searching for the longest match. If + * (stream->min_match > MIN_MATCH) then a lazy match is in effect. + */ +static usize_t +xd3_smatch (xd3_stream *stream, + usize_t base, + usize_t scksum, + usize_t *match_offset) +{ + usize_t cmp_len; + usize_t match_length = 0; + usize_t chain = (stream->min_match == MIN_MATCH ? + stream->smatcher.small_chain : + stream->smatcher.small_lchain); + const uint8_t *inp_max = stream->next_in + stream->avail_in; + const uint8_t *inp; + const uint8_t *ref; + + SMALL_HASH_DEBUG1 (stream, stream->next_in + stream->input_position); + + XD3_ASSERT (stream->min_match + stream->input_position <= stream->avail_in); + + base -= HASH_CKOFFSET; + + again: + + IF_DEBUG2 (DP(RINT "smatch at base=%u inp=%u cksum=%u\n", base, + stream->input_position, scksum)); + + /* For small matches, we can always go to the end-of-input because + * the matching position must be less than the input position. */ + XD3_ASSERT (base < stream->input_position); + + ref = stream->next_in + base; + inp = stream->next_in + stream->input_position; + + SMALL_HASH_DEBUG2 (stream, ref); + + /* Expand potential match forward. */ + while (inp < inp_max && *inp == *ref) + { + ++inp; + ++ref; + } + + cmp_len = (usize_t)(inp - (stream->next_in + stream->input_position)); + + /* Verify correctness */ + XD3_ASSERT (xd3_check_smatch (stream->next_in + base, + stream->next_in + stream->input_position, + inp_max, cmp_len)); + + /* Update longest match */ + if (cmp_len > match_length) + { + ( match_length) = cmp_len; + (*match_offset) = base; + + /* Stop if we match the entire input or have a long_enough match. */ + if (inp == inp_max || cmp_len >= stream->smatcher.long_enough) + { + goto done; + } + } + + /* If we have not reached the chain limit, see if there is another + previous position. */ + while (--chain != 0) + { + /* Calculate the previous offset. */ + usize_t prev_pos = stream->small_prev[base & stream->sprevmask].last_pos; + usize_t diff_pos; + + if (prev_pos == 0) + { + break; + } + + prev_pos -= HASH_CKOFFSET; + + if (prev_pos > base) + { + break; + } + + base = prev_pos; + + XD3_ASSERT (stream->input_position > base); + diff_pos = stream->input_position - base; + + /* Stop searching if we go beyond sprevsz, since those entries + * are for unrelated checksum entries. */ + if (diff_pos & ~stream->sprevmask) + { + break; + } + + goto again; + } + + done: + /* Crude efficiency test: if the match is very short and very far back, it's + * unlikely to help, but the exact calculation requires knowing the state of + * the address cache and adjacent instructions, which we can't do here. + * Rather than encode a probably inefficient copy here and check it later + * (which complicates the code a lot), do this: + */ + if (match_length == 4 && stream->input_position - (*match_offset) >= 1<<14) + { + /* It probably takes >2 bytes to encode an address >= 2^14 from here */ + return 0; + } + if (match_length == 5 && stream->input_position - (*match_offset) >= 1<<21) + { + /* It probably takes >3 bytes to encode an address >= 2^21 from here */ + return 0; + } + + /* It's unlikely that a window is large enough for the (match_length == 6 && + * address >= 2^28) check */ + return match_length; +} + +#if XD3_DEBUG +static void +xd3_verify_small_state (xd3_stream *stream, + const uint8_t *inp, + uint32_t x_cksum) +{ + uint32_t state; + uint32_t cksum = xd3_scksum (&state, inp, stream->smatcher.small_look); + + XD3_ASSERT (cksum == x_cksum); +} + +static void +xd3_verify_large_state (xd3_stream *stream, + const uint8_t *inp, + uint32_t x_cksum) +{ + uint32_t cksum = xd3_lcksum (inp, stream->smatcher.large_look); + XD3_ASSERT (cksum == x_cksum); +} +static void +xd3_verify_run_state (xd3_stream *stream, + const uint8_t *inp, + usize_t x_run_l, + uint8_t *x_run_c) +{ + usize_t slook = stream->smatcher.small_look; + uint8_t run_c; + usize_t run_l = xd3_comprun (inp, slook, &run_c); + + XD3_ASSERT (run_l == 0 || run_c == *x_run_c); + XD3_ASSERT (x_run_l > slook || run_l == x_run_l); +} +#endif /* XD3_DEBUG */ + +/* This function computes more source checksums to advance the window. + * Called at every entrance to the string-match loop and each time + * stream->input_position reaches the value returned as + * *next_move_point. NB: this is one of the most expensive functions + * in this code and also the most critical for good compression. + */ +static int +xd3_srcwin_move_point (xd3_stream *stream, usize_t *next_move_point) +{ + /* the source file is indexed until this point */ + xoff_t target_cksum_pos; + /* the absolute target file input position */ + xoff_t absolute_input_pos; + + if (stream->src->eof_known) + { + xoff_t source_size = xd3_source_eof (stream->src); + XD3_ASSERT(stream->srcwin_cksum_pos <= source_size); + + if (stream->srcwin_cksum_pos == source_size) + { + *next_move_point = USIZE_T_MAX; + return 0; + } + } + + absolute_input_pos = stream->total_in + stream->input_position; + + /* Immediately read the entire window. + * + * Note: this reverses a long held policy, at this point in the + * code, of advancing relatively slowly as the input is read, which + * results in better compression for very-similar inputs, but worse + * compression where data is deleted near the beginning of the file. + * + * The new policy is simpler, somewhat slower and can benefit, or + * slightly worsen, compression performance. */ + if (absolute_input_pos < stream->src->max_winsize / 2) + { + target_cksum_pos = stream->src->max_winsize; + } + else + { + /* TODO: The addition of 2 blocks here is arbitrary. Do a + * better job of stream alignment based on observed source copy + * addresses, and when both input sizes are known, the + * difference in size. */ + target_cksum_pos = absolute_input_pos + + stream->src->max_winsize / 2 + + stream->src->blksize * 2; + target_cksum_pos &= ~stream->src->maskby; + } + + /* A long match may have extended past srcwin_cksum_pos. Don't + * start checksumming already-matched source data. */ + if (stream->maxsrcaddr > stream->srcwin_cksum_pos) + { + stream->srcwin_cksum_pos = stream->maxsrcaddr; + } + + if (target_cksum_pos < stream->srcwin_cksum_pos) + { + target_cksum_pos = stream->srcwin_cksum_pos; + } + + while (stream->srcwin_cksum_pos < target_cksum_pos && + (!stream->src->eof_known || + stream->srcwin_cksum_pos < xd3_source_eof (stream->src))) + { + xoff_t blkno; + xoff_t blkbaseoffset; + usize_t blkrem; + ssize_t oldpos; /* Using ssize_t because of a */ + ssize_t blkpos; /* do { blkpos-- } + while (blkpos >= oldpos); */ + int ret; + xd3_blksize_div (stream->srcwin_cksum_pos, + stream->src, &blkno, &blkrem); + oldpos = blkrem; + + if ((ret = xd3_getblk (stream, blkno))) + { + /* TOOFARBACK should never occur here, since we read forward. */ + if (ret == XD3_TOOFARBACK) + { + ret = XD3_INTERNAL; + } + + IF_DEBUG1 (DP(RINT + "[srcwin_move_point] async getblk return for %"Q"u: %s\n", + blkno, xd3_strerror (ret))); + return ret; + } + + IF_DEBUG1 (DP(RINT + "[srcwin_move_point] block %"Q"u T=%"Q"u S=%"Q"u L=%"Q"u EOF=%"Q"u %s\n", + blkno, + stream->total_in + stream->input_position, + stream->srcwin_cksum_pos, + target_cksum_pos, + xd3_source_eof (stream->src), + stream->src->eof_known ? "known" : "unknown")); + + blkpos = xd3_bytes_on_srcblk (stream->src, blkno); + + if (blkpos < (ssize_t) stream->smatcher.large_look) + { + stream->srcwin_cksum_pos = (blkno + 1) * stream->src->blksize; + IF_DEBUG2 (DP(RINT "[srcwin_move_point] continue (end-of-block): %"Q"u\n", blkpos)); + continue; + } + + /* This inserts checksums for the entire block, in reverse, + * starting from the end of the block. This logic does not test + * stream->srcwin_cksum_pos because it always advances it to the + * start of the next block. + * + * oldpos is the srcwin_cksum_pos within this block. blkpos is + * the number of bytes available. Each iteration inspects + * large_look bytes then steps back large_step bytes. The + * if-stmt above ensures at least one large_look of data. */ + blkpos -= stream->smatcher.large_look; + blkbaseoffset = stream->src->blksize * blkno; + + do + { + uint32_t cksum = xd3_lcksum (stream->src->curblk + blkpos, + stream->smatcher.large_look); + usize_t hval = xd3_checksum_hash (& stream->large_hash, cksum); + + stream->large_table[hval] = + (usize_t) (blkbaseoffset + + (xoff_t)(blkpos + HASH_CKOFFSET)); + + IF_DEBUG (stream->large_ckcnt += 1); + + blkpos -= stream->smatcher.large_step; + } + while (blkpos >= oldpos); + + stream->srcwin_cksum_pos = (blkno + 1) * stream->src->blksize; + } + + if (stream->src->eof_known) + { + xoff_t source_size = xd3_source_eof (stream->src); + if (stream->srcwin_cksum_pos >= source_size) + { + /* This invariant is needed for xd3_source_cksum_offset() */ + stream->srcwin_cksum_pos = source_size; + *next_move_point = USIZE_T_MAX; + IF_DEBUG1 (DP(RINT + "[srcwin_move_point] finished with source input\n")); + return 0; + } + } + + /* How long until this function should be called again. */ + XD3_ASSERT(stream->srcwin_cksum_pos >= target_cksum_pos); + + *next_move_point = stream->input_position + + stream->src->blksize - + ((stream->srcwin_cksum_pos - target_cksum_pos) & stream->src->maskby); + + IF_DEBUG2 (DP(RINT + "[srcwin_move_point] finished T=%"Q"u " + "S=%"Q"u L=%"Q"u EOF=%"Q"u %s again in %u\n", + stream->total_in + stream->input_position, + stream->srcwin_cksum_pos, + target_cksum_pos, + xd3_source_eof (stream->src), + stream->src->eof_known ? "known" : "unknown", + *next_move_point - stream->input_position)); + + return 0; +} + +#endif /* XD3_ENCODER */ + +/******************************************************************** + TEMPLATE pass + *********************************************************************/ + +#endif /* __XDELTA3_C_INLINE_PASS__ */ +#ifdef __XDELTA3_C_TEMPLATE_PASS__ + +#if XD3_ENCODER + +/******************************************************************** + Templates + *******************************************************************/ + +/* Template macros */ +#define XD3_TEMPLATE(x) XD3_TEMPLATE2(x,TEMPLATE) +#define XD3_TEMPLATE2(x,n) XD3_TEMPLATE3(x,n) +#define XD3_TEMPLATE3(x,n) x ## n +#define XD3_STRINGIFY(x) XD3_STRINGIFY2(x) +#define XD3_STRINGIFY2(x) #x + +static int XD3_TEMPLATE(xd3_string_match_) (xd3_stream *stream); + +static const xd3_smatcher XD3_TEMPLATE(__smatcher_) = +{ + XD3_STRINGIFY(TEMPLATE), + XD3_TEMPLATE(xd3_string_match_), +#if SOFTCFG == 1 + 0, 0, 0, 0, 0, 0, 0 +#else + LLOOK, LSTEP, SLOOK, SCHAIN, SLCHAIN, MAXLAZY, LONGENOUGH +#endif +}; + +static int +XD3_TEMPLATE(xd3_string_match_) (xd3_stream *stream) +{ + const int DO_SMALL = ! (stream->flags & XD3_NOCOMPRESS); + const int DO_LARGE = (stream->src != NULL); + const int DO_RUN = (1); + + const uint8_t *inp; + uint32_t scksum = 0; + uint32_t scksum_state = 0; + uint32_t lcksum = 0; + usize_t sinx; + usize_t linx; + uint8_t run_c; + usize_t run_l; + int ret; + usize_t match_length; + usize_t match_offset = 0; + usize_t next_move_point; + + IF_DEBUG2(DP(RINT "[string_match] initial entry %u\n", stream->input_position)); + + /* If there will be no compression due to settings or short input, + * skip it entirely. */ + if (! (DO_SMALL || DO_LARGE || DO_RUN) || + stream->input_position + SLOOK > stream->avail_in) { goto loopnomore; } + + if ((ret = xd3_string_match_init (stream))) { return ret; } + + /* The restartloop label is reached when the incremental loop state + * needs to be reset. */ + restartloop: + + IF_DEBUG2(DP(RINT "[string_match] restartloop %u\n", stream->input_position)); + + /* If there is not enough input remaining for any kind of match, + skip it. */ + if (stream->input_position + SLOOK > stream->avail_in) { goto loopnomore; } + + /* Now reset the incremental loop state: */ + + /* The min_match variable is updated to avoid matching the same lazy + * match over and over again. For example, if you find a (small) + * match of length 9 at one position, you will likely find a match + * of length 8 at the next position. */ + if (xd3_iopt_last_matched (stream) > stream->input_position) + { + stream->min_match = xd3_max (MIN_MATCH, + 1 + xd3_iopt_last_matched(stream) - + stream->input_position); + } + else + { + stream->min_match = MIN_MATCH; + } + + /* The current input byte. */ + inp = stream->next_in + stream->input_position; + + /* Small match state. */ + if (DO_SMALL) + { + scksum = xd3_scksum (&scksum_state, inp, SLOOK); + } + + /* Run state. */ + if (DO_RUN) + { + run_l = xd3_comprun (inp, SLOOK, & run_c); + } + + /* Large match state. We continue the loop even after not enough + * bytes for LLOOK remain, so always check stream->input_position in + * DO_LARGE code. */ + if (DO_LARGE && (stream->input_position + LLOOK <= stream->avail_in)) + { + /* Source window: next_move_point is the point that + * stream->input_position must reach before computing more + * source checksum. Note: this is called unconditionally + * the first time after reentry, subsequent calls will be + * avoided if next_move_point is > input_position */ + if ((ret = xd3_srcwin_move_point (stream, & next_move_point))) + { + return ret; + } + + lcksum = xd3_lcksum (inp, LLOOK); + } + + /* TRYLAZYLEN: True if a certain length match should be followed by + * lazy search. This checks that LEN is shorter than MAXLAZY and + * that there is enough leftover data to consider lazy matching. + * "Enough" is set to 2 since the next match will start at the next + * offset, it must match two extra characters. */ +#define TRYLAZYLEN(LEN,POS,MAX) ((MAXLAZY) > 0 && (LEN) < (MAXLAZY) \ + && (POS) + (LEN) <= (MAX) - 2) + + /* HANDLELAZY: This statement is called each time an instruciton is + * emitted (three cases). If the instruction is large enough, the + * loop is restarted, otherwise lazy matching may ensue. */ +#define HANDLELAZY(mlen) \ + if (TRYLAZYLEN ((mlen), (stream->input_position), (stream->avail_in))) \ + { stream->min_match = (mlen) + LEAST_MATCH_INCR; goto updateone; } \ + else \ + { stream->input_position += (mlen); goto restartloop; } + + /* Now loop over one input byte at a time until a match is found... */ + for (;; inp += 1, stream->input_position += 1) + { + /* Now we try three kinds of string match in order of expense: + * run, large match, small match. */ + + /* Expand the start of a RUN. The test for (run_l == SLOOK) + * avoids repeating this check when we pass through a run area + * performing lazy matching. The run is only expanded once when + * the min_match is first reached. If lazy matching is + * performed, the run_l variable will remain inconsistent until + * the first non-running input character is reached, at which + * time the run_l may then again grow to SLOOK. */ + if (DO_RUN && run_l == SLOOK) + { + usize_t max_len = stream->avail_in - stream->input_position; + + IF_DEBUG (xd3_verify_run_state (stream, inp, run_l, &run_c)); + + while (run_l < max_len && inp[run_l] == run_c) { run_l += 1; } + + /* Output a RUN instruction. */ + if (run_l >= stream->min_match && run_l >= MIN_RUN) + { + if ((ret = xd3_emit_run (stream, stream->input_position, + run_l, &run_c))) { return ret; } + + HANDLELAZY (run_l); + } + } + + /* If there is enough input remaining. */ + if (DO_LARGE && (stream->input_position + LLOOK <= stream->avail_in)) + { + if ((stream->input_position >= next_move_point) && + (ret = xd3_srcwin_move_point (stream, & next_move_point))) + { + return ret; + } + + linx = xd3_checksum_hash (& stream->large_hash, lcksum); + + IF_DEBUG (xd3_verify_large_state (stream, inp, lcksum)); + + if (stream->large_table[linx] != 0) + { + /* the match_setup will fail if the source window has + * been decided and the match lies outside it. + * OPT: Consider forcing a window at this point to + * permit a new source window. */ + xoff_t adj_offset = + xd3_source_cksum_offset(stream, + stream->large_table[linx] - + HASH_CKOFFSET); + if (xd3_source_match_setup (stream, adj_offset) == 0) + { + if ((ret = xd3_source_extend_match (stream))) + { + return ret; + } + + /* Update stream position. match_fwd is zero if no + * match. */ + if (stream->match_fwd > 0) + { + HANDLELAZY (stream->match_fwd); + } + } + } + } + + /* Small matches. */ + if (DO_SMALL) + { + sinx = xd3_checksum_hash (& stream->small_hash, scksum); + + /* Verify incremental state in debugging mode. */ + IF_DEBUG (xd3_verify_small_state (stream, inp, scksum)); + + /* Search for the longest match */ + if (stream->small_table[sinx] != 0) + { + match_length = xd3_smatch (stream, + stream->small_table[sinx], + scksum, + & match_offset); + } + else + { + match_length = 0; + } + + /* Insert a hash for this string. */ + xd3_scksum_insert (stream, sinx, scksum, stream->input_position); + + /* Maybe output a COPY instruction */ + if (match_length >= stream->min_match) + { + IF_DEBUG2 ({ + static int x = 0; + DP(RINT "[target match:%d] " + "(-%d) [ %u bytes ]\n", + x++, + stream->input_position, + stream->input_position + match_length, + match_offset, + match_offset + match_length, + stream->input_position - match_offset, + match_length); + }); + + if ((ret = xd3_found_match (stream, + /* decoder position */ + stream->input_position, + /* length */ match_length, + /* address */ (xoff_t) match_offset, + /* is_source */ 0))) + { + return ret; + } + + /* Copy instruction. */ + HANDLELAZY (match_length); + } + } + + /* The logic above prevents excess work during lazy matching by + * increasing min_match to avoid smaller matches. Each time we + * advance stream->input_position by one, the minimum match + * shortens as well. */ + if (stream->min_match > MIN_MATCH) + { + stream->min_match -= 1; + } + + updateone: + + /* See if there are no more incremental cksums to compute. */ + if (stream->input_position + SLOOK == stream->avail_in) + { + goto loopnomore; + } + + /* Compute next RUN, CKSUM */ + if (DO_RUN) + { + NEXTRUN (inp[SLOOK]); + } + + if (DO_SMALL) + { + scksum = xd3_small_cksum_update (&scksum_state, inp, SLOOK); + } + + if (DO_LARGE && (stream->input_position + LLOOK < stream->avail_in)) + { + lcksum = xd3_large_cksum_update (lcksum, inp, LLOOK); + } + } + + loopnomore: + return 0; +} + +#endif /* XD3_ENCODER */ +#endif /* __XDELTA3_C_TEMPLATE_PASS__ */ diff --git a/app/src/main/rust/stores/vendor/xdelta3/xdelta3.h b/app/src/main/rust/stores/vendor/xdelta3/xdelta3.h new file mode 100644 index 000000000..9fbda6fa2 --- /dev/null +++ b/app/src/main/rust/stores/vendor/xdelta3/xdelta3.h @@ -0,0 +1,1396 @@ +/* xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/* To know more about Xdelta, start by reading xdelta3.c. If you are + * ready to use the API, continue reading here. There are two + * interfaces -- xd3_encode_input and xd3_decode_input -- plus a dozen + * or so related calls. This interface is styled after Zlib. */ + +#ifndef _XDELTA3_H_ +#define _XDELTA3_H_ + +#define _POSIX_SOURCE +#define _ISOC99_SOURCE +#define _C99_SOURCE + +#if HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include +#include +#include +#include + +/****************************************************************/ + +/* Default configured value of stream->winsize. If the program + * supplies xd3_encode_input() with data smaller than winsize the + * stream will automatically buffer the input, otherwise the input + * buffer is used directly. + */ +#ifndef XD3_DEFAULT_WINSIZE +#define XD3_DEFAULT_WINSIZE (1U << 23) +#endif + +/* Default total size of the source window used in xdelta3-main.h */ +#ifndef XD3_DEFAULT_SRCWINSZ +#define XD3_DEFAULT_SRCWINSZ (1U << 26) +#endif + +/* When Xdelta requests a memory allocation for certain buffers, it + * rounds up to units of at least this size. The code assumes (and + * asserts) that this is a power-of-two. */ +#ifndef XD3_ALLOCSIZE +#define XD3_ALLOCSIZE (1U<<14) +#endif + +/* The XD3_HARDMAXWINSIZE parameter is a safety mechanism to protect + * decoders against malicious files. The decoder will never decode a + * window larger than this. If the file specifies VCD_TARGET the + * decoder may require two buffers of this size. + * + * 8-16MB is reasonable, probably don't need to go larger. */ +#ifndef XD3_HARDMAXWINSIZE +#define XD3_HARDMAXWINSIZE (1U<<24) +#endif +/* The IOPT_SIZE value sets the size of a buffer used to batch + * overlapping copy instructions before they are optimized by picking + * the best non-overlapping ranges. The larger this buffer, the + * longer a forced xd3_srcwin_setup() decision is held off. Setting + * this value to 0 causes an unlimited buffer to be used. */ +#ifndef XD3_DEFAULT_IOPT_SIZE +#define XD3_DEFAULT_IOPT_SIZE (1U<<15) +#endif + +/* The maximum distance backward to search for small matches */ +#ifndef XD3_DEFAULT_SPREVSZ +#define XD3_DEFAULT_SPREVSZ (1U<<18) +#endif + +/* The default compression level + */ +#ifndef XD3_DEFAULT_LEVEL +#define XD3_DEFAULT_LEVEL 3 +#endif + +#ifndef XD3_DEFAULT_SECONDARY_LEVEL +#define XD3_DEFAULT_SECONDARY_LEVEL 6 +#endif + +#ifndef XD3_USE_LARGEFILE64 +#define XD3_USE_LARGEFILE64 1 +#endif + +/* Sizes and addresses within VCDIFF windows are represented as usize_t + * + * For source-file offsets and total file sizes, total input and + * output counts, the xoff_t type is used. The decoder and encoder + * generally check for overflow of the xoff_t size (this is tested at + * the 32bit boundary [xdelta3-test.h]). + */ +#ifndef _WIN32 +#include +#else /* WIN32 case */ +#define WIN32_LEAN_AND_MEAN + +#ifndef WINVER +#if XD3_USE_LARGEFILE64 +/* 64 bit file offsets: uses GetFileSizeEx and SetFilePointerEx. + * requires Win2000 or newer version of WinNT */ +#define WINVER 0x0500 +#define _WIN32_WINNT 0x0500 +#else /* xoff_t is 32bit */ +/* 32 bit (DWORD) file offsets: uses GetFileSize and + * SetFilePointer. compatible with win9x-me and WinNT4 */ +#define WINVER 0x0400 +#define _WIN32_WINNT 0x0400 +#endif /* if XD3_USE_LARGEFILE64 */ +#endif /* ifndef WINVER */ + +#include + +/* _MSV_VER is defined by Microsoft tools, not by mingw32 */ +#ifdef _MSC_VER +/*#define inline*/ +typedef signed int ssize_t; +#if _MSC_VER < 1600 +typedef unsigned char uint8_t; +typedef unsigned short uint16_t; +typedef unsigned long uint32_t; +typedef ULONGLONG uint64_t; +#else /* _MSC_VER >= 1600 */ +/* For MSVC10 and above */ +#include +#endif /* _MSC_VER < 1600 */ +#else /* _MSC_VER not defined */ +/* mingw32, lcc and watcom provide a proper header */ +#include +#endif /* _MSC_VER defined */ +#endif /* _WIN32 defined */ + +typedef uint32_t usize_t; + +#if XD3_USE_LARGEFILE64 +/* xoff_t is a 64-bit type */ +#define __USE_FILE_OFFSET64 1 /* GLIBC: for 64bit fileops, ... ? */ + +#ifndef _LARGEFILE_SOURCE +#define _LARGEFILE_SOURCE +#endif + +#ifndef _FILE_OFFSET_BITS +#define _FILE_OFFSET_BITS 64 +#endif + +#if defined(_WIN32) +typedef uint64_t xoff_t; +/* Note: The following generates benign warnings in a mingw + * cross-compiler */ +#define Q "I64" +#elif SIZEOF_UNSIGNED_LONG == 8 +typedef unsigned long xoff_t; +#define Q "l" +#elif SIZEOF_SIZE_T == 8 +typedef size_t xoff_t; +#define Q "z" +#elif SIZEOF_UNSIGNED_LONG_LONG == 8 +typedef unsigned long long xoff_t; +#define Q "ll" +#endif /* #define Q */ + +#define SIZEOF_XOFF_T 8 + +#else /* XD3_USE_LARGEFILE64 == 0 */ + +#if SIZEOF_UNSIGNED_INT == 4 +typedef unsigned int xoff_t; +#elif SIZEOF_UNSIGNED_LONG == 4 +typedef unsigned long xoff_t; +#else +typedef uint32_t xoff_t; +#endif /* xoff_t is 32 bits */ + +#define SIZEOF_XOFF_T 4 +#define Q +#endif /* 64 vs 32 bit xoff_t */ + +/* Note: This gets modified in the 64bithash branch. */ +#define SIZEOF_USIZE_T 4 + +#if SIZEOF_SIZE_T == 4 +#define Z "z" +#elif SIZEOF_SIZE_T == 8 +#ifdef _WIN32 +#define Z "I64" +#else /* !_WIN32 */ +#define Z "z" +#endif /* Windows or not */ +#else +#error Bad configure script +#endif /* size_t printf flags */ + +#define USE_UINT32 (SIZEOF_USIZE_T == 4 || \ + SIZEOF_XOFF_T == 4 || REGRESSION_TEST) +#define USE_UINT64 (SIZEOF_USIZE_T == 8 || \ + SIZEOF_XOFF_T == 8 || REGRESSION_TEST) + +#ifndef UNALIGNED_OK +#ifdef HAVE_ALIGNED_ACCESS_REQUIRED +#define UNALIGNED_OK 0 +#else +/* This generally includes all Windows builds. */ +#define UNALIGNED_OK 1 +#endif +#endif + +/**********************************************************************/ + +/* Whether to build the encoder, otherwise only build the decoder. */ +#ifndef XD3_ENCODER +#define XD3_ENCODER 1 +#endif + +/* The code returned when main() fails, also defined in system + includes. */ +#ifndef EXIT_FAILURE +#define EXIT_FAILURE 1 +#endif + +/* REGRESSION TEST enables the "xdelta3 test" command, which runs a + series of self-tests. */ +#ifndef REGRESSION_TEST +#define REGRESSION_TEST 0 +#endif + +/* XD3_DEBUG=1 enables assertions and various statistics. Levels > 1 + * enable some additional output only useful during development and + * debugging. */ +#ifndef XD3_DEBUG +#define XD3_DEBUG 0 +#endif + +#ifndef PYTHON_MODULE +#define PYTHON_MODULE 0 +#endif + +#ifndef SWIG_MODULE +#define SWIG_MODULE 0 +#endif + +/* There are three string matching functions supplied: one fast, one + * slow (default), and one soft-configurable. To disable any of + * these, use the following definitions. */ +#ifndef XD3_BUILD_SLOW +#define XD3_BUILD_SLOW 1 +#endif +#ifndef XD3_BUILD_FAST +#define XD3_BUILD_FAST 1 +#endif +#ifndef XD3_BUILD_FASTER +#define XD3_BUILD_FASTER 1 +#endif +#ifndef XD3_BUILD_FASTEST +#define XD3_BUILD_FASTEST 1 +#endif +#ifndef XD3_BUILD_SOFT +#define XD3_BUILD_SOFT 1 +#endif +#ifndef XD3_BUILD_DEFAULT +#define XD3_BUILD_DEFAULT 1 +#endif + +#if XD3_DEBUG +#include +#endif + +/* XPRINT. Debug output and VCDIFF_TOOLS functions report to stderr. + * I have used an irregular style to abbreviate [fprintf(stderr, "] as + * [DP(RINT "]. */ +#define DP fprintf +#define RINT stderr, + +typedef struct _xd3_stream xd3_stream; +typedef struct _xd3_source xd3_source; +typedef struct _xd3_hash_cfg xd3_hash_cfg; +typedef struct _xd3_smatcher xd3_smatcher; +typedef struct _xd3_rinst xd3_rinst; +typedef struct _xd3_dinst xd3_dinst; +typedef struct _xd3_hinst xd3_hinst; +typedef struct _xd3_winst xd3_winst; +typedef struct _xd3_rpage xd3_rpage; +typedef struct _xd3_addr_cache xd3_addr_cache; +typedef struct _xd3_output xd3_output; +typedef struct _xd3_desect xd3_desect; +typedef struct _xd3_iopt_buflist xd3_iopt_buflist; +typedef struct _xd3_rlist xd3_rlist; +typedef struct _xd3_sec_type xd3_sec_type; +typedef struct _xd3_sec_cfg xd3_sec_cfg; +typedef struct _xd3_sec_stream xd3_sec_stream; +typedef struct _xd3_config xd3_config; +typedef struct _xd3_code_table_desc xd3_code_table_desc; +typedef struct _xd3_code_table_sizes xd3_code_table_sizes; +typedef struct _xd3_slist xd3_slist; +typedef struct _xd3_whole_state xd3_whole_state; +typedef struct _xd3_wininfo xd3_wininfo; + +/* The stream configuration has three callbacks functions, all of + * which may be supplied with NULL values. If config->getblk is + * provided as NULL, the stream returns XD3_GETSRCBLK. */ + +typedef void* (xd3_alloc_func) (void *opaque, + size_t items, + usize_t size); +typedef void (xd3_free_func) (void *opaque, + void *address); + +typedef int (xd3_getblk_func) (xd3_stream *stream, + xd3_source *source, + xoff_t blkno); + +/* These are internal functions to delay construction of encoding + * tables and support alternate code tables. See the comments & code + * enabled by GENERIC_ENCODE_TABLES. */ + +typedef const xd3_dinst* (xd3_code_table_func) (void); +typedef int (xd3_comp_table_func) (xd3_stream *stream, + const uint8_t **data, + usize_t *size); + + + +#if XD3_DEBUG +#define XD3_ASSERT(x) \ + do { if (! (x)) { DP(RINT "%s:%d: XD3 assertion failed: %s\n", __FILE__, __LINE__, #x); \ + abort (); } } while (0) +#else +#define XD3_ASSERT(x) (void)0 +#endif /* XD3_DEBUG */ + +#define xd3_max(x,y) ((x) < (y) ? (y) : (x)) +#define xd3_min(x,y) ((x) < (y) ? (x) : (y)) + +/**************************************************************** + PUBLIC ENUMS + ******************************************************************/ + +/* These are the five ordinary status codes returned by the + * xd3_encode_input() and xd3_decode_input() state machines. */ +typedef enum { + + /* An application must be prepared to handle these five return + * values from either xd3_encode_input or xd3_decode_input, except + * in the case of no-source compression, in which case XD3_GETSRCBLK + * is never returned. More detailed comments for these are given in + * xd3_encode_input and xd3_decode_input comments, below. */ + XD3_INPUT = -17703, /* need input */ + XD3_OUTPUT = -17704, /* have output */ + XD3_GETSRCBLK = -17705, /* need a block of source input (with no + * xd3_getblk function), a chance to do + * non-blocking read. */ + XD3_GOTHEADER = -17706, /* (decode-only) after the initial VCDIFF & + first window header */ + XD3_WINSTART = -17707, /* notification: returned before a window is + * processed, giving a chance to + * XD3_SKIP_WINDOW or not XD3_SKIP_EMIT that + * window. */ + XD3_WINFINISH = -17708, /* notification: returned after + encode/decode & output for a window */ + XD3_TOOFARBACK = -17709, /* (encoder only) may be returned by + getblk() if the block is too old */ + XD3_INTERNAL = -17710, /* internal error */ + XD3_INVALID = -17711, /* invalid config */ + XD3_INVALID_INPUT = -17712, /* invalid input/decoder error */ + XD3_NOSECOND = -17713, /* when secondary compression finds no + improvement. */ + XD3_UNIMPLEMENTED = -17714 /* currently VCD_TARGET, VCD_CODETABLE */ +} xd3_rvalues; + +/* special values in config->flags */ +typedef enum +{ + XD3_JUST_HDR = (1 << 1), /* used by VCDIFF tools, see + xdelta3-main.h. */ + XD3_SKIP_WINDOW = (1 << 2), /* used by VCDIFF tools, see + xdelta3-main.h. */ + XD3_SKIP_EMIT = (1 << 3), /* used by VCDIFF tools, see + xdelta3-main.h. */ + XD3_FLUSH = (1 << 4), /* flush the stream buffer to + prepare for + xd3_stream_close(). */ + + XD3_SEC_DJW = (1 << 5), /* use DJW static huffman */ + XD3_SEC_FGK = (1 << 6), /* use FGK adaptive huffman */ + XD3_SEC_LZMA = (1 << 24), /* use LZMA secondary */ + + XD3_SEC_TYPE = (XD3_SEC_DJW | XD3_SEC_FGK | XD3_SEC_LZMA), + + XD3_SEC_NODATA = (1 << 7), /* disable secondary compression of + the data section. */ + XD3_SEC_NOINST = (1 << 8), /* disable secondary compression of + the inst section. */ + XD3_SEC_NOADDR = (1 << 9), /* disable secondary compression of + the addr section. */ + + XD3_SEC_NOALL = (XD3_SEC_NODATA | XD3_SEC_NOINST | XD3_SEC_NOADDR), + + XD3_ADLER32 = (1 << 10), /* enable checksum computation in + the encoder. */ + XD3_ADLER32_NOVER = (1 << 11), /* disable checksum verification in + the decoder. */ + + XD3_NOCOMPRESS = (1 << 13), /* disable ordinary data + * compression feature, only search + * the source, not the target. */ + XD3_BEGREEDY = (1 << 14), /* disable the "1.5-pass + * algorithm", instead use greedy + * matching. Greedy is off by + * default. */ + XD3_ADLER32_RECODE = (1 << 15), /* used by "recode". */ + + /* 4 bits to set the compression level the same as the command-line + * setting -1 through -9 (-0 corresponds to the XD3_NOCOMPRESS flag, + * and is independent of compression level). This is for + * convenience, especially with xd3_encode_memory(). */ + + XD3_COMPLEVEL_SHIFT = 20, /* 20 - 23 */ + XD3_COMPLEVEL_MASK = (0xF << XD3_COMPLEVEL_SHIFT), + XD3_COMPLEVEL_1 = (1 << XD3_COMPLEVEL_SHIFT), + XD3_COMPLEVEL_2 = (2 << XD3_COMPLEVEL_SHIFT), + XD3_COMPLEVEL_3 = (3 << XD3_COMPLEVEL_SHIFT), + XD3_COMPLEVEL_6 = (6 << XD3_COMPLEVEL_SHIFT), + XD3_COMPLEVEL_9 = (9 << XD3_COMPLEVEL_SHIFT) + +} xd3_flags; + +/* The values of this enumeration are set in xd3_config using the + * smatch_cfg variable. It can be set to default, slow, fast, etc., + * and soft. */ +typedef enum +{ + XD3_SMATCH_DEFAULT = 0, /* Flags may contain XD3_COMPLEVEL bits, + else default. */ + XD3_SMATCH_SLOW = 1, + XD3_SMATCH_FAST = 2, + XD3_SMATCH_FASTER = 3, + XD3_SMATCH_FASTEST = 4, + XD3_SMATCH_SOFT = 5 +} xd3_smatch_cfg; + +/********************************************************************* + PRIVATE ENUMS +**********************************************************************/ + +/* stream->match_state is part of the xd3_encode_input state machine + * for source matching: + * + * 1. the XD3_GETSRCBLK block-read mechanism means reentrant matching + * 2. this state spans encoder windows: a match and end-of-window + * will continue in the next 3. the initial target byte and source + * byte are a presumed match, to avoid some computation in case the + * inputs are identical. + */ +typedef enum { + + MATCH_TARGET = 0, /* in this state, attempt to match the start of + * the target with the previously set source + * address (initially 0). */ + MATCH_BACKWARD = 1, /* currently expanding a match backward in the + source/target. */ + MATCH_FORWARD = 2, /* currently expanding a match forward in the + source/target. */ + MATCH_SEARCHING = 3 /* currently searching for a match. */ + +} xd3_match_state; + +/* The xd3_encode_input state machine steps through these states in + * the following order. The matcher is reentrant and returns + * XD3_INPUT whenever it requires more data. After receiving + * XD3_INPUT, if the application reads EOF it should call + * xd3_stream_close(). + */ +typedef enum { + + ENC_INIT = 0, /* xd3_encode_input has never been called. */ + ENC_INPUT = 1, /* waiting for xd3_avail_input () to be called. */ + ENC_SEARCH = 2, /* currently searching for matches. */ + ENC_INSTR = 3, /* currently formatting output. */ + ENC_FLUSH = 4, /* currently emitting output. */ + ENC_POSTOUT = 5, /* after an output section. */ + ENC_POSTWIN = 6, /* after all output sections. */ + ENC_ABORTED = 7 /* abort. */ +} xd3_encode_state; + +/* The xd3_decode_input state machine steps through these states in + * the following order. The matcher is reentrant and returns + * XD3_INPUT whenever it requires more data. After receiving + * XD3_INPUT, if the application reads EOF it should call + * xd3_stream_close(). + * + * 0-8: the VCDIFF header + * 9-18: the VCDIFF window header + * 19-21: the three primary sections: data, inst, addr + * 22: producing output: returns XD3_OUTPUT, possibly XD3_GETSRCBLK, + * 23: return XD3_WINFINISH, set state=9 to decode more input + */ +typedef enum { + + DEC_VCHEAD = 0, /* VCDIFF header */ + DEC_HDRIND = 1, /* header indicator */ + + DEC_SECONDID = 2, /* secondary compressor ID */ + + DEC_TABLEN = 3, /* code table length */ + DEC_NEAR = 4, /* code table near */ + DEC_SAME = 5, /* code table same */ + DEC_TABDAT = 6, /* code table data */ + + DEC_APPLEN = 7, /* application data length */ + DEC_APPDAT = 8, /* application data */ + + DEC_WININD = 9, /* window indicator */ + + DEC_CPYLEN = 10, /* copy window length */ + DEC_CPYOFF = 11, /* copy window offset */ + + DEC_ENCLEN = 12, /* length of delta encoding */ + DEC_TGTLEN = 13, /* length of target window */ + DEC_DELIND = 14, /* delta indicator */ + + DEC_DATALEN = 15, /* length of ADD+RUN data */ + DEC_INSTLEN = 16, /* length of instruction data */ + DEC_ADDRLEN = 17, /* length of address data */ + + DEC_CKSUM = 18, /* window checksum */ + + DEC_DATA = 19, /* data section */ + DEC_INST = 20, /* instruction section */ + DEC_ADDR = 21, /* address section */ + + DEC_EMIT = 22, /* producing data */ + + DEC_FINISH = 23, /* window finished */ + + DEC_ABORTED = 24 /* xd3_abort_stream */ +} xd3_decode_state; + +/************************************************************ + internal types + ************************************************************/ + +/* instruction lists used in the IOPT buffer */ +struct _xd3_rlist +{ + xd3_rlist *next; + xd3_rlist *prev; +}; + +/* the raw encoding of an instruction used in the IOPT buffer */ +struct _xd3_rinst +{ + uint8_t type; + uint8_t xtra; + uint8_t code1; + uint8_t code2; + usize_t pos; + usize_t size; + xoff_t addr; + xd3_rlist link; +}; + +/* the code-table form of an single- or double-instruction */ +struct _xd3_dinst +{ + uint8_t type1; + uint8_t size1; + uint8_t type2; + uint8_t size2; +}; + +/* the decoded form of a single (half) instruction. */ +struct _xd3_hinst +{ + uint8_t type; + uint32_t size; /* TODO: why decode breaks if this is usize_t? */ + uint32_t addr; /* TODO: why decode breaks if this is usize_t? */ +}; + +/* the form of a whole-file instruction */ +struct _xd3_winst +{ + uint8_t type; /* RUN, ADD, COPY */ + uint8_t mode; /* 0, VCD_SOURCE, VCD_TARGET */ + usize_t size; + xoff_t addr; + xoff_t position; /* absolute position of this inst */ +}; + +/* used by the encoder to buffer output in sections. list of blocks. */ +struct _xd3_output +{ + uint8_t *base; + usize_t next; + usize_t avail; + xd3_output *next_page; +}; + +/* used by the decoder to buffer input in sections. */ +struct _xd3_desect +{ + const uint8_t *buf; + const uint8_t *buf_max; + uint32_t size; /* TODO: why decode breaks if this is usize_t? */ + usize_t pos; + + /* used in xdelta3-decode.h */ + uint8_t *copied1; + usize_t alloc1; + + /* used in xdelta3-second.h */ + uint8_t *copied2; + usize_t alloc2; +}; + +/* the VCDIFF address cache, see the RFC */ +struct _xd3_addr_cache +{ + usize_t s_near; + usize_t s_same; + usize_t next_slot; /* the circular index for near */ + usize_t *near_array; /* array of size s_near */ + usize_t *same_array; /* array of size s_same*256 */ +}; + +/* the IOPT buffer list is just a list of buffers, which may be allocated + * during encode when using an unlimited buffer. */ +struct _xd3_iopt_buflist +{ + xd3_rinst *buffer; + xd3_iopt_buflist *next; +}; + +/* This is the record of a pre-compiled configuration, a subset of + xd3_config. */ +struct _xd3_smatcher +{ + const char *name; + int (*string_match) (xd3_stream *stream); + usize_t large_look; + usize_t large_step; + usize_t small_look; + usize_t small_chain; + usize_t small_lchain; + usize_t max_lazy; + usize_t long_enough; +}; + +/* hash table size & power-of-two hash function. */ +struct _xd3_hash_cfg +{ + usize_t size; + usize_t shift; + usize_t mask; +}; + +/* the sprev list */ +struct _xd3_slist +{ + usize_t last_pos; +}; + +/* window info (for whole state) */ +struct _xd3_wininfo { + xoff_t offset; + usize_t length; + uint32_t adler32; +}; + +/* whole state for, e.g., merge */ +struct _xd3_whole_state { + usize_t addslen; + uint8_t *adds; + usize_t adds_alloc; + + usize_t instlen; + xd3_winst *inst; + usize_t inst_alloc; + + usize_t wininfolen; + xd3_wininfo *wininfo; + usize_t wininfo_alloc; + + xoff_t length; +}; + +/******************************************************************** + public types + *******************************************************************/ + +/* Settings for the secondary compressor. */ +struct _xd3_sec_cfg +{ + int data_type; /* Which section. (set automatically) */ + usize_t ngroups; /* Number of DJW Huffman groups. */ + usize_t sector_size; /* Sector size. */ + int inefficient; /* If true, ignore efficiency check [avoid XD3_NOSECOND]. */ +}; + +/* This is the user-visible stream configuration. */ +struct _xd3_config +{ + usize_t winsize; /* The encoder window size. */ + usize_t sprevsz; /* How far back small string + matching goes */ + usize_t iopt_size; /* entries in the + instruction-optimizing + buffer */ + + xd3_getblk_func *getblk; /* The three callbacks. */ + xd3_alloc_func *alloc; + xd3_free_func *freef; + void *opaque; /* Not used. */ + int flags; /* stream->flags are initialized + * from xd3_config & never + * modified by the library. Use + * xd3_set_flags to modify flags + * settings mid-stream. */ + + xd3_sec_cfg sec_data; /* Secondary compressor config: data */ + xd3_sec_cfg sec_inst; /* Secondary compressor config: inst */ + xd3_sec_cfg sec_addr; /* Secondary compressor config: addr */ + + xd3_smatch_cfg smatch_cfg; /* See enum: use fields below for + soft config */ + xd3_smatcher smatcher_soft; +}; + +/* The primary source file object. You create one of these objects and + * initialize the first four fields. This library maintains the next + * 5 fields. The configured getblk implementation is responsible for + * setting the final 3 fields when called (and/or when XD3_GETSRCBLK + * is returned). + */ +struct _xd3_source +{ + /* you set */ + usize_t blksize; /* block size */ + const char *name; /* its name, for debug/print + purposes */ + void *ioh; /* opaque handle */ + xoff_t max_winsize; /* maximum visible buffer */ + + /* getblk sets */ + xoff_t curblkno; /* current block number: client + sets after getblk request */ + usize_t onblk; /* number of bytes on current + block: client sets, must be >= 0 + and <= blksize */ + const uint8_t *curblk; /* current block array: client + sets after getblk request */ + + /* xd3 sets */ + usize_t srclen; /* length of this source window */ + xoff_t srcbase; /* offset of this source window + in the source itself */ + int shiftby; /* for power-of-two blocksizes */ + int maskby; /* for power-of-two blocksizes */ + xoff_t cpyoff_blocks; /* offset of dec_cpyoff in blocks */ + usize_t cpyoff_blkoff; /* offset of copy window in + blocks, remainder */ + xoff_t getblkno; /* request block number: xd3 sets + current getblk request */ + + /* See xd3_getblk() */ + xoff_t max_blkno; /* Maximum block, if eof is known, + * otherwise, equals frontier_blkno + * (initially 0). */ + usize_t onlastblk; /* Number of bytes on max_blkno */ + int eof_known; /* Set to true when the first + * partial block is read. */ +}; + +/* The primary xd3_stream object, used for encoding and decoding. You + * may access only two fields: avail_out, next_out. Use the methods + * above to operate on xd3_stream. */ +struct _xd3_stream +{ + /* input state */ + const uint8_t *next_in; /* next input byte */ + usize_t avail_in; /* number of bytes available at + next_in */ + xoff_t total_in; /* how many bytes in */ + + /* output state */ + uint8_t *next_out; /* next output byte */ + usize_t avail_out; /* number of bytes available at + next_out */ + usize_t space_out; /* total out space */ + xoff_t current_window; /* number of windows encoded/decoded */ + xoff_t total_out; /* how many bytes out */ + + /* to indicate an error, xd3 sets */ + const char *msg; /* last error message, NULL if + no error */ + + /* source configuration */ + xd3_source *src; /* source array */ + + /* encoder memory configuration */ + usize_t winsize; /* suggested window size */ + usize_t sprevsz; /* small string, previous window + size (power of 2) */ + usize_t sprevmask; /* small string, previous window + size mask */ + usize_t iopt_size; + usize_t iopt_unlimited; + + /* general configuration */ + xd3_getblk_func *getblk; /* set nxtblk, nxtblkno to scanblkno */ + xd3_alloc_func *alloc; /* malloc function */ + xd3_free_func *free; /* free function */ + void* opaque; /* private data object passed to + alloc, free, and getblk */ + int flags; /* various options */ + + /* secondary compressor configuration */ + xd3_sec_cfg sec_data; /* Secondary compressor config: data */ + xd3_sec_cfg sec_inst; /* Secondary compressor config: inst */ + xd3_sec_cfg sec_addr; /* Secondary compressor config: addr */ + + xd3_smatcher smatcher; + + usize_t *large_table; /* table of large checksums */ + xd3_hash_cfg large_hash; /* large hash config */ + + usize_t *small_table; /* table of small checksums */ + xd3_slist *small_prev; /* table of previous offsets, + circular linked list */ + int small_reset; /* true if small table should + be reset */ + + xd3_hash_cfg small_hash; /* small hash config */ + xd3_addr_cache acache; /* the vcdiff address cache */ + xd3_encode_state enc_state; /* state of the encoder */ + + usize_t taroff; /* base offset of the target input */ + usize_t input_position; /* current input position */ + usize_t min_match; /* current minimum match + length, avoids redundent + matches */ + usize_t unencoded_offset; /* current input, first + * unencoded offset. this value + * is <= the first instruction's + * position in the iopt buffer, + * if there is at least one + * match in the buffer. */ + + /* SRCWIN */ + int srcwin_decided; /* boolean: true if srclen and + srcbase have been + decided. */ + int srcwin_decided_early; /* boolean: true if srclen + and srcbase were + decided early. */ + xoff_t srcwin_cksum_pos; /* Source checksum position */ + + /* MATCH */ + xd3_match_state match_state; /* encoder match state */ + xoff_t match_srcpos; /* current match source + position relative to + srcbase */ + xoff_t match_last_srcpos; /* previously attempted + * srcpos, to avoid loops. */ + xoff_t match_minaddr; /* smallest matching address to + * set window params (reset each + * window xd3_encode_reset) */ + xoff_t match_maxaddr; /* largest matching address to + * set window params (reset each + * window xd3_encode_reset) */ + usize_t match_back; /* match extends back so far */ + usize_t match_maxback; /* match extends back maximum */ + usize_t match_fwd; /* match extends forward so far */ + usize_t match_maxfwd; /* match extends forward maximum */ + + xoff_t maxsrcaddr; /* address of the last source + match (across windows) */ + + uint8_t *buf_in; /* for saving buffered input */ + usize_t buf_avail; /* amount of saved input */ + const uint8_t *buf_leftover; /* leftover content of next_in + (i.e., user's buffer) */ + usize_t buf_leftavail; /* amount of leftover content */ + + xd3_output *enc_current; /* current output buffer */ + xd3_output *enc_free; /* free output buffers */ + xd3_output *enc_heads[4]; /* array of encoded outputs: + head of chain */ + xd3_output *enc_tails[4]; /* array of encoded outputs: + tail of chain */ + uint32_t recode_adler32; /* set the adler32 checksum + * during "recode". */ + + xd3_rlist iopt_used; /* instruction optimizing buffer */ + xd3_rlist iopt_free; + xd3_rinst *iout; /* next single instruction */ + xd3_iopt_buflist *iopt_alloc; + + const uint8_t *enc_appheader; /* application header to encode */ + usize_t enc_appheadsz; /* application header size */ + + /* decoder stuff */ + xd3_decode_state dec_state; /* current DEC_XXX value */ + usize_t dec_hdr_ind; /* VCDIFF header indicator */ + usize_t dec_win_ind; /* VCDIFF window indicator */ + usize_t dec_del_ind; /* VCDIFF delta indicator */ + + uint8_t dec_magic[4]; /* First four bytes */ + usize_t dec_magicbytes; /* Magic position. */ + + usize_t dec_secondid; /* Optional secondary compressor ID. */ + + /* TODO: why decode breaks if this is usize_t? */ + uint32_t dec_codetblsz; /* Optional code table: length. */ + uint8_t *dec_codetbl; /* Optional code table: storage. */ + usize_t dec_codetblbytes; /* Optional code table: position. */ + + /* TODO: why decode breaks if this is usize_t? */ + uint32_t dec_appheadsz; /* Optional application header: + size. */ + uint8_t *dec_appheader; /* Optional application header: + storage */ + usize_t dec_appheadbytes; /* Optional application header: + position. */ + + usize_t dec_cksumbytes; /* Optional checksum: position. */ + uint8_t dec_cksum[4]; /* Optional checksum: storage. */ + uint32_t dec_adler32; /* Optional checksum: value. */ + + /* TODO: why decode breaks if this is usize_t? */ + uint32_t dec_cpylen; /* length of copy window + (VCD_SOURCE or VCD_TARGET) */ + xoff_t dec_cpyoff; /* offset of copy window + (VCD_SOURCE or VCD_TARGET) */ + /* TODO: why decode breaks if this is usize_t? */ + uint32_t dec_enclen; /* length of delta encoding */ + /* TODO: why decode breaks if this is usize_t? */ + uint32_t dec_tgtlen; /* length of target window */ + +#if USE_UINT64 + uint64_t dec_64part; /* part of a decoded uint64_t */ +#endif +#if USE_UINT32 + uint32_t dec_32part; /* part of a decoded uint32_t */ +#endif + + xoff_t dec_winstart; /* offset of the start of + current target window */ + xoff_t dec_window_count; /* == current_window + 1 in + DEC_FINISH */ + usize_t dec_winbytes; /* bytes of the three sections + so far consumed */ + usize_t dec_hdrsize; /* VCDIFF + app header size */ + + const uint8_t *dec_tgtaddrbase; /* Base of decoded target + addresses (addr >= + dec_cpylen). */ + const uint8_t *dec_cpyaddrbase; /* Base of decoded copy + addresses (addr < + dec_cpylen). */ + + usize_t dec_position; /* current decoder position + counting the cpylen + offset */ + usize_t dec_maxpos; /* maximum decoder position + counting the cpylen + offset */ + xd3_hinst dec_current1; /* current instruction */ + xd3_hinst dec_current2; /* current instruction */ + + uint8_t *dec_buffer; /* Decode buffer */ + uint8_t *dec_lastwin; /* In case of VCD_TARGET, the + last target window. */ + usize_t dec_lastlen; /* length of the last target + window */ + xoff_t dec_laststart; /* offset of the start of last + target window */ + usize_t dec_lastspace; /* allocated space of last + target window, for reuse */ + + xd3_desect inst_sect; /* staging area for decoding + window sections */ + xd3_desect addr_sect; + xd3_desect data_sect; + + xd3_code_table_func *code_table_func; + xd3_comp_table_func *comp_table_func; + const xd3_dinst *code_table; + const xd3_code_table_desc *code_table_desc; + xd3_dinst *code_table_alloc; + + /* secondary compression */ + const xd3_sec_type *sec_type; + xd3_sec_stream *sec_stream_d; + xd3_sec_stream *sec_stream_i; + xd3_sec_stream *sec_stream_a; + + /* state for reconstructing whole files (e.g., for merge), this only + * supports loading USIZE_T_MAX instructions, adds, etc. */ + xd3_whole_state whole_target; + + /* statistics */ + xoff_t n_scpy; + xoff_t n_tcpy; + xoff_t n_add; + xoff_t n_run; + + xoff_t l_scpy; + xoff_t l_tcpy; + xoff_t l_add; + xoff_t l_run; + + usize_t i_slots_used; + +#if XD3_DEBUG + usize_t large_ckcnt; + + /* memory usage */ + usize_t alloc_cnt; + usize_t free_cnt; +#endif +}; + +/************************************************************************** + PUBLIC FUNCTIONS + **************************************************************************/ + +/* This function configures an xd3_stream using the provided in-memory + * input buffer, source buffer, output buffer, and flags. The output + * array must be large enough or else ENOSPC will be returned. This + * is the simplest in-memory encoding interface. */ +int xd3_encode_memory (const uint8_t *input, + usize_t input_size, + const uint8_t *source, + usize_t source_size, + uint8_t *output_buffer, + usize_t *output_size, + usize_t avail_output, + int flags); + +/* The reverse of xd3_encode_memory. */ +int xd3_decode_memory (const uint8_t *input, + usize_t input_size, + const uint8_t *source, + usize_t source_size, + uint8_t *output_buf, + usize_t *output_size, + usize_t avail_output, + int flags); + +/* This function encodes an in-memory input using a pre-configured + * xd3_stream. This allows the caller to set a variety of options + * which are not available in the xd3_encode/decode_memory() + * functions. + * + * The output array must be large enough to hold the output or else + * ENOSPC is returned. The source (if any) should be set using + * xd3_set_source_and_size() with a single-block xd3_source. This + * calls the underlying non-blocking interfaces, + * xd3_encode/decode_input(), handling the necessary input/output + * states. This method may be considered a reference for any + * application using xd3_encode_input() directly. + * + * xd3_stream stream; + * xd3_config config; + * xd3_source src; + * + * memset (& src, 0, sizeof (src)); + * memset (& stream, 0, sizeof (stream)); + * memset (& config, 0, sizeof (config)); + * + * if (source != NULL) + * { + * src.size = source_size; + * src.blksize = source_size; + * src.curblkno = 0; + * src.onblk = source_size; + * src.curblk = source; + * src.max_winsize = source_size; + * xd3_set_source(&stream, &src); + * } + * + * config.flags = flags; + * config.winsize = input_size; + * + * ... set smatcher, appheader, encoding-table, compression-level, etc. + * + * xd3_config_stream(&stream, &config); + * xd3_encode_stream(&stream, ...); + * xd3_free_stream(&stream); + */ +int xd3_encode_stream (xd3_stream *stream, + const uint8_t *input, + usize_t input_size, + uint8_t *output, + usize_t *output_size, + usize_t avail_output); + +/* The reverse of xd3_encode_stream. */ +int xd3_decode_stream (xd3_stream *stream, + const uint8_t *input, + usize_t input_size, + uint8_t *output, + usize_t *output_size, + usize_t avail_size); + +/* This is the non-blocking interface. + * + * Handling input and output states is the same for encoding or + * decoding using the xd3_avail_input() and xd3_consume_output() + * routines, inlined below. + * + * Return values: + * + * XD3_INPUT: the process requires more input: call + * xd3_avail_input() then repeat + * + * XD3_OUTPUT: the process has more output: read stream->next_out, + * stream->avail_out, then call xd3_consume_output(), + * then repeat + * + * XD3_GOTHEADER: (decoder-only) notification returned following the + * VCDIFF header and first window header. the decoder + * may use the header to configure itself. + * + * XD3_WINSTART: a general notification returned once for each + * window except the 0-th window, which is implied by + * XD3_GOTHEADER. It is recommended to use a + * switch-stmt such as: + * + * ... + * again: + * switch ((ret = xd3_decode_input (stream))) { + * case XD3_GOTHEADER: { + * assert(stream->current_window == 0); + * stuff; + * } + * // fallthrough + * case XD3_WINSTART: { + * something(stream->current_window); + * goto again; + * } + * ... + * + * XD3_WINFINISH: a general notification, following the complete + * input & output of a window. at this point, + * stream->total_in and stream->total_out are consistent + * for either encoding or decoding. + * + * XD3_GETSRCBLK: If the xd3_getblk() callback is NULL, this value + * is returned to initiate a non-blocking source read. + */ +int xd3_decode_input (xd3_stream *stream); +int xd3_encode_input (xd3_stream *stream); + +/* The xd3_config structure is used to initialize a stream - all data + * is copied into stream so config may be a temporary variable. See + * the [documentation] or comments on the xd3_config structure. */ +int xd3_config_stream (xd3_stream *stream, + xd3_config *config); + +/* Since Xdelta3 doesn't open any files, xd3_close_stream is just an + * error check that the stream is in a proper state to be closed: this + * means the encoder is flushed and the decoder is at a window + * boundary. The application is responsible for freeing any of the + * resources it supplied. */ +int xd3_close_stream (xd3_stream *stream); + +/* This arranges for closes the stream to succeed. Does not free the + * stream.*/ +void xd3_abort_stream (xd3_stream *stream); + +/* xd3_free_stream frees all memory allocated for the stream. The + * application is responsible for freeing any of the resources it + * supplied. */ +void xd3_free_stream (xd3_stream *stream); + +/* This function informs the encoder or decoder that source matching + * (i.e., delta-compression) is possible. For encoding, this should + * be called before the first xd3_encode_input. A NULL source is + * ignored. For decoding, this should be called before the first + * window is decoded, but the appheader may be read first + * (XD3_GOTHEADER). After decoding the header, call xd3_set_source() + * if you have a source file. Note: if (stream->dec_win_ind & VCD_SOURCE) + * is true, it means the first window expects there to be a source file. + */ +int xd3_set_source (xd3_stream *stream, + xd3_source *source); + +/* If the source size is known, call this instead of xd3_set_source(). + * to avoid having stream->getblk called (and/or to avoid XD3_GETSRCBLK). + * + * Follow these steps: + xd3_source source; + memset(&source, 0, sizeof(source)); + source.blksize = size; + source.onblk = size; + source.curblk = buf; + source.curblkno = 0; + int ret = xd3_set_source_and_size(&stream, &source, size); + ... + */ +int xd3_set_source_and_size (xd3_stream *stream, + xd3_source *source, + xoff_t source_size); + +/* This should be called before the first call to xd3_encode_input() + * to include application-specific data in the VCDIFF header. */ +void xd3_set_appheader (xd3_stream *stream, + const uint8_t *data, + usize_t size); + +/* xd3_get_appheader may be called in the decoder after XD3_GOTHEADER. + * For convenience, the decoder always adds a single byte padding to + * the end of the application header, which is set to zero in case the + * application header is a string. */ +int xd3_get_appheader (xd3_stream *stream, + uint8_t **data, + usize_t *size); + +/* To generate a VCDIFF encoded delta with xd3_encode_init() from + * another format, use: + * + * xd3_encode_init_partial() -- initialze encoder state (w/o hash tables) + * xd3_init_cache() -- reset VCDIFF address cache + * xd3_found_match() -- to report a copy instruction + * + * set stream->enc_state to ENC_INSTR and call xd3_encode_input as usual. + */ +int xd3_encode_init_partial (xd3_stream *stream); +void xd3_init_cache (xd3_addr_cache* acache); +int xd3_found_match (xd3_stream *stream, + usize_t pos, usize_t size, + xoff_t addr, int is_source); + +/* Gives an error string for xdelta3-speficic errors, returns NULL for + system errors */ +const char* xd3_strerror (int ret); + +/* For convenience, zero & initialize the xd3_config structure with + specified flags. */ +static inline +void xd3_init_config (xd3_config *config, + int flags) +{ + memset (config, 0, sizeof (*config)); + config->flags = flags; +} + +/* This supplies some input to the stream. + * + * For encoding, if the input is larger than the configured window + * size (xd3_config.winsize), the entire input will be consumed and + * encoded anyway. If you wish to strictly limit the window size, + * limit the buffer passed to xd3_avail_input to the window size. + * + * For encoding, if the input is smaller than the configured window + * size (xd3_config.winsize), the library will create a window-sized + * buffer and accumulate input until a full-sized window can be + * encoded. XD3_INPUT will be returned. The input must remain valid + * until the next time xd3_encode_input() returns XD3_INPUT. + * + * For decoding, the input will be consumed entirely before XD3_INPUT + * is returned again. + */ +static inline +void xd3_avail_input (xd3_stream *stream, + const uint8_t *idata, + usize_t isize) +{ + /* Even if isize is zero, the code expects a non-NULL idata. Why? + * It uses this value to determine whether xd3_avail_input has ever + * been called. If xd3_encode_input is called before + * xd3_avail_input it will return XD3_INPUT right away without + * allocating a stream->winsize buffer. This is to avoid an + * unwanted allocation. */ + XD3_ASSERT (idata != NULL || isize == 0); + + stream->next_in = idata; + stream->avail_in = isize; +} + +/* This acknowledges receipt of output data, must be called after any + * XD3_OUTPUT return. */ +static inline +void xd3_consume_output (xd3_stream *stream) +{ + stream->avail_out = 0; +} + +/* These are set for each XD3_WINFINISH return. */ +static inline +int xd3_encoder_used_source (xd3_stream *stream) { + return stream->src != NULL && stream->src->srclen > 0; +} +static inline +xoff_t xd3_encoder_srcbase (xd3_stream *stream) { + return stream->src->srcbase; +} +static inline +usize_t xd3_encoder_srclen (xd3_stream *stream) { + return stream->src->srclen; +} + +/* Checks for legal flag changes. */ +static inline +void xd3_set_flags (xd3_stream *stream, int flags) +{ + /* The bitwise difference should contain only XD3_FLUSH or + XD3_SKIP_WINDOW */ + XD3_ASSERT(((flags ^ stream->flags) & ~(XD3_FLUSH | XD3_SKIP_WINDOW)) == 0); + stream->flags = flags; +} + +/* Gives some extra information about the latest library error, if any + * is known. */ +static inline +const char* xd3_errstring (xd3_stream *stream) +{ + return stream->msg ? stream->msg : ""; +} + + +/* 64-bit divisions are expensive, which is why we require a + * power-of-two source->blksize. To relax this restriction is + * relatively easy, see the history for xd3_blksize_div(). */ +static inline +void xd3_blksize_div (const xoff_t offset, + const xd3_source *source, + xoff_t *blkno, + usize_t *blkoff) { + *blkno = (xoff_t) (offset >> source->shiftby); + *blkoff = (usize_t) (offset & source->maskby); + XD3_ASSERT (*blkoff < source->blksize); +} + +static inline +void xd3_blksize_add (xoff_t *blkno, + usize_t *blkoff, + const xd3_source *source, + const usize_t add) +{ + usize_t blkdiff; + + /* Does not check for overflow, checked in xdelta3-decode.h. */ + *blkoff += add; + blkdiff = *blkoff >> source->shiftby; + + if (blkdiff) + { + *blkno += blkdiff; + *blkoff &= source->maskby; + } + + XD3_ASSERT (*blkoff < source->blksize); +} + +#define XD3_NOOP 0U +#define XD3_ADD 1U +#define XD3_RUN 2U +#define XD3_CPY 3U /* XD3_CPY rtypes are represented as (XD3_CPY + + * copy-mode value) */ + +#if XD3_DEBUG +#define IF_DEBUG(x) x +#else +#define IF_DEBUG(x) +#endif +#if XD3_DEBUG > 1 +#define IF_DEBUG1(x) x +#else +#define IF_DEBUG1(x) +#endif +#if XD3_DEBUG > 2 +#define IF_DEBUG2(x) x +#else +#define IF_DEBUG2(x) +#endif + +#define SIZEOF_ARRAY(x) (sizeof(x) / sizeof(x[0])) + +#endif /* _XDELTA3_H_ */ diff --git a/app/src/test/java/com/droiddeck/launcher/core/DeviceReportCpuTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DeviceReportCpuTest.kt new file mode 100644 index 000000000..88520b21c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/DeviceReportCpuTest.kt @@ -0,0 +1,20 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceReportCpuTest { + @Test fun onlyFeatureListsAreReportedAndDifferentCoreCapabilitiesAreKept() { + val cpuInfo = """ + processor : 0 + Features : fp asimd aes + Serial : private-device-id + processor : 1 + Features : fp asimd aes atomics + processor : 2 + Features : fp asimd aes + """.trimIndent() + assertEquals("fp asimd aes | fp asimd aes atomics", DeviceReport.cpuFeaturesSummary(cpuInfo)) + assertEquals("not reported", DeviceReport.cpuFeaturesSummary("processor : 0\n")) + } +} 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 73fd0161c..f0c1002eb 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt @@ -6,6 +6,9 @@ import java.net.Socket import java.nio.file.Files import java.security.MessageDigest import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -29,6 +32,9 @@ class DownloaderResumeTest { @Volatile private var misbehave = false @Volatile private var etag: String? = "\"v1\"" @Volatile private var corrupt = false + @Volatile private var holdAfterFirstChunk = false + private var bodyStarted = CountDownLatch(1) + private var continueBody = CountDownLatch(1) @Before fun setUp() { dir = Files.createTempDirectory("downloader-resume").toFile() @@ -70,8 +76,15 @@ class DownloaderResumeTest { val start = if (from == null) "200 OK" else "206 Partial Content" val extra = if (from == null) "" else "Content-Range: bytes 0-${data.size - 1}/${data.size}\r\n" out.write("HTTP/1.1 $start\r\n${extra}${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray()) - out.write(content) - out.flush() + if (holdAfterFirstChunk) { + out.write(content, 0, 1024) + out.flush() + bodyStarted.countDown() + continueBody.await(5, TimeUnit.SECONDS) + } else { + out.write(content) + out.flush() + } } else if (from == null) { out.write("HTTP/1.1 200 OK\r\n${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray()) out.write(content, 0, cutAt) @@ -168,6 +181,48 @@ class DownloaderResumeTest { } + @Test fun cancelStopsTheTransferWithoutTryingAnotherSource() { + val first = "http://127.0.0.1:${server.localPort}/first" + val second = "http://127.0.0.1:${server.localPort}/second" + val target = File(dir, "runtime.tar.zst") + misbehave = true + val polls = java.util.concurrent.atomic.AtomicInteger() + // candidate selection and attempt each check once before the request; later checks follow chunks + val cancelled = java.util.function.BooleanSupplier { polls.incrementAndGet() > 2 } + assertFalse(Downloader.downloadCandidates(first, listOf(first, second), target, true, null, cancelled, null)) + assertEquals("a cancel must not fall back to the next source", listOf(null), requests.toList()) + assertTrue("the transfer stopped early (kept=${target.length()})", target.length() in 1 until data.size) + } + + @Test fun cancellationDisconnectsAStalledTransfer() { + val url = "http://127.0.0.1:${server.localPort}/first" + val target = File(dir, "runtime.tar.zst") + misbehave = true + holdAfterFirstChunk = true + val cancellation = Downloader.Cancellation() + val executor = Executors.newSingleThreadExecutor() + val cancelExecutor = Executors.newSingleThreadExecutor() + try { + val result = executor.submit { Downloader.downloadFile(url, target, true, null, cancellation, null) } + assertTrue("server sent the first chunk", bodyStarted.await(2, TimeUnit.SECONDS)) + val cancelled = cancelExecutor.submit { cancellation.cancel() } + cancelled.get(10, TimeUnit.SECONDS) + assertFalse("cancelled download", result.get(10, TimeUnit.SECONDS)) + assertEquals(listOf(null), requests.toList()) + } finally { + continueBody.countDown() + executor.shutdownNow() + cancelExecutor.shutdownNow() + } + } + + @Test fun aCancelBeforeTheFirstRequestSendsNothing() { + val url = "http://127.0.0.1:${server.localPort}/runtime.tar.zst" + val target = File(dir, "runtime.tar.zst") + assertFalse(Downloader.downloadFile(url, target, true, null, { true }, null)) + assertEquals(emptyList(), requests.toList()) + } + @Test fun withoutADigestOnlyTheOriginalUrlIsTried() { val url = "https://github.com/owner/repo/releases/download/v1/app.AppImage" assertEquals(listOf(url), Downloader.sources(url, null)) 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 38849c41a..b93c439bf 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -3,9 +3,14 @@ package com.droiddeck.launcher.core 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 java.io.StringWriter class LogRedactorTest { + @get:Rule val tmp = TemporaryFolder() + @Test fun labelledJwtIsRedacted() { val out = LogRedactor.redact("Using JWT 25484942796017334") assertFalse(out.contains("25484942796017334")) @@ -226,6 +231,103 @@ class LogRedactorTest { assertEquals(once, LogRedactor.redactForShare(once)) } + @Test fun anOversizedDirectInputIsWithheldBeforeRegex() { + val secret = "sessionid=abcdef123456" + val line = "x".repeat(LogRedactor.MAX_LINE_LENGTH) + secret + assertEquals(LogRedactor.OVERSIZED_LINE, LogRedactor.redact(line)) + assertEquals(LogRedactor.OVERSIZED_LINE, LogRedactor.redactForShare(line)) + } + + @Test fun streamWithholdsAnOversizedLogicalLineWholeAndContinues() { + val secret = "sessionid=abcdef123456" + val src = tmp.newFile("oversized.log") + src.writeText( + "before\r" + + "x".repeat(LogRedactor.MAX_LINE_LENGTH - 3) + secret + "\r\n" + + "after $secret" + ) + val out = StringWriter() + + LogRedactor.scrubTo(src, out) + + assertEquals( + "before\n${LogRedactor.OVERSIZED_LINE}\nafter sessionid=\n", + out.toString(), + ) + assertFalse(out.toString().contains("abcdef123456")) + } + + @Test fun sharedStreamDistinguishesOversizedAndUnsupportedLines() { + val src = tmp.newFile("shared-lines.log") + src.writeText( + "x".repeat(LogRedactor.MAX_LINE_LENGTH + 1) + "\n" + + "private-prefix\u0000private-suffix\n" + + "after sessionid=abcdef123456\n" + ) + val out = StringWriter() + + assertTrue(LogRedactor.scrubForShare(src, out)) + + assertEquals( + "${LogRedactor.OVERSIZED_LINE}\n" + + "\n" + + "after sessionid=\n", + out.toString(), + ) + } + + @Test fun aMaximumLengthLineBeforeCrLfIsStillScrubbed() { + val src = tmp.newFile("maximum.log") + val line = "x ".repeat(LogRedactor.MAX_LINE_LENGTH / 2) + src.writeText(line + "\r\n") + val out = StringWriter() + + LogRedactor.scrubTo(src, out) + + assertEquals(line + "\n", out.toString()) + assertFalse(out.toString().contains(LogRedactor.OVERSIZED_LINE)) + } + + @Test fun streamingScrubStopsBetweenInputChunks() { + val src = tmp.newFile("cancelled.log") + src.writeText("first safe line\n" + "x".repeat(20_000) + " sessionid=abcdef123456\n") + val out = StringWriter() + var checks = 0 + + val complete = LogRedactor.scrubTo(src, out) { ++checks >= 4 } + + assertFalse(complete) + assertFalse(out.toString().contains("abcdef123456")) + assertTrue(checks >= 4) + } + + @Test fun manyLearnedAccountsKeepBoundariesAndLiteralNames() { + val users = tmp.newFile("loginusers.vdf") + users.writeText((0 until 40).joinToString("\n") { + "\"AccountName\" \"probe_account_$it\"" + } + "\n\"PersonaName\" \"Player.+[40]\"\n") + try { + LogRedactor.learnAccounts(users) + assertEquals("owner= persona=", + LogRedactor.redact("owner=probe_account_39 persona=Player.+[40]")) + assertEquals("owner=probe_account_390", LogRedactor.redact("owner=probe_account_390")) + } finally { + LogRedactor.learnAccounts(tmp.root.resolve("missing-users")) + } + } + + @Test fun manyLearnedAddressesKeepOtherAddresses() { + val link = tmp.newFile("droiddeck-net") + link.writeText((1..40).joinToString("\n") { "addr 203.0.113.$it 24" }) + try { + LogRedactor.learnOwnAddresses(link) + assertEquals("local= server=203.0.113.200", + LogRedactor.redact("local=203.0.113.40 server=203.0.113.200")) + } finally { + LogRedactor.learnOwnAddresses(tmp.root.resolve("missing-network")) + } + } + @Test fun learnedAccountsComeFromBothClients() { val root = java.nio.file.Files.createTempDirectory("redactor").toFile() try { diff --git a/app/src/test/java/com/droiddeck/launcher/core/SteamIntegrationSuggestionTest.kt b/app/src/test/java/com/droiddeck/launcher/core/SteamIntegrationSuggestionTest.kt new file mode 100644 index 000000000..82679a076 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/SteamIntegrationSuggestionTest.kt @@ -0,0 +1,27 @@ +package com.droiddeck.launcher.core + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SettingsReset +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 + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], manifest = Config.NONE, application = Application::class) +class SteamIntegrationSuggestionTest { + private val context: Application = ApplicationProvider.getApplicationContext() + + @Test fun closingTheSuggestionKeepsItHiddenForGood() { + assertTrue(SteamIntegration.read(context).suggested) + SteamIntegration.dismissSuggestion(context) + assertFalse(SteamIntegration.read(context).suggested) + SettingsReset.resetAll(context, gameProfiles = false) + assertFalse(SteamIntegration.read(context).suggested) + assertTrue(SessionPrefs.sessionPrefs(context).getBoolean("steam_integration_seen", false)) + } +} 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 00b9988bd..39591f21a 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -112,6 +112,135 @@ class AddedExesTest { assertEquals(listOf(pick.path), AddedGames.scan(app).map { it.exe.path }) } + private fun result(r: AddedGames.AddResult): AddedGames.Game = when (r) { + is AddedGames.AddResult.Added -> r.game + is AddedGames.AddResult.Existing -> r.game + is AddedGames.AddResult.Switched -> r.game + else -> throw AssertionError("not added: $r") + } + + private fun visualNovels(): File { + val storage = tmp.newFolder("PC Games") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + SessionPrefs.setGameStorage(app, storage.path, "SD card") + exe(File(storage, "VNs/Albatross/Albatross Log.exe")) + exe(File(storage, "VNs/Rance 01/Rance01.exe")) + exe(File(storage, "VNs/Angel Beats/SiglusEngine.exe")) + exe(File(storage, "VNs/Sayonara/SiglusEngine.exe")) + return storage + } + + @Test fun aGroupFolderOnGameStorageListsEachGameInsideIt() { + val storage = visualNovels() + val vns = File(storage, "VNs") + assertEquals(listOf("Albatross", "Angel Beats", "Rance 01", "Sayonara").map { File(vns, it).path }, + AddedGames.scan(app).map { it.folder.path }) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun anExePickedInAGroupFolderOnGameStorageIsItsOwnGame() { + val storage = visualNovels() + val vns = File(storage, "VNs") + SessionPrefs.setAddedGameExe(app, vns.path, File(vns, "Albatross/Albatross Log.exe").path) + for (pick in listOf("Rance 01/Rance01.exe", "Sayonara/SiglusEngine.exe", "Angel Beats/SiglusEngine.exe")) { + val game = result(AddedGames.addExe(app, File(vns, pick))) + assertEquals(File(vns, pick).parentFile!!.path, game.folder.path) + } + assertEquals(4, AddedGames.scan(app).size) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aRemovedGroupFolderOnGameStorageStaysRemoved() { + val storage = visualNovels() + val group = File(storage, "VNs") + val pick = File(group, "Rance 01/Rance01.exe") + AddedGames.remove(app, group) + val game = result(AddedGames.addExe(app, pick)) + assertEquals(pick.parentFile!!.path, game.folder.path) + assertTrue(group.path in SessionPrefs.removedAddedGames(app)) + assertEquals(listOf(pick.parentFile!!.path), AddedGames.scan(app).map { it.folder.path }) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aSiblingExeOfAnAddedGameOnGameStorageSwitchesThatGame() { + val storage = tmp.newFolder("PC Games") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + SessionPrefs.setGameStorage(app, storage.path, "SD card") + val held = exe(File(storage, "Game/bin/Game.exe")) + val pick = exe(File(storage, "Game/launcher/Launcher.exe")) + val game = File(storage, "Game") + AddedExes.put(app, AddedExes.Entry(game.path, held.path)) + val result = AddedGames.addExe(app, pick) + assertTrue(result is AddedGames.AddResult.Switched) + assertEquals(game.path, (result as AddedGames.AddResult.Switched).game.folder.path) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aHiddenFolderInAGroupIsAddedAsItsOwnGame() { + val storage = visualNovels() + val vns = File(storage, "VNs") + val pick = exe(File(vns, ".Gamma/Gamma.exe")) + val game = result(AddedGames.addExe(app, pick)) + assertEquals(pick.parentFile!!.path, game.folder.path) + assertTrue(AddedGames.scan(app).any { it.folder.path == pick.parentFile!!.path }) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aFolderThatDeclaresItsExeInASubfolderStaysOneGame() { + val storage = tmp.newFolder("PC Games") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + SessionPrefs.setGameStorage(app, storage.path, "SD card") + exe(File(storage, "Halo/Content/Halo.exe")) + exe(File(storage, "Halo/Tools/ModKit.exe")) + File(storage, "Halo/MicrosoftGame.config").writeText("") + assertEquals(listOf(File(storage, "Halo").path), AddedGames.scan(app).map { it.folder.path }) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aGroupFolderTheUserAddedAsOneGameStaysOneGame() { + val storage = visualNovels() + val vns = File(storage, "VNs") + AddedExes.put(app, AddedExes.Entry(vns.path, File(vns, "Albatross/Albatross Log.exe").path)) + assertEquals(listOf(vns.path), AddedGames.scan(app).map { it.folder.path }) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aGameWithBuildFoldersOnGameStorageStaysOneGame() { + val storage = tmp.newFolder("PC Games") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + SessionPrefs.setGameStorage(app, storage.path, "SD card") + exe(File(storage, "Oddworld/x64/Oddworld.exe")) + exe(File(storage, "Oddworld/x86/Oddworld.exe")) + exe(File(storage, "Episodes/Episode1/Episode1.exe")) + File(storage, "Episodes/Movies").mkdirs() + assertEquals(listOf("Episodes", "Oddworld").map { File(storage, it).path }, AddedGames.scan(app).map { it.folder.path }) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aSiblingExeOfAGameOnGameStorageSwitchesThatGame() { + val storage = tmp.newFolder("PC Games") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + SessionPrefs.setGameStorage(app, storage.path, "SD card") + exe(File(storage, "Game/bin/Game.exe")) + val pick = exe(File(storage, "Game/launcher/Launcher.exe")) + val result = AddedGames.addExe(app, pick) + assertTrue(result is AddedGames.AddResult.Switched) + assertEquals(File(storage, "Game").path, (result as AddedGames.AddResult.Switched).game.folder.path) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + + @Test fun aDeeperExeOfAGameOnGameStorageStillSwitchesThatGame() { + val storage = tmp.newFolder("PC Games") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + SessionPrefs.setGameStorage(app, storage.path, "SD card") + exe(File(storage, "Example/Example.exe")) + val deep = exe(File(storage, "Example/bin/Example64.exe")) + val result = AddedGames.addExe(app, deep) + assertTrue(result is AddedGames.AddResult.Switched) + assertEquals(File(storage, "Example").path, (result as AddedGames.AddResult.Switched).game.folder.path) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + @Test fun anotherBuildOfTheSameExeInASiblingFolderSwitchesTheGame() { noGamesFolders() val held = onStorage("Games2/Oddworld/x64/Oddworld.exe") diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt index b6c98b00a..4d1d23ee1 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt @@ -446,4 +446,55 @@ class WinCompSourcesTest { AutoComponents.drain(app) assertTrue(AutoComponents.queued().isEmpty()) } + + @Test fun aLostPartOfAnInstalledBundleIsReinstalledAndTheGameWaitsForIt() = withFakeWorker { + val bundle = WinComponents.Component("directmusic", "", "", "ready", listOf("dmband"), emptyList()) + AutoComponents.catalogOf = { _, _ -> catalog3 + ready("dmband") + bundle } + installedNow.addAll(listOf("directmusic", "d3dx9")) + WinComponents.setUser(app, "4242", "directmusic", true) + WinComponents.setUser(app, "4242", "d3dx9", true) + session = true + gameUp = true + assertEquals(listOf("dmband"), AutoComponents.restoreMissing(app)) + assertTrue(downloaded.isEmpty()) + assertEquals("[\"dmband\"]", AutoComponents.Pending.read(app).optJSONArray("4242").toString()) + gameUp = false + AutoComponents.drain(app) + assertEquals(listOf("dmband"), downloaded) + assertEquals(0, AutoComponents.Pending.read(app).length()) + assertTrue(AutoComponents.restoreMissing(app).isEmpty()) + } + + @Test fun aRestoreRunsADownloadQueuedBeforeTheSessionWithoutWaitingForItToEnd() = withFakeWorker { + WinComponents.setUser(app, "4243", "vcredist2019_dll", true) + session = true + AutoComponents.queueGame(app, game(tmp.newFolder("Wait"), Library.ADDED), asked = true) + AutoComponents.restoreMissing(app) + AutoComponents.restoreMissing(app) + assertEquals(listOf("vcredist2019_dll"), downloaded) + session = false + AutoComponents.drain(app) + } + + @Test fun aGameJobThatRunsFirstKeepsARestoredComponentPending() = withFakeWorker { + AutoComponents.catalogOf = { _, _ -> catalog3 + ready("dmband") } + val seen = ArrayList>() + AutoComponents.installer = { _, c, _, _ -> + if (c.name == "dmband") seen.add(AutoComponents.Pending.ids(app, "1262580")) + downloaded.add(c.name); installedNow.add(c.name); null + } + val game = payback() + WinComponents.setUser(app, "1262580", "dmband", true) + session = true + gameUp = true + AutoComponents.queueInSession(app, game) + AutoComponents.restoreMissing(app) + gameUp = false + AutoComponents.drain(app) + assertEquals(1, seen.size) + assertTrue("dmband" in seen.single()) + assertEquals(0, AutoComponents.Pending.read(app).length()) + session = false + AutoComponents.drain(app) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/SpatialControllerModeTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/SpatialControllerModeTest.kt index ec647f69c..12ce869e8 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/SpatialControllerModeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/SpatialControllerModeTest.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.glasses import java.nio.ByteBuffer import java.nio.ByteOrder import org.junit.After +import org.junit.Before import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @@ -15,6 +16,8 @@ class SpatialControllerModeTest { private fun snapshot(): ByteBuffer = (SpatialTracking::class.java.getDeclaredField("buffer") .apply { isAccessible = true }.get(null) as ByteBuffer).duplicate().order(ByteOrder.LITTLE_ENDIAN) + @Before fun outputOnGlasses() = SpatialTracking.setOutputUsesGlasses(true) + @After fun cleanUp() { SpatialTracking.setSteadyHandAim(false) SpatialTracking.setControllers(false, false) 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 bee21343d..9a22eb1fd 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/GpuDetectionTest.kt @@ -64,6 +64,18 @@ class GpuDetectionTest { assertEquals(Support.UNSUPPORTED, info(false, 830).support) } + @Test fun adreno722IsSupportedOnlyOnTheRetroidPocketDuo() { + assertEquals(true, GpuInfo.verifiedOn(722, "Retroid Pocket Duo")) + assertEquals(false, GpuInfo.verifiedOn(722, "Retroid Pocket 5")) + assertEquals(false, GpuInfo.verifiedOn(720, "Retroid Pocket Duo")) + assertEquals(false, GpuInfo.verifiedOn(740, "Retroid Pocket Duo")) + val duo = info(true, 722).copy(verifiedDevice = true) + assertEquals(Support.TESTED, duo.support) + assertEquals("Supported", duo.supportText) + assertEquals(Family.A7XX_LOW, duo.family) + assertEquals(Support.UNTESTED, info(true, 722).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) 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 95713589e..123fcee19 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TurnipDriverAutoTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TurnipDriverAutoTest.kt @@ -1,6 +1,11 @@ package com.droiddeck.launcher.gpu +import android.os.Environment +import android.os.Build import com.droiddeck.launcher.session.QuestExperimental +import com.droiddeck.launcher.session.SessionPrefs +import java.io.File +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -9,6 +14,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBuild +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -16,10 +22,23 @@ class TurnipDriverAutoTest { @Before fun reset() { ShadowBuild.setModel("Phone") ShadowBuild.setDevice("phone") + overrideFile().delete() } + @After fun cleanUp() { + overrideFile().delete() + SessionPrefs.setAndroidDriver(RuntimeEnvironment.getApplication(), TurnipDriver.AUTO) + File(RuntimeEnvironment.getApplication().filesDir, "graphics_driver/test-pinned").deleteRecursively() + } + + private fun overrideFile() = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-driver") + + private fun gpu(model: Int, adreno: Boolean = true) = GpuInfo( + "test GPU", model, GpuInfo.familyOf(adreno, model), "", false, + ) + @Test fun questModelsUseTheirPatchedDriverAsTheFallback() { - val driver = TurnipDriver(RuntimeEnvironment.getApplication()) + val driver = TurnipDriver(RuntimeEnvironment.getApplication()) { error("Quest must not use generic GPU detection") } for (model in listOf("Quest 3", "Quest 3S", "Quest 2")) { ShadowBuild.setModel(model) assertEquals(if (model == "Quest 2") QuestExperimental.DRIVER_A6XX else QuestExperimental.DRIVER, @@ -27,8 +46,54 @@ class TurnipDriverAutoTest { } } - @Test fun otherDevicesRetainTheGenericFallback() { - assertEquals("turnip25.1.0", TurnipDriver(RuntimeEnvironment.getApplication()).autoId()) + @Test fun aNonAdrenoDeviceDoesNotAutomaticallyLoadTurnip() { + assertEquals(GpuInfo.Family.NOT_ADRENO, GpuInfo.detect().family) + assertEquals("system", TurnipDriver(RuntimeEnvironment.getApplication()).autoId()) + } + + @Test fun autoSelectionUsesTheDetectedFamilyAndModel() { + val context = RuntimeEnvironment.getApplication() + for ((detected, expected) in listOf( + gpu(0, adreno = false) to "system", + gpu(0) to "turnip25.1.0", + gpu(650) to "turnip26.3.0-a6xx", + gpu(730) to "turnip25.1.0", + gpu(850) to "turnip-sdk36", + )) { + var calls = 0 + assertEquals(expected, TurnipDriver(context) { calls++; detected }.autoId()) + assertEquals(1, calls) + } + } + + @Test fun nonAdrenoDoesNotUseTheAndroid16TurnipFallback() { + val context = RuntimeEnvironment.getApplication() + val sdk = Build.VERSION.SDK_INT + try { + ReflectionHelpers.setStaticField(Build.VERSION::class.java, "SDK_INT", 36) + assertEquals("system", TurnipDriver(context) { gpu(0, adreno = false) }.autoId()) + assertEquals("turnip-sdk36", TurnipDriver(context) { gpu(0) }.autoId()) + } finally { + ReflectionHelpers.setStaticField(Build.VERSION::class.java, "SDK_INT", sdk) + } + } + + @Test fun externalOverrideStillTakesPriorityOverDetection() { + val driver = TurnipDriver(RuntimeEnvironment.getApplication()) { error("override must precede detection") } + overrideFile().parentFile!!.mkdirs() + overrideFile().writeText("system") + assertEquals("system", driver.autoId()) + overrideFile().writeText("a8") + assertEquals("turnip-sdk36", driver.autoId()) + } + + @Test fun aManuallySelectedImportedDriverStillTakesPriorityOverDetection() { + val context = RuntimeEnvironment.getApplication() + val dir = File(context.filesDir, "graphics_driver/test-pinned").apply { mkdirs() } + File(dir, "meta.json").writeText("""{"libraryName":"test.so"}""") + File(dir, "test.so").writeText("") + SessionPrefs.setAndroidDriver(context, "test-pinned") + assertEquals("test-pinned", TurnipDriver(context) { error("manual choice must precede detection") }.install()) } @Test fun normalizedGpuModelsSelectExistingGenerationDrivers() { diff --git a/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt b/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt new file mode 100644 index 000000000..ac9348f52 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt @@ -0,0 +1,138 @@ +package com.droiddeck.launcher.hud + +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 kotlin.random.Random + +class FrameStatsTest { + /** MangoHud's update_hud_info_with_frametime and fps()/frame_timing(), line by line (overlay.cpp). */ + private class Mango { + val frametimeData = ArrayList(List(200) { 0f }) + var lastPresentTime = 0L + var lastFpsUpdate = 0L + var framesSinceUpdate = 0L + var fps = 0.0 + + fun update(frametimeNs: Long, now: Long) { + val elapsed = now - lastFpsUpdate + if (lastPresentTime != 0L) { + frametimeData.add(frametimeNs / 1000000f) + frametimeData.removeAt(0) + } + if (elapsed >= 500000000L) { + fps = 1000000000.0 * framesSinceUpdate / elapsed + framesSinceUpdate = 0 + lastFpsUpdate = now + } + lastPresentTime = now + framesSinceUpdate++ + } + } + + @Test fun readingsMatchMangoHudFrameByFrame() { + val random = Random(7) + val stats = FrameStats() + val mango = Mango() + var now = 5_000_000_000L + val graph = FloatArray(FrameStats.GRAPH_FRAMES) + repeat(20_000) { i -> + val frametime = when { + i % 977 == 0 -> 100_000_000L + i % 131 == 0 -> 33_000_000L + else -> 2_700_000L + random.nextLong(-400_000L, 400_000L) + } + now += frametime + stats.add(frametime, now) + mango.update(frametime, now) + assertEquals("fps at frame $i", mango.fps.toFloat(), stats.fps, 0.0001f) + assertEquals(1000f / maxOf(mango.fps.toFloat(), 1f), stats.frametimeMs(), 0.0001f) + if (i % 499 == 0 || i < 210) { + stats.graph(graph) + assertArrayEquals("graph at frame $i", mango.frametimeData.toFloatArray(), graph, 0f) + assertEquals(mango.frametimeData.min(), stats.graphMinMs(), 0f) + assertEquals(mango.frametimeData.max(), stats.graphMaxMs(), 0f) + } + } + } + + @Test fun compositorFramesWakeTheHudWhenGamescopeSharesNone() { + val feed = GameFrameFeed(java.io.File("/nonexistent/droiddeck-hud/frames")) + var now = 1_000_000_000L + assertFalse(feed.poll(now)) + feed.onCompositorFrame(now) + now += 16_666_667L + feed.onCompositorFrame(now) + assertTrue(feed.poll(now)) + assertFalse(feed.poll(now + 1)) + } + + @Test fun aCompletedPeriodIsCountedWhenMangoHudWouldRedraw() { + val stats = FrameStats() + var now = 1_000_000_000L + stats.add(10_000_000L, now) + assertEquals(1L, stats.fpsUpdates) + repeat(49) { now += 10_000_000L; stats.add(10_000_000L, now) } + assertEquals(1L, stats.fpsUpdates) + now += 10_000_000L; stats.add(10_000_000L, now) + assertEquals(2L, stats.fpsUpdates) + assertEquals(100f, stats.fps, 0.01f) + } + + @Test fun aSteadyRateReadsExactlyWithoutMillisecondRounding() { + val stats = FrameStats() + var now = 1_000_000_000L + val frametime = 16_666_667L + repeat(600) { now += frametime; stats.add(frametime, now) } + assertEquals(60f, stats.fps, 0.01f) + val lows = stats.lows(now) + assertEquals(60f, lows.low1Fps, 0.01f) + assertEquals(60f, lows.low01Fps, 0.01f) + assertEquals(60f, stats.averageFps(), 0.01f) + } + + @Test fun stuttersShowInTheGraphAndTheLows() { + val stats = FrameStats() + var now = 1_000_000_000L + repeat(1000) { i -> + val frametime = if (i % 100 == 50) 100_000_000L else 2_700_000L + now += frametime + stats.add(frametime, now) + } + assertEquals(100f, stats.graphMaxMs(), 0.001f) + assertEquals(2.7f, stats.graphMinMs(), 0.001f) + assertEquals(10f, stats.lows(now).low01Fps, 0.01f) + assertEquals(370.37f, stats.lows(now).low1Fps, 0.01f) + } + + @Test fun pausesStayOutOfTheAverageAndLowsButNotTheGraph() { + val stats = FrameStats() + var now = 1_000_000_000L + repeat(300) { now += 10_000_000L; stats.add(10_000_000L, now) } + now += 3_000_000_000L + stats.add(3_000_000_000L, now) + assertEquals(100f, stats.lows(now).low01Fps, 0.01f) + assertEquals(3000f, stats.graphMaxMs(), 0.01f) + } + + @Test fun resetStartsOver() { + val stats = FrameStats() + var now = 1_000_000_000L + repeat(300) { now += 10_000_000L; stats.add(10_000_000L, now) } + stats.reset() + assertEquals(0f, stats.fps, 0f) + assertEquals(0f, stats.graphMaxMs(), 0f) + assertEquals(FrameStats.Lows.EMPTY, stats.lows(now)) + } + + @Test fun engineNamesFollowMangoHud() { + assertEquals("DXVK", GameFrameFeed.engineLabel("DXVK")) + assertEquals("VKD3D", GameFrameFeed.engineLabel("vkd3d")) + assertEquals("ZINK", GameFrameFeed.engineLabel("mesa zink")) + assertEquals("GAMESCOPE", GameFrameFeed.engineLabel("gamescope")) + assertEquals("GAMESCOPE", GameFrameFeed.engineLabel("")) + assertEquals("VULKAN", GameFrameFeed.engineLabel("UnityEngine")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/hud/FusionHudPanelTest.kt b/app/src/test/java/com/droiddeck/launcher/hud/FusionHudPanelTest.kt new file mode 100644 index 000000000..47c33a1f6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/hud/FusionHudPanelTest.kt @@ -0,0 +1,148 @@ +package com.droiddeck.launcher.hud + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.FrameLayout +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 +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.ByteArrayOutputStream +import java.time.Duration +import java.util.Base64 +import kotlin.math.roundToInt +import kotlin.math.sqrt + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FusionHudPanelTest { + @Test fun minimalIsTheSizeTheHudStartsWith() { + assertEquals(FusionSize.MINIMAL, FusionSize.from(FusionHudConfig.DEFAULTS["hudSize"])) + } + + @Test fun theHudStartsLocked() { + assertEquals("1", FusionHudConfig.DEFAULTS["hudLocked"]) + } + + @Test fun everySizeDrawsTheDPanelWithEverythingInsideIt() { + val stats = FrameStats() + var now = 1_000_000_000L + repeat(2_000) { i -> + val frametime = if (i == 1_700) 25_600_000L else 2_850_000L + now += frametime + stats.add(frametime, now) + } + val snapshot = SNAPSHOT + for (size in FusionSize.values()) { + val view = FusionHudView(RuntimeEnvironment.getApplication()) + view.applyConfig(FusionHudConfig.encode(FusionHudConfig.DEFAULTS + ("hudSize" to size.token))) + view.setFrameStats(stats) + view.showForTest(snapshot, "DXVK", now) + val unspecified = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + view.measure(unspecified, unspecified) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + + val png = ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("FUSIONHUD-${size.token.uppercase()}-PNG ${Base64.getEncoder().encodeToString(png.toByteArray())}") + + val panel = view.panelForTest()!! + assertTrue("$size panel inside the view", + panel.left > 0f && panel.top > 0f && panel.right < view.width && panel.bottom < view.height) + view.graphForTest()?.let { graph -> + val r = view.panelRadiusForTest() + val d = maxOf(panel.top + r - graph.top, graph.bottom - (panel.bottom - r), 0f) + val edge = panel.right - r + sqrt(r * r - d * d) + assertTrue("$size graph clears the round side", graph.right <= edge) + } + val orb = bitmap.getPixel(panel.left.roundToInt() + 1, panel.centerY().roundToInt()) + assertTrue("$size orb on the left side", Color.blue(orb) > 200 && Color.alpha(orb) > 200) + } + } + + @Test fun pillShowsEachBatteryPartOnItsOwnSwitch() { + fun pill(vararg settings: Pair): List { + val view = FusionHudView(RuntimeEnvironment.getApplication()) + view.applyConfig(FusionHudConfig.encode(FusionHudConfig.DEFAULTS + ("hudSize" to FusionSize.PILL.token) + settings)) + view.showForTest(SNAPSHOT, "DXVK", 0L) + return view.linesForTest() + } + val all = pill("showBattery" to "1") + assertTrue(all.toString(), all.any { "BAT 79% · 34°C · 16.0W" in it }) + assertEquals(all.toString(), 1, all.count { "BAT" in it }) + assertFalse(all.toString(), all.single { "RAM" in it }.contains("BAT")) + + val noMemory = pill("showBattery" to "1", "showRAM" to "0") + assertTrue(noMemory.toString(), noMemory.none { "RAM" in it }) + assertTrue(noMemory.toString(), noMemory.any { "BAT 79% · 34°C · 16.0W" in it }) + + val defaults = pill() + assertTrue(defaults.toString(), defaults.any { "BAT 34°C · 16.0W" in it }) + val wattsOnly = pill("showBatteryTemp" to "0") + assertTrue(wattsOnly.toString(), wattsOnly.any { "BAT 16.0W" in it }) + val nothing = pill("showBatteryTemp" to "0", "showPower" to "0") + assertTrue(nothing.toString(), nothing.none { "BAT" in it }) + } + + @Test fun aLockedHudLetsTouchesThroughOnlyWhereAControlTakesThem() { + val app = RuntimeEnvironment.getApplication() + var reached = 0 + val under = View(app).apply { + setOnTouchListener { _, e -> if (e.actionMasked == MotionEvent.ACTION_DOWN) reached++; true } + } + val hud = FusionHudView(app) + val parent = FrameLayout(app) + parent.addView(under, FrameLayout.LayoutParams(2000, 2000)) + parent.addView(hud, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT)) + var control = false + hud.passesTouchAt = { _, _ -> control } + val locks = ArrayList() + hud.setOnLockChangedListener { locks += it } + hud.applyConfig(FusionHudConfig.encode(FusionHudConfig.DEFAULTS + ("hudLocked" to "0"))) + hud.showForTest(SNAPSHOT, "DXVK", 0L) + val exactly = View.MeasureSpec.makeMeasureSpec(2000, View.MeasureSpec.EXACTLY) + parent.measure(exactly, exactly) + parent.layout(0, 0, 2000, 2000) + assertTrue(hud.width > 0 && hud.height > 0) + fun touch(holdMs: Long = 50L) { + val x = hud.width / 2f; val y = hud.height / 2f + val down = SystemClock.uptimeMillis() + MotionEvent.obtain(down, down, MotionEvent.ACTION_DOWN, x, y, 0).also { parent.dispatchTouchEvent(it); it.recycle() } + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(holdMs)) + MotionEvent.obtain(down, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0).also { parent.dispatchTouchEvent(it); it.recycle() } + shadowOf(Looper.getMainLooper()).idle() + } + control = true + touch() + assertEquals("an unlocked HUD keeps the touch (tap, drag, long press)", 0, reached) + hud.applyConfig(FusionHudConfig.encode(FusionHudConfig.DEFAULTS + ("hudLocked" to "1"))) + touch() + assertEquals("a locked HUD passes it to the control under it", 1, reached) + control = false + touch() + assertEquals("a locked HUD keeps a touch no control takes", 1, reached) + touch(holdMs = ViewConfiguration.getLongPressTimeout() + 300L) + assertEquals("a long press there unlocks it", listOf(false), locks) + } + + private companion object { + val SNAPSHOT = HudMetrics.Snapshot(64, 52, 3302, 1050, 89, 79, 10_800_000_000L, 16_000_000_000L, 67f, + 1_800_000_000L, HudMetrics.Battery(16f, false, 79, 34, null), + intArrayOf(91, 91, 48, 50, 48, 19, 17, 73), intArrayOf(2265, 2265, 3148, 3148, 3148, 2956, 2956, 3302), + 2_700_000_000L, 6_400_000_000L, 31_000L, 9_000L) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/hud/MangoReadingsTest.kt b/app/src/test/java/com/droiddeck/launcher/hud/MangoReadingsTest.kt new file mode 100644 index 000000000..246d97296 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/hud/MangoReadingsTest.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.hud + +import org.junit.Assert.assertArrayEquals +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 java.io.File + +class MangoReadingsTest { + @get:Rule val tmp = TemporaryFolder() + + private fun readings() = MangoReadings(tmp.root, tmp.root) + + @Test fun cpuLoadFollowsMangoHudsFormulaAndTruncates() { + val stat = File(tmp.root, "stat") + val m = readings() + // user nice system idle iowait irq softirq steal guest guest_nice + stat.writeText("cpu 1000 100 500 8000 400 50 50 0 200 0\ncpu0 500 50 250 4000 200 25 25 0 100 0\ncpu1 500 50 250 4000 200 25 25 0 100 0\nintr 0\n") + m.cpu(stat) + stat.writeText("cpu 1500 100 700 8600 500 60 70 10 300 0\ncpu0 900 50 400 4100 250 30 35 10 200 0\ncpu1 600 50 300 4500 250 30 35 0 100 0\nintr 0\n") + val cpu = m.cpu(stat)!! + // busy = user-guest 400 + nice 0 + system+irq+softirq 230 + steal 10 + guest 100 = 740 of 1440 (idle+iowait 700) + assertEquals((740 * 100f / 1440).toInt(), cpu.percent) + assertEquals(51, cpu.percent) + assertArrayEquals(intArrayOf(((300 + 165 + 10 + 100) * 100f / (300 + 165 + 10 + 100 + 150)).toInt(), ((100 + 65) * 100f / (100 + 65 + 550)).toInt()), cpu.perCore) + } + + @Test fun anUnchangedCounterKeepsTheLastLoad() { + val stat = File(tmp.root, "stat") + val m = readings() + stat.writeText("cpu 100 0 0 100 0 0 0 0 0 0\n") + m.cpu(stat) + stat.writeText("cpu 200 0 0 100 0 0 0 0 0 0\n") + assertEquals(100, m.cpu(stat)!!.percent) + assertEquals(100, m.cpu(stat)!!.percent) + } + + private fun battery(status: String, chargeNow: Long, chargeFull: Long, powerUw: Long) { + val dir = File(tmp.root, "sys/power_supply/BAT1").apply { mkdirs() } + File(dir, "status").writeText("$status\n") + File(dir, "charge_now").writeText("$chargeNow\n") + File(dir, "charge_full").writeText("$chargeFull\n") + File(dir, "power_now").writeText("$powerUw\n") + } + + @Test fun batteryHidesWattsWhileChargingAsMangoHudDoes() { + battery("Discharging", 3_622_942, 3_854_193, 8_760_000) + val discharging = readings().battery()!! + assertEquals(94, discharging.percent) + assertEquals(8.76f, discharging.watts, 0.001f) + assertFalse(discharging.charging) + battery("Charging", 3_622_942, 3_854_193, 8_760_000) + val charging = readings().battery()!! + assertEquals(0f, charging.watts, 0f) + assertTrue(charging.charging) + } + + @Test fun noBatteryFolderMeansNoReading() { + assertNull(readings().battery()) + } + + @Test fun gpuLoadIsTheLeadingNumber() { + val f = File(tmp.root, "perf_now").apply { writeText(" 6 %\n") } + assertEquals(6.0, MangoReadings.leadingNumber(f)!!, 0.0) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt index 513ee330f..9ef44f360 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt @@ -1,6 +1,7 @@ package com.droiddeck.launcher.input import android.os.Looper +import android.view.InputDevice import android.view.MotionEvent import java.time.Duration import org.junit.Assert.* @@ -13,68 +14,171 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) class DirectTouchHoldTest { - private val events = mutableListOf() + private val touches = mutableListOf() + private val buttons = 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" + buttons += "$pressed at $x,$y" } override fun onWheel(steps: Int) {} override fun onLongPress() {} - }) { events += "cancel touch" }.also { it.configure(TouchGestureConfig(longPressEnabled = true, longPressDelay = 500)) } + }, object : DirectTouchHold.Touch { + override fun down(pointerId: Int, x: Float, y: Float) { touches += "down $pointerId at $x,$y" } + override fun move(pointerId: Int, x: Float, y: Float) { touches += "move $pointerId at $x,$y" } + override fun up(pointerId: Int, x: Float, y: Float) { touches += "up $pointerId at $x,$y" } + override fun cancel() { touches += "cancel" } + }).also { it.configure(TouchGestureConfig(longPressEnabled = true, longPressDelay = 500)) } - @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)) + @Test fun holdClicksRightWithoutTouchingTheGame() { + touch(MotionEvent.ACTION_DOWN) + advance(400) + assertTrue(buttons.isEmpty()) + advance(100) + assertEquals(listOf("true at 40.0,30.0"), buttons) advance(50) - assertEquals("false at 40.0,30.0", events.last()) - assertFalse(touch(MotionEvent.ACTION_DOWN)) - hold.cancel() + assertEquals(listOf("true at 40.0,30.0", "false at 40.0,30.0"), buttons) + touch(MotionEvent.ACTION_MOVE, 44f) + touch(MotionEvent.ACTION_UP) + assertTrue(touches.isEmpty()) } - @Test fun stillHoldKeepsTheTouchWhenLongPressIsOff() { + @Test fun tapForwardsTheTouchWhenItLifts() { + touch(MotionEvent.ACTION_DOWN) + advance(100) + touch(MotionEvent.ACTION_UP, 50f) + assertEquals(listOf("down 0 at 40.0,30.0"), touches) + advance(50) + assertEquals(listOf("down 0 at 40.0,30.0", "up 0 at 50.0,30.0"), touches) + assertTrue(buttons.isEmpty()) + } + + @Test fun noneBehaviorForwardsTheTouchAtOnce() { + hold.configure(TouchGestureConfig(directHoldBehavior = TouchGestureConfig.MOUSE_BEHAVIOR_NONE)) + touch(MotionEvent.ACTION_DOWN) + assertEquals(listOf("down 0 at 40.0,30.0"), touches) + advance(600) + assertTrue(buttons.isEmpty()) + touch(MotionEvent.ACTION_MOVE, 60f) + assertEquals("move 0 at 60.0,30.0", touches.last()) + touch(MotionEvent.ACTION_UP, 60f) + advance(50) + assertEquals("up 0 at 60.0,30.0", touches.last()) + } + + @Test fun dragForwardsTheTouchOnceItMoves() { + touch(MotionEvent.ACTION_DOWN) + touch(MotionEvent.ACTION_MOVE, 60f) + assertEquals(listOf("down 0 at 40.0,30.0", "move 0 at 60.0,30.0"), touches) + advance(600) + assertTrue(buttons.isEmpty()) + touch(MotionEvent.ACTION_UP, 60f) + advance(50) + assertEquals("up 0 at 60.0,30.0", touches.last()) + } + + @Test fun dragKeepsForwardingWithinTheSlop() { + touch(MotionEvent.ACTION_DOWN) + touch(MotionEvent.ACTION_MOVE, 44f) + assertTrue(touches.isEmpty()) + touch(MotionEvent.ACTION_UP, 44f) + advance(50) + assertEquals(listOf("down 0 at 40.0,30.0", "up 0 at 44.0,30.0"), touches) + } + + @Test fun stillHoldClicksRightWithTheDefaultConfig() { hold.configure(TouchGestureConfig()) - assertFalse(touch(MotionEvent.ACTION_DOWN)) + touch(MotionEvent.ACTION_DOWN) advance(2000) - assertFalse(touch(MotionEvent.ACTION_MOVE)) - assertFalse(touch(MotionEvent.ACTION_UP)) - assertTrue(events.isEmpty()) + touch(MotionEvent.ACTION_MOVE) + assertEquals(listOf("true at 40.0,30.0", "false at 40.0,30.0"), buttons) + touch(MotionEvent.ACTION_UP) + assertTrue(touches.isEmpty()) } @Test fun longPressFollowsTheConfiguredDelay() { hold.configure(TouchGestureConfig(longPressEnabled = true, longPressDelay = 800)) touch(MotionEvent.ACTION_DOWN) advance(700) - assertTrue(events.isEmpty()) + assertTrue(buttons.isEmpty()) advance(100) - assertEquals(listOf("cancel touch", "true at 40.0,30.0"), events) + assertEquals(listOf("true at 40.0,30.0"), buttons) 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 holdBehaviorKeepsTheButtonUntilTheLift() { + hold.configure(TouchGestureConfig(longPressDelay = 500, + directHoldBehavior = TouchGestureConfig.MOUSE_BEHAVIOR_HOLD)) + touch(MotionEvent.ACTION_DOWN) + advance(500) + assertEquals(listOf("true at 40.0,30.0"), buttons) + advance(1000) + assertEquals(listOf("true at 40.0,30.0"), buttons) + touch(MotionEvent.ACTION_UP) + assertEquals(listOf("true at 40.0,30.0", "false at 40.0,30.0"), buttons) + assertTrue(touches.isEmpty()) + } + + @Test fun secondFingerForwardsTheTouchAndDropsTheHold() { + touch(MotionEvent.ACTION_DOWN) + touch(MotionEvent.ACTION_POINTER_DOWN, 60f) + assertEquals(listOf("down 0 at 40.0,30.0", "down 0 at 60.0,30.0"), touches) + advance(600) + assertTrue(buttons.isEmpty()) + touch(MotionEvent.ACTION_UP, 60f) } @Test fun modeChangeCancelsPendingHoldAndReleasesAnActiveClick() { touch(MotionEvent.ACTION_DOWN) hold.cancel() advance(600) - assertTrue(events.isEmpty()) + assertTrue(buttons.isEmpty()) + assertTrue(touches.isEmpty()) touch(MotionEvent.ACTION_DOWN) advance(500) hold.cancel() + assertEquals(listOf("true at 40.0,30.0", "false at 40.0,30.0"), buttons) + } + + @Test fun cancelForwardsACancelWhenTheTouchIsLive() { + touch(MotionEvent.ACTION_DOWN) + touch(MotionEvent.ACTION_MOVE, 60f) + hold.cancel() + assertEquals(listOf("down 0 at 40.0,30.0", "move 0 at 60.0,30.0", "cancel"), touches) + } + + @Test fun cancelDropsAPendingLift() { + touch(MotionEvent.ACTION_DOWN) + touch(MotionEvent.ACTION_UP, 60f) + hold.cancel() advance(100) - assertEquals(listOf("cancel touch", "true at 40.0,30.0", "false at 40.0,30.0"), events) + assertEquals(listOf("down 0 at 40.0,30.0", "cancel"), touches) + } + + @Test fun aQuickSecondTapLiftsTheFirstInsteadOfCancellingIt() { + touch(MotionEvent.ACTION_DOWN) + touch(MotionEvent.ACTION_UP, 50f) + advance(20) + touch(MotionEvent.ACTION_DOWN, 60f) + touch(MotionEvent.ACTION_UP, 60f) + advance(50) + assertEquals( + listOf("down 0 at 40.0,30.0", "up 0 at 50.0,30.0", "down 0 at 60.0,30.0", "up 0 at 60.0,30.0"), + touches, + ) + } + + @Test fun theSecondFingerLiftsWithItsOwnId() { + multi(MotionEvent.ACTION_DOWN, 0, 0 to 40f) + multi(MotionEvent.ACTION_POINTER_DOWN, 1, 0 to 40f, 1 to 60f) + multi(MotionEvent.ACTION_POINTER_UP, 0, 0 to 40f, 1 to 60f) + multi(MotionEvent.ACTION_UP, 0, 1 to 60f) + advance(100) + assertEquals( + listOf("down 0 at 40.0,30.0", "down 1 at 60.0,30.0", "up 0 at 40.0,30.0", "up 1 at 60.0,30.0"), + touches, + ) } private fun advance(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)) @@ -82,4 +186,23 @@ class DirectTouchHoldTest { val event = MotionEvent.obtain(0, 0, action, x, 30f, 0) return hold.onTouch(event).also { event.recycle() } } + private fun multi(action: Int, index: Int, vararg points: Pair): Boolean { + val properties = Array(points.size) { + MotionEvent.PointerProperties().apply { + id = points[it].first + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = Array(points.size) { + MotionEvent.PointerCoords().apply { + x = points[it].second + y = 30f + pressure = 1f + size = 1f + } + } + val event = MotionEvent.obtain(0, 0, action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), + points.size, properties, coords, 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, 0) + return hold.onTouch(event).also { event.recycle() } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/DualScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/input/DualScreenTest.kt new file mode 100644 index 000000000..c5fd50d60 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/DualScreenTest.kt @@ -0,0 +1,68 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display +import androidx.test.core.app.ApplicationProvider +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class DualScreenTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val displays = context.getSystemService(DisplayManager::class.java) + + @Test fun internalPanelsCountAsBuiltIn() { + assertTrue(DualScreen.isBuiltIn(1, "Anything")) + assertFalse(DualScreen.isBuiltIn(2, "Retroid Pocket Duo")) + assertFalse(DualScreen.isBuiltIn(5, "AYN Thor")) + } + + @Test fun knownModelsCountWhenTheTypeIsHidden() { + assertTrue(DualScreen.isBuiltIn(null, "Retroid Pocket Duo")) + assertTrue(DualScreen.isBuiltIn(null, " AYN Thor ")) + assertFalse(DualScreen.isBuiltIn(null, "Pixel 8")) + } + + @Test fun aSecondPanelNeedsATouchscreenOfItsOwnSize() { + val top = 1920 to 1080 + val bottom = 1280 to 960 + assertTrue(DualScreen.hasOwnTouchPanel(bottom, top, listOf(1920 to 1080, 1280 to 960))) + assertTrue(DualScreen.hasOwnTouchPanel(bottom, top, listOf(1080 to 1920, 960 to 1280))) + assertTrue(DualScreen.hasOwnTouchPanel(bottom, top, listOf(1920 to 1080, 1279 to 959))) + assertFalse(DualScreen.hasOwnTouchPanel(bottom, top, listOf(1920 to 1080))) + assertFalse(DualScreen.hasOwnTouchPanel(bottom, top, emptyList())) + } + + @Test fun aPhantomPanelTheSizeOfTheMainScreenIsNotASecondScreen() { + val main = 1920 to 1080 + assertFalse(DualScreen.hasOwnTouchPanel(main, main, listOf(1920 to 1080))) + assertFalse(DualScreen.hasOwnTouchPanel(main, main, listOf(1080 to 1920))) + assertTrue(DualScreen.hasOwnTouchPanel(main, main, listOf(1920 to 1080, 1920 to 1080))) + assertTrue(DualScreen.hasOwnTouchPanel(main, null, listOf(1920 to 1080))) + } + + @Test fun oneScreenDevicesGetNoSecondScreenInAnyMode() { + for (mode in DualScreen.modes) { + SessionPrefs.setDualScreenMode(context, mode) + assertNull(DualScreen.display(context, displays, Display.DEFAULT_DISPLAY)) + assertNull(DualScreen.anyDisplay(displays, Display.DEFAULT_DISPLAY)) + } + } + + @Test fun modeDefaultsToAutomaticAndIgnoresUnknownValues() { + assertEquals(DualScreen.MODE_AUTO, SessionPrefs.dualScreenMode(context)) + SessionPrefs.setDualScreenMode(context, "sideways") + assertEquals(DualScreen.MODE_AUTO, SessionPrefs.dualScreenMode(context)) + SessionPrefs.setDualScreenMode(context, DualScreen.MODE_OFF) + assertEquals(DualScreen.MODE_OFF, SessionPrefs.dualScreenMode(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/HomeGuideInputTest.kt b/app/src/test/java/com/droiddeck/launcher/input/HomeGuideInputTest.kt index 3d2a2863e..19ab2fd08 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/HomeGuideInputTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/HomeGuideInputTest.kt @@ -7,6 +7,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBuild @RunWith(RobolectricTestRunner::class) @Config(sdk = [28, 33], manifest = Config.NONE, application = Application::class) @@ -67,4 +68,24 @@ class HomeGuideInputTest { assertEquals(listOf(true, false), states) assertEquals(1, cancellations) } + + private fun quickMenu(action: Int) = KeyEvent(10, 110, action, KeyEvent.KEYCODE_HOME, 0, 0, 10, 194) + + @Test fun duoQuickMenuHomePassesThroughToTheSystemAndCancelsAHeldGuide() { + ShadowBuild.setModel("Retroid Pocket Duo") + assertTrue(input.onKeyEvent(home(KeyEvent.ACTION_DOWN), true)) + assertFalse(input.onKeyEvent(quickMenu(KeyEvent.ACTION_DOWN), true)) + assertEquals(listOf(true, false), states) + assertEquals(1, cancellations) + assertFalse(input.onKeyEvent(quickMenu(KeyEvent.ACTION_UP), true)) + assertTrue(input.onKeyEvent(home(KeyEvent.ACTION_UP), true)) + assertEquals(listOf(true, false), states) + } + + @Test fun theSameScanCodeStillHoldsGuideOnOtherDevices() { + ShadowBuild.setModel("Retroid Pocket 5") + assertTrue(input.onKeyEvent(quickMenu(KeyEvent.ACTION_DOWN), true)) + assertTrue(input.onKeyEvent(quickMenu(KeyEvent.ACTION_UP), true)) + assertEquals(listOf(true, false), states) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt index a857316e5..b1096f3f4 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt @@ -173,6 +173,17 @@ class OnScreenControlsTest { assertEquals(-1, field(control("rs"), "pressedBy")) } + @Test fun takesTouchAtMatchesWhatATouchDoes() { + var checked = 0 + for (x in 0 until 1200 step 40) for (y in 0 until 800 step 40) { + val expected = view.takesTouchAt(x.toFloat(), y.toFloat()) + assertEquals("($x, $y)", expected, touch(MotionEvent.ACTION_DOWN, 0 to (x.toFloat() to y.toFloat()))) + touch(MotionEvent.ACTION_UP, 0 to (x.toFloat() to y.toFloat())) + if (expected) checked++ + } + assertTrue("some points land on controls", checked > 0) + } + @Test fun fixedSticksAndEditorRemainVisibleAtSavedPositions() { ControllerPrefs.setAdaptiveSticks(context, false) view.reload() diff --git a/app/src/test/java/com/droiddeck/launcher/input/PhysicalShortcutsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PhysicalShortcutsTest.kt new file mode 100644 index 000000000..d41aabbdd --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/PhysicalShortcutsTest.kt @@ -0,0 +1,254 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import com.droiddeck.launcher.input.PhysicalShortcuts.Action +import org.json.JSONArray +import org.json.JSONObject +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, shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) +class PhysicalShortcutsTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + private val router = PhysicalShortcutRouter() + private val actions = mutableListOf() + private lateinit var pad: InputDevice + + @Before fun setUp() { + ControllerPrefs.prefs(context).edit().clear().commit() + pad = device(10, "retroid") + } + + @Test fun defaultsDoNotInterceptAnyButton() { + assertTrue(PhysicalShortcuts.read(context).isEmpty()) + for (code in listOf(KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_BUTTON_MODE, + KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_1)) { + assertFalse(route(key(code))) + assertFalse(route(key(code, KeyEvent.ACTION_UP))) + } + assertTrue(actions.isEmpty()) + } + + @Test fun assignmentPersistsAndConsumesTheWholePressWithoutRepeating() { + assign(key(), Action.STEAM) + assertEquals(listOf(PhysicalShortcuts.Binding(button(key()), Action.STEAM)), PhysicalShortcuts.read(context)) + assertTrue(route(key())) + assertTrue(route(key(repeat = 1))) + assertTrue(route(key(repeat = 2))) + assertTrue(route(key(action = KeyEvent.ACTION_UP))) + assertEquals(listOf(Action.STEAM), actions) + assertFalse(route(key(action = KeyEvent.ACTION_UP))) + } + + @Test fun releaseIsConsumedEvenIfAnActionChangedTheRoutingContextOrSettings() { + assign(key(), Action.DROIDDECK) + assertTrue(route(key())) + router.bindings = emptyList() + assertTrue(route(key(action = KeyEvent.ACTION_UP), enabled = false, steam = false)) + assertEquals(listOf(Action.DROIDDECK), actions) + } + + @Test fun aRepeatedDownWithoutAnInitialPressDoesNotTriggerAnAction() { + assign(key(), Action.QAM) + assertTrue(route(key(repeat = 1))) + assertTrue(route(key(action = KeyEvent.ACTION_UP))) + assertTrue(actions.isEmpty()) + } + + @Test fun disabledMappedConfirmLeavesTheWholePressForDrawerNavigation() { + assign(key(KeyEvent.KEYCODE_BUTTON_A), Action.DROIDDECK) + assertFalse(route(key(KeyEvent.KEYCODE_BUTTON_A), enabled = false)) + assertFalse(route(key(KeyEvent.KEYCODE_BUTTON_A, repeat = 1), enabled = false)) + assertFalse(route(key(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.ACTION_UP), enabled = false)) + assertTrue(actions.isEmpty()) + } + + @Test fun mappedConfirmOpeningTheDrawerConsumesItsReleaseWithoutStayingHeld() { + assign(key(KeyEvent.KEYCODE_BUTTON_A), Action.DROIDDECK) + assertTrue(route(key(KeyEvent.KEYCODE_BUTTON_A))) + assertTrue(route(key(KeyEvent.KEYCODE_BUTTON_A, repeat = 1), enabled = false)) + assertTrue(route(key(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.ACTION_UP), enabled = false)) + assertFalse(route(key(KeyEvent.KEYCODE_BUTTON_A), enabled = false)) + assertFalse(route(key(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.ACTION_UP), enabled = false)) + assertTrue(route(key(KeyEvent.KEYCODE_BUTTON_A))) + assertTrue(route(key(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.ACTION_UP))) + assertEquals(listOf(Action.DROIDDECK, Action.DROIDDECK), actions) + } + + @Test fun aResumePressKeepsItsRepeatsAndReleaseInTheResumePath() { + assign(key(), Action.STEAM) + assertFalse(route(key(), enabled = false)) + assertFalse(route(key(repeat = 1), enabled = false)) + assertFalse(route(key(action = KeyEvent.ACTION_UP), enabled = false)) + assertTrue(actions.isEmpty()) + assertTrue(route(key())) + assertEquals(listOf(Action.STEAM), actions) + } + + @Test fun steamActionsAreInactiveOutsideSteamAndAllActionsRespectDisabledContexts() { + for (action in Action.entries) { + assign(key(), action) + assertFalse(route(key(), enabled = false)) + assertFalse(route(key(action = KeyEvent.ACTION_UP), enabled = false)) + assertEquals(action == Action.DROIDDECK, route(key(), steam = false)) + route(key(action = KeyEvent.ACTION_UP), steam = false) + } + assertEquals(listOf(Action.DROIDDECK), actions) + } + + @Test fun reassigningAButtonReplacesItsPreviousAction() { + assign(key(), Action.STEAM) + assign(key(), Action.QAM) + assertEquals(listOf(Action.QAM), PhysicalShortcuts.read(context).map { it.action }) + assertTrue(route(key())) + assertEquals(listOf(Action.QAM), actions) + } + + @Test fun reassigningAnActionOnlyReplacesThatControllersBinding() { + val second = device(11, "bluetooth") + assign(key(), Action.STEAM) + assign(key(device = second), Action.STEAM) + assign(key(KeyEvent.KEYCODE_BUTTON_2), Action.STEAM) + val saved = PhysicalShortcuts.read(context) + assertEquals(2, saved.size) + assertTrue(saved.any { it.button.sameInput(button(key(device = second))) }) + assertTrue(saved.any { it.button.sameInput(button(key(KeyEvent.KEYCODE_BUTTON_2))) }) + assertFalse(route(key())) + } + + @Test fun deviceIdentitySurvivesReconnectionButDoesNotMatchAnUnrelatedController() { + assign(key(), Action.QAM) + assertFalse(route(key(device = device(11, "other")))) + val reconnected = device(12, "retroid") + assertTrue(route(key(device = reconnected))) + assertEquals(listOf(Action.QAM), actions) + } + + @Test fun scanCodesDistinguishButtonsSharingAnAndroidKeyCode() { + assign(key(scan = 700), Action.STEAM) + assign(key(scan = 701), Action.QAM) + assertEquals(2, PhysicalShortcuts.read(context).size) + assertTrue(route(key(scan = 700))) + assertTrue(route(key(scan = 701))) + assertFalse(route(key(scan = 702))) + assertEquals(listOf(Action.STEAM, Action.QAM), actions) + } + + @Test fun keyboardLikeExtraButtonsCanBeExplicitlyAssignedWithoutChangingKeyboardDefaults() { + val keyboard = device(11, "m1-keyboard", InputDevice.SOURCE_KEYBOARD, InputDevice.KEYBOARD_TYPE_ALPHABETIC) + val m1 = key(KeyEvent.KEYCODE_F1, device = keyboard) + assertFalse(PadBridge.isControllerKeyEvent(m1)) + assign(m1, Action.STEAM) + assertTrue(route(m1)) + assertFalse(route(key(KeyEvent.KEYCODE_F2, device = keyboard))) + assertEquals(listOf(Action.STEAM), actions) + } + + @Test fun virtualAndSystemKeysCannotBeAssigned() { + val virtual = device(-1, "virtual") + assertNull(PhysicalShortcuts.button(key(device = virtual))) + for (code in listOf(KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_APP_SWITCH, + KeyEvent.KEYCODE_SLEEP, KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_VOLUME_UP, + KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE)) { + assertNull(PhysicalShortcuts.button(key(code))) + } + assertNull(PhysicalShortcuts.button(key(KeyEvent.KEYCODE_UNKNOWN, scan = 0))) + assertNotNull(PhysicalShortcuts.button(key(KeyEvent.KEYCODE_UNKNOWN, scan = 700))) + } + + @Test fun drawerDirectionalKeysRemainAvailableForNavigation() { + for (code in listOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT)) { + assertTrue(PhysicalShortcuts.isDrawerNavigationKey(key(code))) + } + assertFalse(PhysicalShortcuts.isDrawerNavigationKey(key(KeyEvent.KEYCODE_BUTTON_A))) + } + + @Test fun resetAndRemovalPreserveOtherControllerSettings() { + ControllerPrefs.setSelectSteam(context, true) + ControllerPrefs.setTarget(context, "a", "b") + assign(key(), Action.STEAM) + assign(key(KeyEvent.KEYCODE_BUTTON_2), Action.QAM) + PhysicalShortcuts.remove(context, PhysicalShortcuts.read(context).first()) + assertEquals(listOf(Action.QAM), PhysicalShortcuts.read(context).map { it.action }) + PhysicalShortcuts.remove(context, PhysicalShortcuts.read(context).single()) + assertTrue(PhysicalShortcuts.read(context).isEmpty()) + assertTrue(ControllerPrefs.read(context).selectSteam) + assertEquals("b", ControllerPrefs.target(context, "a")) + assign(key(), Action.STEAM) + ControllerPrefs.resetAll(context) + assertTrue(PhysicalShortcuts.read(context).isEmpty()) + } + + @Test fun disconnectAndPauseClearHeldButtons() { + assign(key(), Action.QAM) + assertTrue(route(key())) + router.onDeviceRemoved(pad.id) + assertFalse(route(key(action = KeyEvent.ACTION_UP))) + assertTrue(route(key())) + router.clear() + assertFalse(route(key(action = KeyEvent.ACTION_UP))) + assertTrue(route(key())) + assertEquals(listOf(Action.QAM, Action.QAM, Action.QAM), actions) + } + + @Test fun malformedPreferencesDoNotCrashInputDispatch() { + ControllerPrefs.prefs(context).edit().putString("physicalShortcuts", "invalid").commit() + assertTrue(PhysicalShortcuts.read(context).isEmpty()) + } + + @Test fun malformedEntriesDoNotDiscardValidAssignmentsWhenSaving() { + assign(key(), Action.STEAM) + assign(key(KeyEvent.KEYCODE_BUTTON_2), Action.QAM) + val entries = JSONArray(ControllerPrefs.prefs(context).getString("physicalShortcuts", null)) + val damaged = JSONArray().put(entries.getJSONObject(0)).put(JSONObject().put("action", "STEAM")) + .put("not an object").put(entries.getJSONObject(1)) + ControllerPrefs.prefs(context).edit().putString("physicalShortcuts", damaged.toString()).commit() + assertEquals(listOf(Action.STEAM, Action.QAM), PhysicalShortcuts.read(context).map { it.action }) + assign(key(KeyEvent.KEYCODE_BUTTON_3), Action.DROIDDECK) + assertEquals(listOf(Action.STEAM, Action.QAM, Action.DROIDDECK), + PhysicalShortcuts.read(context).map { it.action }) + } + + @Test fun buttonSummaryKeepsTechnicalDetailsSeparate() { + val select = button(key(KeyEvent.KEYCODE_BUTTON_SELECT, scan = 314)) + val androidName = KeyEvent.keyCodeToString(KeyEvent.KEYCODE_BUTTON_SELECT) + assertEquals(androidName.removePrefix("KEYCODE_").removePrefix("BUTTON_"), select.label) + assertFalse(select.label.contains("(314)")) + } + + private fun assign(event: KeyEvent, action: Action) { + PhysicalShortcuts.assign(context, button(event), action) + router.bindings = PhysicalShortcuts.read(context) + } + + private fun button(event: KeyEvent) = requireNotNull(PhysicalShortcuts.button(event)) + + private fun route(event: KeyEvent, enabled: Boolean = true, steam: Boolean = true) = + router.onKey(event, enabled, steam) { actions.add(it) } + + private fun key(code: Int = KeyEvent.KEYCODE_BUTTON_1, action: Int = KeyEvent.ACTION_DOWN, + repeat: Int = 0, scan: Int = 700, device: InputDevice = pad): KeyEvent = + KeyEvent(100L, 100L, action, code, repeat, 0, device.id, scan, 0, device.sources) + + private fun device(id: Int, descriptor: String, sources: Int = InputDevice.SOURCE_GAMEPAD, + type: Int = InputDevice.KEYBOARD_TYPE_NONE): InputDevice { + val device = InputDeviceBuilder.newBuilder().setId(id).setName("Test $descriptor") + .setDescriptor(descriptor).setSources(sources).setKeyboardType(type) + .setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)).build() + shadowOf(context.getSystemService(InputManager::class.java)).addInputDevice(device) + return device + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/ScreenKeysTest.kt b/app/src/test/java/com/droiddeck/launcher/input/ScreenKeysTest.kt index 8a95dd512..14905a596 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/ScreenKeysTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/ScreenKeysTest.kt @@ -68,6 +68,22 @@ class ScreenKeysTest { assertTrue(accepts(GestureTouchStreamGate.Source.SURFACE, MotionEvent.ACTION_DOWN)) } + @Test fun takesTouchAtMatchesWhatATouchDoes() { + val keys = ScreenKeys(context) { _, _ -> } + keys.layout(0, 0, 1000, 600) + keys.add(listOf(KeyEvent.KEYCODE_W)) + var checked = 0 + for (x in 0 until 1000 step 25) for (y in 0 until 600 step 25) { + val expected = keys.takesTouchAt(x.toFloat(), y.toFloat()) + val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, x.toFloat(), y.toFloat(), 0) + assertEquals("($x, $y)", expected, keys.onTouchEvent(down)) + down.recycle() + MotionEvent.obtain(0L, 1L, MotionEvent.ACTION_UP, x.toFloat(), y.toFloat(), 0).also { keys.onTouchEvent(it); it.recycle() } + if (expected) checked++ + } + assertTrue("some points land on the key", checked > 0) + } + @Test fun loadingLayoutReleasesLatchedKeysBeforeReplacingEntries() { val sent = mutableListOf>() val keys = ScreenKeys(context) { code, down -> sent += code to down } diff --git a/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt new file mode 100644 index 000000000..da6b3bec2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt @@ -0,0 +1,26 @@ +package com.droiddeck.launcher.input + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SecondScreenModeTest { + @Test fun withNothingRememberedTheControllerPicksThePanel() { + assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening(null, deckPad = true)) + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening(null, deckPad = false)) + } + + @Test fun thePanelTheUserPickedLastOpensAgain() { + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("keyboard-trackpad", deckPad = true)) + assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("deck-controls", deckPad = true)) + assertEquals(SecondScreenMode.TERMINAL, SecondScreenMode.opening("terminal", deckPad = true)) + } + + @Test fun deckControlsNeedTheDeckPad() { + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("deck-controls", deckPad = false)) + } + + @Test fun anUnknownOrClosedChoiceFallsBackToTheDefault() { + assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("none", deckPad = true)) + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("gone", deckPad = false)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt index 7b33c7170..0cad8acba 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt @@ -220,7 +220,12 @@ class SessionGestureRoutingTest { override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { directButtons += button to pressed } override fun onWheel(steps: Int) {} override fun onLongPress() {} - }) {}.also { it.configure(config) } + }, object : DirectTouchHold.Touch { + override fun down(pointerId: Int, x: Float, y: Float) {} + override fun move(pointerId: Int, x: Float, y: Float) {} + override fun up(pointerId: Int, x: Float, y: Float) {} + override fun cancel() {} + }).also { it.configure(config) } setField("directTouchHold", directHold) launcherScroll = ScrollTouchGestures(8f, 50f, object : ScrollTouchGestures.Listener { override fun onMove(x: Float, y: Float) { scrollPositions += x to y } 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 a036849b1..79c139858 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt @@ -25,11 +25,14 @@ class TouchpadGesturesTest { private val listener = object : PointerGestures.Listener { override fun onMove(x: Float, y: Float) { events += "move" } override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { - val name = if (button == PointerGestures.BTN_RIGHT) "right" else "left" + val name = when (button) { PointerGestures.BTN_RIGHT -> "right"; PointerGestures.BTN_MIDDLE -> "middle"; else -> "left" } events += "$name ${if (pressed) "down" else "up"}" } override fun onWheel(steps: Int) { events += "wheel $steps" } override fun onLongPress() { events += "long press" } + override fun onScroll(dx: Float, dy: Float) { events += "scroll" } + override fun onScrollEnd() { events += "scroll end" } + override fun onZoom(steps: Int) { events += "zoom $steps" } } @Before fun setUp() { @@ -41,12 +44,58 @@ class TouchpadGesturesTest { @Test fun tapHoldsTheLeftButtonBeforeReleasingIt() { tap() assertEquals(listOf("left down"), events) - advance(40) + advance(TouchTiming.CLICK_MS - 10) assertEquals(listOf("left down"), events) advance(100) assertEquals(listOf("left down", "left up"), events) } + @Test fun threeFingerTapClicksTheMiddleButton() { + 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_POINTER_DOWN, 2, 1 to (400f to 300f), 2 to (600f to 310f), 3 to (800f to 320f)) + advance(60) + touch(MotionEvent.ACTION_POINTER_UP, 2, 1 to (400f to 300f), 2 to (600f to 310f), 3 to (800f to 320f)) + touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 300f), 2 to (600f to 310f)) + touch(MotionEvent.ACTION_UP, 0, 1 to (400f to 300f)) + advance(100) + assertEquals(listOf("middle down", "middle up"), events) + } + + @Test fun desktopTwoFingerSlideScrollsSmoothlyAndStops() { + touchpad.desktop = true + 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)) + for (step in 1..5) { + advance(16) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 300f + step * 20f), 2 to (600f to 300f + step * 20f)) + } + touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 400f), 2 to (600f to 400f)) + touch(MotionEvent.ACTION_UP, 0, 1 to (400f to 400f)) + advance(100) + assertTrue(events.contains("scroll")) + assertEquals("scroll end", events.last()) + assertFalse(events.any { it.startsWith("right") || it.startsWith("wheel") }) + } + + @Test fun desktopPinchZoomsWithoutScrolling() { + touchpad.desktop = true + touch(MotionEvent.ACTION_DOWN, 0, 1 to (500f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (500f to 300f), 2 to (600f to 300f)) + for (step in 1..6) { + advance(16) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (500f - step * 30f to 300f), 2 to (600f + step * 30f to 300f)) + } + touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (320f to 300f), 2 to (780f to 300f)) + touch(MotionEvent.ACTION_UP, 0, 1 to (320f to 300f)) + advance(100) + assertTrue(events.isNotEmpty() && events.all { it.startsWith("zoom -") }) + } + @Test fun twoFingerTapHoldsTheRightButtonBeforeReleasingIt() { touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) advance(10) @@ -56,7 +105,7 @@ class TouchpadGesturesTest { advance(10) touch(MotionEvent.ACTION_UP, 0, 1 to (400f to 300f)) assertEquals(listOf("right down"), events) - advance(30) + advance(TouchTiming.CLICK_MS - 20) assertEquals(listOf("right down"), events) advance(100) assertEquals(listOf("right down", "right up"), events) @@ -71,7 +120,7 @@ class TouchpadGesturesTest { advance(10) touch(MotionEvent.ACTION_UP, 0, 1 to (400f to 300f)) assertEquals(listOf("right down"), events) - advance(40) + advance(TouchTiming.CLICK_MS - 10) assertEquals(listOf("right down"), events) advance(100) assertEquals(listOf("right down", "right up"), events) diff --git a/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt index ad85d6245..5c49c5265 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt @@ -82,9 +82,9 @@ class TouchscreenGesturesTest { @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) + assertTrue(leftClicks[1].time - leftClicks[0].time >= TouchTiming.CLICK_MS) + assertTrue(leftClicks[2].time - leftClicks[1].time >= TouchTiming.CLICK_MS) + assertTrue(leftClicks[3].time - leftClicks[2].time >= TouchTiming.CLICK_MS) } @Test fun doubleTapUsesTheFirstClickLocationDespiteNormalFingerDrift() { @@ -121,7 +121,7 @@ class TouchscreenGesturesTest { gestures.configure(TouchGestureConfig(tapEnabled = false)) tap(); tap(); advance(250) assertEquals(4, leftClicks.size) - assertTrue(leftClicks[2].time - leftClicks[1].time >= 50) + assertTrue(leftClicks[2].time - leftClicks[1].time >= TouchTiming.CLICK_MS) } @Test fun aRapidThirdTapDoesNotDiscardThePendingSecondClick() { @@ -285,11 +285,11 @@ class TouchscreenGesturesTest { @Test fun twoFingerTapKeepsItsPressAfterTheLastFingerLifts() { twoFingerTap() assertEquals(listOf("button 273 true"), buttons()) - advance(49) + advance(TouchTiming.CLICK_MS - 1) assertEquals(1, rightClicks.size) advance(1) assertEquals(listOf(true, false), rightClicks.map { it.down }) - assertEquals(50L, rightClicks[1].time - rightClicks[0].time) + assertEquals(TouchTiming.CLICK_MS, rightClicks[1].time - rightClicks[0].time) } @Test fun threeFingerKeyboardTapHasAnObservablePressAndReleases() { @@ -298,14 +298,14 @@ class TouchscreenGesturesTest { 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) + assertEquals(TouchTiming.CLICK_MS, 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) + assertEquals(TouchTiming.CLICK_MS, timedKeys.last().first - timedKeys.first().first) } @Test fun threeFingerMouseTapHasAnObservablePressAndReleases() { @@ -314,7 +314,7 @@ class TouchscreenGesturesTest { assertEquals(listOf("button 273 true"), buttons()) advance(50) assertEquals(listOf(true, false), rightClicks.map { it.down }) - assertEquals(50L, rightClicks[1].time - rightClicks[0].time) + assertEquals(TouchTiming.CLICK_MS, rightClicks[1].time - rightClicks[0].time) } @Test fun cancellingAMultiFingerTapReleasesItsButtonAndStopsCallbacks() { @@ -338,7 +338,7 @@ class TouchscreenGesturesTest { 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) + assertTrue(rightClicks[2].time - rightClicks[1].time >= TouchTiming.CLICK_MS) } @Test fun aFastTwoFingerSwipeDuringSettlingDoesNotClick() { diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/AccountsMergeTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/AccountsMergeTest.kt new file mode 100644 index 000000000..b0fd45a04 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/AccountsMergeTest.kt @@ -0,0 +1,47 @@ +package com.droiddeck.launcher.runtime + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AccountsMergeTest { + private val own = "root:x:10123:10123:root:/root:/bin/bash" + private val zero = "root:x:0:0:root:/root:/bin/bash" + + @Test fun systemUsersSurvive() { + val existing = "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n_apt:x:42:65534::/nonexistent:/usr/sbin/nologin\nmessagebus:x:100:102::/nonexistent:/usr/sbin/nologin\n" + assertEquals("$own\n$zero\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n_apt:x:42:65534::/nonexistent:/usr/sbin/nologin\nmessagebus:x:100:102::/nonexistent:/usr/sbin/nologin\n", + LinuxRuntime.mergedAccounts(existing, 2, own, zero, 10123)) + } + + @Test fun anOldSingleLineFileIsReplacedOnce() { + val merged = LinuxRuntime.mergedAccounts("$own\n", 2, own, zero, 10123) + assertEquals("$own\n$zero\n", merged) + assertEquals(merged, LinuxRuntime.mergedAccounts(merged, 2, own, zero, 10123)) + } + + @Test fun anotherAppUidIsDropped() { + assertEquals("$own\n$zero\n", LinuxRuntime.mergedAccounts("root:x:10099:10099:root:/root:/bin/bash\n", 2, own, zero, 10123)) + } + + @Test fun noFileYet() { + assertEquals("$own\n$zero\n", LinuxRuntime.mergedAccounts(null, 2, own, zero, 10123)) + } + + @Test fun groupsKeepMembers() { + val existing = "root:x:0:\nsudo:x:27:\naudio:x:29:pulse\n" + assertEquals("root:x:10123:\nroot:x:0:\nsudo:x:27:\naudio:x:29:pulse\n", + LinuxRuntime.mergedAccounts(existing, 2, "root:x:10123:", "root:x:0:", 10123)) + } + + @Test fun storageGroupsAreAddedWithRoot() { + assertEquals("root:x:0:\nsudo:x:27:\nsdcard_rw:x:1015:root\nmedia_rw:x:1023:root\neverybody:x:9997:root\n", + LinuxRuntime.withStorageGroups("root:x:0:\nsudo:x:27:\n")) + } + + @Test fun existingStorageGroupsGainRootOnce() { + val existing = "root:x:0:\nmedia_rw:x:1023:pulse\nsdcard_rw:x:1015:root\nshared:x:9997:\n" + val updated = LinuxRuntime.withStorageGroups(existing) + assertEquals("root:x:0:\nmedia_rw:x:1023:pulse,root\nsdcard_rw:x:1015:root\nshared:x:9997:root\n", updated) + assertEquals(updated, LinuxRuntime.withStorageGroups(updated)) + } +} 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 dfb328651..befb45605 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt @@ -160,4 +160,25 @@ class BwrapSpawnerPlanTest { assertFalse(plan.binds.any { it.guest.startsWith("/app/bin/") }) assertFalse(File(dir, "zypak-wrapper").exists()) } + + @Test fun preloadsStayOutOfSandboxesWithAnOlderGlibc() { + val base = Files.createTempDirectory("glibc").toFile() + try { + val rootfs = File(base, "rootfs") + File(rootfs, "usr/local/lib").mkdirs() + File(rootfs, "etc").mkdirs() + File(rootfs, "etc/ld.so.preload").writeText("/usr/local/lib/libone.so\n/usr/local/lib/libtwo.so\n") + File(rootfs, "usr/local/lib/libone.so").writeText("x GLIBC_2.17 y GLIBC_2.34 z") + File(rootfs, "usr/local/lib/libtwo.so").writeText("GLIBC_2.38 GLIBC_2.25") + val old = File(base, "old-libc").apply { writeText("GNU C Library (Ubuntu GLIBC 2.23-0ubuntu11) stable release version 2.23, by Roland") } + val new = File(base, "new-libc").apply { writeText("GNU C Library (Ubuntu GLIBC 2.43-0ubuntu1) stable release version 2.43.") } + val same = File(base, "same-libc").apply { writeText("stable release version 2.38.") } + assertFalse(BwrapSpawner.preloadFits(rootfs.path, old.path)) + assertTrue(BwrapSpawner.preloadFits(rootfs.path, new.path)) + assertTrue(BwrapSpawner.preloadFits(rootfs.path, same.path)) + assertTrue(BwrapSpawner.preloadFits(rootfs.path, File(base, "missing").path)) + } finally { + base.deleteRecursively() + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopPackTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopPackTest.kt new file mode 100644 index 000000000..1633139f3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopPackTest.kt @@ -0,0 +1,145 @@ +package com.droiddeck.launcher.runtime + +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.Hashes +import java.io.File +import java.nio.file.Files +import java.util.zip.GZIPOutputStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DesktopPackTest { + private val parent = Files.createTempDirectory("pack").toFile() + private val root = File(parent, "root").apply { mkdirs() } + + @After fun tearDown() { FileUtils.delete(parent) } + + private fun file(path: String, text: String) = File(root, path).apply { parentFile?.mkdirs(); writeText(text) } + + private fun line(kind: Char, path: String): String { + val file = File(root, path) + return when { + Files.isSymbolicLink(file.toPath()) -> "$kind\tL\t0\t${Files.readSymbolicLink(file.toPath())}\t$path" + file.isDirectory -> "$kind\tD\t0\t-\t$path" + else -> "$kind\tF\t${file.length()}\t${Hashes.sha256(file)}\t$path" + } + } + + private fun manifest(lines: List) { + val target = File(root, "${DesktopPack.META}/files.tsv.gz").apply { parentFile?.mkdirs() } + GZIPOutputStream(target.outputStream()).bufferedWriter().use { w -> lines.forEach { w.write(it); w.write("\n") } } + } + + private fun installed(): List { + File(root, DesktopPack.INFO).apply { parentFile?.mkdirs() } + .writeText("""{"tag":"ubuntu-r1-desktop","url":"https://example.invalid/d.tar.zst","sha256":"00","size":5}""") + file("usr/bin/runtime-tool", "runtime") + file("${DesktopPack.META}/originals/var/lib/dpkg/status", "Package: bash\n") + file("${DesktopPack.META}/originals/etc/gone.conf", "removed by the desktop") + file("${DesktopPack.META}/originals/etc/group", "root:x:0:") + file("${DesktopPack.META}/originals/etc/hosts", "runtime hosts") + file("etc/group", "root:x:0:\nsddm:x:120:") + file("etc/hosts", "desktop hosts") + file("var/lib/dpkg/status", "Package: bash\n\nPackage: kwin\n") + file("usr/bin/kwin_wayland", "kwin") + file("usr/share/kwin/effect.qml", "effect") + file("usr/share/kwin/edited.qml", "original") + Files.createSymbolicLink(File(root, "usr/bin/kwin").toPath(), File("kwin_wayland").toPath()) + val lines = listOf( + line('N', "usr/share/kwin"), line('N', "usr/bin/kwin_wayland"), line('N', "usr/share/kwin/effect.qml"), + line('N', "usr/share/kwin/edited.qml"), line('N', "usr/bin/kwin"), line('C', "var/lib/dpkg/status"), line('C', "etc/group"), line('C', "etc/hosts"), + "R\tF\t22\tx\tetc/gone.conf", "N\tF\t1\tx\t../escape", + ) + file("usr/share/kwin/edited.qml", "changed by the user") + file("etc/hosts", "edited by the user") + file("var/lib/dpkg/status", "Package: bash\n\nPackage: kwin\n\nPackage: vlc\n") + return lines + } + + @Test fun readsThePackTheRuntimeNames() { + installed() + val info = DesktopPack.info(root)!! + assertEquals("ubuntu-r1-desktop", info.tag) + assertEquals(5L, info.size) + } + + @Test fun removingPutsTheRuntimeBackAndKeepsWhatTheUserChanged() { + manifest(installed()) + val result = DesktopPack.remove(root) + assertEquals("Package: bash\n", File(root, "var/lib/dpkg/status").readText()) + assertEquals("removed by the desktop", File(root, "etc/gone.conf").readText()) + assertEquals("root:x:0:", File(root, "etc/group").readText()) + assertEquals("edited by the user", File(root, "etc/hosts").readText()) + assertFalse(File(root, "usr/bin/kwin_wayland").exists()) + assertFalse(Files.exists(File(root, "usr/bin/kwin").toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) + assertFalse(File(root, "usr/share/kwin/effect.qml").exists()) + assertEquals("changed by the user", File(root, "usr/share/kwin/edited.qml").readText()) + assertTrue(File(root, "usr/share/kwin").isDirectory) + assertEquals("runtime", File(root, "usr/bin/runtime-tool").readText()) + assertFalse(File(root, DesktopPack.META).exists()) + assertFalse(DesktopPack.installed(root)) + assertEquals(3, result.removed) + assertEquals(3, result.restored) + assertEquals(3, result.kept) + } + + @Test fun emptiedDirectoriesGoAway() { + val lines = installed() + File(root, "usr/share/kwin/edited.qml").delete() + manifest(lines) + DesktopPack.remove(root) + assertFalse(File(root, "usr/share/kwin").exists()) + assertTrue(File(root, "usr/share").isDirectory) + } + + @Test fun finishingAnInstallDeletesWhatTheDesktopRemoved() { + file("etc/gone.conf", "runtime copy") + manifest(listOf("R\tF\t22\tx\tetc/gone.conf", "R\tF\t1\tx\t../outside")) + File(root.parentFile, "outside").writeText("keep") + DesktopPack.finishInstall(root) + assertFalse(File(root, "etc/gone.conf").exists()) + assertTrue(File(root.parentFile, "outside").exists()) + File(root.parentFile, "outside").delete() + } + + @Test fun theDesktopKeepsPackagesTheUserInstalledBeforeIt() { + val bash = "Package: bash\nArchitecture: arm64\nVersion: 5.2" + val vim = "Package: vim\nArchitecture: arm64\nVersion: 9" + val kwin = "Package: kwin\nArchitecture: arm64\nVersion: 6.6" + file("var/lib/dpkg/status", "$bash\n\n$vim\n\n") + DesktopPack.prepareInstall(root) + file("${DesktopPack.META}/originals/var/lib/dpkg/status", "$bash\n\n") + file("var/lib/dpkg/status", "$bash\n\n$kwin\n\n") + manifest(listOf(line('C', "var/lib/dpkg/status"))) + DesktopPack.finishInstall(root) + assertEquals("$bash\n\n$kwin\n\n$vim\n\n", File(root, "var/lib/dpkg/status").readText()) + assertFalse(File(root, "var/lib/dpkg/status.droiddeck").exists()) + + val vlc = "Package: vlc\nArchitecture: arm64\nVersion: 3" + file("var/lib/dpkg/status", "$bash\n\n$kwin\n\n$vim\n\n$vlc\n\n") + DesktopPack.remove(root) + assertEquals("$bash\n\n$vim\n\n$vlc\n\n", File(root, "var/lib/dpkg/status").readText()) + } + + @Test fun mergingKeepsTheTargetWhenBothSidesChangedAPackage() { + val base = "Package: a\nVersion: 1\n\nPackage: b\nVersion: 1\n\nPackage: c\nVersion: 1\n" + val target = "Package: a\nVersion: 2\n\nPackage: b\nVersion: 1\n\nPackage: c\nVersion: 1\n" + val mine = "Package: a\nVersion: 3\n\nPackage: b\nVersion: 3\n" + assertEquals("Package: a\nVersion: 2\n\nPackage: b\nVersion: 3\n\n", DesktopPack.mergeStatus(base, target, mine)) + } + + @Test fun pathsMayNotLeaveTheRuntime() { + assertNull(DesktopPack.inside(root, "../x")) + assertNull(DesktopPack.inside(root, "/etc/passwd")) + Files.createSymbolicLink(File(root, "link").toPath(), root.parentFile.toPath()) + assertNull(DesktopPack.inside(root, "link/x")) + assertEquals(File(root, "usr/x"), DesktopPack.inside(root, "usr/./x")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt index db035a656..fb9a834fa 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt @@ -66,6 +66,15 @@ class DeviceControlsComponentTest { assertEquals(3, audio.getStreamVolume(AudioManager.STREAM_MUSIC)) } + @Test fun everyVolumeStepIsPublishedWithoutWaitingForThePoll() { + for (level in 2..5) { + audio.setStreamVolume(AudioManager.STREAM_MUSIC, level, 0) + context.sendBroadcast(android.content.Intent("android.media.VOLUME_CHANGED_ACTION")) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(level.toString(), File(directory, "volume.state").readText().split(" ").first()) + } + } + @Test fun deniedBrightnessDoesNotOpenSettingsOrReplayAnOldSliderValueAfterPermission() { WriteSettingsShadow.writable = false request("brightness", "0.75") diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/L2sLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/L2sLinkTest.kt new file mode 100644 index 000000000..239acfae0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/L2sLinkTest.kt @@ -0,0 +1,102 @@ +package com.droiddeck.launcher.runtime + +import com.droiddeck.launcher.core.FileUtils +import java.io.File +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.Test + +class L2sLinkTest { + private val dir = Files.createTempDirectory("l2s").toFile() + + @After fun tearDown() { FileUtils.delete(dir) } + + @Test fun aHardLinkBecomesProotsLinkLayout() { + val bin = File(dir, "usr/bin").apply { mkdirs() } + val coreutils = File(bin, "coreutils").apply { writeText("multicall") } + val tail = File(bin, "tail") + assertTrue(LinuxRuntimeInstaller.l2sLink(coreutils, tail)) + val intermediate = File(bin, ".l2s.coreutils0001") + assertEquals(intermediate.toPath(), Files.readSymbolicLink(coreutils.toPath())) + assertEquals(intermediate.toPath(), Files.readSymbolicLink(tail.toPath())) + assertEquals(File(bin, ".l2s.coreutils0001.0002").toPath(), Files.readSymbolicLink(intermediate.toPath())) + assertEquals("multicall", tail.readText()) + assertEquals("multicall", coreutils.readText()) + } + + @Test fun eachFurtherLinkRaisesTheCount() { + val bin = File(dir, "usr/bin").apply { mkdirs() } + val coreutils = File(bin, "coreutils").apply { writeText("multicall") } + assertTrue(LinuxRuntimeInstaller.l2sLink(coreutils, File(bin, "tail"))) + assertTrue(LinuxRuntimeInstaller.l2sLink(coreutils, File(bin, "head"))) + assertTrue(LinuxRuntimeInstaller.l2sLink(File(bin, "tail"), File(bin, "cat"))) + val final = File(bin, ".l2s.coreutils0001.0004") + assertTrue(final.isFile) + assertFalse(File(bin, ".l2s.coreutils0001.0002").exists()) + for (name in listOf("coreutils", "tail", "head", "cat")) assertEquals("multicall", File(bin, name).readText()) + } + + @Test fun aSecondFileWithTheSameNameGetsItsOwnChain() { + val a = File(dir, "a").apply { mkdirs() } + val existing = File(a, ".l2s.tool0001").apply { writeText("taken") } + val tool = File(a, "tool").apply { writeText("tool") } + assertTrue(LinuxRuntimeInstaller.l2sLink(tool, File(a, "alias"))) + assertEquals("taken", existing.readText()) + assertEquals(File(a, ".l2s.tool0002").toPath(), Files.readSymbolicLink(tool.toPath())) + assertEquals("tool", File(a, "alias").readText()) + } + + @Test fun linksMadeInStagingStillResolveAfterTheTreeIsRenamed() { + val staging = File(dir, "linuxfs.new") + val live = File(dir, "linuxfs") + val bin = File(staging, "usr/bin").apply { mkdirs() } + val cargo = File(staging, "usr/lib/cargo/bin/coreutils").apply { mkdirs() } + val coreutils = File(bin, "coreutils").apply { writeText("multicall") } + assertTrue(LinuxRuntimeInstaller.l2sLink(coreutils, File(cargo, "echo"), staging, live)) + assertTrue(LinuxRuntimeInstaller.l2sLink(File(cargo, "echo"), File(cargo, "cat"), staging, live)) + assertEquals(File(live, "usr/bin/.l2s.coreutils0001").toPath(), Files.readSymbolicLink(File(cargo, "echo").toPath())) + assertTrue(File(staging, "usr/bin/.l2s.coreutils0001.0003").isFile) + + assertTrue(staging.renameTo(live)) + + for (rel in listOf("usr/bin/coreutils", "usr/lib/cargo/bin/coreutils/echo", "usr/lib/cargo/bin/coreutils/cat")) + assertEquals(rel, "multicall", File(live, rel).readText()) + assertTrue(LinuxRuntimeInstaller.l2sLink(File(live, "usr/bin/coreutils"), File(live, "usr/bin/tail"))) + assertEquals("multicall", File(live, "usr/bin/tail").readText()) + assertTrue(File(live, "usr/bin/.l2s.coreutils0001.0004").isFile) + } + + @Test fun aHardLinkTargetMayAlreadyBeAnL2sLinkToTheFinalTree() { + val staging = File(dir, "linuxfs.new") + val live = File(dir, "linuxfs") + val bin = File(staging, "usr/bin").apply { mkdirs() } + File(bin, "coreutils").writeText("multicall") + assertTrue(LinuxRuntimeInstaller.l2sLink(File(bin, "coreutils"), File(bin, "["), staging, live)) + assertEquals(File(bin, "coreutils"), LinuxRuntimeInstaller.hardLinkTarget(staging, "./usr/bin/coreutils")) + assertTrue(LinuxRuntimeInstaller.l2sLink(LinuxRuntimeInstaller.hardLinkTarget(staging, "./usr/bin/coreutils"), File(bin, "ls"), staging, live)) + assertTrue(staging.renameTo(live)) + assertEquals("multicall", File(live, "usr/bin/ls").readText()) + } + + @Test fun aHardLinkTargetCannotLeaveTheTree() { + val tree = File(dir, "tree").apply { mkdirs() } + val outside = File(dir, "outside").apply { mkdirs() } + Files.createSymbolicLink(File(tree, "escape").toPath(), outside.toPath()) + assertEquals(null, LinuxRuntimeInstaller.hardLinkTarget(tree, "../outside/x")) + assertEquals(null, LinuxRuntimeInstaller.hardLinkTarget(tree, "escape/x")) + assertEquals(null, LinuxRuntimeInstaller.hardLinkTarget(tree, "..")) + assertEquals(File(tree, "x"), LinuxRuntimeInstaller.hardLinkTarget(tree, "./x")) + } + + @Test fun directoriesAndPlainSymlinksAreLeftAlone() { + val d = File(dir, "d").apply { mkdirs() } + assertFalse(LinuxRuntimeInstaller.l2sLink(d, File(dir, "e"))) + val target = File(dir, "t").apply { writeText("t") } + val link = File(dir, "link") + Files.createSymbolicLink(link.toPath(), target.toPath()) + assertFalse(LinuxRuntimeInstaller.l2sLink(link, File(dir, "other"))) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/ProotFastPathTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/ProotFastPathTest.kt index 9b97bd418..12fb45bf9 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/ProotFastPathTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/ProotFastPathTest.kt @@ -10,6 +10,12 @@ import java.io.File class ProotFastPathTest { private val root = File("/data/data/com.droiddeck.launcher/files/linuxfs") + @Test fun guestEnvironmentCarriesTheHostAndroidApi() { + for (sdk in listOf(33, 35, 36)) { + assertTrue(ProotFastPath.guestEnv(root, emptyList(), "test", sdk).contains("PROOT_FP_ANDROID_API=$sdk")) + } + } + @Test fun aSessionsUsualBindsGetAKey() { val binds = (1..87).map { "/host/$it:/guest/$it" } assertNotNull(ProotFastPath.key(root, binds)) diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeAutoUpdateTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeAutoUpdateTest.kt new file mode 100644 index 000000000..8357f9694 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeAutoUpdateTest.kt @@ -0,0 +1,48 @@ +package com.droiddeck.launcher.runtime + +import android.app.Application +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 = [33], manifest = Config.NONE, application = Application::class) +class RuntimeAutoUpdateTest { + private val context get() = RuntimeEnvironment.getApplication() + + @Test fun anInstalledRuntimeBehindTheCatalogUpdatesByItself() { + assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2")) + } + + @Test fun noRuntimeOrTheSameOneIsLeftAlone() { + assertFalse(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2")) + assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc2", "ubuntu-r1-rc2")) + assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", null)) + assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "")) + } + + @Test fun aFailingUpdateStopsTryingAfterAFewLaunches() { + repeat(RuntimeAutoUpdate.MAX_ATTEMPTS) { assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", it * RuntimeAutoUpdate.RETRY_MS)) } + assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", 10 * RuntimeAutoUpdate.RETRY_MS)) + } + + @Test fun aRetryWaitsBeforeUsingTheNextTry() { + assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", 1_000L)) + assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", 2_000L)) + assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", 1_000L + RuntimeAutoUpdate.RETRY_MS)) + } + + @Test fun aNewerCatalogReleaseStartsAgain() { + repeat(RuntimeAutoUpdate.MAX_ATTEMPTS) { RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", it * RuntimeAutoUpdate.RETRY_MS) } + assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc3", 5 * RuntimeAutoUpdate.RETRY_MS)) + } + + @Test fun goingBackToAnEarlierReleaseKeepsItsCount() { + repeat(RuntimeAutoUpdate.MAX_ATTEMPTS) { RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", it * RuntimeAutoUpdate.RETRY_MS) } + assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc3", 5 * RuntimeAutoUpdate.RETRY_MS)) + assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2", 10 * RuntimeAutoUpdate.RETRY_MS)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt index d977ffb8c..15961981c 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt @@ -25,6 +25,7 @@ class RuntimeUserDataCarryTest { put(old, "root/save.dat", "mine") put(old, "opt/appimages/user/app1/app.AppImage") put(old, "opt/droiddeck-apps/game/run.sh") + put(old, "opt/droiddeck/wincomponents/d3dx9/component.json") put(old, "usr/local/share/applications/droiddeck-app-game.desktop") put(old, "usr/local/share/applications/droiddeck-appimage-app1.desktop") put(old, "usr/local/share/applications/other.desktop") @@ -43,6 +44,7 @@ class RuntimeUserDataCarryTest { assertEquals("mine", File(staging, "root/save.dat").readText()) assertFalse("the shipped home is replaced, not merged", File(staging, "root/.bashrc").exists()) for (rel in listOf("opt/appimages/user/app1/app.AppImage", "opt/droiddeck-apps/game/run.sh", + "opt/droiddeck/wincomponents/d3dx9/component.json", "usr/local/share/applications/droiddeck-app-game.desktop", "usr/local/share/applications/droiddeck-appimage-app1.desktop", "opt/appimages/dolphin.AppImage", "usr/share/applications/droiddeck-dolphin.desktop", ".droiddeck-pkg-dolphin")) { assertTrue(rel, File(staging, rel).exists()) @@ -52,6 +54,47 @@ class RuntimeUserDataCarryTest { assertFalse("only DroidDeck's own menu entries are user data", File(staging, "usr/local/share/applications/other.desktop").exists()) } + @Test fun aMenuEntryTheNewRuntimeShipsIsNotCarried() { + val old = tmp.newFolder("old").also { + put(it, "root/save.dat", "mine") + put(it, "usr/share/applications/droiddeck-return-gaming.desktop", "old runtime") + put(it, "usr/share/applications/droiddeck-steam-x64.desktop", "made by the old desktop") + } + val staging = tmp.newFolder("new").also { + put(it, "root/.bashrc", "shipped") + put(it, "usr/share/applications/droiddeck-return-gaming.desktop", "new runtime") + } + + val paths = RuntimeFileTree.existingUserPaths(old, staging) + + assertTrue("root" in paths) + assertTrue("usr/share/applications/droiddeck-steam-x64.desktop" in paths) + assertFalse("usr/share/applications/droiddeck-return-gaming.desktop" in paths) + RuntimeFileTree.carryUserData(old, staging, false, paths) + assertEquals("new runtime", File(staging, "usr/share/applications/droiddeck-return-gaming.desktop").readText()) + } + + @Test fun cachesTiedToThePreviousRuntimeAreDropped() { + val root = tmp.newFolder("rt") + val keep = listOf("root/.cache/mozilla/cache2/entry", "root/.cache/droiddeck/session.log", + "root/.local/share/droiddeck/apt/packages.txt", "root/.local/share/Steam/steam.sh", "root/.config/kwinrc") + val drop = listOf("root/.local/share/droiddeck/pacman/packages.txt", "root/.cache/droiddeck/pacman-restore.log", + "root/.cache/mesa_shader_cache_db/index", "root/.cache/mesa_shader_cache/a/b", "root/.cache/appstream/x.xb", + "root/.cache/discover/c", "root/.cache/ksycoca6_en-POSIX_abc", "root/.cache/plasma_theme_Vapor.kcache", + "root/.cache/kwinsession.kcache", "root/.cache/qtshadercache-arm64-little_endian-lp64/s", + "root/.cache/event-sound-cache.tdb.1.aarch64-unknown-linux-gnu", "root/.cache/fontconfig/x.cache-9") + (keep + drop).forEach { put(root, it) } + + RuntimeFileTree.dropStaleState(root) + + keep.forEach { assertTrue(it, File(root, it).exists()) } + drop.forEach { assertFalse(it, File(root, it).exists()) } + } + + @Test fun droppingStateWithoutAHomeDoesNothing() { + RuntimeFileTree.dropStaleState(tmp.newFolder("empty")) + } + @Test fun aFailureHalfwayPutsEverythingBack() { val old = userTree() val staging = tmp.newFolder("new") diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt index 6645c6ee1..197512e5b 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt @@ -4,6 +4,7 @@ import android.content.Context import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.runtime.LinuxRuntime import org.json.JSONObject +import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -12,6 +13,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -21,10 +24,137 @@ class GameEnvironmentStoreTest { @Before fun setUp() { context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit() + .remove("logs") + .remove("gameDebugLogsEnabled") + .commit() guest = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json") File(context.filesDir, "game-environment.json").delete() } + @After fun tearDown() { + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit() + .remove("logs") + .remove("gameDebugLogsEnabled") + .commit() + } + + @Test fun gameDebugLogsAreOffByDefault() { + assertFalse(SessionPrefs.gameDebugLogsEnabled(context)) + + GameEnvironmentStore.save(context, GameEnvironment.Config()) + + assertFalse(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + } + + @Test fun gameDebugLogsRequireSessionLogsAndPreserveCustomDiagnostics() { + val config = GameEnvironment.Config(shared = mapOf("PROTON_LOG" to "0", "WINEDEBUG" to "+warn")) + GameEnvironmentStore.save(context, config) + + SessionPrefs.setGameDebugLogsEnabled(context, true) + var published = JSONObject(guest.readText()) + assertTrue(published.getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + assertEquals("0", published.getJSONObject("shared").getString("PROTON_LOG")) + assertEquals("+warn", published.getJSONObject("shared").getString("WINEDEBUG")) + assertEquals(config, GameEnvironmentStore.read(context)) + + SessionPrefs.setLogsEnabled(context, false) + published = JSONObject(guest.readText()) + assertFalse(published.getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + assertTrue(SessionPrefs.gameDebugLogsEnabled(context)) + + SessionPrefs.setLogsEnabled(context, true) + assertTrue(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + } + + @Test fun failedGameDebugOptOutLeavesTheEnabledPreferenceVisible() { + assertTrue(SessionPrefs.setGameDebugLogsEnabled(context, true)) + assertTrue(SessionPrefs.gameDebugLogsEnabled(context)) + val guestDirectory = guest.parentFile!! + guestDirectory.deleteRecursively() + guestDirectory.parentFile!!.mkdirs() + guestDirectory.writeText("blocks the guest configuration directory\n") + try { + assertFalse(SessionPrefs.setGameDebugLogsEnabled(context, false)) + assertTrue(SessionPrefs.gameDebugLogsEnabled(context)) + } finally { + guestDirectory.delete() + guestDirectory.mkdirs() + GameEnvironmentStore.publish(context) + } + assertTrue(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + } + + @Test fun unchangedEffectiveDiagnosticsDoNotRequireGuestPublication() { + assertFalse(SessionPrefs.gameDebugLogsEnabled(context)) + val guestDirectory = guest.parentFile!! + guestDirectory.deleteRecursively() + guestDirectory.parentFile!!.mkdirs() + guestDirectory.writeText("blocks the guest configuration directory\n") + try { + assertTrue(SessionPrefs.setLogsEnabled(context, false)) + assertFalse(SessionPrefs.logsEnabled(context)) + } finally { + guestDirectory.delete() + guestDirectory.mkdirs() + SessionPrefs.setLogsEnabled(context, true) + GameEnvironmentStore.publish(context) + } + } + + @Test fun logPreferenceUpdatesUseThePublisherLock() { + GameEnvironmentStore.publish(context) + val entered = CountDownLatch(1) + val finished = CountDownLatch(1) + lateinit var thread: Thread + synchronized(GameEnvironmentStore) { + thread = Thread { + entered.countDown() + SessionPrefs.setGameDebugLogsEnabled(context, true) + finished.countDown() + } + thread.start() + assertTrue(entered.await(5, TimeUnit.SECONDS)) + assertFalse(finished.await(100, TimeUnit.MILLISECONDS)) + assertFalse(SessionPrefs.gameDebugLogsEnabled(context)) + assertFalse(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + } + assertTrue(finished.await(5, TimeUnit.SECONDS)) + thread.join() + assertTrue(SessionPrefs.gameDebugLogsEnabled(context)) + assertTrue(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + } + + @Test fun gameDebugLogsWaitForARetainedSessionFolder() { + assertNull(SessionPaths.current()) + assertTrue(SessionPaths.currentSessionLogsRetained(context)) + GameEnvironmentStore.publish(context) + SessionPrefs.setLogsEnabled(context, false) + val cacheSession = SessionPaths.beginOrCurrent(context, "game-debug-logs") + try { + assertFalse(SessionPaths.currentSessionLogsRetained(context)) + + SessionPrefs.setLogsEnabled(context, true) + SessionPrefs.setGameDebugLogsEnabled(context, true) + assertTrue(SessionPrefs.gameDebugLogsEnabled(context)) + assertFalse(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + + assertEquals(cacheSession, SessionPaths.take()) + SessionPaths.release(context, cacheSession) + val retainedSession = SessionPaths.beginOrCurrent(context, "game-debug-logs") + assertTrue(SessionPaths.currentSessionLogsRetained(context)) + + GameEnvironmentStore.publish(context) + assertTrue(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) + assertEquals(retainedSession, SessionPaths.current()) + } finally { + SessionPaths.take()?.let { ended -> + SessionPaths.release(context, ended) + ended.deleteRecursively() + } + } + } + @Test fun savedSettingsAndPublishedSettingsPreserveUnsetsAndLiteralValues() { val config = GameEnvironment.Config( shared = mapOf("LITERAL" to "a=b 'quoted' $(echo test)", "EMPTY" to ""), diff --git a/app/src/test/java/com/droiddeck/launcher/session/HudDefaultTest.kt b/app/src/test/java/com/droiddeck/launcher/session/HudDefaultTest.kt new file mode 100644 index 000000000..9b2c052a1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/HudDefaultTest.kt @@ -0,0 +1,39 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.After +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 HudDefaultTest { + private lateinit var context: Context + + @Before fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + } + + @After fun tearDown() { + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun aNewInstallStartsWithTheHudOff() { + assertFalse(SessionPrefs.hudEnabled(context)) + } + + @Test fun aHudTheUserTurnedOnStaysOn() { + SessionPrefs.setHudEnabled(context, true) + assertTrue(SessionPrefs.hudEnabled(context)) + } + + @Test fun aHudTheUserTurnedOffStaysOff() { + SessionPrefs.setHudEnabled(context, false) + assertFalse(SessionPrefs.hudEnabled(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/OutputPacingTest.kt b/app/src/test/java/com/droiddeck/launcher/session/OutputPacingTest.kt index bd7dc380f..c2d0ca835 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/OutputPacingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/OutputPacingTest.kt @@ -8,6 +8,25 @@ class OutputPacingTest { assertEquals(SessionPrefs.FPS_UNPACED, SessionPrefs.compositorFpsLimit(0)) } + @Test fun sessionLimitPacesThePhone() { + assertEquals(45, SessionPrefs.compositorFpsLimit(0, 45)) + } + + @Test fun sessionLimitNeverExceedsTheGlassesRate() { + assertEquals(60, SessionPrefs.compositorFpsLimit(60, 90)) + assertEquals(30, SessionPrefs.compositorFpsLimit(60, 30)) + } + + @Test fun pictureInPictureHoldsTheSessionToThirty() { + assertEquals(SessionPrefs.PIP_FPS, SessionPrefs.compositorFpsLimit(0, pip = true)) + assertEquals(SessionPrefs.PIP_FPS, SessionPrefs.compositorFpsLimit(0, 60, pip = true)) + assertEquals(20, SessionPrefs.compositorFpsLimit(0, 20, pip = true)) + } + + @Test fun pictureInPictureKeepsTheGlassesRate() { + assertEquals(60, SessionPrefs.compositorFpsLimit(60, pip = true)) + } + @Test fun mirroringKeepsTheGlassesRate() { assertEquals(60, SessionPrefs.compositorFpsLimit(60)) } diff --git a/app/src/test/java/com/droiddeck/launcher/session/PerfHudGraphTest.kt b/app/src/test/java/com/droiddeck/launcher/session/PerfHudGraphTest.kt deleted file mode 100644 index bf607d367..000000000 --- a/app/src/test/java/com/droiddeck/launcher/session/PerfHudGraphTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.droiddeck.launcher.session - -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Test - -class PerfHudGraphTest { - @Test fun theGraphShowsOnlyFramesFromThisHudRun() { - val samples = floatArrayOf(9f, 9f, 9f, 9f, 9f, 16.7f, 33.4f, 16.6f) - assertArrayEquals(floatArrayOf(16.7f, 33.4f, 16.6f), PerfHud.recent(samples, 105, 102), 0f) - assertEquals(0, PerfHud.recent(samples, 103, 102).size) - } - - @Test fun anEarlySessionShowsWhatWasShownSoFar() { - val samples = floatArrayOf(16.6f, 16.7f, 120f, 0f, 0f, 0f) - assertArrayEquals(floatArrayOf(16.6f, 16.7f, 50f), PerfHud.recent(samples, 3, 0), 0f) - } -} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt index 0daf6f99b..f7ba39b65 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt @@ -18,8 +18,9 @@ class SessionBackgroundPrefsTest { context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() } - @Test fun nativeIsOptInAndDoesNotChangeDesktopOrExistingPolicies() { - assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, "steam")) + @Test fun nativeIsTheSteamDefaultWithoutChangingOtherModesOrSavedPolicies() { + assertEquals(SessionPrefs.SUSPEND_NATIVE, SessionPrefs.suspendPolicy(context, "steam")) + assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, SessionService.MODE_RUN)) for (policy in listOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_MANUAL, SessionPrefs.SUSPEND_NEVER, SessionPrefs.SUSPEND_NATIVE)) { SessionPrefs.setSuspendPolicy(context, "steam", policy) @@ -28,6 +29,14 @@ class SessionBackgroundPrefsTest { } } + @Test fun automaticPipDefaultsOnAndHonorsSavedChoices() { + assertEquals(true, SessionPrefs.pipAutoEnter(context)) + SessionPrefs.setPipAutoEnter(context, false) + assertEquals(false, SessionPrefs.pipAutoEnter(context)) + SessionPrefs.setPipAutoEnter(context, true) + assertEquals(true, SessionPrefs.pipAutoEnter(context)) + } + @Test fun directGamesUseAutoWithoutOverwritingSteamsNativeChoice() { SessionPrefs.setSuspendPolicy(context, SessionService.MODE_STEAM, SessionPrefs.SUSPEND_NATIVE) assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_RUN)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionEndHintTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionEndHintTest.kt new file mode 100644 index 000000000..c5f75297e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionEndHintTest.kt @@ -0,0 +1,121 @@ +package com.droiddeck.launcher.session + +import android.app.Application +import com.droiddeck.launcher.R +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.gpu.GpuInfo +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.util.ReflectionHelpers +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config( + sdk = [33], application = Application::class, qualifiers = "en-rUS", + instrumentedPackages = ["com.droiddeck.launcher.gpu.GpuInfo"], + shadows = [SessionEndHintTest.DetectedGpu::class], +) +class SessionEndHintTest { + @get:Rule val temporary = TemporaryFolder() + private lateinit var activity: SessionActivity + private var previousArchitecture: String? = null + + @Before + fun setUp() { + previousArchitecture = SessionState.steamClientArch + DetectedGpu.family = GpuInfo.Family.NOT_ADRENO + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + SessionState.steamClientArch = "arm64" + } + + @After + fun tearDown() { + SessionState.steamClientArch = previousArchitecture + DetectedGpu.family = GpuInfo.Family.NOT_ADRENO + } + + @Test + fun sigillOnArm64IncludesTheExperimentalX64Fallback() { + SessionState.steamClientArch = "arm64" + + assertEquals( + activity.getString(R.string.session_end_steam_sigill) + "\n" + activity.getString(R.string.session_end_steam_try_x64), + hintText(sigillLog()), + ) + } + + @Test + fun sigillOnX86_64DoesNotSuggestSwitchingToItself() { + SessionState.steamClientArch = "x86_64" + + assertEquals(activity.getString(R.string.session_end_steam_sigill), hintText(sigillLog())) + } + + @Test + fun sigillWithUnknownArchitectureDoesNotAssumeArm64() { + SessionState.steamClientArch = null + + assertEquals(activity.getString(R.string.session_end_steam_sigill), hintText(sigillLog())) + } + + @Test + fun vulkanFailureOnAdrenoSelectsTheDriverHint() { + for (family in listOf(GpuInfo.Family.A6XX, GpuInfo.Family.ADRENO_UNKNOWN)) { + DetectedGpu.family = family + + assertEquals(family.name, activity.getString(R.string.session_end_vulkan_device), hintText(vulkanLog())) + } + } + + @Test + fun vulkanFailureOnNonAdrenoExplainsTheMissingLinuxDriver() { + DetectedGpu.family = GpuInfo.Family.NOT_ADRENO + + assertEquals(activity.getString(R.string.session_end_gpu_unsupported), hintText(vulkanLog())) + } + + @Test + fun missingEmptyAndUnrelatedLogsHaveNoHint() { + assertNull(hint(null)) + assertNull(hint(File(temporary.root, "missing.log"))) + assertNull(hint(log(""))) + assertNull(hint(log("game: Illegal instruction\n== steam exited rc=132"))) + } + + private fun sigillLog() = log("== STEP 12:00:00 starting the Steam client\n== steam exited rc=132\n") + + private fun vulkanLog() = log("[gamescope] [Error] vulkan: failed to find physical device\n") + + private fun log(text: String): File = temporary.newFile().apply { writeText(text) } + + private fun hint(log: File?): Any? = SessionActivity::class.java + .getDeclaredMethod("sessionEndHint", File::class.java).apply { isAccessible = true }.invoke(activity, log) + + private fun hintText(log: File): String { + val result = hint(log) + assertNotNull(result) + return ReflectionHelpers.getField(result, "text") + } + + @Implements(value = GpuInfo.Companion::class, isInAndroidSdk = false) + class DetectedGpu { + @Implementation + fun detect(): GpuInfo = GpuInfo("test GPU", if (family == GpuInfo.Family.A6XX) 610 else 0, family, "", false) + + companion object { + var family = GpuInfo.Family.NOT_ADRENO + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLaunchFailureTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLaunchFailureTest.kt new file mode 100644 index 000000000..e36f23750 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLaunchFailureTest.kt @@ -0,0 +1,54 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionLaunchFailureTest { + private fun find(vararg lines: String) = SessionLaunchFailure.find(lines.asSequence()) + + @Test fun installedSteamThatExitsWithSigillHasACpuFailureHint() { + assertEquals(SessionLaunchFailure.STEAM_ILLEGAL_INSTRUCTION, find( + "== STEP 06:35:36.987 Steam client ready", + "== STEP 06:35:41.058 starting the Steam client", + "/usr/local/bin/droiddeck-session: line 309: 1192 Illegal instruction steam", + "== steam exited rc=132", + "(EE) failed to read Wayland events: Broken pipe", + )) + } + + @Test fun gamescopeWithoutAPhysicalDeviceHasAGraphicsFailureHint() { + assertEquals(SessionLaunchFailure.VULKAN_DEVICE_MISSING, find( + "[gamescope] [\u001b[0;31mError\u001b[0m] \u001b[0;37mvulkan:\u001b[0m failed to find physical device", + "SDL_Vulkan_CreateSurface failed: VK_KHR_wayland_surface extension is not enabled in the Vulkan instance.Failed to create backend.", + )) + } + + @Test fun unrelatedIllegalInstructionsAndExitCodesAreNotBlamedOnSteam() { + assertNull(find("game: Illegal instruction", "== steam exited rc=132")) + assertNull(find("== STEP 12:00:00 starting the Steam client", "== steam exited rc=139")) + assertNull(find("== STEP 12:00:00 starting the Steam client", "== steam exited rc=1320")) + assertNull(find("== STEP 12:00:00 starting the Steam client", "== steam exited rc=0")) + } + + @Test fun onlyTheLatestClientAttemptCounts() { + assertNull(find( + "== STEP 12:00:00 starting the Steam client", "== steam exited rc=132", + "== STEP 12:01:00 starting the Steam client", "== steam exited rc=0", + )) + } + + @Test fun aLaterSuccessfulDeviceSelectionClearsTheGraphicsFailure() { + assertNull(find( + "[gamescope] [Error] vulkan: failed to find physical device", + "[gamescope] [Info] vulkan: selecting physical device 'Turnip Adreno (TM) 610': queue family 0", + )) + } + + @Test fun aNewSessionDoesNotReuseAnOldFailure() { + assertNull(find( + "== STEP 12:00:00 starting the Steam client", "== steam exited rc=132", + "== droiddeck-session Fri Oct 23 12:01:00 mode=steam", + )) + } +} 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 bc1718bff..2e00e83c6 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt @@ -1,8 +1,10 @@ package com.droiddeck.launcher.session import com.droiddeck.launcher.core.LogRedactor +import org.junit.Assert.assertArrayEquals 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 @@ -12,6 +14,9 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.zip.ZipFile @RunWith(RobolectricTestRunner::class) @@ -147,6 +152,41 @@ class SessionLogShareTest { assertTrue(zip(folder).values.none { it.contains(leaked) }) } + @Test fun aDirectoryListingFailureLeavesTheTreeUnrecordedButDoesNotStopSiblingScrubbing() { + val folder = tmp.newFolder("2026-10-06-06-listing") + val unavailable = File(folder, "unavailable").apply { mkdirs() } + val unavailableLog = File(unavailable, "session.log").apply { writeText("$secret\n") } + val availableLog = File(folder, "available/session.log").apply { + parentFile!!.mkdirs() + writeText("$secret\n") + } + + assertFalse(SessionArtifacts.scrubAndMark(folder, listFiles = { + if (it == unavailable) null else it.listFiles() + })) + + assertTrue(unavailableLog.readText().contains(leaked)) + assertFalse(availableLog.readText().contains(leaked)) + assertFalse(File(folder, ".scrubbed-r${LogRedactor.RULES_VERSION}").exists()) + assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists()) + } + + @Test fun aFailedScrubStillRecordsTheCollectionEventWithoutCompletingTheFolder() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val folder = tmp.newFolder("2026-10-06-06-collect") + File(folder, "wayland.log").writeText("test compositor log\n") + File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).apply { + mkdirs() + resolve("blocker").writeText("force marker write failure\n") + } + + SessionArtifacts.collect(context, folder, "test") + + assertTrue(File(folder, "events.jsonl").readText().contains("session.artifacts_collected")) + assertFalse(File(folder, SessionArtifacts.COMPLETE_MARKER).exists()) + assertFalse(File(folder, ".scrubbed-r${LogRedactor.RULES_VERSION}").exists()) + } + @Test fun steamLogsPastTheSizeLimitStayOut() { val folder = tmp.newFolder("2026-10-06-07-steam") File(folder, "session.log").writeText("ok\n") @@ -160,4 +200,322 @@ class SessionLogShareTest { assertTrue("steam/console_log.txt" in names) assertFalse("steam/cef_log.previous.txt" in names) } + + @Test fun streamingComparisonPreservesUnchangedFileAndReplacesChangedFile() { + val folder = tmp.newFolder("2026-10-06-08-steam") + val unchanged = File(folder, "unchanged.log") + unchanged.bufferedWriter().use { out -> repeat(5000) { out.write("safe diagnostic $it\n") } } + val originalModified = 1_600_000_000_000L + assertTrue(unchanged.setLastModified(originalModified)) + val changed = File(folder, "changed.log") + changed.bufferedWriter().use { out -> + repeat(5000) { out.write("safe diagnostic $it\n") } + out.write("$secret\n") + } + + assertTrue(SessionArtifacts.scrubAndMark(folder)) + + assertEquals(originalModified, unchanged.lastModified()) + assertFalse(changed.readText().contains(leaked)) + assertTrue(changed.readText().contains("sessionid=")) + } + + @Test fun historicalScrubDefersWithoutLeavingMarkersAndResumes() { + val folder = tmp.newFolder("2026-10-06-09-steam") + val log = File(folder, "session.log").apply { writeText("$secret\n") } + val rulesMarker = File(folder, ".scrubbed-r${LogRedactor.RULES_VERSION}").apply { writeText("stale\n") } + val treeMarker = File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).apply { writeText("rules ${LogRedactor.RULES_VERSION}\n") } + + SessionState.running = true + try { + assertFalse(SessionArtifacts.scrubAndMark(folder, stopWhenSessionStarts = true)) + } finally { + SessionState.running = false + } + assertFalse(rulesMarker.exists()) + assertFalse(treeMarker.exists()) + assertTrue(log.readText().contains(leaked)) + + assertTrue(SessionArtifacts.scrubAndMark(folder, stopWhenSessionStarts = true)) + assertFalse(log.readText().contains(leaked)) + assertTrue(File(folder, ".scrubbed-r${LogRedactor.RULES_VERSION}").isFile) + } + + @Test fun historicalScrubCancellationDuringStreamingPreservesTheOriginal() { + val folder = tmp.newFolder("2026-10-06-10-steam") + val original = "first safe line\n" + "x".repeat(20_000) + " $secret\n" + val log = File(folder, "session.log").apply { writeText(original) } + var checks = 0 + + assertFalse(SessionArtifacts.scrubAndMark(folder, stopWhenSessionStarts = true) { ++checks >= 4 }) + + assertEquals(original, log.readText()) + assertFalse(File(folder, ".session.log.scrub").exists()) + assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists()) + } + + @Test fun historicalScrubCancellationBeforeComparisonPreservesTheOriginal() { + val folder = tmp.newFolder("2026-10-06-11-steam") + val original = "safe diagnostic\n" + val log = File(folder, "session.log").apply { writeText(original) } + val temporary = File(folder, ".session.log.scrub") + + assertFalse(SessionArtifacts.scrubAndMark(folder, stopWhenSessionStarts = true) { temporary.length() > 0 }) + + assertEquals(original, log.readText()) + assertFalse(temporary.exists()) + assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists()) + } + + @Test fun historicalScrubDoesNotReplaceALogThatChangedBeforeCommit() { + val folder = tmp.newFolder("2026-10-06-12-steam") + val original = "sessionid=$leaked\n" + val appended = "writer finished after collection started\n" + val log = File(folder, "session.log").apply { writeText(original) } + + assertFalse(SessionArtifacts.scrubAndMark(folder, beforeReplace = { it.appendText(appended) })) + + assertEquals(original + appended, log.readText()) + assertFalse(File(folder, ".session.log.scrub").exists()) + assertFalse(File(folder, ".scrubbed-r${LogRedactor.RULES_VERSION}").exists()) + assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists()) + } + + @Test fun comparisonCancellationWinsOverAnImmediateLengthMismatch() { + val left = tmp.newFile("left.log").apply { writeText("short\n") } + val right = tmp.newFile("right.log").apply { writeText("a different length\n") } + + assertNull(SessionArtifacts.sameContents(left, right) { true }) + } + + @Test fun recoveryRetryKeepsItsSteamLogsWhenALaterSessionLeftNoFolder() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val folder = File(com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context), "2026-10-08-01-steam").apply { mkdirs() } + val saved = File(folder, "steam/console_log.txt").apply { + parentFile!!.mkdirs() + writeText("session A\n") + } + File(folder, "ended-without-teardown.txt").writeText("recovery attempted before session B\n") + val runtime = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs/console_log.txt").apply { + parentFile!!.mkdirs() + writeText("session B\n") + } + + SessionArtifacts.finishAbandoned(context) + + assertEquals("session A\n", saved.readText()) + assertEquals("session B\n", runtime.readText()) + assertTrue(File(folder, "ended-without-teardown.txt").readText().contains("runtime capture did not finish")) + assertTrue(File(folder, SessionArtifacts.COMPLETE_MARKER).isFile) + } + + @Test fun abandonedRecoveryDoesNotCopyLogsFromANewerCompletedSession() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val logs = com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context) + val folder = File(logs, "2026-10-08-02-steam").apply { mkdirs() } + val saved = File(folder, "steam/console_log.txt").apply { + parentFile!!.mkdirs() + writeText("session A\n") + } + File(logs, "2026-10-08-03-steam").apply { mkdirs() } + .resolve(SessionArtifacts.COMPLETE_MARKER).writeText("finished session B\n") + File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs/console_log.txt").apply { + parentFile!!.mkdirs() + writeText("session B\n") + } + + SessionArtifacts.finishAbandoned(context) + + assertEquals("session A\n", saved.readText()) + assertTrue(File(folder, SessionArtifacts.COMPLETE_MARKER).isFile) + } + + @Test fun startupRecoveryRunsBeforeVacMarkerArmWhileOrdinaryCleanupDefers() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val root = com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context) + val folder = File(com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context), "2999-12-31-99-startup-recovery") + val marker = File(root, "root/.droiddeck-vac-diagnostics") + val launches = File(root, "root/.droiddeck-vac-launches") + val runtimeSteamLog = File(SessionPrefs.steamRoot(context, "arm64"), "logs/startup-recovery-vac.log") + val sessionLog = File(folder, "session.log") + val crashLog = File(folder, "crash.log") + val previousRunning = SessionState.running + val previousPhase = SessionState.phase + try { + folder.deleteRecursively() + folder.mkdirs() + SessionArtifacts.recordClient(folder, "arm64") + sessionLog.writeText("$secret\n") + marker.apply { parentFile!!.mkdirs(); writeText("0\narm64\n") } + launches.writeText("startup VAC evidence\n") + runtimeSteamLog.apply { parentFile!!.mkdirs(); writeText("startup VAC evidence\n") } + SessionState.running = true + SessionState.phase = SessionPhase.STARTING_GUEST + + SessionArtifacts.finishAbandoned(context) + + assertFalse(File(folder, "ended-without-teardown.txt").exists()) + assertFalse(File(folder, SessionArtifacts.COMPLETE_MARKER).exists()) + + SessionArtifacts.finishAbandoned(context, beforeGuestStart = true) + + assertEquals("startup VAC evidence\n", File(folder, "steam/startup-recovery-vac.log").readText()) + assertTrue(File(folder, "vac.txt").readText().contains("startup VAC evidence")) + assertTrue(File(folder, ".recovery-runtime-captured").isFile) + assertTrue(crashLog.isFile && crashLog.length() > 0L) + assertTrue(sessionLog.readText().contains(leaked)) + assertFalse(File(folder, SessionArtifacts.COMPLETE_MARKER).exists()) + + SessionState.running = false + SessionArtifacts.finishAbandoned(context) + + assertFalse(sessionLog.readText().contains(leaked)) + assertTrue(File(folder, SessionArtifacts.COMPLETE_MARKER).isFile) + } finally { + SessionState.running = previousRunning + SessionState.phase = previousPhase + folder.deleteRecursively() + runtimeSteamLog.delete() + marker.delete() + launches.delete() + } + } + + @Test fun startupRecoveryExcludesCurrentFolderAndCapturesDeferredRuntime() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val root = com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context) + val logs = com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context) + val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + val olderFolder = File(logs, "$day-8999-older-recovery") + val folder = File(logs, "$day-9000-deferred-recovery") + val marker = File(root, "root/.droiddeck-vac-diagnostics") + val launches = File(root, "root/.droiddeck-vac-launches") + val runtimeSteamLog = File(SessionPrefs.steamRoot(context, "arm64"), "logs/deferred-recovery-vac.log") + val previousRunning = SessionState.running + val previousPhase = SessionState.phase + val previousLogsEnabled = SessionPrefs.logsEnabled(context) + var currentFolder: File? = null + try { + olderFolder.deleteRecursively() + olderFolder.mkdirs() + folder.deleteRecursively() + folder.mkdirs() + SessionArtifacts.recordClient(folder, "arm64") + File(folder, "ended-without-teardown.txt").writeText("recovery began before session start\n") + marker.apply { parentFile!!.mkdirs(); writeText("0\narm64\n") } + launches.writeText("deferred VAC evidence\n") + runtimeSteamLog.apply { parentFile!!.mkdirs(); writeText("deferred VAC evidence\n") } + SessionState.running = true + SessionState.phase = SessionPhase.STARTING_GUEST + SessionPrefs.setLogsEnabled(context, true) + currentFolder = SessionPaths.beginOrCurrent(context, "current-session") + + SessionArtifacts.finishAbandoned(context, beforeGuestStart = true) + + assertEquals("deferred VAC evidence\n", File(folder, "steam/deferred-recovery-vac.log").readText()) + assertTrue(File(folder, "vac.txt").readText().contains("deferred VAC evidence")) + assertTrue(File(folder, ".recovery-runtime-captured").isFile) + assertFalse(File(folder, SessionArtifacts.COMPLETE_MARKER).exists()) + assertFalse(File(olderFolder, "ended-without-teardown.txt").exists()) + assertFalse(File(olderFolder, SessionArtifacts.COMPLETE_MARKER).exists()) + assertFalse(File(currentFolder, "ended-without-teardown.txt").exists()) + assertFalse(File(currentFolder, SessionArtifacts.COMPLETE_MARKER).exists()) + + SessionState.running = false + SessionArtifacts.finishAbandoned(context) + + assertTrue(File(folder, SessionArtifacts.COMPLETE_MARKER).isFile) + assertTrue(File(olderFolder, SessionArtifacts.COMPLETE_MARKER).isFile) + assertFalse(File(currentFolder, SessionArtifacts.COMPLETE_MARKER).exists()) + } finally { + SessionState.running = previousRunning + SessionState.phase = previousPhase + SessionPaths.take()?.let { SessionPaths.release(context, it) } + currentFolder?.deleteRecursively() + SessionPrefs.setLogsEnabled(context, previousLogsEnabled) + olderFolder.deleteRecursively() + folder.deleteRecursively() + runtimeSteamLog.delete() + marker.delete() + launches.delete() + } + } + + @Test fun failedPreGuestRuntimeCaptureWritesNeitherCaptureNorCompleteMarker() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val root = com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context) + val folder = File(com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context), "9999-12-31-01-vac-failure") + val marker = File(root, "root/.droiddeck-vac-diagnostics") + val launches = File(root, "root/.droiddeck-vac-launches") + val runtimeSteamLog = File(SessionPrefs.steamRoot(context, "arm64"), "logs/expired-recovery.log") + val crashLog = File(folder, "crash.log") + val previousRunning = SessionState.running + try { + folder.deleteRecursively() + folder.mkdirs() + SessionArtifacts.recordClient(folder, "arm64") + File(folder, "ended-without-teardown.txt").writeText("recovery began before session start\n") + val firstCrashSnapshot = "first crash snapshot\n".toByteArray() + crashLog.writeBytes(firstCrashSnapshot) + File(folder, "vac.txt").mkdirs() + marker.apply { parentFile!!.mkdirs(); writeText("0\narm64\n") } + launches.writeText("VAC evidence that cannot be reported\n") + runtimeSteamLog.apply { parentFile!!.mkdirs(); writeText("session A\n") } + SessionState.running = true + + SessionArtifacts.finishAbandoned(context, beforeGuestStart = true) + + assertFalse(File(folder, ".recovery-runtime-captured").exists()) + assertTrue(File(folder, ".recovery-runtime-expired").isFile) + assertFalse(File(folder, SessionArtifacts.COMPLETE_MARKER).exists()) + assertArrayEquals(firstCrashSnapshot, crashLog.readBytes()) + + File(folder, "vac.txt").deleteRecursively() + runtimeSteamLog.writeText("session B\n") + SessionArtifacts.finishAbandoned(context, beforeGuestStart = true) + + assertEquals("session A\n", File(folder, "steam/expired-recovery.log").readText()) + assertArrayEquals(firstCrashSnapshot, crashLog.readBytes()) + assertTrue(File(folder, "ended-without-teardown.txt").readText().contains("runtime capture did not finish")) + assertFalse(File(folder, SessionArtifacts.COMPLETE_MARKER).exists()) + + SessionState.running = false + SessionArtifacts.finishAbandoned(context) + + assertArrayEquals(firstCrashSnapshot, crashLog.readBytes()) + assertTrue(File(folder, SessionArtifacts.COMPLETE_MARKER).isFile) + } finally { + SessionState.running = previousRunning + folder.deleteRecursively() + runtimeSteamLog.delete() + marker.delete() + launches.delete() + } + } + + @Test fun emptyCrashSnapshotIsPreservedAcrossRecovery() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val folder = File(com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context), "9999-12-31-02-empty-crash") + val crashLog = File(folder, "crash.log") + val previousRunning = SessionState.running + try { + folder.deleteRecursively() + folder.mkdirs() + crashLog.writeBytes(ByteArray(0)) + SessionState.running = true + + SessionArtifacts.finishAbandoned(context, beforeGuestStart = true) + assertTrue(crashLog.isFile) + assertEquals(0L, crashLog.length()) + + SessionState.running = false + SessionArtifacts.finishAbandoned(context) + assertTrue(crashLog.isFile) + assertEquals(0L, crashLog.length()) + assertTrue(File(folder, SessionArtifacts.COMPLETE_MARKER).isFile) + } finally { + SessionState.running = previousRunning + folder.deleteRecursively() + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index 012d2a916..674f6211e 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.session import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File +import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -20,6 +21,49 @@ import org.robolectric.annotation.Config class SessionLogShareZipTest { private val app get() = RuntimeEnvironment.getApplication() + @Test fun aLargeSteamLogIsRedactedInTheSharedArchive() { + val session = File(LinuxRuntime.logDir(app), "2026-10-08-02-steam").apply { mkdirs() } + val raw = File(session, "steam-42.log") + val earlySecret = "early-sessionid-abcdef123456" + val trailingSecret = "late-refresh-token-abcdef123456" + try { + // One long line exercises the streaming limit; the final ordinary line must still be redacted. + raw.bufferedWriter().use { out -> + out.write("sessionid=$earlySecret") + val chunk = "x".repeat(8192) + repeat(8192) { out.write(chunk) } + out.write("\nrefresh_token=$trailingSecret\n") + } + assertTrue(raw.length() > 64L * 1024 * 1024) + + val zip = SessionLogShare.zipFolder(app, session)!! + ZipFile(zip).use { archive -> + val entry = archive.getEntry("${session.name}/steam-42.log")!! + archive.getInputStream(entry).use { input -> + // Only inspect the bounded scrubbed prefix: the pre-fix raw archive is over 64 MiB. + val expected = "${com.droiddeck.launcher.core.LogRedactor.OVERSIZED_LINE}\n" + val prefix = ByteArray(expected.toByteArray(StandardCharsets.UTF_8).size) + var offset = 0 + while (offset < prefix.size) { + val count = input.read(prefix, offset, prefix.size - offset) + assertTrue("zip entry ended before its scrubbed prefix", count > 0) + offset += count + } + assertEquals(expected, String(prefix, StandardCharsets.UTF_8)) + input.bufferedReader().use { reader -> + val trailing = requireNotNull(reader.readLine()) + assertFalse("trailing secret survived", trailing.contains(trailingSecret)) + assertEquals("refresh_token=", trailing) + assertEquals(null, reader.readLine()) + } + } + } + } finally { + SessionLogShare.clear(app) + session.deleteRecursively() + } + } + @Test fun everyTextFileInTheZipIsScrubbedAndTheStoresAndToolsLogsComeAlong() { val logs = LinuxRuntime.logDir(app) val session = File(logs, "2026-10-08-01-steam").apply { mkdirs() } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSteamLogsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSteamLogsTest.kt index 60c57ecfd..3b996ae3d 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionSteamLogsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSteamLogsTest.kt @@ -2,10 +2,13 @@ package com.droiddeck.launcher.session 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 java.io.File +import java.io.IOException +import java.io.Writer class SessionSteamLogsTest { @get:Rule val tmp = TemporaryFolder() @@ -59,4 +62,37 @@ class SessionSteamLogsTest { assertEquals(listOf("web.txt"), names(copied)) assertFalse(File(out, "web.txt").readText().contains("abcdef123456")) } + + @Test fun aCopyFailurePreservesThePreviousSnapshotAndRemovesPartialStaging() { + val logs = tmp.newFolder("logs") + val session = tmp.newFolder("session") + val out = tmp.newFolder("out") + File(logs, "console_log.txt").writeText("new complete line\n") + val snapshot = File(out, "console_log.txt").apply { writeText("previous valid snapshot\n") } + lateinit var staging: File + + val copied = SessionArtifacts.copySessionLogs(logs, session, out) { target -> + staging = target + val delegate = target.bufferedWriter() + object : Writer() { + private var failed = false + + override fun write(buffer: CharArray, offset: Int, length: Int) { + if (!failed) { + delegate.write(buffer, offset, minOf(length, 4)) + delegate.flush() + failed = true + } + throw IOException("injected writer failure") + } + + override fun flush() = delegate.flush() + override fun close() = delegate.close() + } + } + + assertTrue(copied.isEmpty()) + assertEquals("previous valid snapshot\n", snapshot.readText()) + assertFalse(staging.exists()) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt index 02b38143e..532422d46 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt @@ -1,6 +1,8 @@ package com.droiddeck.launcher.session import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.concurrent.AbstractExecutorService import java.util.concurrent.RejectedExecutionException import java.util.concurrent.TimeUnit @@ -168,6 +170,40 @@ class SessionSuspendControllerTest { assertFalse(fixture.signals.drop(before).any { it.pid == helper.pid }) } + @Test fun frozenTimeIsAddedToTheGuestClockBeforeItContinues() { + val fixture = fixture(17) + fixture.clock.writeBytes(shift(5_000_000L)) + fixture.controller.freeze { assertTrue(it) } + fixture.now += 90_000L + fixture.controller.resume { assertTrue(it) } + val expected = 5_000_000L + 90_000L * 1_000_000L + assertEquals(expected, fixture.clockAtContinue) + assertEquals(expected, shift(fixture.clock)) + fixture.controller.resume { assertTrue(it) } + assertEquals(expected, shift(fixture.clock)) + fixture.controller.freeze { assertTrue(it) } + fixture.now += 1_000L + fixture.controller.closeAndResume {} + assertEquals(expected + 1_000L * 1_000_000L, shift(fixture.clock)) + } + + @Test fun rolledBackFreezeLeavesTheGuestClockAlone() { + val fixture = fixture(17) + fixture.clock.writeBytes(shift(0L)) + fixture.neverStop = true + fixture.controller.freeze { assertFalse(it) } + fixture.controller.resume { } + assertEquals(0L, shift(fixture.clock)) + } + + @Test fun missingClockFileIsNotCreated() { + val fixture = fixture(17) + fixture.controller.freeze { assertTrue(it) } + fixture.now += 5_000L + fixture.controller.resume { assertTrue(it) } + assertFalse(fixture.clock.exists()) + } + @Test fun timeoutRollsBackAllKnownProcessesAndAudio() { val fixture = fixture(17) fixture.neverStop = true @@ -312,6 +348,10 @@ class SessionSuspendControllerTest { assertEquals(0, fixture.audioSuspends) } + private fun shift(value: Long) = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(value).array() + + private fun shift(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN).long + private fun fixture(count: Int, executor: QueueExecutor = InlineExecutor()) = Fixture(temporary.newFolder(), temporary.newFolder(), count, executor) @@ -337,6 +377,8 @@ class SessionSuspendControllerTest { var rootGone = false var afterSleep: (() -> Unit)? = null val control = File(tmp, ".bl-zram") + val clock = File(tmp, ".bl-clock") + var clockAtContinue: Long? = null val pagedOut = mutableListOf>() private var gameResidentKb = 4_000_000L private var gameSwapKb = 0L @@ -349,6 +391,8 @@ class SessionSuspendControllerTest { { rootPid }, { helpers }, { audioSuspends++ }, { audioResumes++ }, proc, ::signal, ::threadSignal, ::sleep, { now }, executor, zramControl = control, + clockControl = clock, + nowNanos = { now * 1_000_000L }, pagedOut = { processes, unacknowledged, resident, swap, _, _ -> pagedOut += Triple(processes, resident, swap) this.unacknowledged = unacknowledged @@ -369,6 +413,9 @@ class SessionSuspendControllerTest { } else if (signal == URG) { pagingOut = true } else if (signal == CONT) { + if (clockAtContinue == null && clock.isFile) { + clockAtContinue = ByteBuffer.wrap(clock.readBytes()).order(ByteOrder.LITTLE_ENDIAN).long + } continuing = true if (!(pid == GAME && blockGameContinue)) group.threads.forEach { if (it.state != 'Z') it.state = 'S' } } 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 c37f59c9c..db1d2b827 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt @@ -34,11 +34,14 @@ class SettingsResetTest { SessionPrefs.setHdr(app, SessionService.MODE_DESKTOP, true) SessionPrefs.setResolutionChoice(app, SessionService.MODE_STEAM, "1280x800") SessionPrefs.setGuestHostname(app, "MyDeck") + assertTrue(SessionPrefs.setGameDebugLogsEnabled(app, true)) SessionPrefs.setHudShowFps(app, false) SessionPrefs.setHudShowBattery(app, false) SessionPrefs.setHudShowGraph(app, false) SessionPrefs.setHudColor(app, "amber") SessionPrefs.setHudOpacity(app, 40) + SessionPrefs.setFusionHudConfig(app, "hudSize=mega,showFPSGraph=0") + SessionPrefs.setFusionHudPosition(app, 120f, 40f) ControllerPrefs.setOpacity(app, 40) AppUiPrefs.setScale(app, 150) FrameGen.set(app, FrameGen.Mode(FrameGen.ENGINE_DDFG, 3)) @@ -54,19 +57,39 @@ class SettingsResetTest { assertFalse(SessionPrefs.hdr(app, SessionService.MODE_DESKTOP)) assertFalse(SessionPrefs.resolutionChosen(app, SessionService.MODE_STEAM)) assertEquals(SessionPrefs.DEFAULT_GUEST_HOSTNAME, SessionPrefs.guestHostname(app)) + assertFalse(SessionPrefs.gameDebugLogsEnabled(app)) + val guest = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app), + "root/.config/droiddeck/game-environment.json") + assertFalse(org.json.JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS)) 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)) + assertNull(SessionPrefs.fusionHudConfig(app)) + assertNull(SessionPrefs.fusionHudPosition(app)) assertEquals(100, ControllerPrefs.read(app).opacity) assertEquals(AppUiPrefs.DEFAULT_SCALE, AppUiPrefs.scale(app)) assertEquals(FrameGen.Mode.OFF.engine, FrameGen.engine(app)) assertFalse(prefs("steam_touch").contains("gyro")) } + @Test fun aFailedDebugOptOutDoesNotHideTheEnabledPreference() { + assertTrue(SessionPrefs.setGameDebugLogsEnabled(app, true)) + val directory = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app), + "root/.config/droiddeck") + directory.deleteRecursively() + directory.writeText("blocks configuration publication") + try { + assertFalse(SettingsReset.resetAll(app, gameProfiles = false)) + assertTrue(SessionPrefs.gameDebugLogsEnabled(app)) + } finally { + directory.delete() + directory.mkdirs() + GameEnvironmentStore.publish(app) + } + } + @Test fun libraryAndRecordsAreKept() { val folder = "/storage/emulated/0/Games/Foo" SessionPrefs.setAddedGameName(app, folder, "Foo") diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamIdleSleepTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamIdleSleepTest.kt new file mode 100644 index 000000000..05fc9c2a6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamIdleSleepTest.kt @@ -0,0 +1,56 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class SteamIdleSleepTest { + @get:Rule val temp = TemporaryFolder() + + private val keys = listOf( + "IdleSuspendBatterySeconds", "IdleSuspendACSeconds", + ) + + private fun home(vararg steamBody: String): File = temp.newFolder().also { + File(it, "config").mkdirs() + File(it, "config/config.vdf").writeText( + "\"InstallConfigStore\"\n{\n\t\"Software\"\n\t{\n\t\t\"Valve\"\n\t\t{\n\t\t\t\"Steam\"\n\t\t\t{\n" + + steamBody.joinToString("") + "\t\t\t}\n\t\t}\n\t}\n}\n", + ) + } + + private fun text(home: File) = File(home, "config/config.vdf").readText() + private fun value(text: String, key: String) = Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1) + + @Test fun freshClientGetsBothSleepTimersDisabled() { + val home = home() + assertTrue(SessionFiles.disableIdleSleep(home)) + keys.forEach { assertEquals(it, "0", value(text(home), it)) } + } + + @Test fun existingSleepTimersAreReplacedBesideTheOtherSettings() { + val home = home( + "\t\t\t\t\"system\"\n\t\t\t\t{\n\t\t\t\t\t\"IdleSuspendACSeconds\"\t\t\"900\"\n" + + "\t\t\t\t\t\"IdleScreensaverACSeconds\"\t\t\"300\"\n\t\t\t\t\t\"IdleBacklightDimBatterySeconds\"\t\t\"120\"\n\t\t\t\t}\n", + ) + assertTrue(SessionFiles.disableIdleSleep(home)) + keys.forEach { assertEquals(it, "0", value(text(home), it)) } + assertEquals("300", value(text(home), "IdleScreensaverACSeconds")) + assertEquals("120", value(text(home), "IdleBacklightDimBatterySeconds")) + } + + @Test fun aChoiceMadeInSteamIsResetAtTheNextSession() { + val home = home() + SessionFiles.disableIdleSleep(home) + File(home, "config/config.vdf").writeText(text(home).replace("\"IdleSuspendACSeconds\"\t\t\"0\"", "\"IdleSuspendACSeconds\"\t\t\"600\"")) + assertTrue(SessionFiles.disableIdleSleep(home)) + assertEquals("0", value(text(home), "IdleSuspendACSeconds")) + } + + @Test fun clientWithoutAConfigIsRetriedNextSession() { + val home = temp.newFolder() + assertFalse(SessionFiles.disableIdleSleep(home)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamImportPromptTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamImportPromptTest.kt new file mode 100644 index 000000000..0ff861749 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamImportPromptTest.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.session + +import android.app.Application +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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], manifest = Config.NONE, application = Application::class) +class SteamImportPromptTest { + @get:Rule val temp = TemporaryFolder() + + private fun ask(root: File, text: String) = File(root, "root/.cache/droiddeck").apply { mkdirs() }.resolve("steam-import.json").writeText(text) + + @Test fun aRequestFromTheGuestIsRead() { + val root = temp.newFolder() + ask(root, """{"token":"7","games":["Absolum","","Hades"]}""") + assertEquals(SteamImportPrompt.Ask("7", listOf("Absolum", "Hades")), SteamImportPrompt.read(root)) + } + + @Test fun noRequestOrABrokenOneIsIgnored() { + val root = temp.newFolder() + assertNull(SteamImportPrompt.read(root)) + ask(root, "{") + assertNull(SteamImportPrompt.read(root)) + ask(root, """{"games":["Hades"]}""") + assertNull(SteamImportPrompt.read(root)) + } + + @Test fun theAnswerCarriesTheTokenAndTheChoice() { + val root = temp.newFolder() + for ((now, choice) in listOf(true to "now", false to "later")) { + SteamImportPrompt.answer(root, "9", now) + val json = JSONObject(File(root, "root/.cache/droiddeck/steam-import.answer").readText()) + assertEquals("9", json.getString("token")) + assertEquals(choice, json.getString("choice")) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/UsbLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/session/UsbLinkTest.kt new file mode 100644 index 000000000..7921ddfee --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/UsbLinkTest.kt @@ -0,0 +1,97 @@ +package com.droiddeck.launcher.session + +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class UsbLinkTest { + private lateinit var sys: File + + @Before fun setUp() { sys = Files.createTempDirectory("usblink").toFile() } + + @After fun tearDown() { + Files.walk(sys.toPath()).use { paths -> paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } } + } + + private val mounts = """ + /dev/block/dm-5 /data ext4 rw 0 0 + /dev/fuse /storage/8C5A-1F23 fuse rw 0 0 + /dev/block/vold/public:8,97 /mnt/media_rw/8C5A-1F23 fuseblk rw 0 0 + """.trimIndent() + + private fun drive(deviceSpeed: String, otherHub: String) { + val controller = File(sys, "devices/platform/xhci") + val hub = File(controller, "usb1").apply { mkdirs(); File(this, "speed").writeText("480\n"); File(this, "idVendor").writeText("1d6b\n") } + File(controller, "usb2").apply { mkdirs(); File(this, "speed").writeText("$otherHub\n"); File(this, "idVendor").writeText("1d6b\n") } + val device = File(hub, "1-1").apply { mkdirs() } + File(device, "speed").writeText("$deviceSpeed\n") + File(device, "idVendor").writeText("04e8\n") + File(device, "busnum").writeText("1\n") + File(device, "devnum").writeText("7\n") + File(device, "manufacturer").writeText("Samsung\n") + File(device, "product").writeText("PSSD T7\n") + val part = File(device, "1-1:1.0/host1/target1:0:0/1:0:0:0/block/sdg/sdg1").apply { mkdirs() } + File(sys, "dev/block").mkdirs() + Files.createSymbolicLink(File(sys, "dev/block/8:97").toPath(), part.toPath()) + File(sys, "bus/usb/devices").mkdirs() + Files.createSymbolicLink(File(sys, "bus/usb/devices/usb1").toPath(), hub.toPath()) + } + + @Test fun findsTheUsbSpeedOfADriveBehindItsMount() { + drive("480", "10000") + val link = UsbLink.forPath("/storage/8C5A-1F23/Games", mounts, sys)!! + assertEquals("8:97", link.device) + assertEquals(480.0, link.mbps, 0.0) + assertEquals("Samsung PSSD T7", link.name) + assertEquals(10000.0, link.portMbps!!, 0.0) + assertEquals("1-7", link.plug) + assertEquals("3/1-7/8:97@480.0", UsbLink.dismissKey(3, link)) + assertFalse(UsbLink.SlowDrive("k", link.name, link.mbps, link.portMbps).portLimited) + } + + @Test fun aPortWithoutSuperSpeedIsReportedAsTheLimit() { + drive("480", "480") + val link = UsbLink.forPath("/storage/8C5A-1F23", mounts, sys)!! + assertTrue(UsbLink.SlowDrive("k", link.name, link.mbps, link.portMbps).portLimited) + } + + @Test fun anUnknownPortSpeedIsNotReportedAsTheLimit() { + drive("480", "10000") + Files.delete(File(sys, "bus/usb/devices/usb1").toPath()) + val link = UsbLink.forPath("/storage/8C5A-1F23", mounts, sys)!! + assertNull(link.portMbps) + assertFalse(UsbLink.SlowDrive("k", link.name, link.mbps, link.portMbps).portLimited) + } + + @Test fun internalStorageAndUnknownMountsHaveNoLink() { + drive("480", "10000") + assertNull(UsbLink.forPath("/storage/emulated/0/Games", mounts, sys)) + assertNull(UsbLink.forPath("/storage/ABCD-0000", mounts, sys)) + } + + @Test fun mountsPickTheLongestMatchingMountPoint() { + assertEquals("8:97", UsbLink.mountDevice("/mnt/media_rw/8C5A-1F23/Games/PC", mounts)) + assertNull(UsbLink.mountDevice("/mnt/media_rw/8C5A-1F230", mounts)) + assertEquals("name:sda1", UsbLink.mountDevice("/mnt/usb/a", "/dev/block/sda1 /mnt/usb vfat rw 0 0")) + } + + @Test fun rawMountPathsAreTreatedAsTheirStorageVolume() { + assertEquals("/storage/8C5A-1F23/Games", UsbLink.storagePath("/mnt/media_rw/8C5A-1F23/Games")) + assertEquals("/storage/emulated/0/Games", UsbLink.storagePath("/storage/emulated/0/Games")) + assertTrue(UsbLink.external(UsbLink.storagePath("/mnt/media_rw/8C5A-1F23/Games"))) + } + + @Test fun generationsFollowTheLinkSpeed() { + assertEquals("USB 1.1", UsbLink.generation(12.0)) + assertEquals("USB 2.0", UsbLink.generation(480.0)) + assertEquals("USB 3.2 Gen 1", UsbLink.generation(5000.0)) + assertEquals("USB 3.2 Gen 2", UsbLink.generation(10000.0)) + assertEquals(42, UsbLink.megabytesPerSecond(480.0)) + } +} 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 0aeb49bb3..cd14c6660 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.stores +import android.app.Application import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals @@ -18,7 +19,7 @@ import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec @RunWith(RobolectricTestRunner::class) -@Config(sdk = [33]) +@Config(sdk = [33], application = Application::class) class StoreAccountsCipherTest { private val app get() = RuntimeEnvironment.getApplication() private fun newKey(): SecretKey = SecretKeySpec(ByteArray(32).also { SecureRandom().nextBytes(it) }, "AES") diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt index 126001797..78508a525 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt @@ -138,6 +138,32 @@ class StoreInstallRootTest { assertTrue(File(LinuxRuntime.rootDir(app), xodusRoot.second.removePrefix("/")).isDirectory) } + @Test fun xodusRootsKeepEveryOtherStoreRootAtTheSameGuestPath() { + val card = File(app.filesDir, "card2").apply { mkdirs() } + val library = File(app.filesDir, "card/steam").apply { mkdirs() } + File(library, "Games").mkdirs() + SessionPrefs.setDefaultStorageFolder(app, card.path, "Card 2") + SessionPrefs.setGameStorage(app, library.path, "Card") + + val xodus = StoreInstallRoot.externalRootsForXodus(app) + assertTrue(xodus.containsAll(StoreInstallRoot.externalRoots(app))) + assertTrue(xodus.any { it.first.absolutePath == File(library, "Games").absolutePath }) + } + + @Test fun theSteamLibraryGetsItsOwnGuestPathWhenItSharesACardWithAnotherStoreRoot() { + val card = File(app.filesDir, "same-card/Games") + val library = File(app.filesDir, "same-card/steam/Games") + val base = "/mnt/droiddeck-stores/ABCD-1234" + + val roots = StoreInstallRoot.withSelectedLibrary(listOf(card to base), library, base) + assertEquals(2, roots.map { it.second }.distinct().size) + assertEquals(base, roots.single { it.first == card }.second) + assertTrue(roots.single { it.first == library }.second.startsWith("$base-")) + assertEquals("${roots.single { it.first == library }.second}/Xbox/Doom", + StoreInstallRoot.externalGuestPath(File(library, "Xbox/Doom"), roots)) + assertEquals(roots, StoreInstallRoot.withSelectedLibrary(roots, library, base)) + } + @Test fun externalStoreBindsCreateGuestTargetsAndSkipUnavailableVolumes() { val host = File(app.filesDir, "card/Games").apply { mkdirs() } val guestRoot = "/mnt/droiddeck-stores/card" diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt new file mode 100644 index 000000000..1d755a6db --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt @@ -0,0 +1,402 @@ +package com.droiddeck.launcher.stores.gog + +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreLanguage +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadState +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +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.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicBoolean + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class GogMaintenanceTest { + @get:Rule val temporary = TemporaryFolder() + + @Test fun selectsPublicBranchAndTreatsBuildIdsAsOpaque() { + val builds = JSONObject("""{"items":[ + {"os":"windows","branch":"beta","build_id":"99","generation":2,"link":"https://example.invalid/beta"}, + {"os":"windows","branch":null,"build_id":"0002","generation":2,"version_name":"RC-final","link":"https://example.invalid/current"}, + {"os":"windows","branch":null,"build_id":"10","generation":2,"link":"https://example.invalid/old"}]}""") + assertEquals("0002", GogBuild.select(builds)!!.id) + assertEquals("99", GogBuild.select(builds, branch = "beta")!!.id) + assertEquals("10", GogBuild.select(builds, pinned = "10")!!.id) + assertNull(GogBuild.select(builds, pinned = "missing")) + assertNull(GogBuild.select(builds, branch = "private")) + } + + @Test fun migrationRetainsKnownBuildLanguageAndInstalledProducts() { + val folder = temporary.newFolder() + File(folder, "_gog_manifest.json").writeText("""{"gameId":"1","buildId":"000123"}""") + File(folder, "goggame-2.info").writeText("{}") + val sidecar = StoreGameSidecar(Store.GOG, "1", "Game", "game.exe", installVersion = "000123", + args = listOf("--custom"), env = mapOf("DD_TEST" to "1"), cloud = false, achievements = false, + extra = mapOf(StoreLanguage.EXTRA to "pl-PL")) + val record = GogInstallRecord.migrate(folder, sidecar) + assertEquals("000123", record.build) + assertEquals("pl-PL", record.language) + assertEquals(listOf("1", "2"), record.products) + assertEquals(record.json().toString(), GogInstallRecord.read(folder)!!.json().toString()) + assertEquals(listOf("--custom"), sidecar.args) + assertFalse(sidecar.cloud) + } + + @Test fun missingManifestNeverInventsLatestBuild() { + val folder = temporary.newFolder() + val record = GogInstallRecord.migrate(folder, StoreGameSidecar(Store.GOG, "1", "Game", "game.exe")) + assertEquals("offline", record.source) + assertEquals("", record.build) + assertEquals(0, record.files.length()) + } + + @Test fun offlineRefreshWithDlcRequiresAnAuthenticMatchingDepotBuild() { + val extracted = temporary.newFolder() + val app = File(extracted, "app").apply { mkdirs() } + val target = GogInstallRecord("1", "depot", build = "0000123", products = listOf("1", "2")) + assertThrows(IllegalArgumentException::class.java) { GogOffline.validateDepotInstaller(extracted, target) } + File(app, "goggame-1.info").writeText("""{"buildId":"0000999"}""") + assertThrows(IllegalArgumentException::class.java) { GogOffline.validateDepotInstaller(extracted, target) } + File(app, "goggame-1.info").writeText("""{"buildId":"0000123"}""") + GogOffline.validateDepotInstaller(extracted, target) + File(app, "goggame-1.info").delete() + GogOffline.validateDepotInstaller(extracted, target.copy(source = "offline", build = "")) + } + + @Test fun offlineRefreshRetainsTheExistingExecutableLayout() { + val installed = temporary.newFolder() + val extracted = temporary.newFolder() + val app = File(extracted, "app").apply { mkdirs() } + File(app, "game.exe").writeText("replacement") + File(installed, "goggame-1.info").writeText("{}") + assertEquals(extracted, GogOffline.layout(extracted, installed, "1", "game.exe").source) + assertEquals("", GogOffline.layout(extracted, installed, "1", "game.exe").prefix) + File(installed, "goggame-1.info").delete() + assertEquals(extracted, GogOffline.layout(extracted, installed, "1", "app/game.exe").source) + val flat = temporary.newFolder() + val legacy = GogOffline.layout(flat, installed, "1", "app/game.exe") + assertEquals(flat, legacy.source) + assertEquals("app/", legacy.prefix) + } + + @Test fun offlineInstallerMatchesRegionalLanguageWithoutChoosingAnotherLanguage() { + val downloads = JSONObject("""{"installers":[ + {"id":"de","os":"windows","language":"de","files":[{"downlink":"https://example.invalid/de"}]}, + {"id":"en","os":"windows","language":"en","files":[{"downlink":"https://example.invalid/en"}]}]}""") + assertEquals("en", GogMaintenance.offlineInstaller(downloads, "en-US").id) + assertThrows(IllegalStateException::class.java) { GogMaintenance.offlineInstaller(downloads, "pl-PL") } + downloads.getJSONArray("installers").put(0, JSONObject("""{"id":"neutral","os":"windows","language":"Neutral","files":[{"downlink":"https://example.invalid/neutral"}]}""")) + assertEquals("en", GogMaintenance.offlineInstaller(downloads, "en-US").id) + assertEquals("neutral", GogMaintenance.offlineInstaller(downloads, "pl-PL").id) + } + + @Test fun incompleteOfflineExtractionCannotReplaceOrRemoveInstalledFiles() { + val folder = temporary.newFolder() + StoreGameSidecar(Store.GOG, "1", "Game", "game.exe").write(folder) + val game = File(folder, "game.exe").apply { writeText("installed game") } + val old = GogInstallRecord("1", "depot", files = JSONArray().put(JSONObject().put("path", game.name) + .put("size", game.length()).put("md5", GogFiles.hash(game)))) + old.write(folder) + val extracted = temporary.newFolder() + File(extracted, "goggame-1.ico").writeText("ancillary file") + assertThrows(IllegalStateException::class.java) { + GogOffline.applyRefresh(GogMaintenance.Proposal(folder, old, old.copy(source = "offline"), GogOperation.REFRESH, 0, 0), extracted, null, AtomicBoolean(false)) + } + assertEquals("installed game", game.readText()) + assertFalse(File(folder, "goggame-1.ico").exists()) + assertEquals("depot", GogInstallRecord.read(folder)!!.source) + val migrated = old.copy(files = JSONArray()) + migrated.write(folder) + assertThrows(IllegalStateException::class.java) { + GogOffline.applyRefresh(GogMaintenance.Proposal(folder, migrated, migrated.copy(source = "offline"), GogOperation.REFRESH, 0, 0), extracted, null, AtomicBoolean(false)) + } + assertEquals("installed game", game.readText()) + assertFalse(File(folder, "goggame-1.ico").exists()) + assertEquals("depot", GogInstallRecord.read(folder)!!.source) + assertEquals(0, GogInstallRecord.read(folder)!!.files.length()) + } + + @Test fun offlineRefreshCombinesRootPayloadAndAppPlaceholdersInBothLayouts() { + for (nested in listOf(false, true)) { + val folder = temporary.newFolder() + val executable = if (nested) "app/game.exe" else "game.exe" + StoreGameSidecar(Store.GOG, "1", "Game", executable).write(folder) + val game = File(folder, executable).apply { parentFile!!.mkdirs(); writeText("old game") } + val old = GogInstallRecord("1", "offline", files = JSONArray().put(JSONObject().put("path", executable) + .put("size", game.length()).put("md5", GogFiles.hash(game)))) + old.write(folder) + val extracted = temporary.newFolder() + File(extracted, "game.exe").writeText("complete new game") + File(extracted, "Data").mkdirs() + File(extracted, "app/Data/SaveGame").mkdirs() + File(extracted, "app/Data/SaveGame/dummy.txt").writeBytes(byteArrayOf()) + File(extracted, "tmp").mkdirs() + File(extracted, "tmp/installer.exe").writeText("installer support") + GogOffline.applyRefresh(GogMaintenance.Proposal(folder, old, old, GogOperation.REFRESH, 0, 0), extracted, null, AtomicBoolean(false)) + assertEquals("complete new game", game.readText()) + assertTrue(File(folder, (if (nested) "app/" else "") + "Data/SaveGame/dummy.txt").isFile) + assertFalse(File(folder, "tmp").exists()) + assertTrue(GogInstallRecord.read(folder)!!.files.objects().any { it.getString("path") == executable }) + } + } + + @Test fun offlineRefreshDefersDlcCleanupAndRecordPublicationUntilDepotCompletion() { + val folder = temporary.newFolder() + StoreGameSidecar(Store.GOG, "1", "Game", "base.exe").write(folder) + val base = File(folder, "base.exe").apply { writeText("old base") } + val dlc = File(folder, "dlc.bin").apply { writeText("old DLC") } + val obsolete = File(folder, "old.bin").apply { writeText("obsolete") } + val save = File(folder, "save.dat").apply { writeText("player progress") } + fun entry(file: File, product: String) = JSONObject().put("path", file.name).put("size", file.length()) + .put("md5", GogFiles.hash(file)).put("product", product).put("offset", 0).put("remotePath", file.name) + val old = GogInstallRecord("1", "depot", build = "old", products = listOf("1", "2"), + files = JSONArray().put(entry(base, "1")).put(entry(dlc, "2")).put(entry(obsolete, "1"))) + old.write(folder) + val recordFile = File(GogFiles.dir(folder), "installed.json") + val record = recordFile.readText() + val extracted = temporary.newFolder() + val app = File(extracted, "app").apply { mkdirs() } + File(app, "goggame-1.info").writeText("""{"buildId":"new"}""") + val replacement = File(app, "base.exe").apply { writeText("new base") } + val expectedDlc = File(temporary.newFolder(), "dlc.bin").apply { writeText("new DLC") } + val target = old.copy(build = "new", files = JSONArray().put(entry(replacement, "1")).put(entry(expectedDlc, "2"))) + GogOffline.applyRefresh(GogMaintenance.Proposal(folder, old, target, GogOperation.REFRESH, 0, 0), extracted, null, AtomicBoolean(false)) + assertEquals("new base", base.readText()) + assertEquals("old DLC", dlc.readText()) + assertTrue(obsolete.isFile) + assertEquals(record, recordFile.readText()) + val server = java.net.ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1")) + val worker = Thread { + server.accept().use { client -> + val reader = client.getInputStream().bufferedReader() + while (!reader.readLine().isNullOrEmpty()) Unit + val content = expectedDlc.readBytes() + client.getOutputStream().apply { + write("HTTP/1.1 206 Partial Content\r\nContent-Length: ${content.size}\r\nConnection: close\r\n\r\n".toByteArray()) + write(content); flush() + } + } + }.apply { isDaemon = true; start() } + try { + GogFallback.transfer(folder, JSONObject().put("files", target.files).put("oldFiles", old.files) + .put("links", JSONObject().put("2", JSONArray().put("http://127.0.0.1:${server.localPort}"))), null, AtomicBoolean(false)) + assertEquals("new DLC", dlc.readText()) + assertFalse(obsolete.exists()) + assertEquals("player progress", save.readText()) + assertEquals(record, recordFile.readText()) + } finally { server.close(); worker.join(2000) } + } + + @Test fun authenticIdentityOverridesAStaleLegacyMarkerWithoutTreatingSchemaAsVersion() { + val folder = temporary.newFolder() + File(folder, "_gog_manifest.json").writeText("""{"buildId":"old"}""") + File(folder, "goggame-1.id").writeText("""{"buildId":"patched"}""") + File(folder, "goggame-1.info").writeText("""{"version":1}""") + val record = GogInstallRecord.migrate(folder, StoreGameSidecar(Store.GOG, "1", "Game", "game.exe", installVersion = "old")) + assertEquals("patched", record.build) + assertEquals("", record.version) + } + + @Test fun authenticGenerationOneResolvesProductDepotsAndRetainsHashesAndBuild() { + fun fixture(name: String) = JSONObject(javaClass.classLoader!!.getResource("gog/$name")!!.readText()) + val build = GogBuild("3301", "", 1, "", "https://example.invalid/repository.json") + val requested = ArrayList() + val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused") { url, _ -> + requested.add(url) + when { + url == build.link -> fixture("rayman-gen1-repository.json") + url.endsWith("a01f6fb8-55a8-4f33-bc5b-5f5d2f591775.json") -> fixture("rayman-gen1-neutral.json") + url.endsWith("ec88140c-2589-41a0-bbf3-8fc1f215fe5a.json") -> fixture("rayman-gen1-english-excerpt.json") + else -> error("Unexpected metadata: $url") + } + } + assertEquals("3301", snapshot.build) + assertEquals(1, snapshot.generation) + assertEquals(4, snapshot.files.length()) + val support = snapshot.files.getJSONObject(0) + assertEquals("support/1207658940/galaxy_rayman2_2.1.0.39.exe", support.getString("path")) + assertEquals("ee2c247373733f29782395f1f92622f6", support.getString("md5")) + val game = snapshot.files.getJSONObject(1) + assertEquals("Data/World/Levels/Raycap/Raycap.snd", game.getString("path")) + assertEquals(384206096L, game.getLong("offset")) + assertEquals("1207658940/main.bin", game.getString("remotePath")) + assertTrue(requested.drop(1).all { "/1207658940/windows/31847977/" in it }) + GogFiles.validate(snapshot.files, temporary.newFolder()) + } + + @Test fun multipartPatchNamesVersionsLanguageAndChecksumsSurviveParsing() { + val downloads = JSONObject("""{"patches":[ + {"id":"patch-1","name":"Patch A to B","version":"B","language":"pl","os":"windows","files":[ + {"id":"part0","filename":"patch_original.exe","downlink":"https://example.invalid/0","size":3,"md5":"abc"}, + {"id":"part1","filename":"patch_original-1.bin","downlink":"https://example.invalid/1","size":4}]}, + {"id":"linux","os":"linux","files":[{"downlink":"https://example.invalid/linux"}]}]}""") + val patch = GogOfflineDownload.parse(downloads, "patches").single() + assertEquals("Patch A to B", patch.name) + assertEquals("B", patch.version) + assertEquals("pl", patch.language) + assertEquals(7, patch.size) + assertEquals(listOf("patch_original.exe", "patch_original-1.bin"), patch.parts.map { it.name }) + assertEquals("abc", patch.parts.first().md5) + assertEquals("patch_(52404)+special.exe", GogContent.downloadFilename("https://example.invalid/patch_%2852404%29+special.exe?token=temporary")) + } + + @Test fun pathsRejectTraversalSymlinksCaseCollisionsAndMetadataOverlap() { + val folder = temporary.newFolder() + for (path in listOf("../escape", "/absolute", "C:/file", "a/./file", "a//file")) { + assertThrows(IllegalArgumentException::class.java) { GogFiles.inside(folder, path) } + } + val files = JSONArray("""[{"path":"Game.exe"},{"path":"game.exe"}]""") + assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(files, folder) } + assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":"Data/a"},{"path":"data/b"}]"""), folder) } + File(folder, "Game.bin").writeText("original") + assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":"game.bin"}]"""), folder) } + assertThrows(IllegalArgumentException::class.java) { GogFiles.validate(JSONArray("""[{"path":".droiddeck-store.json"}]"""), folder) } + val outside = temporary.newFolder() + try { Files.createSymbolicLink(File(folder, "link").toPath(), outside.toPath()) } + catch (_: java.nio.file.FileSystemException) { return } + assertThrows(IllegalArgumentException::class.java) { GogFiles.inside(folder, "link/game.exe") } + } + + @Test fun sameSizeCorruptionFailsVerificationAndLanguageVariantsRemainDistinct() { + val folder = temporary.newFolder() + val file = File(folder, "game.bin").apply { writeText("original") } + val expected = JSONObject().put("path", file.name).put("size", file.length()).put("md5", GogFiles.hash(file)) + file.writeText("modified") + assertFalse(GogFiles.matches(folder, expected)) + assertTrue(GogContent.languageSelected(listOf("*"), "zh-Hant")) + assertFalse(GogContent.languageSelected(listOf("zh-Hans"), "zh-Hant")) + assertTrue(GogContent.languageSelected(listOf("Polish"), "pl")) + } + + @Test fun persistedManifestsContainNoCredentialsOrTemporaryLinks() { + val input = JSONObject("""{"buildId":"1","clientSecret":"secret","urls":[{"url":"https://example.invalid/?token=secret"}], + "products":[{"productId":"2","clientSecret":"nested"}],"dependencies":["ISI"]}""") + val text = GogContent.sanitize(input).toString() + assertFalse(text.contains("secret")) + assertFalse(text.contains("https://")) + assertTrue(text.contains("ISI")) + } + + @Test fun interruptedExternalPatchRestoresRecoveryWithoutExecutingAgain() { + val app = RuntimeEnvironment.getApplication() + val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "Patch recovery").apply { mkdirs() } + try { + val sidecar = StoreGameSidecar(Store.GOG, "recovery-test", "Patch recovery", "game.exe", + args = listOf("--custom"), env = mapOf("SETTING" to "value"), cloud = false, + achievements = false, state = StoreGameSidecar.STATE_UPDATING) + sidecar.write(folder) + val previous = sidecar.toJson().toString() + GogFiles.write(GogFiles.journal(folder), JSONObject().put("operation", "PATCH").put("phase", "patch-applying")) + val item = CatalogItem(Store.GOG, sidecar.id, sidecar.title, imageUrl = null, extra = mapOf( + GogMaintenance.OPERATION to "PATCH", GogMaintenance.FOLDER to folder.path)) + GogMaintenance.restore(app, item) + assertEquals(DownloadState.FAILED, DownloadQueue.entry(item.key)?.state) + assertTrue(DownloadQueue.entry(item.key)!!.error!!.contains("recovery")) + assertEquals(previous, StoreGameSidecar.read(folder)!!.toJson().toString()) + assertEquals("patch-applying", GogFiles.read(GogFiles.journal(folder))!!.getString("phase")) + DownloadQueue.clear(app, item.key) + } finally { folder.deleteRecursively() } + } + + @Test fun signedOutCheckRemainsAFailureInsteadOfCurrent() { + val app = RuntimeEnvironment.getApplication() + com.droiddeck.launcher.stores.StoreAccounts.clear(app, Store.GOG) + GogMaintenance.reset() + val folder = temporary.newFolder() + val game = com.droiddeck.launcher.stores.InstalledStoreGame( + StoreGameSidecar(Store.GOG, "signed-out-check", "Check", "game.exe", installVersion = "opaque-build"), folder) + GogMaintenance.check(app, game, force = true) + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline) { + org.robolectric.shadows.ShadowLooper.idleMainLooper() + if (GogMaintenance.checks[game.sidecar.id]?.state == GogMaintenance.State.FAILED) break + Thread.sleep(10) + } + assertEquals(GogMaintenance.State.FAILED, GogMaintenance.checks[game.sidecar.id]?.state) + assertTrue(GogMaintenance.checks[game.sidecar.id]!!.reason.contains("Sign in")) + } + + @Test fun pauseBeforeInstallerClaimCannotStartExternalApplication() { + val app = RuntimeEnvironment.getApplication() + val ready = java.util.concurrent.CountDownLatch(1) + val proceed = java.util.concurrent.CountDownLatch(1) + val finished = java.util.concurrent.CountDownLatch(1) + val applied = AtomicBoolean(false) + val item = com.droiddeck.launcher.stores.download.DownloadEntry(Store.GOG, "installer-claim", "Installer claim") + DownloadQueue.enqueue(app, item) { + object : DownloadQueue.DownloadJob { + override fun run(handle: DownloadQueue.JobHandle): String? { + ready.countDown() + try { + check(proceed.await(5, java.util.concurrent.TimeUnit.SECONDS)) + handle.interruptible(false) + applied.set(true) + return null + } finally { finished.countDown() } + } + override fun cancel(deleteFiles: Boolean) {} + } + } + try { + assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS)) + DownloadQueue.pause(item.key) + proceed.countDown() + assertTrue(finished.await(5, java.util.concurrent.TimeUnit.SECONDS)) + assertFalse(applied.get()) + } finally { + proceed.countDown() + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5) + while (DownloadQueue.entry(item.key)?.state == DownloadState.RUNNING && System.nanoTime() < deadline) Thread.sleep(10) + DownloadQueue.clear(app, item.key) + } + } + + @Test fun fileFallbackVerifiesRemoteBytesAndPreservesOriginalOnCorruption() { + val folder = temporary.newFolder() + val content = "verified remote content".toByteArray() + val original = File(folder, "game.bin").apply { writeText("local data") } + val server = java.net.ServerSocket(0, 10, java.net.InetAddress.getByName("127.0.0.1")) + var reply = content + var transferred = 0 + val worker = Thread { + while (!server.isClosed) { + val client = try { server.accept() } catch (_: java.io.IOException) { break } + client.use { + val reader = it.getInputStream().bufferedReader() + while (!reader.readLine().isNullOrEmpty()) Unit + it.getOutputStream().apply { + write("HTTP/1.1 206 Partial Content\r\nContent-Length: ${reply.size}\r\nConnection: close\r\n\r\n".toByteArray()) + write(reply); flush(); transferred += reply.size + } + } + } + }.apply { isDaemon = true; start() } + try { + val expected = File(folder, "expected").apply { writeBytes(content) } + val file = JSONObject().put("path", "game.bin").put("size", content.size).put("md5", GogFiles.hash(expected)) + .put("product", "1").put("offset", 0).put("remotePath", "data") + val plan = JSONObject().put("files", JSONArray().put(file)).put("links", JSONObject().put("1", JSONArray().put("http://127.0.0.1:${server.localPort}"))) + reply = ByteArray(content.size) { 65 } + assertThrows(IllegalStateException::class.java) { GogFallback.transfer(folder, plan, null, AtomicBoolean(false)) } + assertEquals("local data", original.readText()) + reply = content + GogFallback.transfer(folder, plan, null, AtomicBoolean(false)) + assertArrayEquals(content, original.readBytes()) + val before = transferred + GogFallback.transfer(folder, plan, null, AtomicBoolean(false)) + assertEquals(before, transferred) + } finally { server.close(); worker.join(2000) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchArchivesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchArchivesTest.kt new file mode 100644 index 000000000..e8ab5f10f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchArchivesTest.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.stores.itch + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class ItchArchivesTest { + private lateinit var dir: File + + @Before fun setUp() { + dir = Files.createTempDirectory("itch-archives").toFile() + } + + @After fun tearDown() { + Files.walk(dir.toPath()).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + + private fun file(name: String, vararg head: Int, size: Int = 64): File { + val f = File(dir, name) + f.parentFile?.mkdirs() + val bytes = ByteArray(maxOf(size, head.size)) + head.forEachIndexed { i, b -> bytes[i] = b.toByte() } + f.writeBytes(bytes) + return f + } + + @Test fun kindsComeFromTheContentNotTheName() { + assertEquals("7z", ItchArchives.kind(file("data.bin", 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C), "data.bin")) + assertEquals("zip", ItchArchives.kind(file("a.7z", 0x50, 0x4B, 3, 4), "a.7z")) + assertEquals("rar", ItchArchives.kind(file("b", 0x52, 0x61, 0x72, 0x21), "b")) + assertEquals("exe", ItchArchives.kind(file("Game.exe", 0x50, 0x4B), "Game.exe")) + } + + @Test fun theLargestArchiveInsideIsUnpackedNext() { + file("readme.txt", 0x48, 0x69) + file("VotV.7z", 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, size = 4096) + file("extras/art.zip", 0x50, 0x4B, 3, 4, size = 128) + assertEquals("VotV.7z", ItchArchives.inner(dir)?.name) + } + + @Test fun archivesDeeperThanTwoFoldersAreLeftAlone() { + file("a/b/c/deep.zip", 0x50, 0x4B, 3, 4) + assertNull(ItchArchives.inner(dir)) + } + + @Test fun nothingPackedMeansNothingToUnpack() { + file("game.pak", 0x00, 0x01) + file("notes.txt", 0x48, 0x69) + assertNull(ItchArchives.inner(dir)) + } + + @Test fun aNamedArchiveWithoutASignatureIsSkipped() { + file("extras.zip", 0x48, 0x69, size = 8192) + file("game.7z", 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, size = 128) + assertEquals("game.7z", ItchArchives.inner(dir)?.name) + } + + @Test fun symlinksAreNotFollowed() { + val root = File(dir, "root").apply { mkdirs() } + val outside = File(dir, "outside").apply { mkdirs() } + File(outside, "keep.zip").writeBytes(byteArrayOf(0x50, 0x4B, 3, 4)) + Files.createSymbolicLink(File(root, "link").toPath(), outside.toPath()) + Files.createSymbolicLink(File(root, "linked.zip").toPath(), File(outside, "keep.zip").toPath()) + assertNull(ItchArchives.inner(root)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBuildsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBuildsTest.kt new file mode 100644 index 000000000..a9630d9e8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBuildsTest.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.stores.itch + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class ItchBuildsTest { + private fun file(id: Int, type: String, sub: String, size: Int = 10) = + JSONObject().put("id", id).put("type", type).put("sub_type", sub).put("size", size).put("state", "uploaded") + + private fun build(id: Int, vararg files: JSONObject) = JSONObject().put("id", id).put("files", JSONArray(files.toList())) + + private fun path(vararg builds: JSONObject) = JSONObject().put("upgrade_path", JSONObject().put("builds", JSONArray(builds.toList()))) + + @Test fun buildIdReadsBothKeyStylesAndIgnoresMissingValues() { + assertEquals("55", ItchBuilds.buildId(JSONObject().put("build_id", 55))) + assertEquals("56", ItchBuilds.buildId(JSONObject().put("buildId", "56"))) + assertEquals("", ItchBuilds.buildId(JSONObject().put("build_id", JSONObject.NULL))) + assertEquals("", ItchBuilds.buildId(JSONObject().put("build_id", 0))) + assertEquals("", ItchBuilds.buildId(JSONObject().put("build_id", "00"))) + assertEquals("57", ItchBuilds.buildId(JSONObject().put("build_id", "057"))) + assertEquals("", ItchBuilds.buildId(JSONObject())) + } + + @Test fun pathSkipsTheInstalledBuildAndPrefersOptimizedPatches() { + val reply = path( + build(1, file(11, "patch", "default")), + build(2, file(21, "patch", "default", 5), file(22, "patch", "optimized", 3), file(23, "signature", "default")), + ) + val steps = ItchBuilds.steps(reply, 1, 2) { it.optJSONArray("files") }!! + assertEquals(1, steps.size) + assertEquals(2L, steps.single().buildId) + assertEquals(22L, steps.single().patchFile) + assertEquals(3L, steps.single().patchSize) + assertEquals(23L, steps.single().signatureFile) + } + + @Test fun pathIsRefusedWithoutASignatureOrAPatchOrTheRightEnd() { + val noSignature = path(build(1), build(2, file(21, "patch", "default"))) + assertNull(ItchBuilds.steps(noSignature, 1, 2) { it.optJSONArray("files") }) + val noPatch = path(build(1), build(2, file(23, "signature", "default"))) + assertNull(ItchBuilds.steps(noPatch, 1, 2) { it.optJSONArray("files") }) + val wrongEnd = path(build(1), build(3, file(31, "patch", "default"), file(32, "signature", "default"))) + assertNull(ItchBuilds.steps(wrongEnd, 1, 2) { it.optJSONArray("files") }) + assertNull(ItchBuilds.steps(JSONObject(), 1, 2) { it.optJSONArray("files") }) + } + + @Test fun pathNeedsFilesFromALookupWhenTheBuildListsNone() { + val reply = path(JSONObject().put("id", 1), JSONObject().put("id", 2)) + val steps = ItchBuilds.steps(reply, 1, 2) { JSONArray().put(file(21, "patch", "default")).put(file(23, "signature", "default")) }!! + assertEquals(21L, steps.single().patchFile) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchStagingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchStagingTest.kt new file mode 100644 index 000000000..226ef3b9b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchStagingTest.kt @@ -0,0 +1,85 @@ +package com.droiddeck.launcher.stores.itch + +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 java.io.File + +class ItchStagingTest { + @get:Rule val tmp = TemporaryFolder() + + private fun file(root: File, path: String, text: String) = File(root, path).also { it.parentFile?.mkdirs(); it.writeText(text) } + + @Test fun newFilesReplaceOldOnesAndEverythingElseStays() { + val game = tmp.newFolder("game") + val staged = tmp.newFolder("staged") + file(game, "game.exe", "old") + file(game, "data/level.dat", "old") + file(game, "saves/slot1.sav", "mine") + file(game, ".droiddeck-store.json", "record") + file(staged, "game.exe", "new") + file(staged, "data/level.dat", "new") + file(staged, "data/extra.dat", "new") + file(staged, "saves/default.sav", "new") + ItchStaging.merge(staged, game) + assertEquals("new", File(game, "game.exe").readText()) + assertEquals("new", File(game, "data/level.dat").readText()) + assertEquals("new", File(game, "data/extra.dat").readText()) + assertEquals("mine", File(game, "saves/slot1.sav").readText()) + assertEquals("new", File(game, "saves/default.sav").readText()) + assertEquals("record", File(game, ".droiddeck-store.json").readText()) + assertTrue(staged.listFiles().orEmpty().all { it.isDirectory && it.listFiles().orEmpty().isEmpty() }) + } + + @Test fun aFileReplacingADirectoryAndTheOtherWayRound() { + val game = tmp.newFolder("game") + val staged = tmp.newFolder("staged") + file(game, "a/inner.txt", "old") + file(game, "b", "old") + file(staged, "a", "new") + file(staged, "b/inner.txt", "new") + ItchStaging.merge(staged, game) + assertTrue(File(game, "a").isFile) + assertEquals("new", File(game, "a").readText()) + assertEquals("new", File(game, "b/inner.txt").readText()) + assertFalse(File(game, "a/inner.txt").exists()) + } + + @Test fun deletingFollowsNoLinkOutOfTheTree() { + val outside = tmp.newFolder("outside") + file(outside, "keep.txt", "keep") + val staged = tmp.newFolder("staged") + file(staged, "own.txt", "x") + java.nio.file.Files.createSymbolicLink(File(staged, "link").toPath(), outside.toPath()) + ItchStaging.delete(staged) + assertFalse(staged.exists()) + assertEquals("keep", File(outside, "keep.txt").readText()) + } + + @Test fun aLinkInTheGameIsReplacedNotFollowed() { + val outside = tmp.newFolder("outside") + file(outside, "keep.txt", "keep") + val game = tmp.newFolder("game") + java.nio.file.Files.createSymbolicLink(File(game, "data").toPath(), outside.toPath()) + val staged = tmp.newFolder("staged") + file(staged, "data/level.dat", "new") + ItchStaging.merge(staged, game) + assertEquals("keep", File(outside, "keep.txt").readText()) + assertFalse(File(outside, "level.dat").exists()) + assertEquals("new", File(game, "data/level.dat").readText()) + } + + @Test fun theInstallRecordIsNeverReplacedByTheArchive() { + val game = tmp.newFolder("game") + val staged = tmp.newFolder("staged") + file(game, ".droiddeck-store.json", "record") + file(staged, ".droiddeck-store.json", "forged") + file(staged, "sub/.droiddeck-store.json", "nested") + ItchStaging.merge(staged, game) + assertEquals("record", File(game, ".droiddeck-store.json").readText()) + assertEquals("nested", File(game, "sub/.droiddeck-store.json").readText()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchUpdatesTest.kt new file mode 100644 index 000000000..8193235c3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchUpdatesTest.kt @@ -0,0 +1,84 @@ +package com.droiddeck.launcher.stores.itch + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class ItchUpdatesTest { + private fun upload(id: String, updated: String, filename: String = "game-$id.zip", windows: Boolean = true, type: String = "default") = + JSONObject().put("id", id).put("filename", filename).put("updated_at", updated).put("type", type).put("storage", "hosted") + .put("platforms", JSONObject().put("windows", windows).put("linux", !windows)) + + private fun list(vararg uploads: JSONObject) = JSONArray().also { a -> uploads.forEach { a.put(it) } } + + @Test fun aNewerStampOnTheSameUploadIsAnUpdate() { + val update = ItchUpdates.evaluate(list(upload("7", "2026-10-20T10:00:00Z")), "7", "2026-09-01T10:00:00Z")!! + assertEquals("7", update.uploadId) + assertEquals("2026-10-20T10:00:00Z", update.updatedAt) + assertFalse(update.replaced) + } + + @Test fun theSameStampIsNotAnUpdate() { + assertNull(ItchUpdates.evaluate(list(upload("7", "2026-09-01T10:00:00Z")), "7", "2026-09-01T10:00:00Z")) + } + + @Test fun anOlderStampIsNotAnUpdate() { + assertNull(ItchUpdates.evaluate(list(upload("7", "2026-08-01T10:00:00Z")), "7", "2026-09-01T10:00:00Z")) + } + + @Test fun aMissingUploadFallsBackToTheNewestWindowsOneAndAsksFirst() { + val update = ItchUpdates.evaluate( + list(upload("8", "2026-10-01T10:00:00Z"), upload("9", "2026-10-05T10:00:00Z"), upload("10", "2026-10-09T10:00:00Z", windows = false)), + "7", "2026-09-01T10:00:00Z", + )!! + assertEquals("9", update.uploadId) + assertTrue(update.replaced) + } + + @Test fun aReplacedUploadWithNoWindowsBuildLeftIsNotOffered() { + val linux = list(upload("8", "2026-10-01T10:00:00Z", windows = false)) + assertNull(ItchUpdates.evaluate(linux, "7", "2026-09-01T10:00:00Z")) + assertNull(ItchUpdates.evaluate(linux, "", "2026-09-01T10:00:00Z")) + } + + @Test fun aWindowsBuildOfAnotherTypeBeatsALinuxDefaultOne() { + val update = ItchUpdates.evaluate( + list(upload("8", "2026-10-09T10:00:00Z", windows = false), upload("9", "2026-10-01T10:00:00Z", type = "other")), + "7", "2026-09-01T10:00:00Z", + )!! + assertEquals("9", update.uploadId) + assertTrue(update.replaced) + } + + @Test fun anUnknownInstalledVersionNeverReportsAnUpdate() { + assertNull(ItchUpdates.evaluate(list(upload("7", "2026-10-20T10:00:00Z")), "7", "")) + } + + @Test fun noAnswerOrNoWindowsBuildMeansNoUpdate() { + assertNull(ItchUpdates.evaluate(null, "7", "2026-09-01T10:00:00Z")) + assertNull(ItchUpdates.evaluate(JSONArray(), "7", "2026-09-01T10:00:00Z")) + } + + @Test fun anInstallWithoutAnUploadIdComparesTheNewestWindowsBuild() { + val update = ItchUpdates.evaluate(list(upload("8", "2026-10-01T10:00:00Z")), "", "2026-09-01T10:00:00Z")!! + assertEquals("8", update.uploadId) + assertFalse(update.replaced) + assertNull(ItchUpdates.evaluate(list(upload("8", "2026-08-01T10:00:00Z")), "", "2026-09-01T10:00:00Z")) + } + + @Test fun foundUpdatesSurviveTheCache() { + val found = mapOf("itch:a/b" to ItchUpdate("9", "2026-10-05T10:00:00Z", "b.zip", true)) + val back = ItchUpdates.decode(ItchUpdates.encode(found)) + assertEquals(setOf("itch:a/b"), back.keys) + assertEquals("9", back.getValue("itch:a/b").uploadId) + assertTrue(back.getValue("itch:a/b").replaced) + assertEquals(emptyMap(), ItchUpdates.decode(null)) + assertEquals(emptyMap(), ItchUpdates.decode("not json")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/BottomScreenTypingTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/BottomScreenTypingTest.kt new file mode 100644 index 000000000..66f8b115c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/BottomScreenTypingTest.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.ui + +import android.view.KeyEvent +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.CommitTextCommand +import androidx.compose.ui.text.input.DeleteAllCommand +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.SetSelectionCommand +import androidx.compose.ui.text.input.TextFieldValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class BottomScreenTypingTest { + @Test fun editsReplaceTheWholeFieldAndKeepTheCursor() { + val edits = TypingRequest.editsFor(TextFieldValue("fall", TextRange(2, 4))) + assertEquals(3, edits.size) + assertTrue(edits[0] is DeleteAllCommand) + assertEquals("fall", (edits[1] as CommitTextCommand).text) + val selection = edits[2] as SetSelectionCommand + assertEquals(2, selection.start) + assertEquals(4, selection.end) + } + + @Test fun requestForwardsTextActionAndClose() { + val sent = mutableListOf() + val actions = mutableListOf() + var closed = 0 + val request = TypingRequest( + TextFieldValue("a"), ImeOptions(imeAction = ImeAction.Search), + { sent += it.text }, { actions += it }, { closed++ }, + ) + request.send(TextFieldValue("ab")) + request.action() + request.close() + assertEquals(listOf("ab"), sent) + assertEquals("ab", request.value.text) + assertEquals(listOf(ImeAction.Search), actions) + assertEquals(1, closed) + } + + @Test fun panelStopsOnlyItsOwnRequest() { + val first = TypingRequest(TextFieldValue(""), ImeOptions.Default, {}, {}) + val second = TypingRequest(TextFieldValue(""), ImeOptions.Default, {}, {}) + DualScreenPanel.startTyping(second) + DualScreenPanel.stopTyping(first) + assertEquals(second, DualScreenPanel.typing) + DualScreenPanel.stopTyping(second) + assertNull(DualScreenPanel.typing) + } + + @Test fun triggersStepThroughSections() { + assertEquals(-1, dualPageStep(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_L2))) + assertEquals(1, dualPageStep(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_R2))) + assertNull(dualPageStep(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_L1))) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/DeckControlsPanelTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/DeckControlsPanelTest.kt new file mode 100644 index 000000000..4aa3a0327 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/DeckControlsPanelTest.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.ui + +import android.view.MotionEvent +import android.view.View +import com.droiddeck.launcher.input.JoyConPadTest +import com.droiddeck.launcher.input.PadBridge +import com.droiddeck.launcher.session.SessionState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.nio.file.Files + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter"], + shadows = [JoyConPadTest.RecordingWriter::class]) +class DeckControlsPanelTest { + @Test fun takesTouchAtMatchesWhatATouchDoes() { + val directory = Files.createTempDirectory("deck-panel").toFile() + try { + SessionState.deckPad = false + // No pad: the Deck layouts (trackpads, grips). A pad without Deck controls: the gamepad layout + // (sticks, d-pad, face buttons). + for (pad in listOf(null, PadBridge(directory))) for (portraitDock in listOf(false, true)) { + val name = "pad=${pad != null} portraitDock=$portraitDock" + val panel = DeckControlsPanel(RuntimeEnvironment.getApplication(), onClose = {}, onSteamMenu = {}, onQam = {}, + pad = pad, portraitDock = portraitDock) + assertEquals(name, pad == null, panel.deckControls) + // The portrait dock sits under a portrait game; its gamepad layout needs a tall panel. + val (w, h) = if (portraitDock) 720 to 1000 else 1200 to 480 + panel.measure(View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY)) + panel.layout(0, 0, w, h) + var taken = 0 + var missed = 0 + for (x in 0 until w step w / 40) for (y in 0 until h step h / 24) { + val expected = panel.takesTouchAt(x.toFloat(), y.toFloat()) + val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, x.toFloat(), y.toFloat(), 0) + assertEquals("$name ($x, $y)", expected, panel.dispatchTouchEvent(down)) + down.recycle() + // Cancel rather than lift, so no button fires its action. + MotionEvent.obtain(0L, 1L, MotionEvent.ACTION_CANCEL, x.toFloat(), y.toFloat(), 0).also { panel.dispatchTouchEvent(it); it.recycle() } + if (expected) taken++ else missed++ + } + assertTrue("$name: some points land on controls", taken > 0) + assertTrue("$name: some points land between them", missed > 0) + } + } finally { + directory.deleteRecursively() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/DualScreenPanelOwnerTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/DualScreenPanelOwnerTest.kt new file mode 100644 index 000000000..010c66cab --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/DualScreenPanelOwnerTest.kt @@ -0,0 +1,32 @@ +package com.droiddeck.launcher.ui + +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 +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class DualScreenPanelOwnerTest { + private val app get() = RuntimeEnvironment.getApplication() + + @After fun clear() = DualScreenPanel.hide() + + @Test fun aLauncherThatStopsLeavesThePanelOfTheOneThatTookItOver() { + DualScreenPanel.show(app, 2, 10) + DualScreenPanel.show(app, 2, 11) + DualScreenPanel.hide(10) + assertEquals(2, DualScreenPanel.shownOn) + DualScreenPanel.hide(11) + assertEquals(-1, DualScreenPanel.shownOn) + } + + @Test fun theOwnerHidesItsOwnPanel() { + DualScreenPanel.show(app, 2, 10) + DualScreenPanel.hide(10) + assertEquals(-1, DualScreenPanel.shownOn) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialogTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialogTest.kt new file mode 100644 index 000000000..a9afe5028 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/PhysicalShortcutsDialogTest.kt @@ -0,0 +1,253 @@ +package com.droiddeck.launcher.ui + +import android.app.Application +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.* +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.ControllerPrefs +import com.droiddeck.launcher.input.PadBridgeRoutingTest +import com.droiddeck.launcher.input.PhysicalShortcuts +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.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.InputDeviceBuilder +import org.robolectric.shadows.ShadowDialog +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h640dp-land", + shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) +class PhysicalShortcutsDialogTest { + @get:Rule val compose = createRobolectricComposeRule() + private val context get() = RuntimeEnvironment.getApplication() + private lateinit var device: InputDevice + + @Before fun setUp() { + context.getSharedPreferences("controller", 0).edit().clear().commit() + device = InputDeviceBuilder.newBuilder().setId(10).setName("Retroid extra buttons") + .setDescriptor("retroid-extra").setSources(InputDevice.SOURCE_KEYBOARD) + .setKeyboardType(InputDevice.KEYBOARD_TYPE_ALPHABETIC) + .setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)).build() + shadowOf(context.getSystemService(InputManager::class.java)).addInputDevice(device) + } + + @Test fun assignmentSavesAutomaticallyOnRelease() { + var changes = 0 + compose.setContent { DroidDeckTheme { PhysicalShortcutRows(onChanged = { changes++ }) } } + assign(PhysicalShortcuts.Action.STEAM) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN) + compose.onNodeWithText(context.getString(R.string.shortcuts_release)).assertExists() + compose.onNodeWithTag("shortcuts-save").assertDoesNotExist() + assertTrue(PhysicalShortcuts.read(context).isEmpty()) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_UP) + compose.onNodeWithTag("shortcuts-save").assertDoesNotExist() + compose.runOnIdle { + val binding = PhysicalShortcuts.read(context).single() + assertEquals(KeyEvent.KEYCODE_F1, binding.button.keyCode) + assertEquals(PhysicalShortcuts.Action.STEAM, binding.action) + assertEquals(1, changes) + } + } + + @Test fun buttonMappingPageOpensPhysicalShortcutAssignments() { + compose.setContent { + DroidDeckTheme { ControllerMappingPage(emptyMap(), { _, _ -> }, {}, {}) } + } + compose.onNodeWithTag("shortcuts-assign-STEAM").assertExists() + compose.onNodeWithTag("shortcuts-assign-QAM").assertExists() + compose.onNodeWithTag("shortcuts-assign-DROIDDECK").assertExists() + } + + @Test fun backCancelsCaptureWithoutSaving() { + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.QAM) + send(KeyEvent.KEYCODE_BACK, KeyEvent.ACTION_DOWN) + compose.onNodeWithTag("shortcuts-cancel").assertDoesNotExist() + compose.runOnIdle { + assertTrue(PhysicalShortcuts.read(context).isEmpty()) + } + compose.onNodeWithTag("shortcuts-assign-QAM").assertExists() + } + + @Test fun controllerBCancelsCaptureWithoutSaving() { + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.QAM) + send(KeyEvent.KEYCODE_BUTTON_B, KeyEvent.ACTION_DOWN) + compose.onNodeWithTag("shortcuts-assign-QAM").assertExists() + assertTrue(PhysicalShortcuts.read(context).isEmpty()) + } + + @Test fun closingConflictKeepsBothPreviousAssignments() { + PhysicalShortcuts.assign(context, requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN))), + PhysicalShortcuts.Action.STEAM) + PhysicalShortcuts.assign(context, requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F2, KeyEvent.ACTION_DOWN))), + PhysicalShortcuts.Action.QAM) + val previous = PhysicalShortcuts.read(context) + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.QAM) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_UP) + compose.onNodeWithTag("shortcuts-save").assertExists() + compose.onNodeWithTag("shortcuts-cancel").performScrollTo().performClick() + assertEquals(previous, PhysicalShortcuts.read(context)) + } + + @Test fun conflictingAssignmentIsNotReplacedUntilSave() { + PhysicalShortcuts.assign(context, requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN))), + PhysicalShortcuts.Action.STEAM) + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.QAM) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_UP) + compose.onNodeWithText(context.getString(R.string.shortcuts_conflict, + context.getString(R.string.drawer_steam_menu))).assertExists() + val button = requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN))) + compose.onAllNodesWithText("${button.label} · ${button.name}").assertCountEquals(2) + assertEquals(PhysicalShortcuts.Action.STEAM, PhysicalShortcuts.read(context).single().action) + compose.onNodeWithTag("shortcuts-save").performScrollTo().performClick() + compose.runOnIdle { assertEquals(PhysicalShortcuts.Action.QAM, PhysicalShortcuts.read(context).single().action) } + } + + @Test fun removalRestoresDefaultAndPreservesOtherAssignmentsAndControllerSettings() { + ControllerPrefs.setSelectSteam(context, true) + PhysicalShortcuts.assign(context, requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN))), + PhysicalShortcuts.Action.STEAM) + PhysicalShortcuts.assign(context, requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F2, KeyEvent.ACTION_DOWN))), + PhysicalShortcuts.Action.QAM) + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.STEAM) + send(KeyEvent.KEYCODE_F3, KeyEvent.ACTION_DOWN) + compose.onNodeWithTag("shortcuts-remove").performScrollTo().performClick() + compose.runOnIdle { + assertEquals(PhysicalShortcuts.Action.QAM, PhysicalShortcuts.read(context).single().action) + assertTrue(ControllerPrefs.read(context).selectSteam) + } + compose.onNodeWithTag("shortcuts-assign-STEAM").assertTextContains(context.getString(R.string.game_settings_default)) + } + + @Test fun retryRecoversFromAMissingReleaseWithoutSavingTheOldButton() { + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.STEAM) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN) + compose.onNodeWithTag("shortcuts-retry").performScrollTo().performClick() + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_UP) + compose.onNodeWithTag("shortcuts-save").assertDoesNotExist() + send(KeyEvent.KEYCODE_F2, KeyEvent.ACTION_DOWN) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_UP) + compose.onNodeWithTag("shortcuts-save").assertDoesNotExist() + send(KeyEvent.KEYCODE_F2, KeyEvent.ACTION_UP) + compose.onNodeWithTag("shortcuts-save").assertDoesNotExist() + val button = requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F2, KeyEvent.ACTION_DOWN))) + compose.onNodeWithText("${button.label} · ${button.name}").assertExists() + compose.runOnIdle { assertEquals(KeyEvent.KEYCODE_F2, PhysicalShortcuts.read(context).single().button.keyCode) } + } + + @Test fun longControllerNameDoesNotSqueezeTheMenuLabelInANarrowDrawer() { + val button = requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.ACTION_DOWN))) + .copy(name = "Xbox Wireless Controller with a long device name") + PhysicalShortcuts.assign(context, button, PhysicalShortcuts.Action.STEAM) + compose.setContent { + DroidDeckTheme { + Box(Modifier.width(360.dp)) { PhysicalShortcutRows() } + } + } + val control = compose.onNodeWithTag("shortcuts-assign-STEAM").getUnclippedBoundsInRoot() + val label = compose.onNodeWithText(context.getString(R.string.drawer_steam_menu)).getUnclippedBoundsInRoot() + assertTrue(control.right - control.left < 200.dp) + assertTrue("Menu label must not wrap vertically: $label", label.bottom - label.top <= 48.dp) + compose.onNodeWithTag("shortcuts-assign-STEAM").assertTextContains(context.getString(R.string.ctrl_custom)) + compose.onNodeWithText("${button.label} · ${button.name}").assertExists() + } + + @Test fun replacingSameActionsButtonSavesWithoutConfirmation() { + val oldButton = requireNotNull(PhysicalShortcuts.button(event(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN))) + PhysicalShortcuts.assign(context, oldButton, PhysicalShortcuts.Action.STEAM) + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.STEAM) + send(KeyEvent.KEYCODE_F2, KeyEvent.ACTION_DOWN) + assertEquals(oldButton, PhysicalShortcuts.read(context).single().button) + send(KeyEvent.KEYCODE_F2, KeyEvent.ACTION_UP) + compose.onNodeWithTag("shortcuts-save").assertDoesNotExist() + compose.runOnIdle { assertEquals(KeyEvent.KEYCODE_F2, PhysicalShortcuts.read(context).single().button.keyCode) } + } + + @Test + @Config(qualifiers = "de-rDE-w400dp-h850dp-port") + fun narrowLargeTextKeepsTranslatedCaptureReachableAndSavesOnRelease() { + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) { + DroidDeckTheme { PhysicalShortcutRows() } + } + } + assign(PhysicalShortcuts.Action.DROIDDECK) + compose.onNodeWithText(context.getString(R.string.shortcuts_capture, + context.getString(R.string.shortcuts_dd_menu))).assertExists() + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_DOWN) + send(KeyEvent.KEYCODE_F1, KeyEvent.ACTION_UP) + compose.onNodeWithTag("shortcuts-save").assertDoesNotExist() + compose.runOnIdle { assertEquals(PhysicalShortcuts.Action.DROIDDECK, PhysicalShortcuts.read(context).single().action) } + } + + @Test + @Config(qualifiers = "ar-rEG-w400dp-h850dp-port") + fun rightToLeftDialogKeepsCancelReachable() { + compose.setContent { DroidDeckTheme { PhysicalShortcutRows() } } + assign(PhysicalShortcuts.Action.QAM) + compose.onNodeWithTag("shortcuts-cancel").performScrollTo().assertIsDisplayed().performClick() + compose.onNodeWithTag("shortcuts-assign-QAM").assertExists() + } + + @Test fun everySupportedLanguageHasALocalizedShortcutTitleAndValidFormatArguments() { + val languages = mutableListOf() + context.resources.getXml(R.xml.locales_config).use { xml -> + while (xml.next() != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) { + if (xml.eventType == org.xmlpull.v1.XmlPullParser.START_TAG && xml.name == "locale") { + languages.add(xml.getAttributeValue("http://schemas.android.com/apk/res/android", "name")) + } + } + } + val english = context.getString(R.string.shortcuts_title) + for (language in languages) { + val config = Configuration(context.resources.configuration).apply { setLocale(Locale.forLanguageTag(language)) } + val localized = context.createConfigurationContext(config) + if (language != "en") assertNotEquals(language, english, localized.getString(R.string.shortcuts_title)) + assertTrue(localized.getString(R.string.shortcuts_capture, "ACTION").contains("ACTION")) + assertTrue(localized.getString(R.string.shortcuts_conflict, "ACTION").contains("ACTION")) + val detected = localized.getString(R.string.shortcuts_detected, "DEVICE", "BUTTON") + assertTrue(detected.contains("DEVICE")) + assertTrue(detected.contains("BUTTON")) + } + } + + private fun assign(action: PhysicalShortcuts.Action) { + compose.onNodeWithTag("shortcuts-assign-${action.name}").performClick() + } + + private fun send(code: Int, action: Int) { + compose.runOnIdle { + assertTrue(ShadowDialog.getLatestDialog().window!!.callback.dispatchKeyEvent(event(code, action))) + } + compose.waitForIdle() + } + + private fun event(code: Int, action: Int) = + KeyEvent(100L, 100L, action, code, 0, 0, device.id, 700, 0, InputDevice.SOURCE_KEYBOARD) +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt b/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt index b21ffa60d..a26973f87 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt @@ -8,6 +8,7 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf fun createRobolectricComposeRule() = createComposeRule().also { + RingVeil.reset() val context = RuntimeEnvironment.getApplication() shadowOf(context.packageManager).addActivityIfNotPresent( ComponentName(context, ComponentActivity::class.java), diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt index c1d5ad10d..35561a41d 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt @@ -19,7 +19,8 @@ class SessionDrawerPageTest { assertEquals(SessionDrawerPage.DISPLAY, connected.step(SessionDrawerPage.SECOND_SCREEN, 1)) assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN }) for (pages in listOf(connected, sessionDrawerPages(false))) { - assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1)) + assertEquals(SessionDrawerPage.HUD, pages.step(SessionDrawerPage.DISPLAY, 1)) + assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.HUD, 1)) assertEquals(SessionDrawerPage.VR, pages.step(SessionDrawerPage.EFFECTS, 1)) assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.VR, 1)) assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1)) diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt index d4c5aa4b2..a09ee55aa 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt @@ -7,7 +7,7 @@ import android.view.InputDevice import android.view.KeyEvent import android.view.View import android.view.ViewGroup -import android.widget.Button +import android.widget.TextView import androidx.compose.ui.test.* import androidx.compose.ui.semantics.SemanticsProperties.StateDescription import androidx.compose.runtime.MutableState @@ -76,6 +76,28 @@ class SessionSecondScreenTest { @Test fun steamTrackpadKeepsWorkingSteamShortcuts() = checkShortcuts(steam = true) + @Test fun trackpadMenuButtonOnlyAppearsWhenTheMenuCanMove() { + SessionState.mode = SessionService.MODE_STEAM + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = activity.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + val label = context.getString(R.string.touch_droiddeck_menu) + for (movable in listOf(false, true)) { + var menus = 0 + val panel = SecondScreenPresentation(activity, display, { _, _, _, _ -> }, { _, _ -> }, {}, + {}, {}, {}, onMenu = if (movable) ({ menus++ }) else null) + try { + panel.create() + panel.showMode(SecondScreenMode.KEYBOARD_TRACKPAD) + val buttons = descendants(panel.window!!.decorView).filter { it.contentDescription == label }.toList() + assertEquals(if (movable) 1 else 0, buttons.size) + buttons.forEach { it.performClick() } + assertEquals(if (movable) 1 else 0, menus) + } finally { + panel.closeControls() + } + } + } + @Test @Config(sdk = [34], shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) fun controllerBumpersReachTheSecondScreenInBothSessionModes() { @@ -86,7 +108,7 @@ class SessionSecondScreenTest { 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("drawerOpenState").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, @@ -106,7 +128,7 @@ class SessionSecondScreenTest { shadowOf(context.getSystemService(InputManager::class.java)).addInputDevice(device) SessionState.mode = SessionService.MODE_STEAM val activity = Robolectric.buildActivity(SessionActivity::class.java).get() - val open: MutableState = ReflectionHelpers.getField(activity, "drawerOpen\$delegate") + val open: MutableState = ReflectionHelpers.getField(activity, "drawerOpenState\$delegate") val controller: MutableState = ReflectionHelpers.getField(activity, "drawerControllerActive\$delegate") controller.value = true for (button in listOf(KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START)) { @@ -188,9 +210,9 @@ class SessionSecondScreenTest { try { panel.create() panel.showMode(SecondScreenMode.KEYBOARD_TRACKPAD) - val buttons = descendants(panel.window!!.decorView).filterIsInstance