mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-20 14:14:44 -04:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a820402c | ||
|
|
0f1b82aa26 | ||
|
|
8b279001a5 | ||
|
|
61254786e6 | ||
|
|
ece1df0da8 | ||
|
|
49d1311808 | ||
|
|
a38514afa0 | ||
|
|
31a6c3d033 | ||
|
|
99ab6dd64e | ||
|
|
9b5df0d224 | ||
|
|
aeb0fa57e3 | ||
|
|
ba582dea65 | ||
|
|
8074ec44e8 | ||
|
|
fdab3cea82 | ||
|
|
b26b43fbd0 | ||
|
|
4ba3e442a8 | ||
|
|
57799bef5a | ||
|
|
cee84fd12a | ||
|
|
48daa064c9 | ||
|
|
a42ad82bcb | ||
|
|
06e819f864 | ||
|
|
4c81c98f12 | ||
|
|
3a8d6aaabb | ||
|
|
64b966af49 | ||
|
|
583c6805fd | ||
|
|
e8ee11612f | ||
|
|
7855bec6a1 | ||
|
|
2a423a729a | ||
|
|
01f41e0553 | ||
|
|
3dd0045874 | ||
|
|
6bbcf3ad8b | ||
|
|
f5bba17f53 | ||
|
|
af57286c98 | ||
|
|
446452a97f | ||
|
|
868f54e425 | ||
|
|
c3bc3e6fbd | ||
|
|
1197be90d9 | ||
|
|
6f29018864 | ||
|
|
ac25283f04 | ||
|
|
2ecfe18f76 | ||
|
|
7515bb9898 | ||
|
|
4555fa266d | ||
|
|
60f40913a2 | ||
|
|
facaa18a3c | ||
|
|
8838cf28ed | ||
|
|
65f44e2b80 | ||
|
|
e321f5025d | ||
|
|
a105e4e49c | ||
|
|
2016a363f2 | ||
|
|
1873a9aee7 | ||
|
|
d5628f7b70 | ||
|
|
e10fc86a76 |
No files matched your search
@@ -64,6 +64,7 @@ jobs:
|
||||
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
|
||||
cp out/kwin/desktop-kde.kwin.txt out/
|
||||
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
|
||||
echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde
|
||||
@@ -74,6 +75,8 @@ jobs:
|
||||
out/desktop-kde.packages.txt
|
||||
out/desktop-kde.kwin.txt
|
||||
out/desktop-kde.sha256
|
||||
out/desktop-kde.i18n.txt
|
||||
out/desktop-kde-i18n-*.tar.zst
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
@@ -88,5 +91,6 @@ jobs:
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
|
||||
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
|
||||
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
|
||||
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
|
||||
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
+106
-12
@@ -29,7 +29,64 @@ env:
|
||||
PRELOAD_CACHE_VERSION: v6
|
||||
|
||||
jobs:
|
||||
xbox:
|
||||
name: Xbox ARM64 backend
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
base-sha: ${{ steps.merge.outputs.base-sha }}
|
||||
tree-sha: ${{ steps.source.outputs.tree-sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Pin and merge the APK source
|
||||
id: merge
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
|
||||
git config user.name "ci"
|
||||
git config user.email "[email protected]"
|
||||
git merge --no-ff --no-edit origin/main
|
||||
- name: Record backend source tree
|
||||
id: source
|
||||
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v4
|
||||
id: xbox-cache
|
||||
with:
|
||||
path: |
|
||||
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
app/build/xbox/xodus-droiddeck-source.tar.xz
|
||||
app/build/xbox/runtime/*-source.tar.xz
|
||||
key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
|
||||
- name: Build Xbox backend
|
||||
if: steps.xbox-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
rustup toolchain install 1.99.0 --profile minimal
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
|
||||
bash tools/xbox/build.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xbox-arm64
|
||||
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
retention-days: 14
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xbox-corresponding-source
|
||||
path: |
|
||||
app/build/xbox/xodus-droiddeck-source.tar.xz
|
||||
app/build/xbox/runtime/*-source.tar.xz
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
build:
|
||||
needs: xbox
|
||||
name: Build APK
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -41,6 +98,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check co-authors
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -86,14 +144,17 @@ jobs:
|
||||
|
||||
- name: Merge onto main, or say what conflicts
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
git config user.name "ci"
|
||||
git config user.email "[email protected]"
|
||||
git fetch --no-tags origin main
|
||||
BASE=$(git rev-parse --short origin/main)
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
BASE=$(git rev-parse --short FETCH_HEAD)
|
||||
HEAD=$(git rev-parse --short HEAD)
|
||||
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
|
||||
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
|
||||
{
|
||||
echo "### Mergeable"
|
||||
echo
|
||||
@@ -140,6 +201,11 @@ jobs:
|
||||
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
|
||||
exit 1
|
||||
|
||||
- name: Verify backend and APK source match
|
||||
env:
|
||||
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
|
||||
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -184,15 +250,14 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
|
||||
-o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
|
||||
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
|
||||
D=app/src/main/assets/linuxfs
|
||||
mkdir -p "$D"
|
||||
@@ -225,7 +290,7 @@ jobs:
|
||||
done
|
||||
if [ -f tools/gamescope/release.env ]; then
|
||||
. tools/gamescope/release.env
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
|
||||
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
|
||||
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
@@ -328,18 +393,17 @@ jobs:
|
||||
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
|
||||
|
||||
- name: Bundle the droiddeck-esync packs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f tools/droiddeck-esync/release.env ]; then
|
||||
. tools/droiddeck-esync/release.env
|
||||
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
|
||||
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
|
||||
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
|
||||
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
|
||||
mkdir -p app/src/main/assets/droiddeck-esync
|
||||
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
|
||||
rm sync-bundle.tzst
|
||||
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
|
||||
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
|
||||
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
|
||||
for id in $revoked; do
|
||||
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
|
||||
@@ -463,6 +527,11 @@ jobs:
|
||||
restore-keys: |
|
||||
cxx-${{ env.NDK_VERSION }}-
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: xbox-arm64
|
||||
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
|
||||
- name: Build GOG Galaxy companion
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -470,6 +539,9 @@ jobs:
|
||||
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
|
||||
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
|
||||
|
||||
- name: Build the add-on packs
|
||||
run: bash tools/packs/build.sh app/build/packs
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
env:
|
||||
@@ -508,6 +580,11 @@ jobs:
|
||||
path: app/src/main/rust/stores/Cargo.lock
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Install Python test dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq python3-yaml
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
@@ -568,6 +645,14 @@ jobs:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: droiddeck-packs
|
||||
path: app/build/packs/*.tar.zst
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
- name: Report on the pull request
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
@@ -670,8 +755,17 @@ jobs:
|
||||
variants: ${{ needs.build.outputs.variants }}
|
||||
secrets: inherit
|
||||
|
||||
packs:
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
uses: ./.github/workflows/publish-packs.yml
|
||||
secrets: inherit
|
||||
|
||||
announce:
|
||||
needs: [build, sign]
|
||||
needs: [build, sign, packs]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -176,6 +176,27 @@ jobs:
|
||||
pattern: droiddeck-pr-signed-*
|
||||
merge-multiple: true
|
||||
path: signed
|
||||
- name: The add-on packs main has not published
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN: ${{ needs.authorize.outputs.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
|
||||
mkdir -p pr-packs
|
||||
for file in packs/*.tar.zst; do
|
||||
name=$(basename "$file" .tar.zst)
|
||||
sha=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
tag="pack-$name-${sha:0:16}"
|
||||
digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } |
|
||||
jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty')
|
||||
if [ "$digest" = "sha256:$sha" ]; then
|
||||
echo "$name: served from DroidDeck-Components $tag"
|
||||
else
|
||||
cp "$file" "pr-packs/$tag.tar.zst"
|
||||
echo "$name: attached to the PR release as $tag.tar.zst"
|
||||
fi
|
||||
done
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -233,6 +254,7 @@ jobs:
|
||||
first=true
|
||||
fi
|
||||
echo "first=$first" >> "$GITHUB_OUTPUT"
|
||||
cp pr-packs/* signed/ 2>/dev/null || true
|
||||
gh release upload "$tag" signed/* -R "$repo" --clobber
|
||||
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
|
||||
keep=$(cd signed && ls | jq -R . | jq -sc .)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Publish add-on packs
|
||||
|
||||
# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
|
||||
# content-addressed tags (pack-<name>-<first 16 hex of sha256>). The APK from that run pins each pack's
|
||||
# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
|
||||
# Only main and release builds publish here; a PR test build attaches the packs main has not published
|
||||
# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
run_id:
|
||||
description: The Build APK run whose packs to publish; empty for the calling run's own
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: publish add-on packs
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: publish-packs
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: The packs (this run's build)
|
||||
if: inputs.run_id == ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-packs
|
||||
path: packs
|
||||
|
||||
- name: The packs (another Build APK run)
|
||||
if: inputs.run_id != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN: ${{ inputs.run_id }}
|
||||
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
|
||||
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: components-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-Components
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
REPO: Droid-Deck/DroidDeck-Components
|
||||
BUILD_RUN: ${{ inputs.run_id || github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
published_digest() {
|
||||
{ gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } |
|
||||
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
|
||||
}
|
||||
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
|
||||
for name in proot directaudio tracking xbox comet; do
|
||||
file="packs/$name.tar.zst"
|
||||
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
|
||||
sha=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
tag="pack-$name-${sha:0:16}"
|
||||
digest=$(published_digest "$tag" "$name.tar.zst")
|
||||
if [ -z "$digest" ]; then
|
||||
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
|
||||
|
||||
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
|
||||
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
|
||||
|| echo "::warning::creating $tag failed; checking whether another run published it"
|
||||
digest=$(published_digest "$tag" "$name.tar.zst")
|
||||
fi
|
||||
if [ "$digest" != "sha256:$sha" ]; then
|
||||
echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha"
|
||||
exit 1
|
||||
fi
|
||||
echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
done
|
||||
@@ -63,9 +63,19 @@ jobs:
|
||||
artifact: droiddeck-release
|
||||
secrets: inherit
|
||||
|
||||
packs:
|
||||
needs: find
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
uses: ./.github/workflows/publish-packs.yml
|
||||
with:
|
||||
run_id: ${{ needs.find.outputs.run_id }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
name: publish the release
|
||||
needs: [find, sign]
|
||||
needs: [find, sign, packs]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -47,10 +47,12 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
|
||||
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
|
||||
|
||||
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
|
||||
|
||||
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
|
||||
|
||||
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment.
|
||||
+33
-16
@@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
|
||||
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
|
||||
def pack = file("build/packs/${name}.tar.zst")
|
||||
if (!pack.isFile()) {
|
||||
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
|
||||
return [(name): [sha256: '', size: 0L]]
|
||||
}
|
||||
def digest = java.security.MessageDigest.getInstance('SHA-256')
|
||||
pack.withInputStream { input ->
|
||||
byte[] buffer = new byte[1 << 20]
|
||||
int n
|
||||
while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n)
|
||||
}
|
||||
[(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]]
|
||||
}
|
||||
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
|
||||
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
|
||||
throw new GradleException('Desktop removal inventories do not match the pinned archive')
|
||||
@@ -119,6 +136,10 @@ android {
|
||||
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
|
||||
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
|
||||
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
|
||||
addonPacks.each { name, pack ->
|
||||
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
|
||||
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
|
||||
}
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
@@ -172,6 +193,11 @@ android {
|
||||
disable 'ExpiredTargetSdkVersion'
|
||||
}
|
||||
|
||||
androidResources {
|
||||
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
|
||||
@@ -179,6 +205,8 @@ android {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
pickFirsts += ['**/libtermux.so']
|
||||
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
|
||||
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +220,8 @@ android {
|
||||
unitTests.returnDefaultValues = true
|
||||
unitTests.includeAndroidResources = true
|
||||
unitTests.all {
|
||||
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
|
||||
maxHeapSize = '2g'
|
||||
def logTarget = providers.systemProperty('robolectric.logging').orNull
|
||||
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
|
||||
}
|
||||
@@ -282,22 +312,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
def verifyHandModel = tasks.register('verifyHandModel') {
|
||||
def models = ['hand_landmarker.task': 'fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1',
|
||||
'hand_landmarker_lite.task': '173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8']
|
||||
.collectEntries { name, hash -> [(file("src/main/assets/models/$name")): hash] }
|
||||
inputs.files(models.keySet())
|
||||
doLast {
|
||||
models.each { model, expected ->
|
||||
def actual = java.security.MessageDigest.getInstance('SHA-256').digest(model.bytes).encodeHex().toString()
|
||||
if (actual != expected) throw new GradleException("Hand Landmarker model checksum mismatch: ${model.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(verifyHandModel) }
|
||||
|
||||
dependencies {
|
||||
implementation 'org.opencv:opencv:4.13.0'
|
||||
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
|
||||
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
|
||||
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
@@ -316,7 +332,8 @@ dependencies {
|
||||
implementation 'com.termux.termux-app:terminal-view:0.118.0'
|
||||
implementation 'androidx.camera:camera-camera2:1.4.2'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.4.2'
|
||||
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
|
||||
implementation 'androidx.webkit:webkit:1.12.1'
|
||||
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
|
||||
exclude group: 'com.google.auto.value', module: 'auto-value'
|
||||
}
|
||||
|
||||
|
||||
@@ -65,11 +65,6 @@
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -88,6 +83,31 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".LauncherDark"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".LauncherLight"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:icon="@mipmap/ic_launcher_light"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name=".HomeActivity"
|
||||
android:enabled="false"
|
||||
@@ -127,6 +147,10 @@
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity android:name=".stores.xbox.XboxLoginActivity" android:exported="false"
|
||||
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,23 +11,23 @@ add_subdirectory(adrenotools)
|
||||
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
|
||||
add_subdirectory(thirdparty/dxbc)
|
||||
|
||||
set(WINFG_UPSTREAM "v0.3.0")
|
||||
set(WINFG_CHAIN_SRC
|
||||
framegen/winfg/framegen.cpp
|
||||
framegen/winfg/framegen.hpp
|
||||
framegen/winfg/record_impl.inc
|
||||
framegen/winfg/config.hpp
|
||||
framegen/winfg/embedded_shaders.hpp
|
||||
framegen/winfg/winfg_engine.cpp
|
||||
set(DDFG_UPSTREAM "v0.3.0")
|
||||
set(DDFG_CHAIN_SRC
|
||||
framegen/ddfg/framegen.cpp
|
||||
framegen/ddfg/framegen.hpp
|
||||
framegen/ddfg/record_impl.inc
|
||||
framegen/ddfg/config.hpp
|
||||
framegen/ddfg/embedded_shaders.hpp
|
||||
framegen/ddfg/ddfg_engine.cpp
|
||||
)
|
||||
set(WINFG_HASH_INPUT "")
|
||||
foreach(f ${WINFG_CHAIN_SRC})
|
||||
set(DDFG_HASH_INPUT "")
|
||||
foreach(f ${DDFG_CHAIN_SRC})
|
||||
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h)
|
||||
string(APPEND WINFG_HASH_INPUT "${_h}")
|
||||
string(APPEND DDFG_HASH_INPUT "${_h}")
|
||||
endforeach()
|
||||
string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}")
|
||||
string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
|
||||
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
|
||||
string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}")
|
||||
string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH)
|
||||
message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})")
|
||||
|
||||
add_subdirectory(waylandcomp)
|
||||
add_subdirectory(questxr)
|
||||
|
||||
@@ -131,7 +131,7 @@ struct VkTable {
|
||||
#include <string>
|
||||
|
||||
namespace lsfg { class Engine; }
|
||||
namespace winfg { class Engine; }
|
||||
namespace ddfg { class Engine; }
|
||||
|
||||
static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2;
|
||||
|
||||
@@ -280,7 +280,7 @@ public:
|
||||
static constexpr int32_t kFgCaptureGame = -1;
|
||||
void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0);
|
||||
void setFrameGenEngine(int kind);
|
||||
void setWinFgTuning(int model, int perfPreset);
|
||||
void setDdFgTuning(int model, int perfPreset);
|
||||
|
||||
private:
|
||||
struct WinTex {
|
||||
@@ -468,12 +468,12 @@ private:
|
||||
uint64_t fgSourceFrames_ = 0;
|
||||
std::string lsfgCachePath_;
|
||||
bool lsfgEngineTried_ = false;
|
||||
std::unique_ptr<winfg::Engine> winfgEngine_;
|
||||
bool winfgEngineTried_ = false;
|
||||
std::unique_ptr<ddfg::Engine> ddfgEngine_;
|
||||
bool ddfgEngineTried_ = false;
|
||||
std::atomic<int> fgEngineKind_{0};
|
||||
std::atomic<int> fgModel_{4};
|
||||
std::atomic<int> fgPerfPreset_{1};
|
||||
bool ensureWinFgEngine();
|
||||
bool ensureDdFgEngine();
|
||||
bool fgCapsOk() const;
|
||||
|
||||
uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; }
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+36
-36
@@ -6,7 +6,7 @@
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
struct Config {
|
||||
bool enabled = false;
|
||||
@@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) {
|
||||
}
|
||||
|
||||
static inline std::string conf_path() {
|
||||
if (const char* p = std::getenv("WIN_FG_CONF")) return p;
|
||||
if (const char* p = std::getenv("DD_FG_CONF")) return p;
|
||||
const char* home = std::getenv("HOME");
|
||||
return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string();
|
||||
return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string();
|
||||
}
|
||||
|
||||
static inline std::string capture_root(const Config& c) {
|
||||
if (!c.capDir.empty()) return c.capDir;
|
||||
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture";
|
||||
return std::string("/tmp/winfg-capture");
|
||||
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture";
|
||||
return std::string("/tmp/ddfg-capture");
|
||||
}
|
||||
|
||||
static inline Config load_config() {
|
||||
Config c;
|
||||
c.enabled = envi("WIN_FG_ENABLE", 0) != 0;
|
||||
if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
|
||||
if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
|
||||
c.model = envi("WIN_FG_MODEL", c.model);
|
||||
c.multiplier = envi("WIN_FG_MULT", c.multiplier);
|
||||
c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages);
|
||||
c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset);
|
||||
if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
|
||||
if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
|
||||
c.frIters = envi("WIN_FG_FR_ITERS", c.frIters);
|
||||
c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda);
|
||||
c.frDt = envf("WIN_FG_FR_DT", c.frDt);
|
||||
c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge);
|
||||
c.frEps = envf("WIN_FG_FR_EPS", c.frEps);
|
||||
c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale);
|
||||
c.beta = envf("WIN_FG_BETA", c.beta);
|
||||
c.lambda = envf("WIN_FG_LAMBDA", c.lambda);
|
||||
c.epsilon = envf("WIN_FG_EPSILON", c.epsilon);
|
||||
c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale);
|
||||
if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
|
||||
c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW);
|
||||
c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH);
|
||||
if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
|
||||
c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches);
|
||||
c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize);
|
||||
c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion);
|
||||
c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB);
|
||||
if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd;
|
||||
if (const char* h = std::getenv("WIN_FG_HUD_RECT")) {
|
||||
c.enabled = envi("DD_FG_ENABLE", 0) != 0;
|
||||
if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
|
||||
if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
|
||||
c.model = envi("DD_FG_MODEL", c.model);
|
||||
c.multiplier = envi("DD_FG_MULT", c.multiplier);
|
||||
c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages);
|
||||
c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset);
|
||||
if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
|
||||
if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
|
||||
c.frIters = envi("DD_FG_FR_ITERS", c.frIters);
|
||||
c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda);
|
||||
c.frDt = envf("DD_FG_FR_DT", c.frDt);
|
||||
c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge);
|
||||
c.frEps = envf("DD_FG_FR_EPS", c.frEps);
|
||||
c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale);
|
||||
c.beta = envf("DD_FG_BETA", c.beta);
|
||||
c.lambda = envf("DD_FG_LAMBDA", c.lambda);
|
||||
c.epsilon = envf("DD_FG_EPSILON", c.epsilon);
|
||||
c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale);
|
||||
if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
|
||||
c.captureW = envi("DD_FG_CAPTURE_W", c.captureW);
|
||||
c.captureH = envi("DD_FG_CAPTURE_H", c.captureH);
|
||||
if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
|
||||
c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches);
|
||||
c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize);
|
||||
c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion);
|
||||
c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB);
|
||||
if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd;
|
||||
if (const char* h = std::getenv("DD_FG_HUD_RECT")) {
|
||||
float r[4] = {0,0,0,0}; int n = 0;
|
||||
std::string s = h; size_t start = 0;
|
||||
for (; n < 4; ++n) {
|
||||
@@ -266,8 +266,8 @@ static inline Config load_config() {
|
||||
c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3];
|
||||
}
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml");
|
||||
if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p);
|
||||
if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml");
|
||||
if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p);
|
||||
c.sanitize();
|
||||
return c;
|
||||
}
|
||||
+13
-10
@@ -1,10 +1,10 @@
|
||||
#include "winfg_engine.h"
|
||||
#include "winfg_vkd.h"
|
||||
#include "ddfg_engine.h"
|
||||
#include "ddfg_vkd.h"
|
||||
#include "log.hpp"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
namespace {
|
||||
|
||||
float alphaFor(uint32_t k, uint32_t n) {
|
||||
@@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); }
|
||||
|
||||
bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev,
|
||||
uint32_t queueFamily, VkQueue queue) {
|
||||
if (!winfgVkdInit(table, dd_, id_)) {
|
||||
WFG_LOGE("native: dispatch incomplete; frame generation unavailable");
|
||||
if (!ddfgVkdInit(table, dd_, id_)) {
|
||||
DDFG_LOGE("native: dispatch incomplete; frame generation unavailable");
|
||||
return false;
|
||||
}
|
||||
phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue;
|
||||
id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_);
|
||||
if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) {
|
||||
WFG_LOGE("native: FrameGen::init failed");
|
||||
DDFG_LOGE("native: FrameGen::init failed");
|
||||
unavailable_ = true;
|
||||
return false;
|
||||
}
|
||||
WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
|
||||
DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
|
||||
|
||||
if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) {
|
||||
WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
|
||||
DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
|
||||
unavailable_ = true;
|
||||
return false;
|
||||
}
|
||||
@@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
}
|
||||
builtW_ = w; builtH_ = h; builtFmt_ = fmt;
|
||||
history_ = 0; curr_ = 0; frames_ = 0;
|
||||
WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
|
||||
DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
|
||||
w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale);
|
||||
return true;
|
||||
}
|
||||
|
||||
static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot");
|
||||
|
||||
uint32_t Engine::plan(uint32_t capacity) const {
|
||||
if (!valid() || history_ < 2) return 0;
|
||||
const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0);
|
||||
@@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou
|
||||
dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite);
|
||||
|
||||
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_);
|
||||
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_,
|
||||
generation, generation > 0);
|
||||
if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots;
|
||||
|
||||
(void)w; (void)h;
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
struct VkTable;
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
constexpr uint32_t kMaxGenerations = 3;
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
#include "winfg_vkd.h"
|
||||
#include "ddfg_vkd.h"
|
||||
#include "../VulkanRendererContext.h"
|
||||
#include <android/log.h>
|
||||
|
||||
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) {
|
||||
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) {
|
||||
bool ok = true;
|
||||
#define COPY(dst, fn) \
|
||||
do { \
|
||||
dst.fn = table.fn; \
|
||||
if (!dst.fn) { \
|
||||
__android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \
|
||||
__android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \
|
||||
"entry point vk" #fn " did not resolve"); \
|
||||
ok = false; \
|
||||
} \
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
#include "vk_dispatch.hpp"
|
||||
struct VkTable;
|
||||
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id);
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
namespace winfg { namespace embedded {
|
||||
namespace ddfg { namespace embedded {
|
||||
static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x0Line truncated
|
||||
static const size_t OF3_LUMA_LEN = 481;
|
||||
static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x0005006Line truncated
|
||||
@@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b
|
||||
static const size_t OF3_GM_PREWARP_LEN = 706;
|
||||
static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,Line truncated
|
||||
static const size_t OF3_FLOWREG_LEN = 1936;
|
||||
static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0xLine truncated
|
||||
static const size_t WFG_SYNTH_LEN = 3082;
|
||||
static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0Line truncated
|
||||
static const size_t DDFG_SYNTH_LEN = 3082;
|
||||
struct Blob { const uint32_t* code; size_t words; };
|
||||
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT };
|
||||
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT };
|
||||
static inline Blob blob(Shader s) {
|
||||
switch (s) {
|
||||
case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN };
|
||||
@@ -35,7 +35,7 @@ static inline Blob blob(Shader s) {
|
||||
case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN };
|
||||
case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN };
|
||||
case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN };
|
||||
case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN };
|
||||
case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN };
|
||||
default: return { nullptr, 0 };
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -4,9 +4,9 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0)
|
||||
#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0)
|
||||
|
||||
uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const {
|
||||
for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i)
|
||||
@@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id,
|
||||
if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false;
|
||||
if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false;
|
||||
if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false;
|
||||
if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false;
|
||||
if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
|
||||
WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
|
||||
if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false;
|
||||
if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
|
||||
DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
|
||||
queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters);
|
||||
return true;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) {
|
||||
if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
|
||||
if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
|
||||
ready_ = true;
|
||||
WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
|
||||
DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
|
||||
extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset);
|
||||
return true;
|
||||
}
|
||||
+4
-2
@@ -3,7 +3,7 @@
|
||||
#include "config.hpp"
|
||||
#include <vector>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
class FrameGen {
|
||||
public:
|
||||
@@ -12,7 +12,8 @@ public:
|
||||
void configure(const Config& c);
|
||||
bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false);
|
||||
void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
|
||||
VkImageView outView, float alpha, uint32_t gmSlot = 0);
|
||||
VkImageView outView, float alpha, uint32_t gmSlot = 0,
|
||||
uint32_t synthSlot = 0, bool reuseFlow = false);
|
||||
void destroy();
|
||||
bool valid() const { return device_ != VK_NULL_HANDLE && ready_; }
|
||||
const Config& config() const { return cfg_; }
|
||||
@@ -25,6 +26,7 @@ public:
|
||||
|
||||
|
||||
static constexpr int kGmSlots = 3;
|
||||
static constexpr int kSynthSlots = 3;
|
||||
static constexpr int kGmLevel = 3;
|
||||
static constexpr int kGmThreads = 512;
|
||||
static constexpr int kGmStride = 32;
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#if defined(__ANDROID__)
|
||||
#include <android/log.h>
|
||||
#define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__)
|
||||
#define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__)
|
||||
#else
|
||||
#include <cstdio>
|
||||
#define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#endif
|
||||
|
||||
#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0)
|
||||
+159
-142
@@ -1,31 +1,34 @@
|
||||
|
||||
namespace {
|
||||
struct ScratchState {
|
||||
VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr;
|
||||
VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{};
|
||||
VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{};
|
||||
void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{};
|
||||
std::vector<VkBuffer> flowUbo; std::vector<VkDeviceMemory> flowUboMem; std::vector<void*> flowUboPtr;
|
||||
VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr;
|
||||
VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr;
|
||||
VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{};
|
||||
VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{};
|
||||
void* gmUboPtr[winfg::FrameGen::kGmSlots]{};
|
||||
VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{};
|
||||
VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{};
|
||||
void* gmUboPtr[ddfg::FrameGen::kGmSlots]{};
|
||||
VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE;
|
||||
std::vector<VkDescriptorSet> sDownA, sDownB, sFlow;
|
||||
VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{};
|
||||
VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{};
|
||||
|
||||
|
||||
|
||||
VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE;
|
||||
VkDescriptorSet sSynth = VK_NULL_HANDLE;
|
||||
VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{};
|
||||
std::vector<VkDescriptorSet> sGmPrewarp[winfg::FrameGen::kGmSlots];
|
||||
VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{};
|
||||
VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{};
|
||||
std::vector<VkDescriptorSet> sGmPrewarp[ddfg::FrameGen::kGmSlots];
|
||||
VkExtent2D builtFor{0,0};
|
||||
bool built = false;
|
||||
bool flowInited = false;
|
||||
bool flowReady = false;
|
||||
};
|
||||
ScratchState g_scratch;
|
||||
}
|
||||
|
||||
static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
|
||||
static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev,
|
||||
const VkPhysicalDeviceMemoryProperties& mp,
|
||||
VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) {
|
||||
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||
@@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
|
||||
return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS;
|
||||
}
|
||||
|
||||
static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev,
|
||||
static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev,
|
||||
VkDescriptorPool pool, VkDescriptorSetLayout layout) {
|
||||
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
||||
ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout;
|
||||
VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s;
|
||||
}
|
||||
|
||||
static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) {
|
||||
VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout;
|
||||
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = ⅈ
|
||||
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
|
||||
}
|
||||
static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
VkBuffer buf, VkDeviceSize range) {
|
||||
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
|
||||
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
@@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet
|
||||
w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi;
|
||||
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
|
||||
}
|
||||
static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
VkBuffer buf, VkDeviceSize range) {
|
||||
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
|
||||
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
@@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3])
|
||||
out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0;
|
||||
}
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool,
|
||||
@@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP
|
||||
b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr;
|
||||
};
|
||||
auto& S = g_scratch;
|
||||
freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr);
|
||||
for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
|
||||
freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr);
|
||||
freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
|
||||
for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]);
|
||||
@@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) {
|
||||
int want = flowFinestForPreset(cfg_.perfPreset);
|
||||
const int maxFinest = kLevels - 2;
|
||||
if (want > maxFinest) {
|
||||
WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
|
||||
DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
|
||||
"(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest);
|
||||
want = maxFinest;
|
||||
}
|
||||
@@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) {
|
||||
const int prevFinest = flowFinest_;
|
||||
flowFinest_ = want;
|
||||
const bool live = (flowFinest_ != prevFinest) && ready_;
|
||||
WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
|
||||
DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
|
||||
cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0);
|
||||
|
||||
if (!live) return;
|
||||
@@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) {
|
||||
dd_->DeviceWaitIdle(device_);
|
||||
destroyScratch(dd_, device_, descPool_);
|
||||
if (!onResize(extent_, VK_FORMAT_UNDEFINED, true))
|
||||
WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
|
||||
DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
|
||||
extent_.width, extent_.height);
|
||||
for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0;
|
||||
for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; }
|
||||
gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f;
|
||||
WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
|
||||
DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
|
||||
}
|
||||
|
||||
bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
|
||||
@@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
|
||||
}
|
||||
|
||||
void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
|
||||
VkImageView outView, float alpha, uint32_t gmSlot) {
|
||||
VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) {
|
||||
if (!valid()) return;
|
||||
if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0;
|
||||
if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0;
|
||||
const bool m4 = (cfg_.model == 4);
|
||||
const Pipe& flowP = m4 ? pFlowM4_ : pFlow_;
|
||||
const Pipe& expandP = m4 ? pExpandM4_ : pExpand_;
|
||||
@@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
|
||||
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
|
||||
}
|
||||
|
||||
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
|
||||
if (g_scratch.gmUboPtr[gmSlot]) {
|
||||
GMUBO g{};
|
||||
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
|
||||
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
|
||||
if (gmEngaged) {
|
||||
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
|
||||
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
|
||||
// Every generation of one real frame is recorded into the same command buffer, so the flow
|
||||
// between prev and curr is computed once and each generation gets its own synth UBO and set.
|
||||
if (reuseFlow && !g_scratch.flowReady) return;
|
||||
if (!reuseFlow) {
|
||||
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
|
||||
if (g_scratch.gmUboPtr[gmSlot]) {
|
||||
GMUBO g{};
|
||||
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
|
||||
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
|
||||
if (gmEngaged) {
|
||||
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
|
||||
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
|
||||
}
|
||||
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
|
||||
}
|
||||
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
|
||||
}
|
||||
{
|
||||
static int lastEng = -1;
|
||||
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
|
||||
WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
|
||||
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
|
||||
lastEng = gmEngaged ? 1 : 0;
|
||||
{
|
||||
static int lastEng = -1;
|
||||
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
|
||||
DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
|
||||
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
|
||||
lastEng = gmEngaged ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
}
|
||||
|
||||
SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0,
|
||||
cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1};
|
||||
if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su));
|
||||
if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su));
|
||||
|
||||
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
|
||||
const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot];
|
||||
wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
|
||||
|
||||
auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) {
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline);
|
||||
@@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
|
||||
};
|
||||
auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); };
|
||||
|
||||
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
{
|
||||
VkClearColorValue zero{};
|
||||
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
if (!reuseFlow) {
|
||||
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
{
|
||||
VkClearColorValue zero{};
|
||||
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
for (int l = flowFinest_; l < kLevels; ++l) {
|
||||
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
b.image = flowLvl_[l].image; b.subresourceRange = rng;
|
||||
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
|
||||
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
}
|
||||
}
|
||||
|
||||
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
|
||||
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
|
||||
bar(pyrA_[0]); bar(pyrB_[0]);
|
||||
for (int l = 1; l < kLevels; ++l) {
|
||||
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
|
||||
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
|
||||
bar(pyrA_[l]); bar(pyrB_[l]);
|
||||
}
|
||||
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
}
|
||||
for (int l = flowFinest_; l < kLevels; ++l) {
|
||||
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
b.image = flowLvl_[l].image; b.subresourceRange = rng;
|
||||
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
|
||||
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
|
||||
bar(pyrAs_[l]);
|
||||
}
|
||||
for (int l = kLevels - 1; l >= flowFinest_; --l) {
|
||||
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
|
||||
bar(flowLvl_[l]);
|
||||
}
|
||||
}
|
||||
|
||||
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
|
||||
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
|
||||
bar(pyrA_[0]); bar(pyrB_[0]);
|
||||
for (int l = 1; l < kLevels; ++l) {
|
||||
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
|
||||
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
|
||||
bar(pyrA_[l]); bar(pyrB_[l]);
|
||||
}
|
||||
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
}
|
||||
for (int l = flowFinest_; l < kLevels; ++l) {
|
||||
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
|
||||
bar(pyrAs_[l]);
|
||||
}
|
||||
for (int l = kLevels - 1; l >= flowFinest_; --l) {
|
||||
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
|
||||
bar(flowLvl_[l]);
|
||||
}
|
||||
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
|
||||
if (frEngaged) {
|
||||
const int F = flowFinest_;
|
||||
const VkExtent2D fe = levelExtent(extent_, F);
|
||||
if (g_scratch.flowRegUboPtr) {
|
||||
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
|
||||
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
|
||||
}
|
||||
FrameGen::Img* last = nullptr;
|
||||
for (int it = 0; it < cfg_.frIters; ++it) {
|
||||
VkDescriptorSet set; FrameGen::Img* outImg;
|
||||
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
|
||||
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
|
||||
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
|
||||
bar(*outImg);
|
||||
last = outImg;
|
||||
}
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
VkImageCopy region{};
|
||||
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.extent = {fe.width, fe.height, 1};
|
||||
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
|
||||
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion);
|
||||
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
0, 1, &mb2, 0, nullptr, 0, nullptr);
|
||||
}
|
||||
{
|
||||
static int lastFr = -1;
|
||||
int fe = frEngaged ? 1 : 0;
|
||||
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
|
||||
DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
|
||||
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
|
||||
lastFr = fe;
|
||||
}
|
||||
}
|
||||
|
||||
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
|
||||
if (frEngaged) {
|
||||
const int F = flowFinest_;
|
||||
const VkExtent2D fe = levelExtent(extent_, F);
|
||||
if (g_scratch.flowRegUboPtr) {
|
||||
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
|
||||
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
|
||||
}
|
||||
FrameGen::Img* last = nullptr;
|
||||
for (int it = 0; it < cfg_.frIters; ++it) {
|
||||
VkDescriptorSet set; FrameGen::Img* outImg;
|
||||
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
|
||||
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
|
||||
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
|
||||
bar(*outImg);
|
||||
last = outImg;
|
||||
}
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
VkImageCopy region{};
|
||||
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.extent = {fe.width, fe.height, 1};
|
||||
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
|
||||
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion);
|
||||
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
0, 1, &mb2, 0, nullptr, 0, nullptr);
|
||||
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
|
||||
bar(flowExpA_); bar(flowExpB_);
|
||||
g_scratch.flowReady = true;
|
||||
}
|
||||
{
|
||||
static int lastFr = -1;
|
||||
int fe = frEngaged ? 1 : 0;
|
||||
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
|
||||
WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
|
||||
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
|
||||
lastFr = fe;
|
||||
}
|
||||
}
|
||||
|
||||
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
|
||||
bar(flowExpA_); bar(flowExpB_);
|
||||
disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0));
|
||||
disp(pSynth_, synthSet, levelExtent(extent_, 0));
|
||||
}
|
||||
|
||||
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
@@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
S = ScratchState{};
|
||||
const auto GEN = VK_IMAGE_LAYOUT_GENERAL;
|
||||
|
||||
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr);
|
||||
for (int s = 0; s < FrameGen::kSynthSlots; ++s)
|
||||
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
|
||||
makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr);
|
||||
if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); }
|
||||
makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
|
||||
@@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN);
|
||||
}
|
||||
|
||||
S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout);
|
||||
wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO));
|
||||
wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
|
||||
wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
|
||||
for (int s = 0; s < FrameGen::kSynthSlots; ++s) {
|
||||
S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout);
|
||||
wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO));
|
||||
wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
|
||||
wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
|
||||
}
|
||||
|
||||
S.builtFor = ext; S.built = true;
|
||||
return true;
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
#include <vulkan/vk_layer.h>
|
||||
#include <cstring>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
static inline void* dispatch_key(void* handle) { return *reinterpret_cast<void**>(handle); }
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#pragma once
|
||||
#if defined(__ANDROID__)
|
||||
#include <android/log.h>
|
||||
#define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__)
|
||||
#define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__)
|
||||
#else
|
||||
#include <cstdio>
|
||||
#define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#endif
|
||||
|
||||
#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0)
|
||||
@@ -1,4 +0,0 @@
|
||||
#pragma once
|
||||
#include "vk_dispatch.hpp"
|
||||
struct VkTable;
|
||||
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id);
|
||||
@@ -43,10 +43,10 @@ target_include_directories(droiddeckwayland PRIVATE
|
||||
)
|
||||
|
||||
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
|
||||
set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
|
||||
set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg)
|
||||
if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
|
||||
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
|
||||
AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp)
|
||||
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)")
|
||||
target_sources(droiddeckwayland PRIVATE
|
||||
src/framegen_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
|
||||
@@ -66,9 +66,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp
|
||||
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp
|
||||
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp
|
||||
)
|
||||
target_include_directories(droiddeckwayland PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/..
|
||||
@@ -77,8 +77,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
target_compile_options(droiddeckwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
|
||||
target_compile_definitions(droiddeckwayland PRIVATE
|
||||
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
|
||||
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
|
||||
DDFG_UPSTREAM="${DDFG_UPSTREAM}"
|
||||
DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}"
|
||||
)
|
||||
target_link_libraries(droiddeckwayland dxbc atomic)
|
||||
else()
|
||||
|
||||
@@ -218,6 +218,8 @@ struct surface {
|
||||
enum surface_role role;
|
||||
struct wl_resource *idle_buffer;
|
||||
struct wl_listener idle_buffer_destroy;
|
||||
uint32_t *idle_px;
|
||||
int idle_w, idle_h;
|
||||
struct wl_resource *xdg_surface, *xdg_toplevel;
|
||||
struct wl_resource *viewport;
|
||||
|
||||
@@ -899,8 +901,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer)
|
||||
g_cursor_shown = s;
|
||||
}
|
||||
|
||||
static void surface_forget_idle_px(struct surface *s) {
|
||||
free(s->idle_px);
|
||||
s->idle_px = NULL;
|
||||
s->idle_w = s->idle_h = 0;
|
||||
}
|
||||
|
||||
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
|
||||
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
|
||||
struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer);
|
||||
if (shm) {
|
||||
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
|
||||
int32_t stride = wl_shm_buffer_get_stride(shm);
|
||||
uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4
|
||||
? realloc(s->idle_px, (size_t)w * h * 4) : NULL;
|
||||
if (px) {
|
||||
wl_shm_buffer_begin_access(shm);
|
||||
const uint8_t *src = wl_shm_buffer_get_data(shm);
|
||||
for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4);
|
||||
wl_shm_buffer_end_access(shm);
|
||||
s->idle_px = px;
|
||||
s->idle_w = w;
|
||||
s->idle_h = h;
|
||||
}
|
||||
}
|
||||
wl_list_remove(&s->idle_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
s->idle_buffer = NULL;
|
||||
@@ -927,6 +951,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) {
|
||||
}
|
||||
|
||||
static void surface_drop_idle(struct surface *s) {
|
||||
surface_forget_idle_px(s);
|
||||
struct wl_resource *buffer = surface_take_idle(s);
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
}
|
||||
@@ -1055,6 +1080,7 @@ static void surface_resource_destroy(struct wl_resource *r) {
|
||||
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
|
||||
if (g_cursor_shown == s) g_cursor_shown = NULL;
|
||||
surface_take_idle(s);
|
||||
surface_forget_idle_px(s);
|
||||
if (g_grab == s) g_grab = NULL;
|
||||
if (g_key_target == s) g_key_target = NULL;
|
||||
if (g_ime_click == s) g_ime_click = NULL;
|
||||
@@ -2042,6 +2068,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
|
||||
wl_buffer_send_release(buffer);
|
||||
} else if (g_cursor_shown == s) {
|
||||
cursor_publish_shown();
|
||||
} else if (s->idle_px) {
|
||||
cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy);
|
||||
g_cursor_shown = s;
|
||||
}
|
||||
}
|
||||
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
@@ -2593,7 +2622,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
|
||||
uint32_t t = now_ms();
|
||||
|
||||
pointer_focus(target->resource, fx, fy);
|
||||
if (!(k && k->is_lock))
|
||||
// A button-only input must not replay a cached absolute position. A game
|
||||
// may have warped its cursor since the last motion (before taking lock,
|
||||
// or while releasing it), so that replay can become an unintended pan.
|
||||
// Absolute touch events still carry their position, including on clicks.
|
||||
if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0))
|
||||
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
|
||||
send_relative_motion(client, dx, dy);
|
||||
for_each_pointer_of(client, sp) {
|
||||
|
||||
@@ -86,7 +86,7 @@ static int64_t now_ns(void) {
|
||||
|
||||
|
||||
void vkp_framegen_set_engine(int kind) {
|
||||
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG;
|
||||
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG;
|
||||
atomic_store(&g_kind, kind);
|
||||
atomic_store(&g_failed_kind, -1);
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
@@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) {
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_winfg_tuning(int model, int perf_preset) {
|
||||
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) {
|
||||
atomic_store(&g_model, model == 3 ? 3 : 4);
|
||||
atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset);
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
@@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f
|
||||
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
|
||||
fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled);
|
||||
g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier;
|
||||
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s",
|
||||
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s",
|
||||
fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(),
|
||||
fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable");
|
||||
fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable");
|
||||
}
|
||||
|
||||
void vkp_framegen_device_lost(void) { g_dev_lost = 1; }
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
#endif
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
|
||||
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 };
|
||||
#define VKP_FG_MAX_GENERATIONS 3
|
||||
|
||||
void vkp_framegen_set_engine(int kind);
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path);
|
||||
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
|
||||
void vkp_framegen_set_winfg_tuning(int model, int perf_preset);
|
||||
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset);
|
||||
int vkp_framegen_problem(void);
|
||||
const char *vkp_framegen_caps_reason(void);
|
||||
void vkp_framegen_stats(float out[6]);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "lsfg/lsfg_engine.h"
|
||||
#include "lsfg/lsfg_probe.h"
|
||||
#include "lsfg/lsfg_vkd.h"
|
||||
#include "winfg/winfg_engine.h"
|
||||
#include "ddfg/ddfg_engine.h"
|
||||
|
||||
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
@@ -30,7 +30,7 @@ uint32_t g_qfam = 0;
|
||||
bool g_probed = false, g_ready = false;
|
||||
|
||||
std::unique_ptr<lsfg::Engine> g_lsfg;
|
||||
std::unique_ptr<winfg::Engine> g_winfg;
|
||||
std::unique_ptr<ddfg::Engine> g_ddfg;
|
||||
int g_kind = -1;
|
||||
char g_build[96] = "";
|
||||
|
||||
@@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) {
|
||||
fge_stop();
|
||||
if (!g_ready) return -1;
|
||||
if (kind == 1) {
|
||||
auto e = std::make_unique<winfg::Engine>();
|
||||
auto e = std::make_unique<ddfg::Engine>();
|
||||
if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) {
|
||||
ELOGE("winfg-native: engine init failed");
|
||||
ELOGE("ddfg-native: engine init failed");
|
||||
return -1;
|
||||
}
|
||||
g_winfg = std::move(e);
|
||||
g_ddfg = std::move(e);
|
||||
g_kind = 1;
|
||||
#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH)
|
||||
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH);
|
||||
#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH)
|
||||
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH);
|
||||
#else
|
||||
std::snprintf(g_build, sizeof(g_build), "embedded chain");
|
||||
#endif
|
||||
@@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) {
|
||||
}
|
||||
|
||||
void fge_stop(void) {
|
||||
if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
|
||||
if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
|
||||
g_lsfg.reset();
|
||||
g_winfg.reset();
|
||||
g_ddfg.reset();
|
||||
g_kind = -1;
|
||||
}
|
||||
|
||||
int fge_unavailable(void) {
|
||||
if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0;
|
||||
if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0;
|
||||
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
|
||||
}
|
||||
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
|
||||
int model, int perf_preset) {
|
||||
if (multiplier < 2) multiplier = 2;
|
||||
if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale);
|
||||
if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale);
|
||||
else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz);
|
||||
}
|
||||
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0;
|
||||
if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0;
|
||||
if (!g_lsfg) return 0;
|
||||
g_lsfg->setGuestExtent(w, h);
|
||||
return g_lsfg->prepare(w, h, fmt) ? 1 : 0;
|
||||
}
|
||||
|
||||
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
|
||||
if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0;
|
||||
if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0;
|
||||
if (!g_lsfg) return 0;
|
||||
g_lsfg->setPresentedRate(presented_rate);
|
||||
return g_lsfg->plan(capacity, source_frames);
|
||||
}
|
||||
|
||||
void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) {
|
||||
if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; }
|
||||
if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; }
|
||||
if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens);
|
||||
}
|
||||
|
||||
void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view,
|
||||
uint32_t w, uint32_t h) {
|
||||
if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; }
|
||||
if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; }
|
||||
if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h);
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
|
||||
}
|
||||
}
|
||||
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
|
||||
const char *fge_build_info(void) { return g_build; }
|
||||
|
||||
}
|
||||
@@ -34,5 +34,5 @@ void fge_forget_targets(void) {}
|
||||
void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
|
||||
*accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f;
|
||||
}
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
|
||||
const char *fge_build_info(void) { return "stub"; }
|
||||
@@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz,
|
||||
jint model, jint perfPreset) {
|
||||
(void)env; (void)clazz;
|
||||
vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset);
|
||||
vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
|
||||
@@ -20,12 +20,17 @@ class App : Application() {
|
||||
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
|
||||
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
|
||||
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
|
||||
if (process == "$packageName:xboxlogin") {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
|
||||
return
|
||||
}
|
||||
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
com.droiddeck.launcher.core.DownloadSource.init(this)
|
||||
prefetchAddonPacks()
|
||||
GpuClockPin.clearLeftover(this)
|
||||
com.droiddeck.launcher.glasses.Glasses.init(this)
|
||||
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
|
||||
@@ -35,5 +40,24 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||
}
|
||||
|
||||
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
|
||||
private fun prefetchAddonPacks() {
|
||||
val app = this
|
||||
Thread({
|
||||
runCatching {
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
|
||||
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
|
||||
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
|
||||
}
|
||||
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
|
||||
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
|
||||
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
|
||||
com.droiddeck.launcher.handtracking.HandTracking.load(app)
|
||||
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
|
||||
}, "addon-packs").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
object LauncherIcon {
|
||||
const val DARK = "dark"
|
||||
const val LIGHT = "light"
|
||||
|
||||
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.LauncherDark", LIGHT to "com.droiddeck.launcher.LauncherLight")
|
||||
|
||||
val options: List<Pair<String, Int>> = listOf(
|
||||
DARK to R.string.setup_launcher_icon_dark,
|
||||
LIGHT to R.string.setup_launcher_icon_light,
|
||||
)
|
||||
|
||||
private fun component(context: Context, style: String) =
|
||||
ComponentName(context, aliases.getValue(style))
|
||||
|
||||
private fun enabled(context: Context, style: String): Boolean =
|
||||
when (context.packageManager.getComponentEnabledSetting(component(context, style))) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> style == DARK
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun current(context: Context): String = aliases.keys.firstOrNull { enabled(context, it) } ?: DARK
|
||||
|
||||
fun activity(context: Context): ComponentName = component(context, current(context))
|
||||
|
||||
fun set(context: Context, style: String) {
|
||||
if (style !in aliases || style == current(context)) return
|
||||
val pm = context.packageManager
|
||||
pm.setComponentEnabledSetting(component(context, style),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
aliases.keys.filter { it != style }.forEach {
|
||||
pm.setComponentEnabledSetting(component(context, it),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var frameGenLabel by mutableStateOf("")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var pendingSwitch by mutableStateOf<Intent?>(null)
|
||||
private var switchingTo: Intent? = null
|
||||
private var switchPoll: Runnable? = null
|
||||
@@ -164,6 +165,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var downloadMirror by mutableStateOf("")
|
||||
private var downloadPrefix by mutableStateOf("")
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
@@ -484,6 +486,20 @@ class MainActivity : ComponentActivity() {
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
|
||||
}
|
||||
|
||||
private fun switchLauncherIcon(style: String): Boolean {
|
||||
val before = LauncherIcon.current(this)
|
||||
LauncherIcon.set(this, style)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
if (launcherIcon == before) return false
|
||||
// Android finishes activities started through the alias that was just disabled.
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(LauncherIcon.activity(this))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher"))
|
||||
finishAndRemoveTask()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun readGameIntent(request: Intent) {
|
||||
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
|
||||
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
|
||||
@@ -739,6 +755,7 @@ class MainActivity : ComponentActivity() {
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
DownloadSource.probe()
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
@@ -799,6 +816,7 @@ class MainActivity : ComponentActivity() {
|
||||
downloadMirror = downloadMirror,
|
||||
downloadPrefix = downloadPrefix,
|
||||
animationsEnabled = animationsEnabled,
|
||||
launcherIcon = launcherIcon,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
@@ -952,6 +970,7 @@ class MainActivity : ComponentActivity() {
|
||||
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLauncherIcon = { style -> switchLauncherIcon(style) },
|
||||
onLanguage = { tag ->
|
||||
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
|
||||
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
|
||||
@@ -1106,6 +1125,13 @@ class MainActivity : ComponentActivity() {
|
||||
onDismiss = { pendingSwitch = null },
|
||||
)
|
||||
}
|
||||
showRuntimeActions?.let { release ->
|
||||
com.droiddeck.launcher.ui.RuntimeActionsDialog(
|
||||
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
|
||||
onRemove = { showRuntimeActions = null; showRemove = true },
|
||||
onDismiss = { showRuntimeActions = null },
|
||||
)
|
||||
}
|
||||
if (showRemove) ConfirmDialog(
|
||||
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
|
||||
@@ -1262,6 +1288,7 @@ class MainActivity : ComponentActivity() {
|
||||
syncLossless()
|
||||
updates.onResume()
|
||||
drivers.checkOnOpen()
|
||||
resumeInterruptedPackages()
|
||||
protons.checkUpdates()
|
||||
if (!SessionState.running) Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
@@ -1515,6 +1542,27 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, path))
|
||||
}
|
||||
|
||||
private fun resumeInterruptedPackages() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val first = DesktopCatalog.interrupted(this).firstOrNull()
|
||||
if (first == null) {
|
||||
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
|
||||
return
|
||||
}
|
||||
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
|
||||
Thread({
|
||||
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
|
||||
ui.post { pkgStage = stage; pkgPercent = percent }
|
||||
}
|
||||
ui.post {
|
||||
pkgStage = null; pkgId = null
|
||||
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshPackages()
|
||||
refresh()
|
||||
}
|
||||
}, "resume-pkg").start()
|
||||
}
|
||||
|
||||
private fun installPackage(id: String) {
|
||||
val entry = catalog?.firstOrNull { it.id == id } ?: return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
|
||||
@@ -1668,12 +1716,12 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
|
||||
onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() },
|
||||
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
|
||||
onImportBootMovie = {
|
||||
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
|
||||
},
|
||||
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
|
||||
onBootMovieChanged = { loadBootMovie() },
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
|
||||
@@ -1882,7 +1930,7 @@ class MainActivity : ComponentActivity() {
|
||||
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
|
||||
if (listX64 != steamGamesX64) {
|
||||
steamGamesX64 = listX64
|
||||
steamGames = steamGames.filterNot(Library::fromSteamClient)
|
||||
steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) }
|
||||
}
|
||||
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
|
||||
if (showComponents) drivers.refreshDrivers()
|
||||
@@ -1916,12 +1964,13 @@ class MainActivity : ComponentActivity() {
|
||||
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
|
||||
if (ready) {
|
||||
val quick = runCatching { Library.steamGames(this) }.getOrNull()
|
||||
val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty()
|
||||
if (quick != null) ui.post {
|
||||
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
|
||||
val before = steamGames.map { it.gameId }.toSet()
|
||||
val fresh = quick.filter { it.gameId !in before }
|
||||
if (fresh.isNotEmpty()) {
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId }
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
|
||||
}
|
||||
}
|
||||
@@ -1960,10 +2009,14 @@ class MainActivity : ComponentActivity() {
|
||||
private fun onRuntimeButton() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val release = available
|
||||
if (removalPending || installed != null && release?.version == installed) {
|
||||
if (removalPending) {
|
||||
showRemove = true
|
||||
return
|
||||
}
|
||||
if (installed != null && release?.version == installed) {
|
||||
showRuntimeActions = release
|
||||
return
|
||||
}
|
||||
if (release == null) {
|
||||
Thread({ checkCatalog() }, "catalog").start()
|
||||
return
|
||||
@@ -2044,7 +2097,7 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM)
|
||||
if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show()
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
recreate()
|
||||
if (!switchLauncherIcon(LauncherIcon.DARK)) recreate()
|
||||
}
|
||||
}, "reset-settings").start()
|
||||
}
|
||||
|
||||
@@ -75,11 +75,13 @@ import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadMouse
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.input.DirectTouchHold
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.input.TouchpadGestures
|
||||
import com.droiddeck.launcher.input.TouchscreenGestures
|
||||
import com.droiddeck.launcher.input.ScrollTouchGestures
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
@@ -188,7 +190,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
private var watching = true
|
||||
private lateinit var touchpad: TouchpadGestures
|
||||
private lateinit var directTouchHold: DirectTouchHold
|
||||
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
|
||||
private lateinit var touchscreenGestures: TouchscreenGestures
|
||||
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
|
||||
private var gestureGame: String? = null
|
||||
private var gestureSettingsOpen by mutableStateOf(false)
|
||||
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
|
||||
private var gesturePointerX = 0f
|
||||
private var gesturePointerY = 0f
|
||||
private var gesturePointerReady = false
|
||||
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
|
||||
private val gestureKeysDown = mutableSetOf<Int>()
|
||||
private lateinit var scrollTouch: ScrollTouchGestures
|
||||
private lateinit var padMouse: PadMouse
|
||||
@Volatile private var padMouseOn = false
|
||||
@@ -209,6 +222,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
|
||||
uiHandler.post {
|
||||
if (pointerLocked == locked) return@post
|
||||
gesturePointerReady = false
|
||||
pointerLocked = locked
|
||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
||||
padMouseCheckedAt = 0L
|
||||
@@ -483,7 +497,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
|
||||
keyboard = KeyboardHost(this).also { root.addView(it) }
|
||||
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
|
||||
directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
}
|
||||
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
|
||||
touchscreenGestures = TouchscreenGestures(
|
||||
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
|
||||
)
|
||||
refreshGestureConfig()
|
||||
// A real touch target lets FrameLayout split fingers between the
|
||||
// game surface and on-screen buttons, in either finger-down order.
|
||||
root.isMotionEventSplittingEnabled = true
|
||||
surfaceView.setOnTouchListener { _, event ->
|
||||
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
|
||||
}
|
||||
padMouse = PadMouse(padMouseListener)
|
||||
bridge.setTouchRoute(object : PadBridge.TouchRoute {
|
||||
override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn
|
||||
@@ -500,12 +527,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
|
||||
if (!SessionState.running) {
|
||||
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
|
||||
val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false)
|
||||
intent.removeExtra(EXTRA_REPAIR_RUNTIME)
|
||||
val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
|
||||
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
|
||||
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
|
||||
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
|
||||
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
|
||||
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
|
||||
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
|
||||
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
|
||||
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
|
||||
}
|
||||
hud = PerfHud(this)
|
||||
hud.onPresentingWindowChanged = {
|
||||
@@ -635,6 +665,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onCancel = { SessionService.stop(this@SessionActivity); finish() },
|
||||
endedDetail = loading.endedDetail,
|
||||
onRetry = { retrySession() },
|
||||
onRepair = if (repairOffered) ({ repairRuntime() }) else null,
|
||||
onShareLogs = { shareCurrentSessionLogs() },
|
||||
onClose = { finish() },
|
||||
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
|
||||
@@ -643,6 +674,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
|
||||
},
|
||||
)
|
||||
LaunchedEffect(AgentGuest.focusRevision) {
|
||||
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
|
||||
else {
|
||||
val snapshot = AgentGuest.focus(this@SessionActivity)
|
||||
val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L }
|
||||
val windows = snapshot?.optJSONArray("focusableApps")
|
||||
focused?.toString() ?: windows?.let { values ->
|
||||
(0 until values.length()).map { values.optLong(it) }
|
||||
.filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() }
|
||||
}
|
||||
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
|
||||
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
|
||||
cancelTouchGestures()
|
||||
}
|
||||
}
|
||||
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
|
||||
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
|
||||
game = gestureGame,
|
||||
onDismiss = { gestureSettingsOpen = false },
|
||||
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
|
||||
)
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
syncLossless()
|
||||
@@ -818,6 +875,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
@@ -1296,6 +1354,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val nextTouchMode = SessionPrefs.touchMode(this)
|
||||
if (touchMode != nextTouchMode) {
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
if (CompositorHost.isStarted) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
@@ -1320,17 +1379,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
|
||||
@Volatile private var installingRuntime = false
|
||||
private var repairOffered by mutableStateOf(false)
|
||||
|
||||
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
|
||||
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
|
||||
if (SessionState.stopRequested) return
|
||||
installingRuntime = true
|
||||
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
|
||||
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
|
||||
SessionEvents.transition(
|
||||
SessionPhase.INSTALLING_RUNTIME,
|
||||
"${SessionState.installing}.installing",
|
||||
mapOf("component" to SessionState.installing),
|
||||
)
|
||||
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
|
||||
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
|
||||
loading.say(getString(first.downloading), first.topic, readable = true)
|
||||
loading.percent = -1
|
||||
Thread({
|
||||
@@ -1340,6 +1400,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
problem = installRuntime()
|
||||
if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
|
||||
}
|
||||
if (problem == null && resume && !SessionState.stopRequested) {
|
||||
SessionState.installing = "package"
|
||||
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package"))
|
||||
problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs())
|
||||
?.let { getString(R.string.session_package_resume_failed, it) }
|
||||
if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package"
|
||||
}
|
||||
if (problem == null && desktop && !SessionState.stopRequested) {
|
||||
SessionState.installing = "desktop"
|
||||
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
|
||||
@@ -1364,7 +1431,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return@post
|
||||
}
|
||||
if (problem != null) {
|
||||
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
|
||||
val code = when (failedComponent) {
|
||||
"runtime" -> "RUNTIME_INSTALL_FAILED"
|
||||
"package" -> "PACKAGE_RESUME_FAILED"
|
||||
else -> "DESKTOP_INSTALL_FAILED"
|
||||
}
|
||||
SessionEvents.fail(code, problem)
|
||||
collectStartArtifacts("session start failed")
|
||||
loading.showEnded(problem)
|
||||
@@ -1412,9 +1483,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
|
||||
uiHandler.post {
|
||||
loading.percent = percent
|
||||
loading.say(stage, LoadingState.Topic.RUNTIME, readable = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installRuntime(): String? {
|
||||
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
|
||||
?: return getString(R.string.session_runtime_catalog_unreachable)
|
||||
runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) }
|
||||
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
|
||||
progressFor(RUNTIME_LINES, release.size / 1_000_000))
|
||||
return if (ok) null else getString(R.string.session_runtime_install_failed)
|
||||
@@ -1537,6 +1616,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
surfaceW = width
|
||||
surfaceH = height
|
||||
if (resized) {
|
||||
cancelTouchGestures()
|
||||
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
|
||||
CompositorHost.resize(holder.surface) { applyFrameGen() }
|
||||
followWindow(width, height)
|
||||
@@ -1555,7 +1635,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun followChoice(): Boolean {
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = displayMode()
|
||||
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
|
||||
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
|
||||
}
|
||||
@@ -1571,6 +1651,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
cancelTouchGestures()
|
||||
applyFollowOrientation(newConfig)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
@@ -1579,12 +1660,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Hiding the phone surface after a handoff delivers a late destruction
|
||||
// callback. It must not reset the new output's size or attachment.
|
||||
if (holder !== outputHolder()) return
|
||||
cancelTouchGestures()
|
||||
surfaceW = 0
|
||||
surfaceH = 0
|
||||
glassesOutputActive = false
|
||||
CompositorHost.detach(holder.surface)
|
||||
}
|
||||
|
||||
private fun cancelTouchGestures() {
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
scrollTouchGesture = false
|
||||
gesturePointerReady = false
|
||||
}
|
||||
|
||||
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
|
||||
windowManager.maximumWindowMetrics.bounds
|
||||
} else {
|
||||
@@ -1594,8 +1684,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
|
||||
}
|
||||
|
||||
private fun displayMode(): String = SessionPrefs.displayMode(this,
|
||||
intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM))
|
||||
|
||||
private fun outputSize(): Pair<Int, Int> {
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = displayMode()
|
||||
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
|
||||
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
@@ -1609,8 +1702,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
glassesScreen?.hz?.takeIf { it > 1f }?.let { return it }
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
|
||||
val wanted = window.attributes.preferredDisplayModeId
|
||||
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
?: display?.refreshRate ?: 60f
|
||||
val requested = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
val running = display?.refreshRate?.takeIf { it > 1f }
|
||||
val hz = listOfNotNull(requested, running).minOrNull() ?: 60f
|
||||
val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY
|
||||
val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap)
|
||||
return if (capped > 1f) capped else 60f
|
||||
@@ -1714,7 +1808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
Thread({
|
||||
val hint = sessionEndHint(SessionState.logFile)
|
||||
val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile)
|
||||
runOnUiThread {
|
||||
if (isFinishing || isDestroyed) return@runOnUiThread
|
||||
showEnded(status, hint)
|
||||
@@ -1733,6 +1827,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
|
||||
else -> getString(R.string.session_ended_named, "Steam")
|
||||
}
|
||||
repairOffered = hint?.repair == true
|
||||
loading.showEnded(
|
||||
hint?.text ?: stopped,
|
||||
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
|
||||
@@ -1751,6 +1846,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
uiHandler.post { sessionOverlay.requestFocus() }
|
||||
}
|
||||
|
||||
private fun repairRuntime() {
|
||||
val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true)
|
||||
closeAtOnce = true
|
||||
finish()
|
||||
startActivity(again)
|
||||
}
|
||||
|
||||
private fun retrySession() {
|
||||
val again = Intent(intent)
|
||||
closeAtOnce = true
|
||||
@@ -1758,7 +1860,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
startActivity(again)
|
||||
}
|
||||
|
||||
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
|
||||
private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false)
|
||||
|
||||
private fun runtimeDamageHint(): EndHint? {
|
||||
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList())
|
||||
if (broken.isEmpty()) return null
|
||||
Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}")
|
||||
return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true)
|
||||
}
|
||||
|
||||
private fun sessionEndHint(log: File?): EndHint? {
|
||||
if (log == null || !log.isFile) return null
|
||||
@@ -1982,6 +2091,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
private fun refreshGestureConfig() {
|
||||
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
|
||||
gesturePointerReady = false
|
||||
recognizedGesture = null
|
||||
}
|
||||
|
||||
private val gestureOutput = object : TouchscreenGestures.Output {
|
||||
override fun moveTo(x: Float, y: Float) {
|
||||
val rect = drawnRect() ?: return
|
||||
if (pointerLocked) {
|
||||
if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true)
|
||||
} else movePointer(x, y)
|
||||
gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right)
|
||||
gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom)
|
||||
gesturePointerReady = true
|
||||
cursorVisible = false
|
||||
}
|
||||
override fun moveBy(dx: Float, dy: Float, relative: Boolean) {
|
||||
val rect = drawnRect() ?: return
|
||||
if (relative || pointerLocked) {
|
||||
val scale = SessionState.outputSize.first / rect.width()
|
||||
WaylandCompositor.sendPointerDelta(dx * scale, dy * scale)
|
||||
} else moveTo(gesturePointerX + dx, gesturePointerY + dy)
|
||||
}
|
||||
override fun button(code: Int, down: Boolean) {
|
||||
WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0)
|
||||
}
|
||||
override fun prepareRelativePan() {
|
||||
if (pointerLocked) return
|
||||
val rect = drawnRect() ?: return
|
||||
// Gamescope retains the last absolute position while a game can warp
|
||||
// its own cursor to the center. Its first unlocked motion must start
|
||||
// from that same origin, before the game switches to pointer lock.
|
||||
moveTo(rect.centerX(), rect.centerY())
|
||||
}
|
||||
override fun key(code: Int, down: Boolean) {
|
||||
if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code)
|
||||
if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
|
||||
}
|
||||
override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
|
||||
override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } }
|
||||
override fun gesture(gesture: TouchscreenGestures.Gesture) {
|
||||
recognizedGesture = gesture
|
||||
uiHandler.removeCallbacks(clearRecognizedGesture)
|
||||
uiHandler.postDelayed(clearRecognizedGesture, 1200)
|
||||
}
|
||||
}
|
||||
|
||||
private val pointerListener = object : PointerGestures.Listener {
|
||||
override fun onMove(x: Float, y: Float) {
|
||||
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
|
||||
@@ -2272,6 +2430,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
|
||||
return when {
|
||||
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
|
||||
SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES
|
||||
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
|
||||
else -> SessionPrefs.TOUCH_DIRECT
|
||||
}
|
||||
@@ -2284,7 +2443,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
touchpad.sensitivity = 1.15f * speed
|
||||
}
|
||||
if (next == routedTouchMode) return
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
gesturePointerReady = false
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
@@ -2319,6 +2481,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true
|
||||
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false
|
||||
return touchscreenGestures.onTouch(event)
|
||||
}
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
scrollTouchGesture = scrollTouchMode()
|
||||
scrollRestX = 0f
|
||||
@@ -2352,6 +2519,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
|
||||
return true
|
||||
}
|
||||
if (directTouchHold.onTouch(event)) return true
|
||||
fun sendTouch(action: Int, index: Int) {
|
||||
val x = event.getX(index).coerceIn(rect.left, rect.right)
|
||||
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
|
||||
@@ -2501,6 +2669,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
controls.visibility = View.GONE
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
applyTouchRouting()
|
||||
return
|
||||
}
|
||||
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
|
||||
@@ -2513,6 +2682,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (steam == null) {
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
applyTouchRouting()
|
||||
}
|
||||
controls.setButtonsOnly(buttonsOnly)
|
||||
val show = when (forced) {
|
||||
@@ -2547,7 +2717,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun sendGuestKey(code: Int, down: Boolean) {
|
||||
if (down && pipUi) return
|
||||
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
|
||||
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
|
||||
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
|
||||
}
|
||||
|
||||
private fun pipBounds(): android.graphics.Rect {
|
||||
@@ -2575,6 +2745,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
releasePads()
|
||||
touchpad.cancel()
|
||||
scrollTouch.cancel()
|
||||
directTouchHold.cancel()
|
||||
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
|
||||
keyboard?.hide()
|
||||
sessionClipboard.stop()
|
||||
@@ -2701,6 +2872,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onPause() {
|
||||
if (runtimeRemovalBlocked) { super.onPause(); return }
|
||||
sessionClipboard.stop()
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
uiHandler.removeCallbacks(clearRecognizedGesture)
|
||||
gestureSettingsOpen = false
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
@@ -2950,7 +3126,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun glassesOutputSize(): Pair<Int, Int>? {
|
||||
if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null
|
||||
val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = displayMode()
|
||||
val display = com.droiddeck.launcher.session.SessionDisplay
|
||||
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
|
||||
val size = display.resolveChoice(panel, choice)
|
||||
@@ -3076,6 +3252,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (!hasFocus && ::padMouse.isInitialized) padMouse.reset()
|
||||
if (runtimeRemovalBlocked) return
|
||||
if (!hasFocus) {
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
}
|
||||
if (hasFocus && !pipUi) {
|
||||
goFullscreen()
|
||||
refreshClipboard()
|
||||
@@ -3186,6 +3366,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
companion object {
|
||||
const val EXTRA_RETURN_HOME = "returnHome"
|
||||
const val EXTRA_STEAMVR = "steamVr"
|
||||
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val TAG = "SessionActivity"
|
||||
private var startedScreens = 0
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
|
||||
* DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
|
||||
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
|
||||
* was made with install, whichever host serves them.
|
||||
*/
|
||||
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
|
||||
sealed interface State {
|
||||
data object Missing : State
|
||||
data class Downloading(val progress: Float) : State
|
||||
data object Failed : State
|
||||
data object Ready : State
|
||||
}
|
||||
|
||||
private val mutableState = MutableStateFlow<State>(State.Missing)
|
||||
val state: StateFlow<State> = mutableState.asStateFlow()
|
||||
private val lock = Any()
|
||||
|
||||
val available: Boolean get() = sha256.isNotEmpty()
|
||||
private val tag get() = "pack-$name-${sha256.take(16)}"
|
||||
private val urls get() = listOfNotNull(
|
||||
"https://github.com/$REPO/releases/download/$tag/$name.tar.zst",
|
||||
BuildConfig.PR_NUMBER.takeIf { it > 0 }?.let { "https://github.com/$PR_REPO/releases/download/pr-$it/$tag.tar.zst" },
|
||||
)
|
||||
|
||||
private fun root(context: Context) = File(context.filesDir, "addons/$name")
|
||||
/** App-private and written only after the archive matched the pinned digest. */
|
||||
fun dir(context: Context) = File(root(context), sha256)
|
||||
fun file(context: Context, relative: String) = File(dir(context), relative)
|
||||
|
||||
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
|
||||
|
||||
fun refresh(context: Context): State {
|
||||
val now = mutableState.value
|
||||
if (now is State.Downloading) return now
|
||||
return (if (installed(context)) State.Ready else if (now == State.Failed) State.Failed else State.Missing)
|
||||
.also { mutableState.value = it }
|
||||
}
|
||||
|
||||
/** Starts the download in the background unless the pack is already present or on its way. */
|
||||
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
synchronized(lock) {
|
||||
if (installed(app)) { mutableState.value = State.Ready; return }
|
||||
if (mutableState.value is State.Downloading) return
|
||||
mutableState.value = State.Downloading(0f)
|
||||
}
|
||||
scope.launch {
|
||||
val ok = runCatching { install(app) }.onFailure { Log.w(TAG, "$name: install failed", it) }.getOrDefault(false)
|
||||
mutableState.value = if (ok) State.Ready else State.Failed
|
||||
if (ok) onReady(app)
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
|
||||
@JvmOverloads
|
||||
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
|
||||
if (installed(context)) return true
|
||||
ensure(context)
|
||||
while (running()) {
|
||||
val now = state.value
|
||||
onState(now)
|
||||
when (now) {
|
||||
State.Ready -> return installed(context)
|
||||
State.Failed -> return false
|
||||
else -> try { Thread.sleep(250) } catch (e: InterruptedException) { return false }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun install(context: Context): Boolean {
|
||||
if (!available) {
|
||||
Log.w(TAG, "$name: this build has no pack")
|
||||
return false
|
||||
}
|
||||
val root = root(context).apply { mkdirs() }
|
||||
val archive = File(root, "$sha256.tar.zst")
|
||||
val downloaded = urls.any { url ->
|
||||
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
|
||||
.also { if (!it) archive.delete() }
|
||||
}
|
||||
if (!downloaded) return false
|
||||
val staging = File(root, "$sha256.partial")
|
||||
staging.deleteRecursively()
|
||||
try {
|
||||
if (!FileInputStream(archive).use { TarZst.extract(it, staging) }) return false
|
||||
check(required.all { File(staging, it).isFile }) { "$name pack is incomplete" }
|
||||
File(staging, COMPLETE).writeText(sha256)
|
||||
val target = dir(context)
|
||||
target.deleteRecursively()
|
||||
check(staging.renameTo(target)) { "could not move the $name pack into place" }
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
archive.delete()
|
||||
}
|
||||
root.listFiles().orEmpty().filter { it.name != sha256 }.forEach { it.deleteRecursively() }
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AddonPack"
|
||||
private const val REPO = "Droid-Deck/DroidDeck-Components"
|
||||
private const val PR_REPO = "Droid-Deck/DroidDeck-CI"
|
||||
const val COMPLETE = ".complete"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@JvmField
|
||||
val PROOT = AddonPack("proot", BuildConfig.PACK_PROOT_SHA256, BuildConfig.PACK_PROOT_SIZE,
|
||||
listOf("libproot.so", "libproot-loader.so"))
|
||||
@JvmField
|
||||
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
|
||||
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
|
||||
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
|
||||
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
|
||||
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
|
||||
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
|
||||
listOf("version", "xodus-cli", "xodus-service"))
|
||||
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
|
||||
listOf("version", "comet"))
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,7 @@ object DeviceReport {
|
||||
h("Drivers")
|
||||
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
|
||||
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
|
||||
if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
|
||||
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
|
||||
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
|
||||
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
|
||||
@@ -224,6 +225,9 @@ object DeviceReport {
|
||||
k("Installed version", LinuxRuntimeInstaller.installedVersion(context))
|
||||
k("Runtime ready", LinuxRuntime.isInstalled(context))
|
||||
k("Desktop installed", DesktopCatalog.desktopInstalled(context))
|
||||
k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" })
|
||||
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList())
|
||||
k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}")
|
||||
k("Runtime root", LinuxRuntime.rootDir(context).path)
|
||||
|
||||
h("Settings in effect")
|
||||
|
||||
@@ -204,8 +204,10 @@ object AddedGames {
|
||||
GamesFolderMigration.run(context)
|
||||
val path = canonical(exe)
|
||||
fun holds(f: File) = inside(path, canonical(f))
|
||||
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
|
||||
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
|
||||
val entries = AddedExes.list(context)
|
||||
fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe)
|
||||
entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
|
||||
SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
|
||||
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
|
||||
val dirs = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
@@ -220,6 +222,24 @@ object AddedGames {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun currentExe(context: Context, entries: List<AddedExes.Entry>, folder: File): File? =
|
||||
SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File)
|
||||
?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) }
|
||||
?: rank(folder).exes.firstOrNull()
|
||||
|
||||
private fun sameGame(folder: File, current: File?, pick: File): Boolean {
|
||||
val base = canonical(folder)
|
||||
if (current == null || pick.parentFile?.let(::canonical) == base) return true
|
||||
val known = canonical(current)
|
||||
if (!inside(known, base) || known.substringBeforeLast('/') == base) return true
|
||||
val picked = canonical(pick)
|
||||
fun top(p: String) = p.removePrefix("$base/").substringBefore('/')
|
||||
fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() }
|
||||
return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) }
|
||||
}
|
||||
|
||||
private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$")
|
||||
|
||||
fun addExe(context: Context, exe: File): AddResult {
|
||||
val app = context.applicationContext
|
||||
if (!exe.isFile) return AddResult.NotFound
|
||||
@@ -228,7 +248,10 @@ object AddedGames {
|
||||
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
|
||||
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
|
||||
}
|
||||
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
|
||||
if (guestPath(app, exe) == null) {
|
||||
(owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) }
|
||||
if (guestPath(app, exe) == null) return AddResult.Unreachable
|
||||
}
|
||||
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
|
||||
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
|
||||
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
|
||||
@@ -243,6 +266,8 @@ object AddedGames {
|
||||
|
||||
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
|
||||
val app = context.applicationContext
|
||||
// Binds are mounted at session start, so a new one reaches the session from the next start.
|
||||
if (guestPath(app, dir) == null) bindFolder(app, dir)
|
||||
val removed = SessionPrefs.removedAddedGames(app).toSet()
|
||||
val listed = listedFolders(app, removed)
|
||||
var already = 0
|
||||
@@ -266,6 +291,20 @@ object AddedGames {
|
||||
return FolderResult(added, already)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
internal fun bindFolder(context: Context, dir: File) {
|
||||
if (!dir.isDirectory || !dir.canRead()) return
|
||||
val binds = SessionPrefs.gameFolderBinds(context)
|
||||
if (binds.any { it.first == dir.path }) return
|
||||
val taken = binds.map { it.second }.toSet()
|
||||
val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest
|
||||
var guest = base
|
||||
var n = 2
|
||||
while (guest in taken) guest = "$base-${n++}"
|
||||
SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest))
|
||||
Log.i(TAG, "games: bound $dir at $guest")
|
||||
}
|
||||
|
||||
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
GamesFolderMigration.run(context)
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.LauncherIcon
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
@@ -23,7 +24,7 @@ object GameShortcuts {
|
||||
val name = game.name.ifBlank { context.getString(R.string.app_name) }
|
||||
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
|
||||
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
|
||||
.setActivity(LauncherIcon.activity(context))
|
||||
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
|
||||
.setIntent(launchIntent(context, game.gameIdString)).build()
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ object Library {
|
||||
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val PRIVATE_APPS = Regex("\"PrivateApps_\\d+\"\\s*\"\\[([^\\]]*)\\]\"")
|
||||
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
|
||||
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
|
||||
@@ -137,13 +138,35 @@ object Library {
|
||||
}
|
||||
|
||||
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
|
||||
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
|
||||
val root = SessionPrefs.activeSteamRoot(context)
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val concealed = concealedSteamApps(root)
|
||||
return cache.listFiles().orEmpty().mapNotNull { dir ->
|
||||
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
|
||||
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES && it.toLong() !in concealed } ?: return@mapNotNull null
|
||||
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Apps the user hid (the "hidden" collection) or marked private in Steam. */
|
||||
internal fun concealedSteamApps(root: File): Set<Long> {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptySet()
|
||||
val hidden = runCatching {
|
||||
val entries = org.json.JSONArray(File(config, "cloudstorage/cloud-storage-namespace-1.json").readText())
|
||||
val entry = (0 until entries.length()).map { entries.getJSONArray(it) }
|
||||
.firstOrNull { it.optString(0) == "user-collections.hidden" }?.optJSONObject(1)
|
||||
if (entry == null || entry.optBoolean("is_deleted")) emptySet() else {
|
||||
val collection = org.json.JSONObject(entry.optString("value"))
|
||||
fun ids(key: String) = collection.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optLong(it) } }.orEmpty()
|
||||
ids("added").toSet() - ids("removed").toSet()
|
||||
}
|
||||
}.getOrDefault(emptySet())
|
||||
val private = runCatching {
|
||||
PRIVATE_APPS.find(File(config, "localconfig.vdf").readText())?.groupValues?.get(1)
|
||||
?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet()
|
||||
}.getOrNull().orEmpty()
|
||||
return hidden + private
|
||||
}
|
||||
|
||||
fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM
|
||||
|
||||
fun launchableGames(
|
||||
@@ -155,9 +178,23 @@ object Library {
|
||||
var steam = steamGames(context, strictRead, arch)
|
||||
val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
|
||||
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch)
|
||||
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
val claimed = claimedFolders(steam)
|
||||
val own = list.filter { g ->
|
||||
g.steamAppId != null || claimed.isEmpty() ||
|
||||
(runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed &&
|
||||
AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed })
|
||||
}
|
||||
return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun claimedFolders(steam: List<SteamGame>): Set<String> = steam.filter(::fromSteamClient).flatMap { g ->
|
||||
val dir = g.gameFiles ?: return@flatMap emptyList()
|
||||
listOfNotNull(
|
||||
runCatching { dir.canonicalPath }.getOrNull(),
|
||||
runCatching { java.nio.file.Files.readSymbolicLink(dir.toPath()).toString() }.getOrNull(),
|
||||
)
|
||||
}.toSet()
|
||||
|
||||
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
|
||||
@@ -153,7 +153,7 @@ object SteamAppInfo {
|
||||
return String(bytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
|
||||
internal fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (b.hasRemaining()) {
|
||||
val type = b.get().toInt() and 0xFF
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
internal enum class HandModel(val asset: String, val sha256: String) {
|
||||
FULL("models/hand_landmarker.task", "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
|
||||
LITE("models/hand_landmarker_lite.task", "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
|
||||
import com.droiddeck.launcher.handtracking.TrackingPack
|
||||
|
||||
internal enum class HandModel(val file: String, val sha256: String) {
|
||||
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
|
||||
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
|
||||
|
||||
companion object {
|
||||
val DEFAULT = FULL
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.handtracking.TrackingPack
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
@@ -11,7 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
|
||||
import com.google.mediapipe.tasks.core.Delegate
|
||||
import com.google.mediapipe.tasks.vision.core.RunningMode
|
||||
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
|
||||
import org.opencv.android.OpenCVLoader
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.*
|
||||
import org.opencv.imgproc.Imgproc
|
||||
@@ -170,11 +171,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
|
||||
} }
|
||||
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
|
||||
val bytes=context.assets.open(selected.asset).use { it.readBytes() }
|
||||
val bytes=TrackingPack.file(context,selected.file).readBytes()
|
||||
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
|
||||
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetPath(selected.asset)
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(ByteBuffer.allocateDirect(bytes.size).put(bytes).apply { rewind() })
|
||||
.setDelegate(if(useGpu) Delegate.GPU else Delegate.CPU).build())
|
||||
.setRunningMode(RunningMode.VIDEO).setNumHands(maxHands).setMinHandDetectionConfidence(0.6f)
|
||||
.setMinHandPresenceConfidence(0.6f).setMinTrackingConfidence(0.6f).build())
|
||||
@@ -325,7 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
|
||||
}
|
||||
private fun loop() {
|
||||
try { check(OpenCVLoader.initLocal()) } catch(e: Throwable) { message=context.getString(R.string.spatial_opencv_error, e.message.orEmpty());return }
|
||||
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
|
||||
if(state is AddonPack.State.Downloading)
|
||||
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
|
||||
}
|
||||
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
|
||||
var grayL=Mat();var grayR=Mat();val rgba=Mat()
|
||||
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
|
||||
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
|
||||
@@ -336,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
|
||||
var rate=0f
|
||||
try {
|
||||
check(OpenCVLoader.initLocal()) { context.getString(R.string.spatial_opencv_load_error) }
|
||||
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
|
||||
joyConFlow=StereoJoyConFlow()
|
||||
while(running) {
|
||||
while(true) {
|
||||
|
||||
@@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
object FrameGen {
|
||||
const val ENGINE_OFF = "off"
|
||||
const val ENGINE_WINFG = "winfg"
|
||||
const val ENGINE_DDFG = "ddfg"
|
||||
private const val LEGACY_ENGINE_WINFG = "winfg"
|
||||
const val ENGINE_LSFG = "lsfg"
|
||||
|
||||
private const val WINFG_PERF_PRESET = 2
|
||||
private const val WINFG_MODEL = 4
|
||||
private const val FLOW_SCALE_WINFG = 0.60f
|
||||
private const val DDFG_PERF_PRESET = 2
|
||||
private const val DDFG_MODEL = 4
|
||||
private const val FLOW_SCALE_DDFG = 0.60f
|
||||
private const val FLOW_SCALE_LSFG = 0.80f
|
||||
|
||||
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
|
||||
@@ -29,7 +30,7 @@ object FrameGen {
|
||||
|
||||
fun mode(context: Context): Mode {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
|
||||
val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: ENGINE_OFF }
|
||||
val target = prefs.getInt("target", 0)
|
||||
if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target)
|
||||
return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4))
|
||||
@@ -43,7 +44,7 @@ object FrameGen {
|
||||
fun label(context: Context): String = label(context, mode(context))
|
||||
|
||||
fun label(context: Context, mode: Mode): String = when {
|
||||
mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier)
|
||||
mode.engine == ENGINE_DDFG -> context.getString(R.string.frame_gen_ddfg, mode.multiplier)
|
||||
mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target)
|
||||
mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier)
|
||||
else -> context.getString(R.string.frame_gen_off)
|
||||
@@ -68,10 +69,10 @@ object FrameGen {
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target)
|
||||
}
|
||||
ENGINE_WINFG -> {
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG)
|
||||
WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz)
|
||||
ENGINE_DDFG -> {
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG)
|
||||
WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
|
||||
}
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
|
||||
@@ -11,8 +11,10 @@ object TpUbwcHint {
|
||||
|
||||
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
|
||||
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
|
||||
internal fun features(model: Int, systemDriverVersion: String?): String? {
|
||||
// Samsung's One UI firmware sets it whatever blob version it reports.
|
||||
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
|
||||
if (model != 740) return null
|
||||
if (samsung) return "enable_tp_ubwc_flag_hint=1"
|
||||
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
|
||||
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
|
||||
}
|
||||
@@ -20,11 +22,12 @@ object TpUbwcHint {
|
||||
@Synchronized
|
||||
fun forThisDevice(): String? {
|
||||
if (resolved) return cached
|
||||
val model = GpuInfo.detect().model
|
||||
val gpu = GpuInfo.detect()
|
||||
val model = gpu.model
|
||||
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
|
||||
cached = features(model, version)
|
||||
cached = features(model, version, gpu.oneUi8Gen2)
|
||||
resolved = true
|
||||
Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
|
||||
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
|
||||
return cached
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -188,9 +186,8 @@ internal class HandCamera(
|
||||
private fun prepare() {
|
||||
if (stopped) return
|
||||
runCatching {
|
||||
val bytes = context.assets.open(MODEL).use { it.readBytes() }
|
||||
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
|
||||
model.rewind()
|
||||
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
|
||||
val model = TrackingPack.model(context, TrackingPack.POSE)
|
||||
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
|
||||
.setRunningMode(RunningMode.VIDEO)
|
||||
@@ -305,7 +302,6 @@ internal class HandCamera(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HandCamera"
|
||||
private const val MODEL = "handtracking/pose_landmarker_lite.task"
|
||||
private const val ANALYSIS_WIDTH = 960
|
||||
private const val ANALYSIS_HEIGHT = 720
|
||||
private const val MIN_CONFIDENCE = 0.5f
|
||||
|
||||
@@ -13,8 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
|
||||
import com.google.mediapipe.tasks.core.Delegate
|
||||
import com.google.mediapipe.tasks.vision.core.RunningMode
|
||||
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -134,7 +132,6 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
|
||||
hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height)
|
||||
|
||||
companion object {
|
||||
private const val MODEL = "handtracking/hand_landmarker.task"
|
||||
private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)
|
||||
private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY)
|
||||
private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX)
|
||||
@@ -153,10 +150,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
|
||||
private const val MIN_CONFIDENCE = 0.4f
|
||||
|
||||
fun open(context: Context): HandModel {
|
||||
val bytes = context.assets.open(MODEL).use { it.readBytes() }
|
||||
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
|
||||
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
|
||||
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
|
||||
model.rewind()
|
||||
val model = TrackingPack.model(context, TrackingPack.HAND)
|
||||
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
|
||||
.setRunningMode(mode)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -16,9 +17,10 @@ internal object HandTracking {
|
||||
val side: String? = null,
|
||||
val permitted: Boolean = false,
|
||||
val sides: Set<String> = emptySet(),
|
||||
val downloaded: Boolean = false,
|
||||
) {
|
||||
val usable: Boolean get() = sides.isNotEmpty()
|
||||
val ready: Boolean get() = enabled && permitted && usable
|
||||
val ready: Boolean get() = enabled && permitted && usable && downloaded
|
||||
}
|
||||
|
||||
private val state = MutableStateFlow(Settings())
|
||||
@@ -38,8 +40,11 @@ internal object HandTracking {
|
||||
side = prefs.getString(SIDE, null),
|
||||
permitted = permitted(context),
|
||||
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
|
||||
downloaded = TrackingPack.installed(context),
|
||||
)
|
||||
state.value = next
|
||||
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
|
||||
TrackingPack.ensure(context)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
|
||||
internal object TrackingPack {
|
||||
const val HAND = "hand_landmarker.task"
|
||||
const val HAND_LITE = "hand_landmarker_lite.task"
|
||||
const val POSE = "pose_landmarker_lite.task"
|
||||
private const val OPENCV = "libopencv_java4.so"
|
||||
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
|
||||
private const val TAG = "TrackingPack"
|
||||
|
||||
private val pack = AddonPack.TRACKING
|
||||
val state get() = pack.state
|
||||
val size get() = pack.size
|
||||
private val lock = Any()
|
||||
@Volatile private var nativeLoaded = false
|
||||
|
||||
fun installed(context: Context) = pack.installed(context)
|
||||
fun refresh(context: Context) = pack.refresh(context)
|
||||
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
|
||||
|
||||
fun file(context: Context, name: String): File = pack.file(context, name)
|
||||
|
||||
fun model(context: Context, name: String): ByteBuffer =
|
||||
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
|
||||
|
||||
/**
|
||||
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
|
||||
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
|
||||
*/
|
||||
fun loadNative(context: Context): Boolean {
|
||||
if (nativeLoaded) return true
|
||||
synchronized(lock) {
|
||||
if (nativeLoaded) return true
|
||||
if (!pack.installed(context)) return false
|
||||
return runCatching {
|
||||
System.load(file(context, OPENCV).absolutePath)
|
||||
System.load(file(context, MEDIAPIPE).absolutePath)
|
||||
nativeLoaded = true
|
||||
true
|
||||
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
|
||||
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
|
||||
pack.await(context, running, onState) && loadNative(context)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.abs
|
||||
|
||||
class DirectTouchHold(
|
||||
private val slopPx: Float,
|
||||
private val listener: PointerGestures.Listener,
|
||||
private val cancelTouch: () -> Unit,
|
||||
) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var x = 0f
|
||||
private var y = 0f
|
||||
private var consumed = false
|
||||
private var pressed = false
|
||||
private val release = Runnable {
|
||||
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
|
||||
pressed = false
|
||||
}
|
||||
private val hold = Runnable {
|
||||
cancelTouch()
|
||||
consumed = true
|
||||
listener.onLongPress()
|
||||
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
|
||||
pressed = true
|
||||
handler.postDelayed(release, 50L)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(release)
|
||||
release.run()
|
||||
consumed = false
|
||||
}
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
cancel()
|
||||
x = event.x; y = event.y
|
||||
handler.postDelayed(hold, 500L)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
|
||||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
|
||||
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
|
||||
MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
|
||||
MotionEvent.ACTION_CANCEL -> cancel()
|
||||
}
|
||||
return consumed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0
|
||||
* Modified for DroidDeck on 2026-10-15.
|
||||
*/
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
object GestureKeys {
|
||||
val codes: Map<String, Int> = linkedMapOf(
|
||||
"ESC" to 1,
|
||||
"1" to 2,
|
||||
"2" to 3,
|
||||
"3" to 4,
|
||||
"4" to 5,
|
||||
"5" to 6,
|
||||
"6" to 7,
|
||||
"7" to 8,
|
||||
"8" to 9,
|
||||
"9" to 10,
|
||||
"0" to 11,
|
||||
"MINUS" to 12,
|
||||
"EQUAL" to 13,
|
||||
"BKSP" to 14,
|
||||
"TAB" to 15,
|
||||
"Q" to 16,
|
||||
"W" to 17,
|
||||
"E" to 18,
|
||||
"R" to 19,
|
||||
"T" to 20,
|
||||
"Y" to 21,
|
||||
"U" to 22,
|
||||
"I" to 23,
|
||||
"O" to 24,
|
||||
"P" to 25,
|
||||
"BRACKET_LEFT" to 26,
|
||||
"BRACKET_RIGHT" to 27,
|
||||
"ENTER" to 28,
|
||||
"CTRL_L" to 29,
|
||||
"A" to 30,
|
||||
"S" to 31,
|
||||
"D" to 32,
|
||||
"F" to 33,
|
||||
"G" to 34,
|
||||
"H" to 35,
|
||||
"J" to 36,
|
||||
"K" to 37,
|
||||
"L" to 38,
|
||||
"SEMICOLON" to 39,
|
||||
"APOSTROPHE" to 40,
|
||||
"GRAVE" to 41,
|
||||
"SHIFT_L" to 42,
|
||||
"BACKSLASH" to 43,
|
||||
"Z" to 44,
|
||||
"X" to 45,
|
||||
"C" to 46,
|
||||
"V" to 47,
|
||||
"B" to 48,
|
||||
"N" to 49,
|
||||
"M" to 50,
|
||||
"COMMA" to 51,
|
||||
"PERIOD" to 52,
|
||||
"SLASH" to 53,
|
||||
"SHIFT_R" to 54,
|
||||
"KP_MULTIPLY" to 55,
|
||||
"ALT_L" to 56,
|
||||
"SPACE" to 57,
|
||||
"CAPS_LOCK" to 58,
|
||||
"F1" to 59,
|
||||
"F2" to 60,
|
||||
"F3" to 61,
|
||||
"F4" to 62,
|
||||
"F5" to 63,
|
||||
"F6" to 64,
|
||||
"F7" to 65,
|
||||
"F8" to 66,
|
||||
"F9" to 67,
|
||||
"F10" to 68,
|
||||
"NUM_LOCK" to 69,
|
||||
"SCROLL_LOCK" to 70,
|
||||
"KP_7" to 71,
|
||||
"KP_8" to 72,
|
||||
"KP_9" to 73,
|
||||
"KP_SUBTRACT" to 74,
|
||||
"KP_4" to 75,
|
||||
"KP_5" to 76,
|
||||
"KP_6" to 77,
|
||||
"KP_ADD" to 78,
|
||||
"KP_1" to 79,
|
||||
"KP_2" to 80,
|
||||
"KP_3" to 81,
|
||||
"KP_0" to 82,
|
||||
"KP_DEL" to 83,
|
||||
"F11" to 87,
|
||||
"F12" to 88,
|
||||
"KP_ENTER" to 96,
|
||||
"CTRL_R" to 97,
|
||||
"KP_DIVIDE" to 98,
|
||||
"PRTSCN" to 99,
|
||||
"ALT_R" to 100,
|
||||
"HOME" to 102,
|
||||
"UP" to 103,
|
||||
"PRIOR" to 104,
|
||||
"LEFT" to 105,
|
||||
"RIGHT" to 106,
|
||||
"END" to 107,
|
||||
"DOWN" to 108,
|
||||
"NEXT" to 109,
|
||||
"INSERT" to 110,
|
||||
"DEL" to 111,
|
||||
)
|
||||
@JvmStatic fun code(name: String): Int? = codes[name]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/** Global defaults with optional overrides for the currently focused game. */
|
||||
object GesturePrefs {
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE)
|
||||
|
||||
fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game")
|
||||
|
||||
fun read(context: Context, game: String? = null): TouchGestureConfig {
|
||||
val p = prefs(context)
|
||||
val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null)
|
||||
return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json)
|
||||
}
|
||||
|
||||
fun save(context: Context, game: String?, config: TouchGestureConfig) {
|
||||
prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply()
|
||||
}
|
||||
|
||||
fun clear(context: Context, game: String?) {
|
||||
prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply()
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.SteamAppInfo
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.Locale
|
||||
|
||||
object SteamTouchConfig {
|
||||
private const val TAG = "SteamTouch"
|
||||
@@ -125,17 +129,37 @@ object SteamTouchConfig {
|
||||
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
|
||||
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
|
||||
|
||||
fun steamId(appId: Int): String = Integer.toUnsignedString(appId)
|
||||
|
||||
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
|
||||
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
|
||||
|
||||
|
||||
fun gameName(context: Context, appId: Int): String? = try {
|
||||
fun gameName(context: Context, appId: Int): String? = if (appId < 0) shortcutName(context, appId) else try {
|
||||
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
|
||||
(kv.child("AppState") ?: kv).string("name")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun steamKey(context: Context, appId: Int): String =
|
||||
if (appId < 0) shortcutName(context, appId)?.lowercase(Locale.ROOT)
|
||||
?.takeIf { name -> name != "." && name != ".." && name.none { it == '"' || it == '\\' || it == '/' || it < ' ' } } ?: steamId(appId)
|
||||
else steamId(appId)
|
||||
|
||||
private fun shortcutName(context: Context, appId: Int): String? {
|
||||
val account = accountId(context) ?: return null
|
||||
return try {
|
||||
val bytes = File(steamRoot(context), "userdata/$account/config/shortcuts.vdf").readBytes()
|
||||
val root = SteamAppInfo.readMap(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), null)
|
||||
(root["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance<Map<*, *>>()
|
||||
?.firstOrNull { (it["appid"] as? String)?.toIntOrNull() == appId }
|
||||
?.let { (it["AppName"] ?: it["appname"]) as? String }?.takeIf { it.isNotEmpty() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context)
|
||||
|
||||
fun configDir(context: Context): File? {
|
||||
@@ -158,10 +182,11 @@ object SteamTouchConfig {
|
||||
|
||||
fun load(context: Context, appId: Int): Config {
|
||||
val dir = configDir(context)
|
||||
val file = dir?.let { deviceConfig(context, it, appId) }
|
||||
?: dir?.let { autosaveConfig(it, appId) }
|
||||
val key = steamKey(context, appId)
|
||||
val file = dir?.let { deviceConfig(context, it, key) }
|
||||
?: dir?.let { autosaveConfig(it, key) }
|
||||
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
|
||||
?: dir?.let { configsetConfig(context, it, appId) }
|
||||
?: dir?.let { configsetConfig(context, it, key) }
|
||||
if (file == null) {
|
||||
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
|
||||
return Config(null, null, null, emptyMap())
|
||||
@@ -193,7 +218,7 @@ object SteamTouchConfig {
|
||||
}
|
||||
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
|
||||
if (touchIndex == null) null else {
|
||||
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
|
||||
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID ${steamId(appId)}, Controller $touchIndex: ([^\n]+)""")
|
||||
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
|
||||
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
|
||||
}
|
||||
@@ -201,22 +226,22 @@ object SteamTouchConfig {
|
||||
null
|
||||
}
|
||||
|
||||
private fun autosaveConfig(dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
|
||||
private fun autosaveConfig(dir: File, key: String): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(key)
|
||||
if (set?.string("autosave") == "1") File(dir, "$key/controller_mobile_touch.vdf").takeIf { it.isFile } else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
|
||||
private fun configsetConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, CONFIGSET, key)
|
||||
|
||||
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
|
||||
private fun deviceConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, DEVICE_CONFIGSET, key)
|
||||
|
||||
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
|
||||
private fun entryConfig(context: Context, dir: File, configset: String, key: String): File? = try {
|
||||
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(key)
|
||||
when {
|
||||
set == null -> null
|
||||
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
set.string("autosave") == "1" -> File(dir, "$key/controller_mobile_touch.vdf")
|
||||
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
|
||||
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
|
||||
else -> null
|
||||
@@ -333,6 +358,11 @@ object SteamTouchConfig {
|
||||
}.sortedBy { it.type }
|
||||
}
|
||||
|
||||
fun remapHeld(held: Map<Int, Int?>, next: List<Element>): Map<Int, Element> {
|
||||
val byType = next.associateBy { it.type }
|
||||
return held.mapNotNull { (id, type) -> byType[type]?.let { id to it } }.toMap()
|
||||
}
|
||||
|
||||
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
|
||||
|
||||
|
||||
@@ -359,7 +389,8 @@ object SteamTouchConfig {
|
||||
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
|
||||
SteamTouchBindings.rewrite(text, ref, change) ?: text
|
||||
}
|
||||
val target = File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
val key = steamKey(context, appId)
|
||||
val target = File(dir, "$key/controller_mobile_touch.vdf")
|
||||
val old = config.layouts
|
||||
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
|
||||
val written = sets.map { (id, elements) ->
|
||||
@@ -377,27 +408,27 @@ object SteamTouchConfig {
|
||||
val tmp = File(target.path + ".tmp")
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) return null
|
||||
selectAutosave(dir, appId)
|
||||
dropDeviceEntry(dir, appId)
|
||||
selectAutosave(dir, key)
|
||||
dropDeviceEntry(dir, key)
|
||||
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
|
||||
return target
|
||||
}
|
||||
|
||||
private fun dropDeviceEntry(dir: File, appId: Int) {
|
||||
private fun dropDeviceEntry(dir: File, key: String) {
|
||||
val file = File(dir, DEVICE_CONFIGSET)
|
||||
if (!file.isFile) return
|
||||
val text = file.readText()
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
|
||||
if (!block.containsMatchIn(text)) return
|
||||
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
|
||||
}
|
||||
|
||||
private fun selectAutosave(dir: File, appId: Int) {
|
||||
private fun selectAutosave(dir: File, key: String) {
|
||||
val file = File(dir, CONFIGSET)
|
||||
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
|
||||
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
|
||||
val entry = "\t\"$key\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
|
||||
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
|
||||
val next = if (block.containsMatchIn(text)) text.replace(block) { "\n$entry" }
|
||||
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
|
||||
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ class SteamTouchControls(
|
||||
actionSet = state.actionSet
|
||||
layers = state.layers
|
||||
appId = app
|
||||
rebuild()
|
||||
rebuild(keepHeld = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ class SteamTouchControls(
|
||||
refreshState()
|
||||
}
|
||||
|
||||
private fun rebuild() {
|
||||
private fun rebuild(keepHeld: Boolean = false) {
|
||||
elements = SteamTouchConfig.elementsFor(config, actionSet, layers)
|
||||
val nextOptions = pendingOptions ?: config.layouts?.options ?: SteamTouchConfig.Options()
|
||||
if (nextOptions != options) { options = nextOptions; onOptions(options) }
|
||||
@@ -183,7 +183,7 @@ class SteamTouchControls(
|
||||
rebuildVisuals()
|
||||
if (!editing) {
|
||||
Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls")
|
||||
releaseAll()
|
||||
if (keepHeld) keepHeldFingers() else releaseAll()
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
@@ -590,6 +590,13 @@ class SteamTouchControls(
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun keepHeldFingers() {
|
||||
val moved = SteamTouchConfig.remapHeld(fingers.mapValues { it.value.element?.type }, live())
|
||||
fingers.keys.retainAll(moved.keys)
|
||||
fingers.replaceAll { id, f -> Finger(moved.getValue(id), f.x, f.y, f.toolbar) }
|
||||
publish()
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
@@ -958,7 +965,7 @@ class SteamTouchControls(
|
||||
BIG_PICTURE -> "Steam"
|
||||
else -> gameNames[appId] ?: run {
|
||||
val app = appId
|
||||
gameNames[app] = context.getString(R.string.touch_app_id, app)
|
||||
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app))
|
||||
loader.execute {
|
||||
val name = SteamTouchConfig.gameName(context, app) ?: return@execute
|
||||
handler.post { gameNames[app] = name; invalidate() }
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0
|
||||
* Modified for DroidDeck on 2026-10-15.
|
||||
*/
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
data class TouchGestureConfig(
|
||||
val tapEnabled: Boolean = true,
|
||||
val tapAction: String = ACTION_LEFT_CLICK,
|
||||
val dragEnabled: Boolean = true,
|
||||
val dragAction: String = PAN_LEFT_CLICK_DRAG,
|
||||
val longPressEnabled: Boolean = false,
|
||||
val longPressAction: String = ACTION_RIGHT_CLICK,
|
||||
val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
|
||||
val longPressDelay: Int = DEFAULT_DELAY_MS,
|
||||
val doubleTapEnabled: Boolean = true,
|
||||
val doubleTapDelay: Int = DEFAULT_DELAY_MS,
|
||||
val twoFingerDragEnabled: Boolean = true,
|
||||
val twoFingerDragAction: String = PAN_ARROW_KEYS,
|
||||
val pinchEnabled: Boolean = true,
|
||||
val pinchAction: String = ZOOM_SCROLL_WHEEL,
|
||||
val twoFingerTapEnabled: Boolean = true,
|
||||
val twoFingerTapAction: String = ACTION_RIGHT_CLICK,
|
||||
val twoFingerHoldEnabled: Boolean = true,
|
||||
val twoFingerHoldAction: String = ACTION_MIDDLE_CLICK,
|
||||
val twoFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
|
||||
val twoFingerHoldDelay: Int = DEFAULT_DELAY_MS,
|
||||
val threeFingerTapEnabled: Boolean = true,
|
||||
val threeFingerTapAction: String = ACTION_SHOW_KEYBOARD,
|
||||
val threeFingerDragEnabled: Boolean = false,
|
||||
val threeFingerDragAction: String = PAN_ARROW_KEYS,
|
||||
val threeFingerHoldEnabled: Boolean = true,
|
||||
val threeFingerHoldAction: String = ACTION_KEY_ESC,
|
||||
val threeFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
|
||||
val threeFingerHoldDelay: Int = DEFAULT_DELAY_MS,
|
||||
val showGestureDebugOverlay: Boolean = false,
|
||||
val gestureThreshold: Int = DEFAULT_GESTURE_THRESHOLD,
|
||||
val mouseDragMovementMode: String = MOUSE_DRAG_MOVEMENT_DIRECT,
|
||||
) {
|
||||
|
||||
fun toJson(): String {
|
||||
return JSONObject().apply {
|
||||
put(KEY_TAP_ENABLED, tapEnabled)
|
||||
put(KEY_TAP_ACTION, tapAction)
|
||||
put(KEY_DRAG_ENABLED, dragEnabled)
|
||||
put(KEY_DRAG_ACTION, dragAction)
|
||||
put(KEY_LONG_PRESS_ENABLED, longPressEnabled)
|
||||
put(KEY_LONG_PRESS_ACTION, longPressAction)
|
||||
put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior)
|
||||
put(KEY_LONG_PRESS_DELAY, longPressDelay)
|
||||
put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled)
|
||||
put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay)
|
||||
put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled)
|
||||
put(KEY_TWO_FINGER_DRAG_ACTION, twoFingerDragAction)
|
||||
put(KEY_PINCH_ENABLED, pinchEnabled)
|
||||
put(KEY_PINCH_ACTION, pinchAction)
|
||||
put(KEY_TWO_FINGER_TAP_ENABLED, twoFingerTapEnabled)
|
||||
put(KEY_TWO_FINGER_TAP_ACTION, twoFingerTapAction)
|
||||
put(KEY_TWO_FINGER_HOLD_ENABLED, twoFingerHoldEnabled)
|
||||
put(KEY_TWO_FINGER_HOLD_ACTION, twoFingerHoldAction)
|
||||
put(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, twoFingerHoldMouseBehavior)
|
||||
put(KEY_TWO_FINGER_HOLD_DELAY, twoFingerHoldDelay)
|
||||
put(KEY_THREE_FINGER_TAP_ENABLED, threeFingerTapEnabled)
|
||||
put(KEY_THREE_FINGER_TAP_ACTION, threeFingerTapAction)
|
||||
put(KEY_THREE_FINGER_DRAG_ENABLED, threeFingerDragEnabled)
|
||||
put(KEY_THREE_FINGER_DRAG_ACTION, threeFingerDragAction)
|
||||
put(KEY_THREE_FINGER_HOLD_ENABLED, threeFingerHoldEnabled)
|
||||
put(KEY_THREE_FINGER_HOLD_ACTION, threeFingerHoldAction)
|
||||
put(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, threeFingerHoldMouseBehavior)
|
||||
put(KEY_THREE_FINGER_HOLD_DELAY, threeFingerHoldDelay)
|
||||
put(KEY_SHOW_GESTURE_DEBUG_OVERLAY, showGestureDebugOverlay)
|
||||
put(KEY_GESTURE_THRESHOLD, gestureThreshold)
|
||||
put(KEY_MOUSE_DRAG_MOVEMENT_MODE, mouseDragMovementMode)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_DELAY_MS = 300
|
||||
const val DEFAULT_GESTURE_THRESHOLD = 40
|
||||
const val ACTION_LEFT_CLICK = "left_click"
|
||||
const val ACTION_RIGHT_CLICK = "right_click"
|
||||
const val ACTION_MIDDLE_CLICK = "middle_click"
|
||||
|
||||
const val MOUSE_BEHAVIOR_HOLD = "hold"
|
||||
const val MOUSE_BEHAVIOR_CLICK = "click"
|
||||
|
||||
const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct"
|
||||
const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative"
|
||||
const val ACTION_SHOW_KEYBOARD = "show_keyboard"
|
||||
const val ACTION_KEY_ESC = "key_ESC"
|
||||
const val ACTION_KEY_TILDE = "key_TILDE"
|
||||
const val PAN_WASD = "wasd"
|
||||
const val PAN_INVERTED_WASD = "inverted_wasd"
|
||||
const val PAN_ARROW_KEYS = "arrow_keys"
|
||||
const val PAN_INVERTED_ARROW_KEYS = "inverted_arrow_keys"
|
||||
const val PAN_MIDDLE_MOUSE = "middle_mouse_pan"
|
||||
const val PAN_INVERTED_MIDDLE_MOUSE = "inverted_middle_mouse_pan"
|
||||
const val PAN_LEFT_CLICK_DRAG = "left_click_drag"
|
||||
const val PAN_RIGHT_CLICK_DRAG = "right_click_drag"
|
||||
const val ZOOM_SCROLL_WHEEL = "scroll_wheel"
|
||||
const val ZOOM_PLUS_MINUS = "plus_minus"
|
||||
const val ZOOM_PAGE_UP_DOWN = "page_up_down"
|
||||
private const val KEY_TAP_ENABLED = "tapEnabled"
|
||||
private const val KEY_TAP_ACTION = "tapAction"
|
||||
private const val KEY_DRAG_ENABLED = "dragEnabled"
|
||||
private const val KEY_DRAG_ACTION = "dragAction"
|
||||
private const val KEY_LONG_PRESS_ENABLED = "longPressEnabled"
|
||||
private const val KEY_LONG_PRESS_ACTION = "longPressAction"
|
||||
private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior"
|
||||
private const val KEY_LONG_PRESS_DELAY = "longPressDelay"
|
||||
private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled"
|
||||
private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay"
|
||||
private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled"
|
||||
private const val KEY_TWO_FINGER_DRAG_ACTION = "twoFingerDragAction"
|
||||
private const val KEY_PINCH_ENABLED = "pinchEnabled"
|
||||
private const val KEY_PINCH_ACTION = "pinchAction"
|
||||
private const val KEY_TWO_FINGER_TAP_ENABLED = "twoFingerTapEnabled"
|
||||
private const val KEY_TWO_FINGER_TAP_ACTION = "twoFingerTapAction"
|
||||
private const val KEY_TWO_FINGER_HOLD_ENABLED = "twoFingerHoldEnabled"
|
||||
private const val KEY_TWO_FINGER_HOLD_ACTION = "twoFingerHoldAction"
|
||||
private const val KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR = "twoFingerHoldMouseBehavior"
|
||||
private const val KEY_TWO_FINGER_HOLD_DELAY = "twoFingerHoldDelay"
|
||||
private const val KEY_THREE_FINGER_TAP_ENABLED = "threeFingerTapEnabled"
|
||||
private const val KEY_THREE_FINGER_TAP_ACTION = "threeFingerTapAction"
|
||||
private const val KEY_THREE_FINGER_DRAG_ENABLED = "threeFingerDragEnabled"
|
||||
private const val KEY_THREE_FINGER_DRAG_ACTION = "threeFingerDragAction"
|
||||
private const val KEY_THREE_FINGER_HOLD_ENABLED = "threeFingerHoldEnabled"
|
||||
private const val KEY_THREE_FINGER_HOLD_ACTION = "threeFingerHoldAction"
|
||||
private const val KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR = "threeFingerHoldMouseBehavior"
|
||||
private const val KEY_THREE_FINGER_HOLD_DELAY = "threeFingerHoldDelay"
|
||||
private const val KEY_SHOW_GESTURE_DEBUG_OVERLAY = "showGestureDebugOverlay"
|
||||
private const val KEY_GESTURE_THRESHOLD = "gestureThreshold"
|
||||
private const val KEY_MOUSE_DRAG_MOVEMENT_MODE = "mouseDragMovementMode"
|
||||
|
||||
private fun fallbackDefaults(): TouchGestureConfig = TouchGestureConfig(
|
||||
twoFingerHoldEnabled = false,
|
||||
threeFingerTapEnabled = false,
|
||||
threeFingerHoldEnabled = false,
|
||||
)
|
||||
|
||||
fun fromJson(json: String?): TouchGestureConfig {
|
||||
if (json.isNullOrBlank()) return fallbackDefaults()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
TouchGestureConfig(
|
||||
tapEnabled = obj.optBoolean(KEY_TAP_ENABLED, true),
|
||||
tapAction = obj.optString(KEY_TAP_ACTION, ACTION_LEFT_CLICK),
|
||||
dragEnabled = obj.optBoolean(KEY_DRAG_ENABLED, true),
|
||||
dragAction = obj.optString(KEY_DRAG_ACTION, PAN_LEFT_CLICK_DRAG),
|
||||
longPressEnabled = obj.optBoolean(KEY_LONG_PRESS_ENABLED, false),
|
||||
longPressAction = obj.optString(KEY_LONG_PRESS_ACTION, ACTION_RIGHT_CLICK),
|
||||
longPressMouseBehavior = normalizeMouseBehavior(
|
||||
obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
|
||||
),
|
||||
longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS),
|
||||
doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true),
|
||||
doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS),
|
||||
twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true),
|
||||
twoFingerDragAction = obj.optString(KEY_TWO_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
|
||||
pinchEnabled = obj.optBoolean(KEY_PINCH_ENABLED, true),
|
||||
pinchAction = obj.optString(KEY_PINCH_ACTION, ZOOM_SCROLL_WHEEL),
|
||||
twoFingerTapEnabled = obj.optBoolean(KEY_TWO_FINGER_TAP_ENABLED, true),
|
||||
twoFingerTapAction = obj.optString(KEY_TWO_FINGER_TAP_ACTION, ACTION_RIGHT_CLICK),
|
||||
twoFingerHoldEnabled = obj.optBoolean(KEY_TWO_FINGER_HOLD_ENABLED, false),
|
||||
twoFingerHoldAction = obj.optString(KEY_TWO_FINGER_HOLD_ACTION, ACTION_MIDDLE_CLICK),
|
||||
twoFingerHoldMouseBehavior = normalizeMouseBehavior(
|
||||
obj.optString(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
|
||||
),
|
||||
twoFingerHoldDelay = obj.optInt(KEY_TWO_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
|
||||
threeFingerTapEnabled = obj.optBoolean(KEY_THREE_FINGER_TAP_ENABLED, false),
|
||||
threeFingerTapAction = obj.optString(KEY_THREE_FINGER_TAP_ACTION, ACTION_SHOW_KEYBOARD),
|
||||
threeFingerDragEnabled = obj.optBoolean(KEY_THREE_FINGER_DRAG_ENABLED, false),
|
||||
threeFingerDragAction = obj.optString(KEY_THREE_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
|
||||
threeFingerHoldEnabled = obj.optBoolean(KEY_THREE_FINGER_HOLD_ENABLED, false),
|
||||
threeFingerHoldAction = obj.optString(KEY_THREE_FINGER_HOLD_ACTION, ACTION_KEY_ESC),
|
||||
threeFingerHoldMouseBehavior = normalizeMouseBehavior(
|
||||
obj.optString(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
|
||||
),
|
||||
threeFingerHoldDelay = obj.optInt(KEY_THREE_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
|
||||
showGestureDebugOverlay = obj.optBoolean(KEY_SHOW_GESTURE_DEBUG_OVERLAY, false),
|
||||
gestureThreshold = obj.optInt(KEY_GESTURE_THRESHOLD, DEFAULT_GESTURE_THRESHOLD),
|
||||
mouseDragMovementMode = normalizeMouseDragMovementMode(
|
||||
obj.optString(KEY_MOUSE_DRAG_MOVEMENT_MODE, MOUSE_DRAG_MOVEMENT_DIRECT),
|
||||
),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
fallbackDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeMouseBehavior(value: String): String {
|
||||
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
|
||||
}
|
||||
|
||||
private fun normalizeMouseDragMovementMode(mode: String?): String {
|
||||
return if (mode == MOUSE_DRAG_MOVEMENT_RELATIVE) {
|
||||
MOUSE_DRAG_MOVEMENT_RELATIVE
|
||||
} else {
|
||||
MOUSE_DRAG_MOVEMENT_DIRECT
|
||||
}
|
||||
}
|
||||
|
||||
val PAN_ACTIONS = listOf(
|
||||
PAN_ARROW_KEYS,
|
||||
PAN_INVERTED_ARROW_KEYS,
|
||||
PAN_WASD,
|
||||
PAN_INVERTED_WASD,
|
||||
PAN_MIDDLE_MOUSE,
|
||||
PAN_INVERTED_MIDDLE_MOUSE,
|
||||
PAN_LEFT_CLICK_DRAG,
|
||||
PAN_RIGHT_CLICK_DRAG,
|
||||
)
|
||||
|
||||
val ZOOM_ACTIONS = listOf(
|
||||
ZOOM_SCROLL_WHEEL,
|
||||
ZOOM_PLUS_MINUS,
|
||||
ZOOM_PAGE_UP_DOWN,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun isMouseButtonAction(action: String?): Boolean =
|
||||
action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class TouchpadGestures(
|
||||
private var moverLastX = 0f
|
||||
private var moverLastY = 0f
|
||||
private var holdDrag = false
|
||||
private var scrollLocked = false
|
||||
private var clicked = 0
|
||||
private val longPress = Runnable {
|
||||
held = true
|
||||
@@ -67,6 +68,7 @@ class TouchpadGestures(
|
||||
dragging = false
|
||||
held = false
|
||||
holdDrag = false
|
||||
scrollLocked = false
|
||||
anchorId = -1
|
||||
moverId = -1
|
||||
lastTapAt = 0L
|
||||
@@ -84,7 +86,7 @@ class TouchpadGestures(
|
||||
lastX = event.x; lastY = event.y
|
||||
downAt = event.eventTime
|
||||
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
|
||||
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false
|
||||
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
|
||||
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
|
||||
dragging = true
|
||||
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
|
||||
@@ -117,10 +119,12 @@ class TouchpadGestures(
|
||||
val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId)
|
||||
if (ai >= 0 && mi >= 0) {
|
||||
val mx = event.getX(mi); val my = event.getY(mi)
|
||||
if (!holdDrag && !scrolled) {
|
||||
if (!holdDrag && !scrolled && !scrollLocked) {
|
||||
val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY)
|
||||
val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY)
|
||||
if (moverMoved > slopPx && anchorMoved < slopPx) {
|
||||
if (anchorMoved >= slopPx) {
|
||||
scrollLocked = true
|
||||
} else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) {
|
||||
holdDrag = true
|
||||
secondWasTap = false
|
||||
if (!dragging) {
|
||||
@@ -186,9 +190,9 @@ class TouchpadGestures(
|
||||
if (gone == moverId) moverId = -1
|
||||
if (gone == anchorId) {
|
||||
anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0)
|
||||
event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let {
|
||||
lastX = event.getX(it); lastY = event.getY(it)
|
||||
}
|
||||
}
|
||||
event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let {
|
||||
lastX = event.getX(it); lastY = event.getY(it)
|
||||
}
|
||||
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
|
||||
click(PointerGestures.BTN_RIGHT)
|
||||
@@ -212,7 +216,7 @@ class TouchpadGestures(
|
||||
}
|
||||
}
|
||||
dragging = false; held = false; twoFingers = false
|
||||
holdDrag = false; anchorId = -1; moverId = -1
|
||||
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -225,5 +229,7 @@ class TouchpadGestures(
|
||||
private const val CLICK_MS = 50L
|
||||
private const val DOUBLE_TAP_MS = 300L
|
||||
private const val SCROLL_STEP_PX = 36f
|
||||
private const val HOLD_DRAG_MOVER_SLOPS = 2f
|
||||
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -47,6 +47,53 @@ object DesktopCatalog {
|
||||
|
||||
private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id")
|
||||
|
||||
private const val JOURNAL_SUFFIX = ".installing"
|
||||
|
||||
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
|
||||
|
||||
/** Packages whose extraction over the runtime started and never finished. */
|
||||
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context))
|
||||
|
||||
fun interrupted(root: File): List<Entry> =
|
||||
root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty()
|
||||
.sortedBy { it.name }
|
||||
.mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } }
|
||||
|
||||
private val installLock = Any()
|
||||
|
||||
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
|
||||
|
||||
/** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */
|
||||
fun pruneDownloads(context: Context) = synchronized(installLock) {
|
||||
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
|
||||
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
|
||||
?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
|
||||
for (entry in interrupted(context)) {
|
||||
Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}")
|
||||
install(context, entry, listener)?.let { return it }
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
internal fun entryJson(e: Entry): JSONObject = JSONObject()
|
||||
.put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind)
|
||||
.put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes)
|
||||
.put("icon", e.icon).put("category", e.category)
|
||||
|
||||
internal fun entryFrom(o: JSONObject) = Entry(
|
||||
o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"),
|
||||
o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""),
|
||||
o.optString("icon", "applications-games"), o.optString("category", "Game"),
|
||||
)
|
||||
|
||||
/** A finished download is kept until its extraction completes, so resuming does not fetch it again. */
|
||||
private fun downloaded(file: File, entry: Entry): Boolean =
|
||||
file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() &&
|
||||
(entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true)
|
||||
|
||||
fun installed(context: Context, id: String): String? =
|
||||
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
@@ -82,22 +129,38 @@ object DesktopCatalog {
|
||||
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
|
||||
DesktopRemoval.remove(context, listener)
|
||||
|
||||
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
|
||||
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
|
||||
installLocked(context, entry, listener)
|
||||
}
|
||||
|
||||
private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
|
||||
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
|
||||
val download = File(context.cacheDir, "pkg-${entry.id}.download")
|
||||
try {
|
||||
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
|
||||
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
|
||||
val download = downloadFile(context, entry)
|
||||
val journal = journal(root, entry.id)
|
||||
var keepDownload = false
|
||||
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
|
||||
val report = object : LinuxRuntimeInstaller.ProgressListener {
|
||||
override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent)
|
||||
override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
|
||||
RuntimeInstallService.packageProgress(stage, percent)
|
||||
listener?.onProgress(step, stage, percent)
|
||||
}
|
||||
if (!ok) return context.getString(R.string.user_apps_download_failed)
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
|
||||
val actual = Hashes.sha256(download)
|
||||
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
|
||||
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
|
||||
}
|
||||
try {
|
||||
if (!downloaded(download, entry)) {
|
||||
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
|
||||
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
|
||||
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
|
||||
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
|
||||
}
|
||||
if (!ok) return context.getString(R.string.user_apps_download_failed)
|
||||
report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
|
||||
val actual = Hashes.sha256(download)
|
||||
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
|
||||
}
|
||||
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
|
||||
when (entry.kind) {
|
||||
"appimage" -> {
|
||||
val dir = File(root, "opt/appimages").apply { mkdirs() }
|
||||
@@ -112,23 +175,28 @@ object DesktopCatalog {
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> {
|
||||
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
|
||||
keepDownload = true
|
||||
marker(context, entry.id).delete()
|
||||
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
}
|
||||
}
|
||||
if (entry.id == DESKTOP_ID) {
|
||||
cleanLegacy(context, listener)
|
||||
cleanLegacy(context, report)
|
||||
val pending = File(root, DesktopRemoval.PENDING)
|
||||
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
|
||||
}
|
||||
FileUtils.writeString(marker(context, entry.id), entry.version)
|
||||
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
|
||||
check(!journal.exists() || journal.delete()) { "Could not finish package installation" }
|
||||
keepDownload = false
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${entry.id}", e)
|
||||
return e.message ?: context.getString(R.string.deskpkg_install_failed)
|
||||
} finally {
|
||||
download.delete()
|
||||
if (!keepDownload) download.delete()
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.io.File
|
||||
|
||||
object GuestCommand {
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
@@ -19,13 +19,14 @@ object GuestCommand {
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
} finally {
|
||||
log?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
|
||||
fastPath: Boolean, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
@@ -33,8 +34,10 @@ object GuestCommand {
|
||||
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
|
||||
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
|
||||
if (linkDir != null) cmd.add(1, "--link2symlink")
|
||||
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
|
||||
.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
|
||||
binds.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
// Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
|
||||
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
|
||||
cmd += listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
|
||||
@@ -42,6 +45,7 @@ object GuestCommand {
|
||||
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
|
||||
cmd += argv
|
||||
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
|
||||
builder.environment().apply {
|
||||
@@ -49,6 +53,7 @@ object GuestCommand {
|
||||
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
|
||||
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.runtime;
|
||||
|
||||
import com.droiddeck.launcher.core.AddonPack;
|
||||
import android.content.Context;
|
||||
import android.os.Process;
|
||||
import android.system.ErrnoException;
|
||||
@@ -64,14 +65,12 @@ public final class LinuxRuntime {
|
||||
private static final String HOST_DIR = "opt/android-host";
|
||||
|
||||
public static File prootBinary(Context context) {
|
||||
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so");
|
||||
if (packaged.isFile()) return packaged;
|
||||
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot.so");
|
||||
return new File(rootDir(context), HOST_DIR + "/proot");
|
||||
}
|
||||
|
||||
public static File prootLoader(Context context) {
|
||||
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so");
|
||||
if (packaged.isFile()) return packaged;
|
||||
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot-loader.so");
|
||||
return new File(rootDir(context), HOST_DIR + "/loader");
|
||||
}
|
||||
|
||||
@@ -187,10 +186,15 @@ public final class LinuxRuntime {
|
||||
} catch (java.io.IOException e) {
|
||||
}
|
||||
}
|
||||
File fileMax = new File(fakeProc, "file-max");
|
||||
if (!fileMax.isFile()) {
|
||||
String[][] seeded = {
|
||||
{"file-max", "9223372036854775807\n"},
|
||||
{"overcommit_memory", "0\n"},
|
||||
};
|
||||
for (String[] entry : seeded) {
|
||||
File fake = new File(fakeProc, entry[0]);
|
||||
if (fake.isFile()) continue;
|
||||
try {
|
||||
Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII));
|
||||
Files.write(fake.toPath(), entry[1].getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (IOException e) {
|
||||
}
|
||||
}
|
||||
@@ -205,6 +209,7 @@ public final class LinuxRuntime {
|
||||
{"overflowuid", "/proc/sys/kernel/overflowuid"},
|
||||
{"overflowgid", "/proc/sys/kernel/overflowgid"},
|
||||
{"file-max", "/proc/sys/fs/file-max"},
|
||||
{"overcommit_memory", "/proc/sys/vm/overcommit_memory"},
|
||||
};
|
||||
for (String[] entry : procFiles) {
|
||||
File fake = new File(fakeProc, entry[0]);
|
||||
@@ -214,6 +219,9 @@ public final class LinuxRuntime {
|
||||
bind(cmd, fake.getPath() + ":" + entry[1]);
|
||||
}
|
||||
}
|
||||
if (unreadablePciDevice()) {
|
||||
bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/bus/pci/devices");
|
||||
}
|
||||
bindGpuNode(context, cmd);
|
||||
bindAdrenoStats(cmd, extraBinds);
|
||||
bindCpuTemps(cmd, root);
|
||||
@@ -225,6 +233,15 @@ public final class LinuxRuntime {
|
||||
return specs;
|
||||
}
|
||||
|
||||
private static boolean unreadablePciDevice() {
|
||||
File[] devices = new File("/sys/bus/pci/devices").listFiles();
|
||||
if (devices == null) return false;
|
||||
for (File device : devices) {
|
||||
if (!new File(device, "vendor").canRead()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void bindGpuNode(Context context, List<String> cmd) {
|
||||
StructStat st;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.runtime;
|
||||
|
||||
import com.droiddeck.launcher.core.AddonPack;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
@@ -261,6 +262,8 @@ public final class LinuxRuntimeInstaller {
|
||||
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
if (!AddonPack.PROOT.await(context)) throw new IOException("Cannot download proot");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
|
||||
if (listener != null) {
|
||||
listener.onProgress(Step.DOWNLOADING, downloading,
|
||||
@@ -456,9 +459,12 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
// Packages extract over the live runtime and their archives list a link before the file it
|
||||
// names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
|
||||
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
|
||||
long entries = 0;
|
||||
String extracting = context.getString(R.string.rtinst_extracting);
|
||||
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
|
||||
try (InputStream in = new ZstdCompressorInputStream(
|
||||
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
|
||||
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
|
||||
@@ -477,29 +483,35 @@ public final class LinuxRuntimeInstaller {
|
||||
if (parent != null && !parent.isDirectory()) parent.mkdirs();
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
file.delete();
|
||||
FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath());
|
||||
links.remove(file);
|
||||
links.put(file, entry.getLinkName());
|
||||
} else if (entry.isLink()) {
|
||||
File target = ArchivePaths.inside(destination, entry.getLinkName());
|
||||
if (target == null) {
|
||||
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
|
||||
continue;
|
||||
}
|
||||
file.delete();
|
||||
links.remove(file);
|
||||
File part = partFile(file);
|
||||
part.delete();
|
||||
try {
|
||||
Files.createLink(file.toPath(), target.toPath());
|
||||
Files.createLink(part.toPath(), target.toPath());
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
if (target.isFile()) {
|
||||
Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
if (part.exists()) place(part, file);
|
||||
} else if (entry.isFile()) {
|
||||
try (OutputStream out = new FileOutputStream(file)) {
|
||||
links.remove(file);
|
||||
File part = partFile(file);
|
||||
try (OutputStream out = new FileOutputStream(part)) {
|
||||
byte[] buffer = new byte[1 << 16];
|
||||
int read;
|
||||
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
|
||||
}
|
||||
if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false);
|
||||
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
|
||||
place(part, file);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
@@ -507,10 +519,32 @@ public final class LinuxRuntimeInstaller {
|
||||
listener.onProgress(extracting, -1);
|
||||
}
|
||||
}
|
||||
for (java.util.Map.Entry<File, String> link : links.entrySet()) {
|
||||
File file = link.getKey();
|
||||
File part = partFile(file);
|
||||
try {
|
||||
Files.deleteIfExists(part.toPath());
|
||||
Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue()));
|
||||
place(part, file);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e);
|
||||
part.delete();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "extract", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static File partFile(File file) {
|
||||
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
|
||||
}
|
||||
|
||||
private static void place(File part, File file) throws IOException {
|
||||
java.nio.file.Path path = file.toPath();
|
||||
if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path);
|
||||
Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
@@ -11,7 +12,7 @@ object ProotFastPath {
|
||||
|
||||
fun enabled(context: Context): Boolean =
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
|
||||
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
|
||||
LinuxRuntime.prootBinary(context).parentFile == AddonPack.PROOT.dir(context)
|
||||
|
||||
fun key(root: File, binds: List<String>): String? {
|
||||
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
object RuntimeHealth {
|
||||
private const val MAX_HOPS = 16
|
||||
|
||||
fun brokenLibraryLinks(context: Context): List<String> = brokenLibraryLinks(LinuxRuntime.rootDir(context))
|
||||
|
||||
/** A complete runtime has no library link in usr/lib whose target is missing. */
|
||||
fun brokenLibraryLinks(root: File): List<String> {
|
||||
val lib = File(root, "usr/lib")
|
||||
val names = lib.list() ?: return emptyList()
|
||||
return names.sorted().filter { name ->
|
||||
val path = File(lib, name).toPath()
|
||||
Files.isSymbolicLink(path) && !resolves(root, path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolves(root: File, link: Path): Boolean = try {
|
||||
var path = link
|
||||
var hops = 0
|
||||
while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) {
|
||||
val target = Files.readSymbolicLink(path)
|
||||
path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target)
|
||||
}
|
||||
!Files.isSymbolicLink(path) && Files.exists(path)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class RuntimeInstallService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
|
||||
val removal = intent?.action == ACTION_REMOVE
|
||||
val release = intent?.let(::releaseFrom)
|
||||
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
|
||||
@@ -44,7 +45,27 @@ class RuntimeInstallService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun notification(stage: String, percent: Int): Notification {
|
||||
private fun holdPackage(startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
|
||||
Thread({
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
var shown = ""
|
||||
while (packageHolds.get() > 0) {
|
||||
val now = "$packageStage:$packagePercent"
|
||||
if (now != shown) {
|
||||
shown = now
|
||||
manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
|
||||
}
|
||||
Thread.sleep(500)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
stopSelf(startId)
|
||||
}, "package-install").start()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun notification(stage: String, percent: Int, title: String? = null): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
|
||||
NotificationManager.IMPORTANCE_LOW).apply {
|
||||
@@ -58,7 +79,7 @@ class RuntimeInstallService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentText(stage)
|
||||
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
|
||||
.setContentIntent(open)
|
||||
@@ -72,6 +93,11 @@ class RuntimeInstallService : Service() {
|
||||
private const val CHANNEL_ID = "runtime-install"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
|
||||
private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE"
|
||||
private val packageHolds = java.util.concurrent.atomic.AtomicInteger()
|
||||
@Volatile private var packageTitle = ""
|
||||
@Volatile private var packageStage = ""
|
||||
@Volatile private var packagePercent = -1
|
||||
private const val EXTRA_VERSION = "version"
|
||||
private const val EXTRA_URL = "url"
|
||||
private const val EXTRA_SHA256 = "sha256"
|
||||
@@ -91,6 +117,24 @@ class RuntimeInstallService : Service() {
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
/** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */
|
||||
fun holdForPackage(context: Context, title: String): () -> Unit {
|
||||
packageTitle = title
|
||||
packageStage = title
|
||||
packagePercent = -1
|
||||
packageHolds.incrementAndGet()
|
||||
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE)
|
||||
runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) }
|
||||
.onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") }
|
||||
val released = java.util.concurrent.atomic.AtomicBoolean()
|
||||
return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() }
|
||||
}
|
||||
|
||||
fun packageProgress(stage: String, percent: Int) {
|
||||
packageStage = stage
|
||||
packagePercent = percent
|
||||
}
|
||||
|
||||
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
|
||||
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
|
||||
val url = intent.getStringExtra(EXTRA_URL) ?: return null
|
||||
|
||||
@@ -71,6 +71,9 @@ object UserApps {
|
||||
private fun menuEntry(context: Context, id: String) =
|
||||
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
|
||||
|
||||
fun launches(program: String?): Boolean =
|
||||
program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER
|
||||
|
||||
fun list(context: Context): List<App> {
|
||||
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
|
||||
App(
|
||||
|
||||
@@ -30,7 +30,7 @@ object BootMovie {
|
||||
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
|
||||
|
||||
sealed class Import {
|
||||
data class Done(val name: String, val audioDropped: Boolean) : Import()
|
||||
data class Done(val name: String, val audioDropped: Boolean, val id: String = "") : Import()
|
||||
object TooLong : Import()
|
||||
data class Unsupported(val codec: String?) : Import()
|
||||
object Failed : Import()
|
||||
@@ -39,6 +39,21 @@ object BootMovie {
|
||||
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
|
||||
private fun customFile(context: Context) = File(dir(context), "custom.webm")
|
||||
|
||||
/** A movie imported before movies were kept in Steam's own folder. */
|
||||
internal fun legacyCustomFile(context: Context) = customFile(context)
|
||||
|
||||
/** Unpacks one of DroidDeck's own movies on demand; null for anything else. */
|
||||
internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) }
|
||||
|
||||
/** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */
|
||||
fun select(context: Context, id: String) {
|
||||
SessionPrefs.setBootMovie(context, id)
|
||||
SessionPrefs.setBootMovieApplyNext(context, true)
|
||||
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
|
||||
// Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
|
||||
SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
|
||||
}
|
||||
|
||||
fun customName(context: Context): String? =
|
||||
if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null
|
||||
|
||||
@@ -46,21 +61,127 @@ object BootMovie {
|
||||
val chosen = SessionPrefs.bootMovie(context)
|
||||
return when {
|
||||
chosen == CUSTOM && customFile(context).isFile -> CUSTOM
|
||||
isLibraryId(chosen) && BootMovieLibrary.file(context, chosen) != null -> chosen
|
||||
chosen in ASSETS -> chosen
|
||||
else -> DROIDDECK_30
|
||||
}
|
||||
}
|
||||
|
||||
/** The file the current choice plays from, before any volume or length change. */
|
||||
fun currentFile(context: Context): File? = sourceOf(context, choice(context))
|
||||
|
||||
private fun sourceOf(context: Context, choice: String): File? = when {
|
||||
choice == CUSTOM -> customFile(context)
|
||||
isLibraryId(choice) -> BootMovieLibrary.file(context, choice)
|
||||
else -> ASSETS[choice]?.let { extractAsset(context, it) }
|
||||
}
|
||||
|
||||
private fun isLibraryId(id: String) = id.startsWith(BootMovieLibrary.PREFIX) || id.startsWith(BootMovieLibrary.STEAM_PREFIX)
|
||||
|
||||
fun prepare(context: Context): File? {
|
||||
val choice = choice(context)
|
||||
val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null
|
||||
if (SessionPrefs.bootMovieSound(context)) return source
|
||||
val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm")
|
||||
if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted
|
||||
if (remux(source, muted, audio = false)) return muted
|
||||
if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted
|
||||
Log.e(TAG, "could not strip audio from ${source.name}; not staging it")
|
||||
return null
|
||||
BootMovieLibrary.restore(context)
|
||||
val source = sourceOf(context, choice(context)) ?: return null
|
||||
return variant(context, source)
|
||||
}
|
||||
|
||||
/** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */
|
||||
private fun variant(context: Context, source: File): File? {
|
||||
val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0
|
||||
val seconds = SessionPrefs.bootMovieSeconds(context)
|
||||
val folder = File(dir(context), "variants")
|
||||
val plan = if (isOversized(source)) oversizedPlan(volume) else plan(volume, seconds)
|
||||
if (plan.passthrough) {
|
||||
prune(folder, null)
|
||||
return source
|
||||
}
|
||||
folder.mkdirs()
|
||||
val limitUs = if (seconds > 0) seconds * 1_000_000L else Long.MAX_VALUE
|
||||
val out = File(folder, BootMovieLibrary.variantPrefix(source) + "v$volume-s$seconds.webm")
|
||||
if (out.isFile && out.length() > 0L && out.lastModified() >= source.lastModified()) {
|
||||
prune(folder, out.name)
|
||||
return out
|
||||
}
|
||||
val staged = File(folder, out.name + ".staged")
|
||||
val ok = runCatching {
|
||||
plan.steps.any { step ->
|
||||
when (step) {
|
||||
Step.REMUX_SILENT -> remux(source, staged, audio = false, limitUs)
|
||||
Step.REMUX_AUDIO -> remux(source, staged, audio = true, limitUs)
|
||||
Step.TRANSCODE_SILENT -> transcodeSilent(source, staged, limitUs)
|
||||
Step.TRANSCODE_AUDIO -> BootMovieTranscoder.transcode(source, staged, limitUs = limitUs, gain = volume / 100f) {} != null
|
||||
Step.REGAIN -> BootMovieTranscoder.regain(source, staged, volume / 100f, limitUs)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
if (ok && staged.renameTo(out)) {
|
||||
prune(folder, out.name)
|
||||
return out
|
||||
}
|
||||
staged.delete()
|
||||
Log.e(TAG, "could not prepare ${source.name} (volume $volume, $seconds s)")
|
||||
return if (plan.sourceOnFailure) source else null
|
||||
}
|
||||
|
||||
internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN }
|
||||
|
||||
/**
|
||||
* How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and
|
||||
* whether it still plays as it is when every step fails.
|
||||
*/
|
||||
internal class Plan(val passthrough: Boolean, val steps: List<Step>, val sourceOnFailure: Boolean)
|
||||
|
||||
internal fun plan(volume: Int, seconds: Int): Plan = when {
|
||||
volume == 100 && seconds == 0 -> Plan(true, emptyList(), true)
|
||||
volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false)
|
||||
// Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder.
|
||||
volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true)
|
||||
else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false)
|
||||
}
|
||||
|
||||
/** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */
|
||||
internal const val MAX_LONG_SIDE = 1920
|
||||
internal const val MAX_SHORT_SIDE = 1080
|
||||
|
||||
internal fun oversized(width: Int, height: Int) = maxOf(width, height) > MAX_LONG_SIDE || minOf(width, height) > MAX_SHORT_SIDE
|
||||
|
||||
private fun oversized(format: MediaFormat) =
|
||||
format.containsKey(MediaFormat.KEY_WIDTH) && format.containsKey(MediaFormat.KEY_HEIGHT) &&
|
||||
oversized(format.getInteger(MediaFormat.KEY_WIDTH), format.getInteger(MediaFormat.KEY_HEIGHT))
|
||||
|
||||
private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true
|
||||
|
||||
/** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */
|
||||
internal fun oversizedPlan(volume: Int): Plan =
|
||||
Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false)
|
||||
|
||||
private fun transcodeSilent(source: File, target: File, limitUs: Long) =
|
||||
BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null
|
||||
|
||||
/** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */
|
||||
private fun prune(folder: File, keep: String?) {
|
||||
folder.listFiles()?.forEach { if (it.name != keep) it.delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before,
|
||||
* that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
|
||||
*/
|
||||
fun syncFromSteam(context: Context): Boolean {
|
||||
val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false
|
||||
if (path == SessionPrefs.bootMovieSteamSeen(context)) return false
|
||||
SessionPrefs.setBootMovieSteamSeen(context, path)
|
||||
if (path == SessionFiles.BOOT_MOVIE_PATH) return false
|
||||
val prefix = "/uioverrides/movies/"
|
||||
// The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
|
||||
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
|
||||
val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) }
|
||||
listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path
|
||||
}
|
||||
if (BootMovieLibrary.file(context, id) == null) return false
|
||||
val changed = SessionPrefs.bootMovie(context) != id
|
||||
BootMovieReport.note(context, "Steam names $path, taken over as $id")
|
||||
SessionPrefs.setBootMovie(context, id)
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun extractAsset(context: Context, name: String): File? {
|
||||
@@ -82,11 +203,15 @@ object BootMovie {
|
||||
}
|
||||
}
|
||||
|
||||
fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import {
|
||||
fun import(
|
||||
context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {},
|
||||
title: String? = null, source: String = BootMovieLibrary.SOURCE_IMPORT, repoId: String? = null,
|
||||
cover: ByteArray? = null, use: Boolean = true,
|
||||
): Import {
|
||||
val (format, audio) = probe(file) ?: return Import.Unsupported(null)
|
||||
val video = format.getString(MediaFormat.KEY_MIME)
|
||||
val audioPlayable = audio == null || audio in AUDIO_CODECS
|
||||
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES
|
||||
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES && !oversized(format)
|
||||
val copy = playable && audioPlayable && isWebm(file)
|
||||
val remux = !copy && playable && video in REMUX_CODECS
|
||||
if (!copy && !remux) {
|
||||
@@ -95,11 +220,12 @@ object BootMovie {
|
||||
if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong
|
||||
onStart()
|
||||
}
|
||||
val target = customFile(context)
|
||||
val staged = File(dir(context), "custom.webm.staged")
|
||||
val shown = title?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension
|
||||
val library = BootMovieLibrary.libraryDir(context)
|
||||
val staged = File(library, ".import-${System.nanoTime()}.staged")
|
||||
var audioDropped = remux && !audioPlayable
|
||||
return try {
|
||||
target.parentFile?.mkdirs()
|
||||
library.mkdirs()
|
||||
val ok = when {
|
||||
copy -> {
|
||||
file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } }
|
||||
@@ -108,11 +234,10 @@ object BootMovie {
|
||||
remux -> remux(file, staged, audio = true)
|
||||
else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null
|
||||
}
|
||||
if (!ok || !staged.renameTo(target)) return Import.Failed
|
||||
File(target.parentFile, "custom-muted.webm").delete()
|
||||
SessionPrefs.setBootMovieCustomName(context, file.name)
|
||||
SessionPrefs.setBootMovie(context, CUSTOM)
|
||||
Import.Done(file.name, audioDropped)
|
||||
if (!ok || !staged.setReadable(true, false)) return Import.Failed
|
||||
val name = BootMovieLibrary.commit(context, staged, shown, source, repoId, cover) ?: return Import.Failed
|
||||
if (use) select(context, BootMovieLibrary.idOf(name))
|
||||
Import.Done(shown, audioDropped, BootMovieLibrary.idOf(name))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not import ${file.name}", e)
|
||||
Import.Failed
|
||||
@@ -124,8 +249,9 @@ object BootMovie {
|
||||
fun removeCustom(context: Context) {
|
||||
customFile(context).delete()
|
||||
File(dir(context), "custom-muted.webm").delete()
|
||||
File(dir(context), "variants").listFiles { f -> f.name.startsWith(BootMovieLibrary.variantPrefix(customFile(context))) }?.forEach { it.delete() }
|
||||
SessionPrefs.setBootMovieCustomName(context, null)
|
||||
if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30)
|
||||
if (SessionPrefs.bootMovie(context) == CUSTOM) select(context, DROIDDECK_30)
|
||||
}
|
||||
|
||||
private fun isWebm(file: File): Boolean {
|
||||
@@ -156,7 +282,7 @@ object BootMovie {
|
||||
}
|
||||
}
|
||||
|
||||
private fun remux(source: File, target: File, audio: Boolean): Boolean {
|
||||
private fun remux(source: File, target: File, audio: Boolean, limitUs: Long = Long.MAX_VALUE): Boolean {
|
||||
val staged = File(target.parentFile, target.name + ".remux")
|
||||
val extractor = MediaExtractor()
|
||||
var muxer: MediaMuxer? = null
|
||||
@@ -183,7 +309,7 @@ object BootMovie {
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val size = extractor.readSampleData(buffer, 0)
|
||||
if (size < 0) break
|
||||
if (size < 0 || extractor.sampleTime > limitUs) break
|
||||
val track = tracks[extractor.sampleTrackIndex]
|
||||
if (track != null) {
|
||||
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Bitmap.CompressFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in
|
||||
* Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own
|
||||
* three startup movies and the movies bought in Steam's points shop (neither can be removed).
|
||||
*/
|
||||
object BootMovieLibrary {
|
||||
private const val TAG = "BootMovieLibrary"
|
||||
const val PREFIX = "file:"
|
||||
|
||||
/** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:<path Steam names>`. */
|
||||
const val STEAM_PREFIX = "steam:"
|
||||
|
||||
/** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */
|
||||
private val lock = Any()
|
||||
|
||||
/** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */
|
||||
internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm")
|
||||
|
||||
const val SOURCE_DROIDDECK = "droiddeck"
|
||||
const val SOURCE_IMPORT = "import"
|
||||
const val SOURCE_REPO = "repo"
|
||||
const val SOURCE_STEAM = "steam"
|
||||
const val SOURCE_POINTSHOP = "pointshop"
|
||||
|
||||
class Entry(val id: String, val title: String, val source: String, val file: File?, val removable: Boolean)
|
||||
|
||||
internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies")
|
||||
|
||||
/**
|
||||
* The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the
|
||||
* x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file.
|
||||
*/
|
||||
internal fun customMovieDirs(context: Context): List<File> =
|
||||
listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path }
|
||||
|
||||
/** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */
|
||||
internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library")
|
||||
|
||||
private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json")
|
||||
|
||||
internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
|
||||
/** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */
|
||||
internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf")
|
||||
|
||||
private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs")
|
||||
|
||||
fun idOf(name: String) = PREFIX + name
|
||||
|
||||
/** Every Steam client folder: the usual one and the x86-64 client's. */
|
||||
internal fun steamRoots(context: Context): List<File> = listOf(
|
||||
steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
|
||||
).filter { it.isDirectory }
|
||||
|
||||
private fun steamFile(context: Context, id: String): File? {
|
||||
val path = id.removePrefix(STEAM_PREFIX)
|
||||
if (!path.startsWith("/") || path.contains("..") || path.contains('\\') || !path.endsWith(".webm", ignoreCase = true)) return null
|
||||
return steamRoots(context).firstNotNullOfOrNull { root ->
|
||||
listOf("config", "steamui", "").map { File(root, it + path) }.firstOrNull { it.isFile && it.length() > 0L }
|
||||
}
|
||||
}
|
||||
|
||||
fun file(context: Context, id: String): File? {
|
||||
if (id.startsWith(STEAM_PREFIX)) return steamFile(context, id)
|
||||
if (!id.startsWith(PREFIX)) return null
|
||||
val name = id.removePrefix(PREFIX)
|
||||
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return null
|
||||
return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L }
|
||||
}
|
||||
|
||||
private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) &&
|
||||
file.name !in STAGED && !file.name.startsWith(".")
|
||||
|
||||
private fun readMeta(context: Context): JSONObject =
|
||||
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
|
||||
|
||||
/** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */
|
||||
private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) {
|
||||
val meta = readMeta(context)
|
||||
if (change(meta)) {
|
||||
val file = metaFile(context)
|
||||
file.parentFile?.mkdirs()
|
||||
val staged = File(file.parentFile, file.name + ".staged")
|
||||
runCatching {
|
||||
staged.writeText(meta.toString())
|
||||
if (!staged.renameTo(file)) staged.delete()
|
||||
}.onFailure { staged.delete(); Log.w(TAG, "could not write the movie list", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The post a movie was installed from, if it came from SteamDeckRepo. */
|
||||
fun repoId(context: Context, name: String): String? =
|
||||
readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() }
|
||||
|
||||
fun installedRepoIds(context: Context): Set<String> {
|
||||
val meta = readMeta(context)
|
||||
return meta.keys().asSequence().filter { file(context, idOf(it)) != null }
|
||||
.mapNotNull { meta.optJSONObject(it)?.optString("repo")?.takeIf { id -> id.isNotEmpty() } }.toSet()
|
||||
}
|
||||
|
||||
fun nameForRepo(context: Context, repoId: String): String? {
|
||||
val meta = readMeta(context)
|
||||
return meta.keys().asSequence().firstOrNull { file(context, idOf(it)) != null && meta.optJSONObject(it)?.optString("repo") == repoId }
|
||||
}
|
||||
|
||||
fun list(context: Context): List<Entry> {
|
||||
val out = ArrayList<Entry>()
|
||||
out.add(Entry(BootMovie.DROIDDECK_30, "DroidDeck · 30 fps", SOURCE_DROIDDECK, null, removable = false))
|
||||
out.add(Entry(BootMovie.DROIDDECK_60, "DroidDeck · 60 fps", SOURCE_DROIDDECK, null, removable = false))
|
||||
BootMovie.customName(context)?.let { out.add(Entry(BootMovie.CUSTOM, it, SOURCE_IMPORT, BootMovie.legacyCustomFile(context), removable = true)) }
|
||||
restore(context)
|
||||
val meta = readMeta(context)
|
||||
val names = HashSet<String>()
|
||||
customMovieDirs(context).flatMap { dir -> dir.listFiles { f -> isMovie(f) }.orEmpty().toList() }
|
||||
.filter { names.add(it.name) }.sortedBy { it.name.lowercase() }.forEach { f ->
|
||||
val info = meta.optJSONObject(f.name)
|
||||
val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM
|
||||
val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension
|
||||
// Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand).
|
||||
out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true))
|
||||
}
|
||||
val steam = LinkedHashMap<String, File>()
|
||||
SessionFiles.startupMoviePath(steamConfig(context))?.takeIf { !it.startsWith(OVERRIDES) }?.let { path ->
|
||||
steamFile(context, STEAM_PREFIX + path)?.let { steam[STEAM_PREFIX + path] = it }
|
||||
}
|
||||
val bought = HashMap<String, String>()
|
||||
for (root in steamRoots(context)) {
|
||||
File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
|
||||
.sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) }
|
||||
val scan = pointShopMovies(root)
|
||||
bought.putAll(scan.titles)
|
||||
for (found in scan.files) steam.putIfAbsent(STEAM_PREFIX + "/" + found.relativeTo(root).invariantSeparatorsPath, found)
|
||||
}
|
||||
val seen = HashSet<String>()
|
||||
for ((id, file) in steam) {
|
||||
if (file.name in STAGED || !seen.add(file.canonicalPath)) continue
|
||||
val native = file.nameWithoutExtension.lowercase() in NATIVE
|
||||
// Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
|
||||
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
|
||||
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
|
||||
private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup")
|
||||
|
||||
private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache")
|
||||
|
||||
private val scanLock = Any()
|
||||
private class Scan(val at: Long, val files: List<File>, val titles: Map<String, String>)
|
||||
|
||||
private val scans = HashMap<String, Scan>()
|
||||
|
||||
/** Forgets the last search for Steam's own movies. */
|
||||
internal fun forgetScan() = synchronized(scanLock) { scans.clear() }
|
||||
|
||||
/**
|
||||
* Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the
|
||||
* client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder.
|
||||
*/
|
||||
private fun pointShopMovies(root: File): Scan = synchronized(scanLock) {
|
||||
val now = System.currentTimeMillis()
|
||||
scans[root.path]?.let { if (now - it.at < 15_000L) return it }
|
||||
val found = listOf("config", "userdata", "appcache", "steamui").flatMap { top ->
|
||||
val base = File(root, top)
|
||||
if (!base.isDirectory) return@flatMap emptyList()
|
||||
base.walkTopDown().maxDepth(5)
|
||||
.onEnter { dir -> dir.name !in skippedFolders && !(top == "steamui" && dir.parentFile == base && dir.name == "movies") }
|
||||
.take(20_000).filter { isMovie(it) }.toList()
|
||||
}.sortedBy { it.path.lowercase() }
|
||||
val titles = HashMap<String, String>()
|
||||
for (file in found) boughtId(file)?.let { id -> boughtTitle(root, id)?.let { titles[file.path] = it } }
|
||||
val scan = Scan(now, found, titles)
|
||||
scans[root.path] = scan
|
||||
scan
|
||||
}
|
||||
|
||||
/** The item number in the name Steam gives a bought movie, `<item number>_<hash>.webm`; null for any other name. */
|
||||
private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1)
|
||||
|
||||
/** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */
|
||||
private fun boughtTitle(root: File, id: String): String? {
|
||||
val key = "communityitemid\\\":\\\"$id\\\""
|
||||
val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)")
|
||||
for (user in File(root, "userdata").listFiles { f -> f.isDirectory }.orEmpty()) {
|
||||
val text = runCatching { File(user, "config/localconfig.vdf").readText() }.getOrNull() ?: continue
|
||||
val at = text.indexOf(key)
|
||||
if (at < 0) continue
|
||||
title.find(text.substring(at, (at + 3000).coerceAtMost(text.length)))?.groupValues?.get(1)?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private const val OVERRIDES = "/uioverrides/movies/"
|
||||
|
||||
/** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */
|
||||
internal fun restore(context: Context) = synchronized(lock) {
|
||||
migrateLegacyMeta(context)
|
||||
val meta = readMeta(context)
|
||||
val library = libraryDir(context)
|
||||
meta.keys().asSequence().toList().forEach { name ->
|
||||
val kept = File(library, name)
|
||||
if (name.contains('/') || !kept.isFile || kept.length() == 0L) return@forEach
|
||||
val copy = File(moviesDir(context), name)
|
||||
if (!copy.isFile || copy.length() != kept.length()) mirror(kept, copy)
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */
|
||||
private fun migrateLegacyMeta(context: Context) {
|
||||
val legacy = File(moviesDir(context), ".droiddeck-movies.json")
|
||||
if (!legacy.isFile) return
|
||||
val old = runCatching { JSONObject(legacy.readText()) }.getOrNull() ?: return
|
||||
editMeta(context) { meta ->
|
||||
var changed = false
|
||||
old.keys().asSequence().toList().forEach { name ->
|
||||
val info = old.optJSONObject(name) ?: return@forEach
|
||||
if (meta.optJSONObject(name) == null && File(moviesDir(context), name).isFile) { meta.put(name, info); changed = true }
|
||||
}
|
||||
changed
|
||||
}
|
||||
legacy.delete()
|
||||
}
|
||||
|
||||
/** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */
|
||||
private fun mirror(kept: File, copy: File): Boolean {
|
||||
val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged")
|
||||
return try {
|
||||
copy.parentFile?.mkdirs()
|
||||
kept.inputStream().use { input -> staged.outputStream().use { output -> input.copyTo(output) } }
|
||||
staged.setReadable(true, false) && staged.renameTo(copy)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not copy ${kept.name} into Steam's movies", e)
|
||||
false
|
||||
} finally {
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id
|
||||
|
||||
/** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */
|
||||
fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id)
|
||||
|
||||
/** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */
|
||||
fun freeName(context: Context, title: String): String {
|
||||
val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" }
|
||||
val dirs = customMovieDirs(context)
|
||||
val library = libraryDir(context)
|
||||
var name = "$base.webm"
|
||||
var n = 2
|
||||
while (dirs.any { File(it, name).exists() } || File(library, name).exists() || name in STAGED) name = "$base ($n).webm".also { n++ }
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its
|
||||
* title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time.
|
||||
*/
|
||||
internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? =
|
||||
synchronized(lock) {
|
||||
val name = freeName(context, title)
|
||||
val kept = File(libraryDir(context), name)
|
||||
if (!staged.renameTo(kept)) return null
|
||||
if (!mirror(kept, File(moviesDir(context), name))) { kept.delete(); return null }
|
||||
register(context, name, title, source, repoId, cover)
|
||||
name
|
||||
}
|
||||
|
||||
/** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */
|
||||
fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) {
|
||||
editMeta(context) { meta ->
|
||||
val info = JSONObject().put("title", title).put("source", source)
|
||||
if (repoId != null) info.put("repo", repoId)
|
||||
meta.put(name, info)
|
||||
true
|
||||
}
|
||||
if (cover != null) runCatching {
|
||||
thumbsDir(context).mkdirs()
|
||||
File(thumbsDir(context), coverName(name)).writeBytes(cover)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String): Boolean {
|
||||
val removed = when {
|
||||
id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true }
|
||||
else -> {
|
||||
// Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone.
|
||||
if (!id.startsWith(PREFIX)) return false
|
||||
val name = id.removePrefix(PREFIX)
|
||||
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false
|
||||
val copies = customMovieDirs(context).map { File(it, name) }
|
||||
// A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted.
|
||||
val target = copies.firstOrNull { it.exists() }
|
||||
val managed = File(libraryDir(context), name)
|
||||
if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false
|
||||
synchronized(lock) {
|
||||
val main = File(moviesDir(context), name)
|
||||
val another = copies.any { it != target && it.exists() }
|
||||
val ownsRecord = target == null || target.path == main.path || (!another && !managed.isFile)
|
||||
var gone = target == null || target.delete()
|
||||
if (gone && ownsRecord) {
|
||||
gone = !managed.exists() || managed.delete()
|
||||
if (gone) {
|
||||
editMeta(context) { it.remove(name) != null }
|
||||
File(thumbsDir(context), coverName(name)).delete()
|
||||
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
if (gone) {
|
||||
val prefixes = listOfNotNull(target, main.takeIf { ownsRecord }).map { variantPrefix(it) }
|
||||
File(context.filesDir, "boot-movie/variants").listFiles { f -> prefixes.any { f.name.startsWith(it) } }?.forEach { it.delete() }
|
||||
}
|
||||
gone
|
||||
}
|
||||
}
|
||||
}
|
||||
if (removed && SessionPrefs.bootMovie(context) == id) BootMovie.select(context, BootMovie.DROIDDECK_30)
|
||||
return removed
|
||||
}
|
||||
|
||||
/** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */
|
||||
internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-"
|
||||
|
||||
private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
|
||||
private fun coverName(name: String) = "cover-" + safe(name)
|
||||
|
||||
/** How long a movie runs, in whole seconds; 0 when it cannot be read. */
|
||||
fun durationSeconds(file: File?): Int {
|
||||
if (file == null || !file.isFile) return 0
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
retriever.setDataSource(file.path)
|
||||
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
|
||||
((ms + 500) / 1000).toInt()
|
||||
} catch (e: Exception) {
|
||||
0
|
||||
} finally {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
}
|
||||
|
||||
/** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */
|
||||
private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? {
|
||||
var best: Bitmap? = null
|
||||
var bestLuma = -1
|
||||
for (fraction in doubleArrayOf(0.33, 0.5, 0.66, 0.2, 0.8, 0.1, 0.9)) {
|
||||
val frame = runCatching { retriever.getFrameAtTime((durationMs * 1000L * fraction).toLong(), MediaMetadataRetriever.OPTION_CLOSEST) }.getOrNull() ?: continue
|
||||
val luma = averageLuma(frame)
|
||||
if (luma >= MIN_COVER_LUMA) return frame
|
||||
if (luma > bestLuma) { best = frame; bestLuma = luma }
|
||||
}
|
||||
return best ?: runCatching { retriever.frameAtTime }.getOrNull()
|
||||
}
|
||||
|
||||
private fun averageLuma(bitmap: Bitmap): Int {
|
||||
val small = Bitmap.createScaledBitmap(bitmap, 16, 9, true)
|
||||
var sum = 0L
|
||||
for (y in 0 until small.height) for (x in 0 until small.width) {
|
||||
val c = small.getPixel(x, y)
|
||||
sum += (((c shr 16) and 0xFF) * 3 + ((c shr 8) and 0xFF) * 6 + (c and 0xFF)) / 10
|
||||
}
|
||||
return (sum / (small.width * small.height)).toInt()
|
||||
}
|
||||
|
||||
private const val MIN_COVER_LUMA = 24
|
||||
|
||||
/** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */
|
||||
fun cover(context: Context, entry: Entry): File? {
|
||||
val file = fileOf(context, entry) ?: return null
|
||||
val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id
|
||||
// Points Shop covers once kept a picture that was not always the movie's own; those files are not used.
|
||||
val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name))
|
||||
if (given.isFile && given.length() > 0L) return given
|
||||
val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg"
|
||||
val frame = File(thumbsDir(context), key)
|
||||
if (frame.isFile && frame.length() > 0L) return frame
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
retriever.setDataSource(file.path)
|
||||
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
|
||||
val bitmap = brightestFrame(retriever, ms) ?: return null
|
||||
val width = 480
|
||||
val scaled = if (bitmap.width > width) Bitmap.createScaledBitmap(bitmap, width, (bitmap.height * width / bitmap.width).coerceAtLeast(1), true) else bitmap
|
||||
thumbsDir(context).mkdirs()
|
||||
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
|
||||
val staged = File(thumbsDir(context), "$key.staged")
|
||||
staged.outputStream().use { scaled.compress(CompressFormat.JPEG, 82, it) }
|
||||
if (staged.renameTo(frame)) frame else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "no cover for ${file.name}", e)
|
||||
null
|
||||
} finally {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and
|
||||
* where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config.
|
||||
*/
|
||||
object BootMovieReport {
|
||||
private const val TAG = "BootMovieReport"
|
||||
private const val MAX_TRACE_LINES = 200
|
||||
private const val MAX_HITS = 60
|
||||
private val KNOWN_TITLES = listOf("Turret Factory")
|
||||
private val SKIPPED = setOf("steamapps", "compatdata", "shadercache", "logs")
|
||||
private val BINARY = setOf("webm", "jpg", "jpeg", "png", "ico", "mp4", "ogg", "mp3", "wav", "ttf", "woff", "woff2", "gif", "webp", "bin", "so", "dll", "exe", "zip", "pak")
|
||||
private val lock = Any()
|
||||
|
||||
private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log")
|
||||
|
||||
/** Adds a line to the short trace kept for the report; only the latest lines are kept. */
|
||||
fun note(context: Context, line: String) {
|
||||
synchronized(lock) {
|
||||
runCatching {
|
||||
val file = traceFile(context)
|
||||
file.parentFile?.mkdirs()
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
|
||||
val lines = (if (file.isFile) file.readLines() else emptyList()) + "$stamp $line"
|
||||
file.writeText(lines.takeLast(MAX_TRACE_LINES).joinToString("\n") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */
|
||||
fun write(context: Context): File? {
|
||||
val text = runCatching { build(context) }.getOrElse {
|
||||
Log.w(TAG, "could not build the report", it)
|
||||
return null
|
||||
}
|
||||
val folders = listOfNotNull(
|
||||
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck"),
|
||||
context.getExternalFilesDir(null),
|
||||
)
|
||||
for (folder in folders) {
|
||||
val file = File(folder, "boot-movie-report.txt")
|
||||
val saved = runCatching { folder.mkdirs(); file.writeText(text); true }.getOrDefault(false)
|
||||
if (saved) return file
|
||||
Log.w(TAG, "could not write the report to ${file.path}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun describe(file: File) =
|
||||
"${file.path} ${file.length()} bytes ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(file.lastModified()))}"
|
||||
|
||||
internal fun build(context: Context): String {
|
||||
val out = StringBuilder()
|
||||
fun line(text: String = "") { out.append(text).append('\n') }
|
||||
fun listing(title: String, folder: File) {
|
||||
line("-- $title: ${folder.path}")
|
||||
val files = folder.listFiles()?.sortedBy { it.name.lowercase() }
|
||||
if (files == null) line(" (missing)") else if (files.isEmpty()) line(" (empty)")
|
||||
else files.forEach { line(" " + describe(it)) }
|
||||
}
|
||||
|
||||
line("DroidDeck boot movie report, ${Date()}")
|
||||
line()
|
||||
line("== DroidDeck state")
|
||||
line("choice=${BootMovie.choice(context)} saved choice=${SessionPrefs.bootMovie(context)} pending pick=${SessionPrefs.bootMovieApplyNext(context)}")
|
||||
line("last Steam path seen=${SessionPrefs.bootMovieSteamSeen(context)}")
|
||||
line("sound=${SessionPrefs.bootMovieSound(context)} volume=${SessionPrefs.bootMovieVolume(context)} seconds=${SessionPrefs.bootMovieSeconds(context)}")
|
||||
line("session running=${SessionState.running}")
|
||||
line()
|
||||
line("== The movie list as shown")
|
||||
BootMovieLibrary.list(context).forEach { line("${it.id} title=${it.title} source=${it.source} removable=${it.removable} file=${it.file?.path}") }
|
||||
line()
|
||||
line("== DroidDeck folders")
|
||||
listing("library", BootMovieLibrary.libraryDir(context))
|
||||
listing("processed copies", File(context.filesDir, "boot-movie/variants"))
|
||||
val meta = File(context.filesDir, "boot-movie/movies.json")
|
||||
line("-- movie list file: ${if (meta.isFile) meta.readText().take(8000) else "(missing)"}")
|
||||
|
||||
val roots = BootMovieLibrary.steamRoots(context)
|
||||
val movieIds = HashSet<String>()
|
||||
for (root in roots) {
|
||||
line()
|
||||
line("== Steam folder ${root.path}")
|
||||
val config = File(root, "config/config.vdf")
|
||||
line("config.vdf: ${if (config.isFile) describe(config) else "(missing)"}")
|
||||
val text = runCatching { config.readText() }.getOrNull()
|
||||
if (text != null) {
|
||||
line("-- StartupMovie block:")
|
||||
(SessionFiles.startupMovieBlock(config) ?: " (none)").lines().forEach { line(" $it") }
|
||||
line("-- Customization section with the lines before it:")
|
||||
(SessionFiles.customizationBlock(config) ?: " (none)").lines().forEach { line(" $it") }
|
||||
line("-- every line of config.vdf that mentions a movie:")
|
||||
text.lines().filter { it.contains("movie", ignoreCase = true) }.take(40).forEach { line(" ${it.trim()}") }
|
||||
Regex("\"MovieID\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)?.takeIf { it.isNotEmpty() && it != "0" }?.let { movieIds.add(it) }
|
||||
}
|
||||
listing("custom movies", File(root, "config/uioverrides/movies"))
|
||||
listing("Steam's own movies", File(root, "steamui/movies"))
|
||||
listing("config/movies", File(root, "config/movies"))
|
||||
line("-- every .webm below config, userdata, appcache and steamui:")
|
||||
var shown = 0
|
||||
for (top in listOf("config", "userdata", "appcache", "steamui")) {
|
||||
val base = File(root, top)
|
||||
if (!base.isDirectory) continue
|
||||
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
|
||||
if (!file.isFile || !file.name.endsWith(".webm", ignoreCase = true)) continue
|
||||
if (shown++ >= 300) break
|
||||
line(" " + describe(file))
|
||||
Regex("^(\\d+)_[0-9a-fA-F]+").find(file.name)?.groupValues?.get(1)?.let { movieIds.add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
line()
|
||||
line("== Where Steam keeps movie names (text found in Steam's files)")
|
||||
val needles = (KNOWN_TITLES + movieIds).distinct()
|
||||
line("looking for: ${needles.joinToString(", ")}")
|
||||
search(roots, needles) { line(" $it") }
|
||||
|
||||
line()
|
||||
line("== What each session start did to Steam's config (latest last)")
|
||||
val trace = traceFile(context)
|
||||
if (trace.isFile) trace.readLines().forEach { line(" $it") } else line(" (nothing yet: start a session after picking a movie)")
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun search(roots: List<File>, needles: List<String>, report: (String) -> Unit) {
|
||||
if (needles.isEmpty()) return
|
||||
val deadline = System.currentTimeMillis() + 25_000L
|
||||
var scanned = 0
|
||||
var hits = 0
|
||||
outer@ for (root in roots) {
|
||||
for (top in listOf("config", "userdata", "appcache", "steamui")) {
|
||||
val base = File(root, top)
|
||||
if (!base.isDirectory) continue
|
||||
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
|
||||
if (!file.isFile || file.length() > (2L shl 20) || file.extension.lowercase() in BINARY) continue
|
||||
if (++scanned > 20_000 || System.currentTimeMillis() > deadline || hits >= MAX_HITS) break@outer
|
||||
val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: continue
|
||||
for (needle in needles) {
|
||||
val at = text.indexOf(needle)
|
||||
if (at < 0) continue
|
||||
hits++
|
||||
val around = text.substring((at - 60).coerceAtLeast(0), (at + needle.length + 100).coerceAtMost(text.length))
|
||||
report("$needle in ${file.path}: " + around.map { if (it in ' '..'~') it else '.' }.joinToString(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
report("(searched $scanned files, $hits matches)")
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,77 @@ internal object BootMovieTranscoder {
|
||||
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? {
|
||||
fun transcode(source: File, target: File, keepAudio: Boolean = true, limitUs: Long = Long.MAX_VALUE, gain: Float = 1f, onProgress: (Int) -> Unit): Result? {
|
||||
val hadAudio = keepAudio && trackFormat(source, "audio/") != null
|
||||
val audio = if (hadAudio) runCatching { encodeAudio(source) }
|
||||
val audio = if (hadAudio) runCatching { encodeAudio(source, gain, limitUs) }
|
||||
.onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null
|
||||
val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) }
|
||||
val muxedAudio = runCatching { encodeVideo(source, target, audio, limitUs, onProgress) }
|
||||
.onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null
|
||||
return Result(audioDropped = hadAudio && !muxedAudio)
|
||||
}
|
||||
|
||||
/** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */
|
||||
fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean {
|
||||
val audio = runCatching { encodeAudio(source, gain, limitUs) }
|
||||
.onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false
|
||||
val extractor = MediaExtractor()
|
||||
var muxer: MediaMuxer? = null
|
||||
val staged = File(target.parentFile, target.name + ".regain")
|
||||
return try {
|
||||
extractor.setDataSource(source.path)
|
||||
var videoTrack = -1
|
||||
var videoFormat: MediaFormat? = null
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (videoTrack < 0 && (mime == MediaFormat.MIMETYPE_VIDEO_VP8 || mime == MediaFormat.MIMETYPE_VIDEO_VP9)) {
|
||||
videoTrack = i
|
||||
videoFormat = format
|
||||
}
|
||||
}
|
||||
if (videoTrack < 0 || videoFormat == null) return false
|
||||
extractor.selectTrack(videoTrack)
|
||||
val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
|
||||
val outVideo = mux.addTrack(videoFormat)
|
||||
val outAudio = mux.addTrack(audio.format)
|
||||
mux.start()
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var audioIndex = 0
|
||||
fun writeAudioUpTo(timeUs: Long) {
|
||||
while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) {
|
||||
val sample = audio.samples[audioIndex++]
|
||||
info.set(0, sample.data.size, sample.timeUs, 0)
|
||||
mux.writeSampleData(outAudio, ByteBuffer.wrap(sample.data), info)
|
||||
}
|
||||
}
|
||||
var bufferSize = 8 shl 20
|
||||
if (videoFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
|
||||
val buffer = ByteBuffer.allocate(bufferSize)
|
||||
while (true) {
|
||||
val size = extractor.readSampleData(buffer, 0)
|
||||
if (size < 0 || extractor.sampleTime > limitUs) break
|
||||
val time = extractor.sampleTime
|
||||
writeAudioUpTo(time)
|
||||
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
|
||||
info.set(0, size, time, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0)
|
||||
mux.writeSampleData(outVideo, buffer, info)
|
||||
extractor.advance()
|
||||
}
|
||||
writeAudioUpTo(Long.MAX_VALUE)
|
||||
mux.stop()
|
||||
mux.release()
|
||||
muxer = null
|
||||
staged.renameTo(target)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not change the volume of ${source.name}", e)
|
||||
false
|
||||
} finally {
|
||||
runCatching { muxer?.release() }
|
||||
extractor.release()
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackFormat(source: File, prefix: String): Pair<Int, MediaFormat>? {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
@@ -75,6 +137,14 @@ internal object BootMovieTranscoder {
|
||||
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
|
||||
}
|
||||
|
||||
/** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */
|
||||
internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray {
|
||||
val frames = pcm.size / channels
|
||||
val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt()
|
||||
if (gain == 1f && kept == frames) return pcm
|
||||
return ShortArray(kept * channels) { (pcm[it] * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() }
|
||||
}
|
||||
|
||||
internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray {
|
||||
if (rate == OPUS_RATE) return pcm
|
||||
val frames = pcm.size / channels
|
||||
@@ -94,7 +164,10 @@ internal object BootMovieTranscoder {
|
||||
return out
|
||||
}
|
||||
|
||||
private fun encodeAudio(source: File): Audio? {
|
||||
/** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */
|
||||
private const val MAX_PCM_BYTES = 64 shl 20
|
||||
|
||||
private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? {
|
||||
val (track, inFormat) = trackFormat(source, "audio/") ?: return null
|
||||
val extractor = MediaExtractor()
|
||||
var decoder: MediaCodec? = null
|
||||
@@ -139,13 +212,16 @@ internal object BootMovieTranscoder {
|
||||
}
|
||||
dec.releaseOutputBuffer(index, false)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
|
||||
// Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped.
|
||||
if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break
|
||||
if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break }
|
||||
}
|
||||
}
|
||||
if (channels !in 1..2) return null
|
||||
val raw = pcm.toByteArray()
|
||||
val shorts = ShortArray(raw.size / 2)
|
||||
ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts)
|
||||
val samples = resample(shorts, channels, rate)
|
||||
val samples = scale(resample(shorts, channels, rate), channels, gain, limitUs)
|
||||
|
||||
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels)
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
|
||||
@@ -201,7 +277,7 @@ internal object BootMovieTranscoder {
|
||||
private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else
|
||||
runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() }
|
||||
|
||||
private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean {
|
||||
private fun encodeVideo(source: File, target: File, audio: Audio?, limitUs: Long, onProgress: (Int) -> Unit): Boolean {
|
||||
val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track")
|
||||
val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360)
|
||||
val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation)
|
||||
@@ -278,7 +354,8 @@ internal object BootMovieTranscoder {
|
||||
val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val timeUs = decInfo.presentationTimeUs
|
||||
val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
|
||||
val pastLimit = timeUs > limitUs
|
||||
val keep = decInfo.size > 0 && !pastLimit && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
|
||||
dec.releaseOutputBuffer(index, keep)
|
||||
if (keep) {
|
||||
if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out")
|
||||
@@ -290,9 +367,10 @@ internal object BootMovieTranscoder {
|
||||
if (percent != lastProgress) { lastProgress = percent; onProgress(percent) }
|
||||
}
|
||||
}
|
||||
if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
if (pastLimit || decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
enc.signalEndOfInputStream()
|
||||
decoderDone = true
|
||||
inputDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class PerfHud(context: Context) {
|
||||
val mode = FrameGen.mode(context)
|
||||
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
|
||||
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
|
||||
val name = if (lsfg) "LSFG" else "Win-FG"
|
||||
val name = if (lsfg) "LSFG" else "DD-FG"
|
||||
val label = FrameGen.label(context, mode)
|
||||
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
|
||||
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LegacyNames
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
@@ -15,6 +17,7 @@ object SessionFiles {
|
||||
private const val TAG = "SessionFiles"
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
|
||||
private const val STAGED_LEDGER = "usr/local/.droiddeck-staged"
|
||||
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
|
||||
|
||||
internal val DESKTOP_FILES = arrayOf(
|
||||
@@ -27,7 +30,8 @@ object SessionFiles {
|
||||
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
|
||||
)
|
||||
|
||||
fun stage(context: Context, root: File) {
|
||||
/** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */
|
||||
fun stage(context: Context, root: File, startsSession: Boolean = false) {
|
||||
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
|
||||
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
|
||||
GameEnvironmentStore.publish(context)
|
||||
@@ -66,6 +70,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
|
||||
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
|
||||
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
|
||||
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
|
||||
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
|
||||
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
|
||||
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
|
||||
@@ -133,13 +138,15 @@ object SessionFiles {
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
|
||||
) + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
|
||||
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
|
||||
val ledger = StageLedger(File(root, STAGED_LEDGER), apkIdentity(context))
|
||||
for ((asset, relative) in all) {
|
||||
val target = File(root, relative)
|
||||
if (ledger.current(relative, target)) continue
|
||||
val staged = File(target.parentFile, target.name + ".staged")
|
||||
var installed = false
|
||||
try {
|
||||
@@ -153,7 +160,7 @@ object SessionFiles {
|
||||
} finally {
|
||||
if (!installed) staged.delete()
|
||||
}
|
||||
if (!installed) Log.e(TAG, "$relative NOT staged")
|
||||
if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged")
|
||||
}
|
||||
File(root, "usr/local/bin/bannerlator-steam-compat").delete()
|
||||
LegacyNames.migrateRootfs(root)
|
||||
@@ -165,13 +172,17 @@ object SessionFiles {
|
||||
"i386-windows/winedirectaudio.drv",
|
||||
"version.txt",
|
||||
)
|
||||
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
|
||||
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
|
||||
val directAudioPack = AddonPack.DIRECTAUDIO.takeIf { it.installed(context) }?.dir(context)
|
||||
if (directAudioPack == null) Log.w(TAG, "DirectAudio pack is not installed; drivers not staged")
|
||||
if (directAudioPack != null) for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
|
||||
val key = "$DIRECTAUDIO_DIR/$set/lib/wine/$relative"
|
||||
val target = File(root, key)
|
||||
if (ledger.current(key, target)) continue
|
||||
val staged = File(target.parentFile, target.name + ".staged")
|
||||
var installed = false
|
||||
try {
|
||||
target.parentFile?.mkdirs()
|
||||
context.assets.open("directaudio/$set/$relative").use { input ->
|
||||
File(directAudioPack, "$set/$relative").inputStream().use { input ->
|
||||
staged.outputStream().use { output -> FileUtils.copy(input, output) }
|
||||
}
|
||||
installed = staged.setReadable(true, false) && staged.renameTo(target)
|
||||
@@ -180,8 +191,9 @@ object SessionFiles {
|
||||
} finally {
|
||||
if (!installed) staged.delete()
|
||||
}
|
||||
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
|
||||
if (installed) ledger.staged(key, target) else Log.e(TAG, "DirectAudio $set/$relative NOT staged")
|
||||
}
|
||||
ledger.save()
|
||||
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
|
||||
val preload = StringBuilder("/usr/local/lib/libblsession.so\n")
|
||||
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
|
||||
@@ -198,20 +210,118 @@ object SessionFiles {
|
||||
if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) {
|
||||
Log.e(TAG, "could not write steam-compatibility-label")
|
||||
}
|
||||
val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() }
|
||||
val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged")
|
||||
if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n")
|
||||
|| !stagedSocket.renameTo(File(droiddeckEtc, "bwrap-socket"))) {
|
||||
stagedSocket.delete()
|
||||
Log.e(TAG, "could not write bwrap-socket")
|
||||
}
|
||||
|
||||
BootMovie.syncFromSteam(context)
|
||||
val startupMovie = BootMovie.prepare(context)
|
||||
// The x86-64 client's config is left alone until its first start creates it.
|
||||
val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam"))
|
||||
.filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory }
|
||||
for (steam in steamRoots) {
|
||||
val startupMovieDir = File(steam, "config/uioverrides/movies")
|
||||
val bigPictureMovieInstalled = startupMovie != null &&
|
||||
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
|
||||
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
|
||||
if (bigPictureMovieInstalled) {
|
||||
ensureStartupMovieDefault(File(steam, "config/config.vdf"))
|
||||
val force = SessionPrefs.bootMovieApplyNext(context)
|
||||
val steamHomes = listOf(File(root, "root/.local/share/Steam")) +
|
||||
listOf(File(root, "root/.droiddeck-x64/.local/share/Steam")).filter { File(it, "config").isDirectory }
|
||||
var allDone = true
|
||||
var allInstalled = true
|
||||
var seen: String? = null
|
||||
val activeIsX64 = SessionPrefs.activeSteamClientArch(context) == "x86_64"
|
||||
for (home in steamHomes) {
|
||||
val movieDir = File(home, "config/uioverrides/movies")
|
||||
val config = File(home, "config/config.vdf")
|
||||
if (startupMovie == null) {
|
||||
// Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either.
|
||||
for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) {
|
||||
File(movieDir, name).delete()
|
||||
File(movieDir, ".$name.source").delete()
|
||||
}
|
||||
}
|
||||
val bigPictureMovieInstalled = startupMovie != null && stageStartupMovie(startupMovie, movieDir, "bigpicture_startup.webm")
|
||||
if (startupMovie != null) stageStartupMovie(startupMovie, movieDir, "steam_os_startup.webm")
|
||||
val before = startupMoviePath(config)
|
||||
val done = if (bigPictureMovieInstalled) ensureStartupMovieDefault(config, force) else resetMissingStartupMovie(config, force)
|
||||
BootMovieReport.note(
|
||||
context,
|
||||
"stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " +
|
||||
"staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}",
|
||||
)
|
||||
if (!done) allDone = false
|
||||
if (!bigPictureMovieInstalled) allInstalled = false
|
||||
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
|
||||
}
|
||||
// A pick made in the app is done once a session started with every Steam client's config having it; guest commands run
|
||||
// before that, and a Steam that is already running writes its own choice back when it exits.
|
||||
if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false)
|
||||
seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that
|
||||
* could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config
|
||||
* no longer names a missing movie.
|
||||
*/
|
||||
internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean {
|
||||
if (!config.isFile) return true
|
||||
val text = runCatching { config.readText() }.getOrElse {
|
||||
Log.w(TAG, "could not read Steam config to reset the startup movie", it)
|
||||
return false
|
||||
}
|
||||
val movie = activeStartupMovie(text) ?: return true
|
||||
val block = text.substring(movie.keyStart, movie.close + 1)
|
||||
val path = scalar(block, "LocalPath").orEmpty()
|
||||
if (path.isEmpty()) return true
|
||||
val missing = File(config.parentFile, "uioverrides/movies/" + path.removePrefix(BOOT_MOVIES_DIR))
|
||||
val gone = path.startsWith(BOOT_MOVIES_DIR) && !missing.isFile && (force || path == BOOT_MOVIE_PATH)
|
||||
if (!gone) return false
|
||||
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
|
||||
val updated = setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", "", newline)
|
||||
val staged = File(config.parentFile, config.name + ".staged")
|
||||
return try {
|
||||
staged.writeText(text.replaceRange(movie.keyStart, movie.close + 1, updated))
|
||||
(staged.renameTo(config)).also { if (!it) staged.delete() }
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not reset Steam startup movie selection", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun apkIdentity(context: Context): String {
|
||||
val apk = File(context.applicationInfo.sourceDir)
|
||||
return "${apk.path} ${apk.length()} ${apk.lastModified()}"
|
||||
}
|
||||
|
||||
private class StageLedger(private val file: File, private val identity: String) {
|
||||
private val before: Map<String, String> = FileUtils.readString(file)?.lines()
|
||||
?.takeIf { it.firstOrNull() == identity }
|
||||
?.drop(1)?.mapNotNull { line -> line.split('\t').takeIf { it.size == 2 }?.let { it[0] to it[1] } }?.toMap()
|
||||
.orEmpty()
|
||||
private val after = LinkedHashMap<String, String>()
|
||||
|
||||
fun current(relative: String, target: File): Boolean {
|
||||
val mark = mark(target) ?: return false
|
||||
if (before[relative] != mark) return false
|
||||
after[relative] = mark
|
||||
return true
|
||||
}
|
||||
|
||||
fun staged(relative: String, target: File) {
|
||||
mark(target)?.let { after[relative] = it }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
if (after == before) return
|
||||
val text = (listOf(identity) + after.map { (relative, mark) -> "$relative\t$mark" }).joinToString("\n")
|
||||
val staged = File(file.parentFile, file.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text) || !staged.renameTo(file)) {
|
||||
staged.delete()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mark(target: File): String? =
|
||||
if (target.isFile) "${target.length()} ${target.lastModified()} ${target.canExecute()}" else null
|
||||
}
|
||||
|
||||
private fun stageStartupMovie(source: File, directory: File, name: String): Boolean {
|
||||
@@ -227,7 +337,11 @@ object SessionFiles {
|
||||
staged.outputStream().use { output -> FileUtils.copy(input, output) }
|
||||
}
|
||||
installed = staged.setReadable(true, false) && staged.renameTo(movie)
|
||||
if (installed) FileUtils.writeString(stamp, identity)
|
||||
if (installed) {
|
||||
FileUtils.writeString(stamp, identity)
|
||||
// Steam's picker draws its preview from this picture and does not redraw it when the movie changes.
|
||||
File(directory, name.removeSuffix(".webm") + ".jpg").delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not stage Steam startup movie $name", e)
|
||||
} finally {
|
||||
@@ -239,43 +353,108 @@ object SessionFiles {
|
||||
|
||||
private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
|
||||
|
||||
private fun ensureStartupMovieDefault(config: File) {
|
||||
val path = "/uioverrides/movies/bigpicture_startup.webm"
|
||||
internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/"
|
||||
internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm"
|
||||
|
||||
/**
|
||||
* Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam
|
||||
* settings is where older clients kept it, and DroidDeck still keeps it in step.
|
||||
*/
|
||||
private fun customizationMovie(text: String): VdfBlock? {
|
||||
var from = 0
|
||||
while (true) {
|
||||
val customization = findObject(text, "Customization", from, text.length) ?: return null
|
||||
findObject(text, "StartupMovie", customization.open + 1, customization.close)?.let { return it }
|
||||
from = customization.close + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** The older `StartupMovie` section: the first one that is not inside `Customization`. */
|
||||
private fun olderMovie(text: String): VdfBlock? {
|
||||
val inside = customizationMovie(text)
|
||||
var from = 0
|
||||
while (true) {
|
||||
val movie = findObject(text, "StartupMovie", from, text.length) ?: return null
|
||||
if (inside == null || movie.keyStart != inside.keyStart) return movie
|
||||
from = movie.close + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** The section Steam reads: the one under `Customization` when there is one. */
|
||||
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
|
||||
|
||||
/** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
|
||||
internal fun startupMoviePath(config: File): String? {
|
||||
val text = runCatching { config.readText() }.getOrNull() ?: return null
|
||||
val movie = activeStartupMovie(text) ?: return null
|
||||
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
|
||||
internal fun startupMovieBlock(config: File): String? {
|
||||
val text = runCatching { config.readText() }.getOrNull() ?: return null
|
||||
val parts = listOfNotNull(
|
||||
customizationMovie(text)?.let { "under Customization:\n" + text.substring(it.keyStart, it.close + 1) },
|
||||
olderMovie(text)?.let { "older place:\n" + text.substring(it.keyStart, it.close + 1) },
|
||||
)
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
||||
}
|
||||
|
||||
/** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */
|
||||
internal fun customizationBlock(config: File): String? {
|
||||
val text = runCatching { config.readText() }.getOrNull() ?: return null
|
||||
val customization = findObject(text, "Customization", 0, text.length) ?: return null
|
||||
val before = text.substring(0, customization.keyStart).lines().takeLast(4).joinToString("\n")
|
||||
return (before + "\n" + text.substring(customization.keyStart, customization.close + 1)).take(4000)
|
||||
}
|
||||
|
||||
private fun pointAt(text: String, movie: VdfBlock, path: String, newline: String): String {
|
||||
val block = text.substring(movie.keyStart, movie.close + 1)
|
||||
return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline))
|
||||
}
|
||||
|
||||
/** True when Steam's config selects DroidDeck's staged movie once this returns. */
|
||||
internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean {
|
||||
val path = BOOT_MOVIE_PATH
|
||||
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
|
||||
var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
|
||||
Log.w(TAG, "could not read Steam config for startup movie", it)
|
||||
return
|
||||
return false
|
||||
} else ""
|
||||
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
|
||||
val movie = findObject(text, "StartupMovie", 0, text.length)
|
||||
if (movie != null) {
|
||||
val block = text.substring(movie.keyStart, movie.close + 1)
|
||||
val custom = customizationMovie(text)
|
||||
val active = custom ?: olderMovie(text)
|
||||
if (active != null) {
|
||||
val block = text.substring(active.keyStart, active.close + 1)
|
||||
val selectedId = scalar(block, "MovieID")
|
||||
val selectedPath = scalar(block, "LocalPath")
|
||||
if (selectedPath == path) {
|
||||
if (selectedPath == path && custom != null) {
|
||||
Log.i(TAG, "Steam startup movie is set to DroidDeck")
|
||||
return
|
||||
return true
|
||||
}
|
||||
val legacyDefault = selectedId == "0" && selectedPath == legacyPath
|
||||
val hasExplicitSelection =
|
||||
(!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()
|
||||
if (!legacyDefault && hasExplicitSelection) {
|
||||
if (!force && !legacyDefault && hasExplicitSelection && selectedPath != path) {
|
||||
Log.i(TAG, "Steam startup movie selection preserved")
|
||||
return
|
||||
return false
|
||||
}
|
||||
val updated = setScalar(block, "MovieID", "0", newline)
|
||||
.let { setScalar(it, "LocalPath", path, newline) }
|
||||
text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
|
||||
} else {
|
||||
}
|
||||
// Later sections first, so the positions of earlier ones stay valid.
|
||||
val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart }
|
||||
for (target in targets) text = pointAt(text, target, path, newline)
|
||||
if (olderMovie(text) == null) {
|
||||
val steam = findSteamBlock(text)
|
||||
if (steam == null) {
|
||||
if (text.isNotBlank()) {
|
||||
if (text.isNotBlank() && customizationMovie(text) == null) {
|
||||
Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (text.isBlank()) {
|
||||
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
|
||||
val section = startupMovieSection("\t\t\t\t", path, newline)
|
||||
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
|
||||
}
|
||||
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
|
||||
val section = startupMovieSection("\t\t\t\t", path, newline)
|
||||
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
|
||||
} else {
|
||||
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
|
||||
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
|
||||
@@ -283,22 +462,35 @@ object SessionFiles {
|
||||
text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
|
||||
}
|
||||
}
|
||||
if (customizationMovie(text) == null) text = withCustomization(text, path, newline)
|
||||
val staged = File(config.parentFile, config.name + ".staged")
|
||||
try {
|
||||
return try {
|
||||
config.parentFile?.mkdirs()
|
||||
staged.writeText(text)
|
||||
if (!staged.renameTo(config)) {
|
||||
staged.delete()
|
||||
Log.e(TAG, "could not write Steam startup movie selection")
|
||||
false
|
||||
} else {
|
||||
Log.i(TAG, "Steam startup movie default selected")
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not write Steam startup movie selection", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */
|
||||
private fun withCustomization(text: String, path: String, newline: String): String {
|
||||
val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text
|
||||
val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 }
|
||||
val indent = text.substring(lineStart, root.close).takeWhile { it == '\t' || it == ' ' }
|
||||
val section = listOf(indent + "\t\"Customization\"", indent + "\t{", startupMovieSection(indent + "\t\t", path, newline), indent + "\t}").joinToString(newline)
|
||||
return text.substring(0, lineStart) + section + newline + text.substring(lineStart)
|
||||
}
|
||||
|
||||
private fun findSteamBlock(text: String): VdfBlock? {
|
||||
val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
|
||||
val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
|
||||
|
||||
@@ -118,8 +118,22 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
|
||||
}
|
||||
|
||||
fun storesTabOrder(context: Context): List<String> = idList(prefs(context).getString("storesTabOrder", null))
|
||||
|
||||
fun setStoresTabOrder(context: Context, order: List<String>) {
|
||||
prefs(context).edit().putString("storesTabOrder", order.joinToString(",")).apply()
|
||||
}
|
||||
|
||||
fun storesTabHidden(context: Context): Set<String> = idList(prefs(context).getString("storesTabHidden", null)).toSet()
|
||||
|
||||
fun setStoresTabHidden(context: Context, hidden: Set<String>) {
|
||||
prefs(context).edit().putString("storesTabHidden", hidden.joinToString(",")).apply()
|
||||
}
|
||||
|
||||
private fun idList(raw: String?): List<String> = raw.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
|
||||
val STORES_VIEWS = listOf("cards", "list")
|
||||
val STORES_VIEWS = listOf("cards", "capsules", "list")
|
||||
|
||||
const val STORES_OPEN_LIBRARY = "library"
|
||||
const val STORES_OPEN_STORE = "store"
|
||||
@@ -168,6 +182,7 @@ object SessionPrefs {
|
||||
const val TOUCH_AUTO = "auto"
|
||||
const val TOUCH_PAD = "touchpad"
|
||||
const val TOUCH_DIRECT = "direct"
|
||||
const val TOUCH_GESTURES = "gestures"
|
||||
const val TOUCH_OFF = "off"
|
||||
|
||||
fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO
|
||||
@@ -527,6 +542,17 @@ object SessionPrefs {
|
||||
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
|
||||
fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true)
|
||||
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
|
||||
fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100)
|
||||
fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() }
|
||||
/** How long the boot movie plays, in seconds; 0 plays all of it. */
|
||||
fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0)
|
||||
fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() }
|
||||
/** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */
|
||||
fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false)
|
||||
fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() }
|
||||
/** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */
|
||||
fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null)
|
||||
fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() }
|
||||
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
|
||||
fun setBootMovieCustomName(context: Context, name: String?) {
|
||||
prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply()
|
||||
@@ -878,6 +904,11 @@ object SessionPrefs {
|
||||
|
||||
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
|
||||
|
||||
fun displayMode(context: Context, mode: String, program: String?): String =
|
||||
if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) &&
|
||||
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
|
||||
else prefMode(mode)
|
||||
|
||||
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
|
||||
|
||||
fun setPipAutoEnter(context: Context, enabled: Boolean) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
|
||||
@@ -80,6 +81,7 @@ class SessionService : Service() {
|
||||
private var nativeSleepToken: String? = null
|
||||
private var nativeSleepReady = false
|
||||
private var nativeSleepFallback = false
|
||||
private var nativeSleepIdle = false
|
||||
private var nativeSleepTimeout: Runnable? = null
|
||||
private var suspendOperationPending = false
|
||||
private var pipTask = false
|
||||
@@ -293,14 +295,13 @@ class SessionService : Service() {
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun tuDebug(linuxDriverId: String): String? {
|
||||
val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
|
||||
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
|
||||
if (!override.isNullOrEmpty()) return override
|
||||
if (SessionPrefs.tuSysmem(this)) return "sysmem"
|
||||
if (linuxDriverId.isEmpty()) return null
|
||||
private fun tuDebugOverride(): String? = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
|
||||
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun sysmemDriver(linuxDriverId: String): Boolean {
|
||||
if (linuxDriverId.isEmpty()) return false
|
||||
val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase()
|
||||
return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null
|
||||
return name.contains("710-720") || name.contains("710_720")
|
||||
}
|
||||
|
||||
|
||||
@@ -319,7 +320,9 @@ class SessionService : Service() {
|
||||
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
|
||||
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
|
||||
killStragglers()
|
||||
SessionFiles.stage(this, root)
|
||||
for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO))
|
||||
if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable")
|
||||
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)
|
||||
@@ -575,7 +578,9 @@ class SessionService : Service() {
|
||||
)
|
||||
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
|
||||
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
|
||||
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
|
||||
val tuOverride = tuDebugOverride()
|
||||
val tuDriver = sysmemDriver(linuxDriverId)
|
||||
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
|
||||
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
|
||||
if (SessionPrefs.zinkLazy(this)) {
|
||||
guest.add("ZINK_DESCRIPTORS=lazy")
|
||||
@@ -603,8 +608,9 @@ class SessionService : Service() {
|
||||
if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) &&
|
||||
com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
|
||||
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
|
||||
extraEnv().forEach { guest.add(it) }
|
||||
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
|
||||
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
|
||||
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
|
||||
extra.forEach { guest.add(it) }
|
||||
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
|
||||
if (clientPinned) {
|
||||
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
|
||||
@@ -1019,7 +1025,7 @@ class SessionService : Service() {
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
suspendAttemptFailed = false
|
||||
@@ -1219,16 +1225,22 @@ class SessionService : Service() {
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
|
||||
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
|
||||
else -> false
|
||||
}
|
||||
if (!steamPause) {
|
||||
completeNativeSleep()
|
||||
} else if (nativeSleepToken == null && steamSleepToken == null &&
|
||||
!SessionState.suspended && !suspendAttemptFailed) {
|
||||
prepareNativeSleep()
|
||||
}
|
||||
val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady
|
||||
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
|
||||
else -> false
|
||||
}
|
||||
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
|
||||
@@ -1296,7 +1308,7 @@ class SessionService : Service() {
|
||||
|
||||
private fun resumeSteamSleepOnReturn() {
|
||||
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
}
|
||||
@@ -1307,13 +1319,14 @@ class SessionService : Service() {
|
||||
nativeSleepToken = token
|
||||
nativeSleepReady = false
|
||||
nativeSleepFallback = false
|
||||
nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE
|
||||
val dir = LinuxRuntime.sessionRoot(this)
|
||||
runCatching {
|
||||
val staged = File(dir, "steam-native-sleep.tmp")
|
||||
staged.writeText("$token\n")
|
||||
staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n")
|
||||
check(staged.renameTo(File(dir, "steam-native-sleep")))
|
||||
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
|
||||
SessionEvents.record("steam.native_sleep_requested")
|
||||
SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle))
|
||||
val gen = sessionGen
|
||||
val timeout = Runnable {
|
||||
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
|
||||
@@ -1339,6 +1352,7 @@ class SessionService : Service() {
|
||||
nativeSleepToken = null
|
||||
nativeSleepReady = false
|
||||
nativeSleepFallback = false
|
||||
nativeSleepIdle = false
|
||||
}
|
||||
|
||||
private fun requestSteamWake() {
|
||||
@@ -1665,6 +1679,7 @@ class SessionService : Service() {
|
||||
private const val TAG = "SessionService"
|
||||
private const val TRACER_NICE = -6
|
||||
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
|
||||
private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
|
||||
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
|
||||
private const val ENV_SWITCH_NAME = "droiddeck-env"
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ object SettingsReset {
|
||||
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
|
||||
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
|
||||
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound",
|
||||
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
|
||||
"bootMovieApplyNext", "bootMovieSteamSeen",
|
||||
"questDefaultsV1",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */
|
||||
object SteamDeckRepo {
|
||||
private const val TAG = "SteamDeckRepo"
|
||||
const val BASE = "https://steamdeckrepo.com"
|
||||
|
||||
/** The site shows twelve movies a page. */
|
||||
const val PAGE_SIZE = 12
|
||||
const val MAX_DOWNLOAD_BYTES = 120L shl 20
|
||||
private const val MAX_JSON_BYTES = 1 shl 20
|
||||
private const val USER_AGENT = "DroidDeck (+https://github.com/Droid-Deck/DroidDeck)"
|
||||
private const val CACHE_MS = 10 * 60 * 1000L
|
||||
|
||||
enum class Sort(val key: String) {
|
||||
TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"),
|
||||
}
|
||||
|
||||
/** `null` is every device. */
|
||||
val DEVICES = listOf("steam_deck", "steam_machine")
|
||||
|
||||
class Post(
|
||||
val id: String, val title: String, val content: String, val author: String, val thumbnail: String, val video: String,
|
||||
val seconds: Int, val likes: Int, val downloads: Int, val devices: List<String>, val url: String,
|
||||
)
|
||||
|
||||
sealed class Page {
|
||||
class Ok(val posts: List<Post>) : Page()
|
||||
object Failed : Page()
|
||||
}
|
||||
|
||||
/** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */
|
||||
private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
|
||||
|
||||
fun parse(json: String): List<Post>? {
|
||||
val array = runCatching { JSONObject(json).optJSONArray("posts") }.getOrNull() ?: return null
|
||||
val out = ArrayList<Post>()
|
||||
for (i in 0 until array.length()) {
|
||||
val o = array.optJSONObject(i) ?: continue
|
||||
val id = o.text("id")
|
||||
val video = o.text("video")
|
||||
if (id.isEmpty() || !video.startsWith("https://")) continue
|
||||
val devices = o.optJSONArray("devices")?.let { d -> (0 until d.length()).map { d.optString(it) } }.orEmpty()
|
||||
out.add(
|
||||
Post(
|
||||
id = id, title = o.text("title").ifBlank { id }, content = o.text("content"),
|
||||
author = o.optJSONObject("user")?.text("steam_name").orEmpty(),
|
||||
thumbnail = o.text("thumbnail").takeIf { it.startsWith("https://") }.orEmpty(), video = video,
|
||||
seconds = o.optInt("video_duration"), likes = o.optInt("likes"), downloads = o.optInt("downloads"),
|
||||
devices = devices, url = o.text("url"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun url(sort: Sort, device: String?, search: String, page: Int = 1): String {
|
||||
val query = StringBuilder("sort=").append(sort.key)
|
||||
if (device != null) query.append("&device=").append(URLEncoder.encode(device, "UTF-8"))
|
||||
val term = search.trim()
|
||||
if (term.isNotEmpty()) query.append("&search=").append(URLEncoder.encode(term, "UTF-8").replace("+", "%20"))
|
||||
if (page > 1) query.append("&page=").append(page)
|
||||
return "$BASE/api/posts?$query"
|
||||
}
|
||||
|
||||
private class Cached(val at: Long, val page: Page.Ok)
|
||||
private val cache = HashMap<String, Cached>()
|
||||
|
||||
fun fetch(sort: Sort, device: String?, search: String, page: Int = 1, now: Long = System.currentTimeMillis()): Page {
|
||||
val url = url(sort, device, search, page)
|
||||
synchronized(cache) { cache[url]?.takeIf { now - it.at < CACHE_MS }?.let { return it.page } }
|
||||
val body = get(url) ?: return Page.Failed
|
||||
val posts = parse(body) ?: return Page.Failed
|
||||
val result = Page.Ok(posts.take(PAGE_SIZE))
|
||||
synchronized(cache) { cache[url] = Cached(now, result) }
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a
|
||||
* page past the end is empty and the last one is short. Null when the site cannot be reached.
|
||||
*/
|
||||
fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p ->
|
||||
(fetch(sort, device, search, p) as? Page.Ok)?.posts?.size
|
||||
}): Int? {
|
||||
val first = count(1) ?: return null
|
||||
if (first < PAGE_SIZE) return 1
|
||||
var good = 1
|
||||
var bad = -1
|
||||
var probe = 2
|
||||
while (probe <= cap) {
|
||||
val n = count(probe) ?: return null
|
||||
if (n == 0) { bad = probe; break }
|
||||
if (n < PAGE_SIZE) return probe
|
||||
good = probe
|
||||
probe *= 2
|
||||
}
|
||||
if (bad < 0) return good
|
||||
while (bad - good > 1) {
|
||||
val mid = (good + bad) / 2
|
||||
val n = count(mid) ?: return null
|
||||
if (n == 0) bad = mid else if (n < PAGE_SIZE) return mid else good = mid
|
||||
}
|
||||
return good
|
||||
}
|
||||
|
||||
/** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */
|
||||
internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? {
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
if (out.size() + n > limit) return null
|
||||
out.write(buffer, 0, n)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun get(url: String): String? {
|
||||
var connection: HttpURLConnection? = null
|
||||
return try {
|
||||
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 20_000
|
||||
setRequestProperty("User-Agent", USER_AGENT)
|
||||
setRequestProperty("Accept", "application/json")
|
||||
}
|
||||
if (connection.responseCode !in 200..299) null else connection.inputStream.use { readLimited(it, MAX_JSON_BYTES)?.toString(Charsets.UTF_8) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "request failed: ${e.javaClass.simpleName}")
|
||||
null
|
||||
} finally {
|
||||
runCatching { connection?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches a small picture such as a cover; null when it cannot be had. */
|
||||
fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? {
|
||||
if (!url.startsWith("https://")) return null
|
||||
var connection: HttpURLConnection? = null
|
||||
return try {
|
||||
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 20_000
|
||||
setRequestProperty("User-Agent", USER_AGENT)
|
||||
}
|
||||
if (connection.responseCode !in 200..299 || connection.contentLengthLong > limit) null
|
||||
else connection.inputStream.use { input -> readLimited(input, limit)?.takeIf { it.isNotEmpty() } }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} finally {
|
||||
runCatching { connection?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a movie to [target] through the site's own download address, so the site counts it like a download from
|
||||
* its website, and falls back to the video's address. True when a file was saved.
|
||||
*/
|
||||
fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean {
|
||||
for (url in listOf("$BASE/post/download/${post.id}", post.video)) {
|
||||
if (save(url, target, onProgress)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun save(url: String, target: File, onProgress: (Int) -> Unit): Boolean {
|
||||
var connection: HttpURLConnection? = null
|
||||
val staged = File(target.parentFile, target.name + ".part")
|
||||
return try {
|
||||
target.parentFile?.mkdirs()
|
||||
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", USER_AGENT)
|
||||
}
|
||||
if (connection.responseCode !in 200..299) return false
|
||||
val type = connection.contentType.orEmpty().lowercase()
|
||||
if (type.startsWith("text/") || type.contains("json")) return false
|
||||
val total = connection.contentLengthLong
|
||||
if (total > MAX_DOWNLOAD_BYTES) return false
|
||||
var done = 0L
|
||||
var lastPercent = -1
|
||||
connection.inputStream.use { input ->
|
||||
staged.outputStream().use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
done += n
|
||||
if (done > MAX_DOWNLOAD_BYTES) return false
|
||||
output.write(buffer, 0, n)
|
||||
if (total > 0) {
|
||||
val percent = (done * 100 / total).toInt()
|
||||
if (percent != lastPercent) { lastPercent = percent; onProgress(percent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
done > 0 && (total <= 0 || done == total) && staged.renameTo(target)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "download failed: ${e.javaClass.simpleName}")
|
||||
false
|
||||
} finally {
|
||||
runCatching { connection?.disconnect() }
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONObject
|
||||
@@ -57,7 +58,7 @@ object CloudSaves {
|
||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
||||
}
|
||||
Store.AMAZON -> ""
|
||||
Store.AMAZON, Store.ITCH, Store.XBOX -> ""
|
||||
} ?: return null
|
||||
p.edit().putString("cloud_template_$id", fetched).apply()
|
||||
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
|
||||
@@ -95,7 +96,7 @@ object CloudSaves {
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH, Store.XBOX -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
@@ -120,7 +121,7 @@ object CloudSaves {
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH, Store.XBOX -> null
|
||||
}
|
||||
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
@@ -150,19 +151,22 @@ object CloudSaves {
|
||||
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
|
||||
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
|
||||
|
||||
fun downloadOrDefer(context: Context, store: Store, id: String): Result {
|
||||
fun downloadOrDefer(context: Context, store: Store, id: String): Result =
|
||||
downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() })
|
||||
|
||||
internal fun downloadOrDefer(context: Context, store: Store, id: String, executor: Executor): Result {
|
||||
val app = context.applicationContext
|
||||
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
|
||||
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
|
||||
Thread({
|
||||
executor.execute {
|
||||
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
|
||||
while (System.currentTimeMillis() < end) {
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@execute }
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
|
||||
}, "cloud-deferred").start()
|
||||
}
|
||||
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ internal object LoginFlows {
|
||||
Store.GOG -> GogFlow(state)
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
Store.ITCH, Store.XBOX -> null
|
||||
}
|
||||
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.stores
|
||||
enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
GOG("gog", "GOG", "GOG"),
|
||||
EPIC("epic", "Epic Games", "Epic"),
|
||||
AMAZON("amazon", "Amazon Games", "Amazon");
|
||||
AMAZON("amazon", "Amazon Games", "Amazon"),
|
||||
XBOX("xbox", "Xbox", "Xbox"),
|
||||
ITCH("itch", "itch.io", "itch");
|
||||
|
||||
companion object {
|
||||
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
|
||||
|
||||
@@ -67,7 +67,10 @@ object StoreAccounts {
|
||||
fun write(context: Context, store: Store, json: JSONObject) {
|
||||
unavailable.remove(store)
|
||||
val f = credentialsFile(context, store)
|
||||
if (seal(f, json) == null) put(f, json.toString())
|
||||
if (seal(f, json, retry = store == Store.XBOX) == null) {
|
||||
check(store != Store.XBOX) { "Android Keystore is unavailable for Xbox credentials" }
|
||||
put(f, json.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -86,18 +89,19 @@ object StoreAccounts {
|
||||
|
||||
fun signedInAs(context: Context, store: Store): String? {
|
||||
val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null
|
||||
if (store == Store.XBOX) return json.optString("username").takeIf { it.isNotBlank() }
|
||||
if (json.optString("access_token", "").isEmpty()) return null
|
||||
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
|
||||
}
|
||||
|
||||
private fun seal(f: File, json: JSONObject): File? {
|
||||
if (keystoreFailed) return null
|
||||
private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? {
|
||||
if (keystoreFailed && !retry) return null
|
||||
val text = json.toString()
|
||||
val envelope = try {
|
||||
CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } }
|
||||
} catch (e: Exception) {
|
||||
keystoreFailed = true
|
||||
Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})")
|
||||
Log.w(TAG, "stores: keystore unavailable (${e.javaClass.simpleName})")
|
||||
return null
|
||||
}
|
||||
put(f, envelope.toString())
|
||||
|
||||
@@ -21,7 +21,9 @@ data class CatalogItem(
|
||||
val sizeBytes: Long = 0L,
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
val mature: Boolean = false,
|
||||
val subscription: Boolean = false,
|
||||
) {
|
||||
val accessible: Boolean get() = owned || subscription
|
||||
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
|
||||
val key: String get() = "${store.id}:$id"
|
||||
}
|
||||
@@ -49,7 +51,9 @@ fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: L
|
||||
return installed.filter { it.sidecar.store == store }.map { game ->
|
||||
val s = game.sidecar
|
||||
byId[s.id] ?: byTitle[s.title.lowercase()]
|
||||
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true)
|
||||
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero,
|
||||
owned = s.store != Store.XBOX || s.extra["access"] == "owned",
|
||||
subscription = s.store == Store.XBOX && s.extra["access"] == "gamepass")
|
||||
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.File
|
||||
|
||||
@@ -63,6 +64,14 @@ object StoreLaunch {
|
||||
private const val CODE_VAR = "DD_X"
|
||||
private const val CLIENT_VAR = "DD_A"
|
||||
|
||||
fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? {
|
||||
val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder ->
|
||||
StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true
|
||||
} ?: return null
|
||||
val game = AddedGames.single(context, folder.path) ?: return null
|
||||
return Library.addedGame(context, game)
|
||||
}
|
||||
|
||||
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
|
||||
if (sidecar.launcher != LAUNCHER) return
|
||||
val file = File(folder, LAUNCHER)
|
||||
|
||||
@@ -37,6 +37,7 @@ object StoreLaunchRequests {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
@@ -50,6 +51,13 @@ object StoreLaunchRequests {
|
||||
return
|
||||
}
|
||||
val reply = when (request.optString("op")) {
|
||||
"xbox-released" -> {
|
||||
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease"))
|
||||
JSONObject().put("ok", true)
|
||||
}
|
||||
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
|
||||
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
|
||||
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
|
||||
"epic-code" -> {
|
||||
|
||||
@@ -31,7 +31,7 @@ object StoreSizes {
|
||||
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
|
||||
|
||||
fun request(context: Context, item: CatalogItem) {
|
||||
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
|
||||
if (!item.accessible || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
|
||||
resolve(context, item).thenAccept { bytes ->
|
||||
if (bytes != null) StoresState.post { known[item.key] = bytes }
|
||||
}
|
||||
@@ -39,7 +39,7 @@ object StoreSizes {
|
||||
|
||||
fun resolve(context: Context, item: CatalogItem): CompletableFuture<Long?> {
|
||||
val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 }
|
||||
if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
|
||||
if (!item.accessible || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
|
||||
if (available != null) return CompletableFuture.completedFuture(available)
|
||||
val app = context.applicationContext
|
||||
val version = item.extra["version"].orEmpty()
|
||||
@@ -54,7 +54,8 @@ object StoreSizes {
|
||||
when (item.store) {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.GOG -> {}
|
||||
Store.ITCH -> {}
|
||||
Store.GOG, Store.XBOX -> {}
|
||||
}
|
||||
}
|
||||
}, pool).exceptionally { error ->
|
||||
@@ -107,6 +108,7 @@ object StoreSizes {
|
||||
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
|
||||
AmazonManifest.parse(bytes).totalInstallSize
|
||||
}
|
||||
Store.GOG -> 0L
|
||||
Store.GOG, Store.XBOX -> 0L
|
||||
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
data class StoreTab(val store: Store, val shown: Boolean)
|
||||
|
||||
fun arrangeStoreTabs(order: List<String>, hidden: Set<String>): List<StoreTab> {
|
||||
val known = Store.entries.associateBy { it.id }
|
||||
val listed = order.mapNotNull { known[it] }.distinct()
|
||||
return (listed + Store.entries.filterNot { it in listed }).map { StoreTab(it, it.id !in hidden) }
|
||||
}
|
||||
@@ -16,14 +16,19 @@ import java.util.Locale
|
||||
|
||||
object StoresState {
|
||||
private const val TAG = "Stores"
|
||||
private const val SEARCH_DELAY_MS = 400L
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
val accounts = mutableStateMapOf<Store, String>()
|
||||
var accountsLoaded by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val library = mutableStateMapOf<Store, List<CatalogItem>>()
|
||||
|
||||
val shelves = mutableStateMapOf<Store, StoreShelves>()
|
||||
|
||||
val found = mutableStateMapOf<Store, List<CatalogItem>>()
|
||||
|
||||
val status = mutableStateMapOf<Store, String>()
|
||||
|
||||
val problems = mutableStateMapOf<Store, String>()
|
||||
@@ -64,6 +69,8 @@ object StoresState {
|
||||
|
||||
private val backends = HashMap<Store, StoreBackend>()
|
||||
|
||||
private val searchSeq = java.util.concurrent.ConcurrentHashMap<Store, Int>()
|
||||
|
||||
fun backend(store: Store): StoreBackend? = backends[store]
|
||||
|
||||
fun register(backend: StoreBackend) { backends[backend.store] = backend }
|
||||
@@ -79,11 +86,15 @@ object StoresState {
|
||||
val partial = StoreInstallRoot.unfinished(app)
|
||||
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
|
||||
main.post {
|
||||
accounts.clear()
|
||||
found.forEach { (store, name) -> accounts[store] = name }
|
||||
installed = onDisk
|
||||
unfinished = partial
|
||||
engine = version
|
||||
androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot {
|
||||
val restored = found.toMap()
|
||||
accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) }
|
||||
accounts.putAll(restored)
|
||||
installed = onDisk
|
||||
unfinished = partial
|
||||
engine = version
|
||||
accountsLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +140,26 @@ object StoresState {
|
||||
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
|
||||
}
|
||||
|
||||
fun search(context: Context, store: Store, query: String) {
|
||||
val backend = backends[store] ?: return
|
||||
val text = query.trim()
|
||||
if (text.isEmpty()) {
|
||||
searchSeq.remove(store)
|
||||
found.remove(store)
|
||||
return
|
||||
}
|
||||
val seq = (searchSeq[store] ?: 0) + 1
|
||||
searchSeq[store] = seq
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
Thread.sleep(SEARCH_DELAY_MS)
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList())
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
main.post { found[store] = items }
|
||||
}, "stores-search").start()
|
||||
}
|
||||
|
||||
fun signIn(context: Context, store: Store) {
|
||||
expired.remove(store)
|
||||
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
|
||||
@@ -137,10 +168,12 @@ object StoresState {
|
||||
fun signOut(context: Context, store: Store) {
|
||||
val app = context.applicationContext
|
||||
background("stores-signout") {
|
||||
backends[store]?.signOut(app)
|
||||
val backend = backends[store]
|
||||
backend?.signOut(app)
|
||||
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
|
||||
else StoreAccounts.clear(app, store)
|
||||
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
|
||||
main.post {
|
||||
if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post
|
||||
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
|
||||
logLine("${store.label}: signed out")
|
||||
}
|
||||
@@ -265,6 +298,7 @@ interface StoreBackend {
|
||||
fun signOut(context: Context)
|
||||
fun syncLibrary(context: Context, force: Boolean)
|
||||
fun loadShelves(context: Context, force: Boolean)
|
||||
fun search(context: Context, query: String): List<CatalogItem> = emptyList()
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File)
|
||||
fun uninstall(context: Context, game: InstalledStoreGame)
|
||||
}
|
||||
@@ -335,7 +335,7 @@ object DownloadQueue {
|
||||
StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
|
||||
StoresState.notifyLibraryChanged(context)
|
||||
val games = runCatching {
|
||||
com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
}.getOrNull()
|
||||
StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ object SavedQueue {
|
||||
put("store", i.store.id); put("id", i.id); put("title", i.title)
|
||||
put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
|
||||
put("tags", i.tags); put("developer", i.developer); put("description", i.description)
|
||||
put("size", i.sizeBytes); put("owned", i.owned)
|
||||
put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription)
|
||||
put("extra", JSONObject(i.extra))
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ object SavedQueue {
|
||||
id = o.getString("id"), title = o.getString("title"),
|
||||
imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null },
|
||||
tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"),
|
||||
owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"),
|
||||
owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), sizeBytes = o.optLong("size"),
|
||||
extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -24,7 +25,8 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
object GogComet {
|
||||
private const val TAG = "GogComet"
|
||||
private const val BIN = "/usr/local/lib/droiddeck-comet/comet"
|
||||
private const val DIR = "/usr/local/lib/droiddeck-comet"
|
||||
private const val BIN = "$DIR/comet"
|
||||
private val lock = Any()
|
||||
@Volatile private var process: Process? = null
|
||||
private var pid = -1
|
||||
@@ -41,7 +43,27 @@ object GogComet {
|
||||
|
||||
fun setEnabled(context: Context, on: Boolean) {
|
||||
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
|
||||
if (on) AddonPack.COMET.ensure(context)
|
||||
}
|
||||
|
||||
private fun stage(context: Context): Boolean {
|
||||
if (!AddonPack.COMET.installed(context)) {
|
||||
AddonPack.COMET.ensure(context)
|
||||
return false
|
||||
}
|
||||
val pack = AddonPack.COMET.dir(context)
|
||||
val root = File(LinuxRuntime.rootDir(context), DIR.removePrefix("/"))
|
||||
val packaged = File(pack, "version").readText()
|
||||
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return true
|
||||
root.mkdirs()
|
||||
pack.listFiles().orEmpty().filter { it.isFile && it.name != "version" && it.name != AddonPack.COMPLETE }.forEach { source ->
|
||||
val staged = File(root, source.name + ".staged")
|
||||
source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } }
|
||||
staged.setExecutable(true, false)
|
||||
check(staged.renameTo(File(root, source.name)))
|
||||
}
|
||||
File(root, "version").writeText(packaged)
|
||||
return true
|
||||
}
|
||||
|
||||
fun begin() = synchronized(lock) {
|
||||
@@ -113,7 +135,8 @@ object GogComet {
|
||||
return JSONObject().put("ok", true).put("ready", true).put("reason", "ready")
|
||||
}
|
||||
stopLocked()
|
||||
if (!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
|
||||
if (!runCatching { stage(context) }.onFailure { Log.w(TAG, "staging failed", it) }.getOrDefault(false) ||
|
||||
!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
|
||||
val data = File(context.filesDir, "gog-achievements").apply { mkdirs() }
|
||||
val ready = CountDownLatch(1)
|
||||
val command = LinuxRuntime.command(context, null, null, Environment.getExternalStorageDirectory(),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchApi {
|
||||
const val KEYS_PAGE = "https://itch.io/user/settings/api-keys"
|
||||
|
||||
private const val BASE = "https://api.itch.io"
|
||||
private const val WEB = "https://itch.io"
|
||||
|
||||
fun get(path: String, key: String): JSONObject? {
|
||||
val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null
|
||||
return runCatching { JSONObject(body) }.getOrNull()
|
||||
}
|
||||
|
||||
fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user")
|
||||
|
||||
fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
|
||||
|
||||
fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
|
||||
get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads")
|
||||
|
||||
fun search(key: String, query: String, page: Int): JSONArray? {
|
||||
val q = java.net.URLEncoder.encode(query, "UTF-8")
|
||||
return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games")
|
||||
}
|
||||
|
||||
fun downloadUrl(uploadId: String, downloadKeyId: String): String =
|
||||
"$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId"
|
||||
|
||||
fun pageUrl(slug: String): String = "$WEB/$slug"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchAuth {
|
||||
private val TOKEN = Regex("[A-Za-z0-9]{40,64}")
|
||||
|
||||
fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty()
|
||||
|
||||
fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty()
|
||||
|
||||
fun verify(key: String): JSONObject? = ItchApi.profile(key)
|
||||
|
||||
fun save(context: Context, key: String, user: JSONObject) {
|
||||
StoreAccounts.write(
|
||||
context,
|
||||
Store.ITCH,
|
||||
JSONObject()
|
||||
.put("access_token", key)
|
||||
.put("username", user.optString("username"))
|
||||
.put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") })
|
||||
.put("user_id", user.optLong("id")),
|
||||
)
|
||||
}
|
||||
|
||||
fun candidates(text: String): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) }
|
||||
TOKEN.findAll(text).forEach { out.add(it.value) }
|
||||
return out.take(8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreBackend
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreSizes
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import java.io.File
|
||||
|
||||
object ItchBackend : StoreBackend {
|
||||
override val store: Store = Store.ITCH
|
||||
|
||||
override fun signIn(context: Context) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
override fun signOut(context: Context) {
|
||||
ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
|
||||
StoresState.post {
|
||||
StoresState.library.remove(store)
|
||||
StoresState.shelves.remove(store)
|
||||
StoresState.found.remove(store)
|
||||
StoresState.problems.remove(store)
|
||||
StoresState.status.remove(store)
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncLibrary(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } }
|
||||
Thread({
|
||||
val shelves = ItchShelves.featured(app, force)
|
||||
if (shelves != null) {
|
||||
StoresState.post { StoresState.shelves[store] = shelves }
|
||||
} else {
|
||||
StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) }
|
||||
}
|
||||
}, "itch-shelves").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = ItchShelves.search(context, query)
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
if (item.extra["downloadKeyId"].isNullOrEmpty()) {
|
||||
StoresState.logLine("itch.io: \"${item.title}\" is not in the library")
|
||||
return
|
||||
}
|
||||
val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root)
|
||||
val entry = DownloadEntry(
|
||||
Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item),
|
||||
location = StoreInstallRoot.labelFor(app, folder),
|
||||
)
|
||||
DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) }
|
||||
}
|
||||
|
||||
override fun uninstall(context: Context, game: InstalledStoreGame) {}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchCatalog {
|
||||
fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/')
|
||||
|
||||
fun shelvesJson(shelves: StoreShelves): String = JSONObject()
|
||||
.put("new", array(shelves.whatsNew))
|
||||
.put("deals", array(shelves.deals))
|
||||
.put("free", array(shelves.free))
|
||||
.put("top", array(shelves.trending))
|
||||
.toString()
|
||||
|
||||
fun shelves(raw: String?): StoreShelves? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val shelves = StoreShelves(
|
||||
whatsNew = list(o.optJSONArray("new")),
|
||||
deals = list(o.optJSONArray("deals")),
|
||||
free = list(o.optJSONArray("free")),
|
||||
trending = list(o.optJSONArray("top")),
|
||||
)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
fun array(items: List<CatalogItem>): JSONArray {
|
||||
val out = JSONArray()
|
||||
items.forEach { out.put(json(it)) }
|
||||
return out
|
||||
}
|
||||
|
||||
fun list(raw: JSONArray?): List<CatalogItem> {
|
||||
if (raw == null) return emptyList()
|
||||
return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) }
|
||||
}
|
||||
|
||||
private fun json(i: CatalogItem): JSONObject = JSONObject()
|
||||
.put("id", i.id)
|
||||
.put("title", i.title)
|
||||
.put("image", i.imageUrl ?: "")
|
||||
.put("tall", i.tallImageUrl ?: "")
|
||||
.put("tags", i.tags)
|
||||
.put("free", i.isFree)
|
||||
.put("hasPrice", i.hasPrice)
|
||||
.put("finalPrice", i.finalPrice)
|
||||
.put("originalPrice", i.originalPrice)
|
||||
.put("discount", i.discountPercent)
|
||||
.put("url", i.storeUrl)
|
||||
.put("developer", i.developer)
|
||||
.put("publisher", i.publisher)
|
||||
.put("releaseDate", i.releaseDate)
|
||||
.put("description", i.description)
|
||||
.put("owned", i.owned)
|
||||
.put("size", i.sizeBytes)
|
||||
.put("extra", JSONObject(i.extra))
|
||||
.put("mature", i.mature)
|
||||
|
||||
private fun item(o: JSONObject): CatalogItem {
|
||||
val extra = o.optJSONObject("extra")
|
||||
val map = HashMap<String, String>()
|
||||
extra?.keys()?.forEach { k -> map[k] = extra.optString(k) }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = o.optString("id"),
|
||||
title = o.optString("title"),
|
||||
imageUrl = o.optString("image").ifBlank { null },
|
||||
tallImageUrl = o.optString("tall").ifBlank { null },
|
||||
tags = o.optString("tags"),
|
||||
isFree = o.optBoolean("free"),
|
||||
hasPrice = o.optBoolean("hasPrice"),
|
||||
finalPrice = o.optString("finalPrice"),
|
||||
originalPrice = o.optString("originalPrice"),
|
||||
discountPercent = o.optInt("discount"),
|
||||
storeUrl = o.optString("url"),
|
||||
developer = o.optString("developer"),
|
||||
publisher = o.optString("publisher"),
|
||||
releaseDate = o.optString("releaseDate"),
|
||||
description = o.optString("description"),
|
||||
owned = o.optBoolean("owned"),
|
||||
sizeBytes = o.optLong("size"),
|
||||
extra = map,
|
||||
mature = o.optBoolean("mature"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.GuestCommand
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreExe
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal class ItchInstaller(
|
||||
private val app: Context,
|
||||
private val item: CatalogItem,
|
||||
private val folder: File,
|
||||
) : DownloadQueue.DownloadJob {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
val key = ItchAuth.key(app)
|
||||
if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in))
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty()
|
||||
if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned))
|
||||
val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') }
|
||||
StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl)
|
||||
handle.stage(DownloadStage.MANIFEST)
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId))
|
||||
?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload))
|
||||
val uploadId = upload.optString("id")
|
||||
val filename = ItchUploads.filename(upload)
|
||||
val size = ItchUploads.size(upload)
|
||||
val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)
|
||||
scratch.mkdirs()
|
||||
val archive = File(scratch, filename.ifBlank { "download.bin" })
|
||||
val exe = try {
|
||||
handle.stage(DownloadStage.DOWNLOAD)
|
||||
download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle)
|
||||
if (cancelled.get()) return null
|
||||
handle.stage(DownloadStage.INSTALL)
|
||||
install(archive, filename, handle)
|
||||
} catch (t: Throwable) {
|
||||
if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw t
|
||||
}
|
||||
if (exe == null) {
|
||||
if (cancelled.get()) return null
|
||||
StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_no_exe))
|
||||
}
|
||||
archive.delete()
|
||||
StoreInstalls.deleteTree(scratch)
|
||||
val sidecar = StoreGameSidecar(
|
||||
store = Store.ITCH,
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
exe = exe,
|
||||
installVersion = upload.optString("updated_at", upload.optString("updatedAt")),
|
||||
installedAt = System.currentTimeMillis(),
|
||||
cover = item.imageUrl,
|
||||
hero = item.imageUrl,
|
||||
extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId),
|
||||
cloud = false,
|
||||
)
|
||||
StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }
|
||||
return folder.path
|
||||
}
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)))
|
||||
}
|
||||
|
||||
private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long,
|
||||
handle: DownloadQueue.JobHandle) {
|
||||
// A partial file is only resumed when it came from this exact upload build.
|
||||
val origin = File(target.path + ".from")
|
||||
val stamp = "$uploadId $version $expected"
|
||||
if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) {
|
||||
target.delete()
|
||||
origin.writeText(stamp)
|
||||
}
|
||||
if (expected > 0 && target.length() == expected) {
|
||||
handle.progress(expected, expected)
|
||||
return
|
||||
}
|
||||
for (attempt in 0..1) {
|
||||
val have = target.length()
|
||||
var conn: HttpURLConnection? = null
|
||||
try {
|
||||
conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 30_000
|
||||
readTimeout = 60_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Authorization", "Bearer $key")
|
||||
setRequestProperty("Accept", "*/*")
|
||||
if (have > 0) setRequestProperty("Range", "bytes=$have-")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
if (code == 416 && attempt == 0) {
|
||||
target.delete()
|
||||
continue
|
||||
}
|
||||
if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
val append = code == 206 && have > 0
|
||||
if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over")
|
||||
var done = if (append) have else 0L
|
||||
val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected
|
||||
// The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput.
|
||||
handle.progress(done, total)
|
||||
val buffer = ByteArray(128 * 1024)
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(target, append).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
handle.progress(done, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
return
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? {
|
||||
folder.mkdirs()
|
||||
when (val kind = sniff(archive, filename)) {
|
||||
"zip" -> unzip(archive, handle)
|
||||
"7z" -> sevenZip(archive, handle)
|
||||
"rar" -> unrar(archive, handle)
|
||||
"exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" }))
|
||||
"tar" -> untar(archive, null, handle)
|
||||
"gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle)
|
||||
else -> copy(archive, File(folder, filename.ifBlank { "game" }))
|
||||
}
|
||||
val hint = filename.substringBeforeLast('.')
|
||||
val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title)
|
||||
if (exe.isBlank()) {
|
||||
handle.log("itch.io: no exe found in ${folder.name}")
|
||||
return null
|
||||
}
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun sniff(file: File, filename: String): String {
|
||||
val extension = filename.substringAfterLast('.', "").lowercase()
|
||||
if (extension == "exe") return "exe"
|
||||
val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull()
|
||||
?: return extension
|
||||
fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() }
|
||||
return when {
|
||||
at(0, 0x50, 0x4B) -> "zip"
|
||||
at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z"
|
||||
at(0, 0x52, 0x61, 0x72, 0x21) -> "rar"
|
||||
at(0, 0x1F, 0x8B) -> "gz"
|
||||
at(0, 0x42, 0x5A, 0x68) -> "bz2"
|
||||
at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz"
|
||||
at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar"
|
||||
else -> extension
|
||||
}
|
||||
}
|
||||
|
||||
private fun target(name: String): File? {
|
||||
val file = File(folder, name.replace('\\', '/'))
|
||||
val root = folder.canonicalPath
|
||||
val path = file.canonicalPath
|
||||
return if (path == root || path.startsWith(root + File.separator)) file else null
|
||||
}
|
||||
|
||||
// No JVM library reads RAR5; the runtime's bsdtar (libarchive) does.
|
||||
private fun unrar(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar"))
|
||||
val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null
|
||||
else listOf(folder.absolutePath)
|
||||
fun bsdtar(args: List<String>, onLine: (String) -> Unit) =
|
||||
GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line ->
|
||||
if (!line.startsWith("proot ")) onLine(line)
|
||||
}
|
||||
var entries = 0L
|
||||
bsdtar(listOf("-tf", archive.absolutePath)) { entries++ }
|
||||
var done = 0L
|
||||
val tail = ArrayDeque<String>()
|
||||
val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line ->
|
||||
if (line.startsWith("x ")) handle.stageProgress(++done, entries) else {
|
||||
if (tail.size == 5) tail.removeFirst()
|
||||
tail.addLast(line)
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) return
|
||||
if (code != 0) {
|
||||
handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}")
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
SevenZFile.builder().setFile(archive).get().use { seven ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val entry = seven.nextEntry ?: break
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = seven.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
var entries = 0
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
val opened = runCatching {
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"gz" -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> BufferedInputStream(raw)
|
||||
}
|
||||
stream.use { input ->
|
||||
TarArchiveInputStream(input).use { tar ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val entry = tar.nextEntry ?: break
|
||||
entries++
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val read = tar.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
if (opened.isSuccess && entries > 0) return Unit
|
||||
if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) {
|
||||
val name = archive.name.substringBeforeLast('.')
|
||||
val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive")
|
||||
file.parentFile?.mkdirs()
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
}
|
||||
stream.use { input ->
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copy(source: File, target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } }
|
||||
}
|
||||
|
||||
// Streaming readers stop silently on zips whose data descriptors disagree with the local headers;
|
||||
// the central directory is authoritative.
|
||||
private fun unzip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
ZipFile.builder().setFile(archive).get().use { zip ->
|
||||
for (entry in zip.entries) {
|
||||
if (cancelled.get()) return
|
||||
val target = target(entry.name)
|
||||
if (target == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
target.mkdirs()
|
||||
continue
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import com.droiddeck.launcher.stores.mergeItems
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object ItchLibrary {
|
||||
private const val KEY_CACHE = "library_cache"
|
||||
private const val KEY_AT = "library_synced_at"
|
||||
private const val MAX_PAGES = 20
|
||||
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
|
||||
|
||||
private val running = AtomicBoolean(false)
|
||||
|
||||
fun cached(context: Context): List<CatalogItem> {
|
||||
val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
|
||||
return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun sync(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
try {
|
||||
val key = ItchAuth.key(app)
|
||||
val cache = cached(app)
|
||||
val prefs = ItchPrefs.get(app)
|
||||
if (key.isBlank()) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in)
|
||||
}
|
||||
return
|
||||
}
|
||||
val quiet = cache.isNotEmpty() && !force
|
||||
if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) {
|
||||
StoresState.post { StoresState.library[Store.ITCH] = cache }
|
||||
return
|
||||
}
|
||||
if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) }
|
||||
val items = fetch(key)
|
||||
if (items == null) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed)
|
||||
}
|
||||
return
|
||||
}
|
||||
prefs.edit()
|
||||
.putString(KEY_CACHE, ItchCatalog.array(items).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis())
|
||||
.apply()
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
StoresState.problems.remove(Store.ITCH)
|
||||
StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items)
|
||||
}
|
||||
StoresState.logLine("itch.io: ${items.size} owned games")
|
||||
} finally {
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun size(context: Context, item: CatalogItem): Long {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return 0L
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L }
|
||||
val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L }
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L
|
||||
return ItchUploads.size(upload)
|
||||
}
|
||||
|
||||
private fun fetch(key: String): List<CatalogItem>? {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
var page = 1
|
||||
var keys = 0
|
||||
while (page <= MAX_PAGES) {
|
||||
val body = ItchApi.ownedKeys(key, page) ?: return null
|
||||
val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out
|
||||
if (list.length() == 0) break
|
||||
out.addAll(items(list))
|
||||
keys += list.length()
|
||||
page++
|
||||
}
|
||||
val games = out.distinctBy { it.id }
|
||||
StoresState.logLine("itch.io: $keys download keys, ${games.size} games")
|
||||
return games
|
||||
}
|
||||
|
||||
internal fun items(keys: JSONArray): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>(keys.length())
|
||||
for (i in 0 until keys.length()) {
|
||||
val entry = keys.optJSONObject(i) ?: continue
|
||||
val game = entry.optJSONObject("game") ?: continue
|
||||
item(game, entry.optString("id"))?.let { out.add(it) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.text(snake: String, camel: String): String =
|
||||
optString(snake).ifBlank { optString(camel) }
|
||||
|
||||
internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? {
|
||||
val gameId = game.optLong("id")
|
||||
if (gameId <= 0L) return null
|
||||
val user = game.optJSONObject("user")
|
||||
val url = game.optString("url").ifBlank {
|
||||
val owner = user?.optString("username").orEmpty()
|
||||
if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId")
|
||||
}
|
||||
val traits = game.optJSONArray("traits")?.let { arr ->
|
||||
(0 until arr.length()).map { arr.optString(it) }
|
||||
}.orEmpty()
|
||||
val platforms = game.optJSONObject("platforms")
|
||||
val sale = game.optJSONObject("sale")
|
||||
val rate = sale?.optDouble("rate", 0.0) ?: 0.0
|
||||
val cents = game.optLong("min_price", game.optLong("minPrice"))
|
||||
val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits))
|
||||
val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0
|
||||
val price = if (canBuy && cents > 0) money(cents) else ""
|
||||
val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else ""
|
||||
val tags = buildList {
|
||||
if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows")
|
||||
if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux")
|
||||
if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS")
|
||||
}.joinToString(", ")
|
||||
val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") }
|
||||
val tall = game.text("still_cover_url", "stillCoverUrl")
|
||||
val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(url).ifBlank { gameId.toString() },
|
||||
title = game.optString("title").ifBlank { "itch.io game $gameId" },
|
||||
imageUrl = cover.ifBlank { null },
|
||||
tallImageUrl = tall.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = price.isEmpty(),
|
||||
hasPrice = price.isNotEmpty(),
|
||||
finalPrice = salePrice.ifEmpty { price },
|
||||
originalPrice = if (salePrice.isNotEmpty()) price else "",
|
||||
discountPercent = if (salePrice.isNotEmpty()) discount else 0,
|
||||
storeUrl = url,
|
||||
developer = author,
|
||||
publisher = author,
|
||||
releaseDate = game.text("published_at", "publishedAt").take(10),
|
||||
description = cleanStoreText(game.text("short_text", "shortText")),
|
||||
owned = downloadKeyId.isNotBlank(),
|
||||
extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()),
|
||||
)
|
||||
}
|
||||
|
||||
private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0')
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object ItchPrefs {
|
||||
const val FILE = "stores.itch"
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal object ItchShelves {
|
||||
private const val TAG = "ItchShelves"
|
||||
private const val KEY = "shelves_cache"
|
||||
private const val KEY_AT = "shelves_at"
|
||||
private const val FRESH_MS = 3 * 60 * 60 * 1000L
|
||||
private const val GAP_MS = 1_200L
|
||||
private const val RETRY_MS = 20_000L
|
||||
|
||||
private val FEEDS = listOf(
|
||||
"https://itch.io/games/newest.xml",
|
||||
"https://itch.io/games/on-sale.xml",
|
||||
"https://itch.io/games/price-free.xml",
|
||||
"https://itch.io/games/top-rated.xml",
|
||||
)
|
||||
private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile")
|
||||
|
||||
@Volatile
|
||||
private var memory: StoreShelves? = null
|
||||
|
||||
fun cached(context: Context): StoreShelves? {
|
||||
memory?.let { return it }
|
||||
val stored = stored(ItchPrefs.get(context.applicationContext))
|
||||
if (stored != null) memory = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
fun featured(context: Context, force: Boolean): StoreShelves? {
|
||||
val prefs = ItchPrefs.get(context.applicationContext)
|
||||
val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS
|
||||
if (fresh && !force) {
|
||||
cached(context)?.let { return it }
|
||||
}
|
||||
val fetched = fetch()
|
||||
if (fetched != null) {
|
||||
memory = fetched
|
||||
prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
return fetched
|
||||
}
|
||||
return cached(context)
|
||||
}
|
||||
|
||||
fun search(context: Context, query: String): List<CatalogItem> {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return emptyList()
|
||||
val out = ArrayList<CatalogItem>()
|
||||
for (page in 1..2) {
|
||||
val games = ItchApi.search(key, query, page) ?: break
|
||||
if (games.length() == 0) break
|
||||
for (i in 0 until games.length()) {
|
||||
val game = games.optJSONObject(i) ?: continue
|
||||
ItchLibrary.item(game, "")?.let { out.add(it) }
|
||||
}
|
||||
if (games.length() < 30) break
|
||||
Thread.sleep(GAP_MS)
|
||||
}
|
||||
return out.distinctBy { it.id }
|
||||
}
|
||||
|
||||
private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null))
|
||||
|
||||
private fun fetch(): StoreShelves? {
|
||||
val lists = ArrayList<List<CatalogItem>>(FEEDS.size)
|
||||
var answered = false
|
||||
for ((index, url) in FEEDS.withIndex()) {
|
||||
if (index > 0) Thread.sleep(GAP_MS)
|
||||
var (code, body) = open(url)
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed 429; backing off")
|
||||
Thread.sleep(RETRY_MS)
|
||||
val again = open(url)
|
||||
code = again.first
|
||||
body = again.second
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed still 429; keeping the cached shelves")
|
||||
lists.add(emptyList())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (code in 200..299 && body != null) {
|
||||
answered = true
|
||||
lists.add(parse(body))
|
||||
} else {
|
||||
lists.add(emptyList())
|
||||
}
|
||||
}
|
||||
if (!answered) return null
|
||||
while (lists.size < FEEDS.size) lists.add(emptyList())
|
||||
val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12)
|
||||
val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
private fun open(url: String): Pair<Int, String?> {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "feed ${e.javaClass.simpleName}")
|
||||
0 to null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parse(xml: String): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(StringReader(xml))
|
||||
var event = parser.eventType
|
||||
var field: String? = null
|
||||
var current: HashMap<String, String>? = null
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
if (parser.name == "item") current = HashMap()
|
||||
field = parser.name
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val map = current
|
||||
val name = field
|
||||
if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name == "item") {
|
||||
current?.let { item(it)?.let(out::add) }
|
||||
current = null
|
||||
}
|
||||
field = null
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun item(m: Map<String, String>): CatalogItem? {
|
||||
val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() }
|
||||
if (link.isBlank()) return null
|
||||
val title = m["plainTitle"]?.trim().orEmpty()
|
||||
.ifBlank { m["title"].orEmpty().substringBefore(" [").trim() }
|
||||
.ifBlank { ItchCatalog.slug(link) }
|
||||
val price = m["price"]?.trim().orEmpty()
|
||||
val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true)
|
||||
val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty())
|
||||
.map { it.groupValues[1].trim() }
|
||||
.filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS }
|
||||
.joinToString(", ")
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(link),
|
||||
title = title,
|
||||
imageUrl = m["imageurl"]?.trim()?.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = free,
|
||||
hasPrice = !free,
|
||||
finalPrice = if (free) "" else price,
|
||||
originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(),
|
||||
discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0,
|
||||
storeUrl = link,
|
||||
releaseDate = date(m["pubDate"]),
|
||||
description = cleanStoreText(m["description"]),
|
||||
)
|
||||
}
|
||||
|
||||
private fun date(raw: String?): String {
|
||||
val text = raw?.trim().orEmpty()
|
||||
if (text.isBlank()) return ""
|
||||
return runCatching {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text)
|
||||
}.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 337 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user