mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-20 14:14:44 -04:00
Compare commits
228
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31ab5fa96 | ||
|
|
8ee1cbe3eb | ||
|
|
6c5d6cda73 | ||
|
|
38c9b733b5 | ||
|
|
02fa7983e9 | ||
|
|
5e55189d03 | ||
|
|
27e5f86793 | ||
|
|
f76a58ea1e | ||
|
|
f53b844add | ||
|
|
c729f4c8e7 | ||
|
|
9f343278c3 | ||
|
|
6beeb29486 | ||
|
|
ade2e982b6 | ||
|
|
8a61a1898b | ||
|
|
dfa3b12f48 | ||
|
|
9ed25d8ae5 | ||
|
|
b4b31d5a08 | ||
|
|
f394906726 | ||
|
|
b50dfa76d1 | ||
|
|
3f81ddff9b | ||
|
|
d1dec8d7a4 | ||
|
|
e60097e5cb | ||
|
|
7c7bf2dcbe | ||
|
|
75593cfd29 | ||
|
|
00d0f8cd2b | ||
|
|
faf4b4e8b5 | ||
|
|
5a61d37c5d | ||
|
|
25079ab819 | ||
|
|
736b7bed5e | ||
|
|
a3f4c911e5 | ||
|
|
caa7682c6f | ||
|
|
e217cbe66d | ||
|
|
4914b2cc69 | ||
|
|
6ecd6fa1d0 | ||
|
|
0f4e83eefd | ||
|
|
d62525f5af | ||
|
|
f4d2c37fbd | ||
|
|
ffd9f92d7b | ||
|
|
213d110766 | ||
|
|
1a8c91b49b | ||
|
|
9644a4cc7f | ||
|
|
24c250fdb4 | ||
|
|
d4636afa97 | ||
|
|
32930e704e | ||
|
|
a8b18947a1 | ||
|
|
7c422577b6 | ||
|
|
1cc1ee994d | ||
|
|
d8d375cb9a | ||
|
|
221644e8be | ||
|
|
448feb30bc | ||
|
|
9d891d4db1 | ||
|
|
240f1194c0 | ||
|
|
7b74eebce2 | ||
|
|
7592684b6f | ||
|
|
7b78e1629b | ||
|
|
30741302fb | ||
|
|
645f7d0bf1 | ||
|
|
124f162ee8 | ||
|
|
3c577fd9dd | ||
|
|
659ff4817f | ||
|
|
626adb62af | ||
|
|
71cb100a40 | ||
|
|
68002f3fa7 | ||
|
|
dae7592788 | ||
|
|
e10f5b4430 | ||
|
|
3e32b75a63 | ||
|
|
435f7a1b03 | ||
|
|
1fd8c50225 | ||
|
|
e797bc5f01 | ||
|
|
0cc3fe6bca | ||
|
|
ab8542e4e5 | ||
|
|
c2bcf20172 | ||
|
|
2341659cea | ||
|
|
b2eaf380dc | ||
|
|
197fc3f163 | ||
|
|
9530e7b394 | ||
|
|
0e0f133496 | ||
|
|
d98c211168 | ||
|
|
614b4d4de4 | ||
|
|
43a19a831a | ||
|
|
0d308af024 | ||
|
|
128f73da8f | ||
|
|
e22a55e949 | ||
|
|
67cb005e6c | ||
|
|
73f76f6799 | ||
|
|
961a060461 | ||
|
|
dadbbf841a | ||
|
|
1ce8280371 | ||
|
|
78bdc0e3b5 | ||
|
|
ce5f2f5513 | ||
|
|
0f2792c7ac | ||
|
|
7185b775d4 | ||
|
|
cda6e89c12 | ||
|
|
5f80898c27 | ||
|
|
85560d6471 | ||
|
|
be6c875277 | ||
|
|
91beced154 | ||
|
|
ebc3da08b4 | ||
|
|
41eb780495 | ||
|
|
65fc2a38a3 | ||
|
|
359d1535ac | ||
|
|
4c3a9f027c | ||
|
|
5a6cc3c76e | ||
|
|
89bd7deceb | ||
|
|
169f2c3e34 | ||
|
|
e6a797695a | ||
|
|
f1a2ecc857 | ||
|
|
36916f6bde | ||
|
|
1b96d13a51 | ||
|
|
cb1d70bb14 | ||
|
|
37af590ca2 | ||
|
|
02f356f8c0 | ||
|
|
5448177814 | ||
|
|
ed6ce0ca41 | ||
|
|
9b440686c5 | ||
|
|
e7b7e9dc43 | ||
|
|
1120040aee | ||
|
|
3506a272da | ||
|
|
b752748b7d | ||
|
|
8990e7d7a4 | ||
|
|
c92a423ba1 | ||
|
|
3b82b3e1f8 | ||
|
|
e985ce1975 | ||
|
|
8496ce3e51 | ||
|
|
47188f40ed | ||
|
|
e186c9bc4c | ||
|
|
7705f3c222 | ||
|
|
5675b4897f | ||
|
|
1c87d92ef7 | ||
|
|
031e64dfe1 | ||
|
|
f590dd9763 | ||
|
|
af7dc40538 | ||
|
|
a28aae6e4a | ||
|
|
c024470cc7 | ||
|
|
16ae5ab84a | ||
|
|
144947305a | ||
|
|
563bc6cad7 | ||
|
|
59cde93821 | ||
|
|
c104e1c276 | ||
|
|
b5da0d0db6 | ||
|
|
5defc3e38b | ||
|
|
6a9f52f932 | ||
|
|
06a8d75769 | ||
|
|
ec12ddd0a8 | ||
|
|
570902d5f3 | ||
|
|
6120069975 | ||
|
|
3e337c9bda | ||
|
|
2869e76406 | ||
|
|
61fdb80823 | ||
|
|
fd180a108a | ||
|
|
4de74c740c | ||
|
|
b7c32c92d2 | ||
|
|
8610ca0928 | ||
|
|
a6b1ad37d0 | ||
|
|
97b13f7488 | ||
|
|
5d5b45a012 | ||
|
|
5ebafaabda | ||
|
|
56627a8aaf | ||
|
|
f59ced4e98 | ||
|
|
296e848718 | ||
|
|
8c63413019 | ||
|
|
2861c6d284 | ||
|
|
69c2d8e566 | ||
|
|
414fddf7b8 | ||
|
|
b5ca6145a6 | ||
|
|
5a5948b071 | ||
|
|
e58b5088b3 | ||
|
|
ecbce4c8d3 | ||
|
|
96bf0a3bf3 | ||
|
|
da7522a3ee | ||
|
|
5605e777ca | ||
|
|
a10e2e1fe8 | ||
|
|
4fb623fa33 | ||
|
|
3b4956d088 | ||
|
|
18deaafc34 | ||
|
|
0c4eede6d2 | ||
|
|
54681f2647 | ||
|
|
5271edd972 | ||
|
|
e4bb9856ef | ||
|
|
e462bbb24a | ||
|
|
d8f3090d9b | ||
|
|
d1af8b0114 | ||
|
|
dae64bbe69 | ||
|
|
8b127bed84 | ||
|
|
67187b1340 | ||
|
|
02cac57438 | ||
|
|
277dcd174c | ||
|
|
3c23ae7310 | ||
|
|
8cb13d40b5 | ||
|
|
c950d31960 | ||
|
|
ae570cd5b7 | ||
|
|
0fd1dc4fce | ||
|
|
f0854e8518 | ||
|
|
ceffd3f759 | ||
|
|
dc1816612c | ||
|
|
1d04a7e0e3 | ||
|
|
1e523c4e93 | ||
|
|
d83f766cf3 | ||
|
|
bb5e1ae63d | ||
|
|
f27a4f6c95 | ||
|
|
d7ff92106a | ||
|
|
34a17acaba | ||
|
|
110aec0b6f | ||
|
|
06502bb49b | ||
|
|
c20a166134 | ||
|
|
162fa8e2de | ||
|
|
ae5614d212 | ||
|
|
b72d8a8bab | ||
|
|
d4cf890d8a | ||
|
|
82e494269d | ||
|
|
c982d65675 | ||
|
|
f4e606fb15 | ||
|
|
9dbdb5e771 | ||
|
|
fa12478fdd | ||
|
|
c3b0d8ef03 | ||
|
|
cbb9a168e6 | ||
|
|
73e2fc58a0 | ||
|
|
500f4c69eb | ||
|
|
aa843b5a31 | ||
|
|
f249acc50f | ||
|
|
9422608960 | ||
|
|
de37ddcfb5 | ||
|
|
4c90196890 | ||
|
|
30f6ca6ffc | ||
|
|
ecff3240a6 | ||
|
|
ddd4a0c24d | ||
|
|
019949847a | ||
|
|
4dcbf269d2 |
No files matched your search
+162
-6
@@ -22,11 +22,13 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
# Replace older builds only for the same PR. Each push/dispatch gets its own group so one main
|
||||
# build cannot cancel another before it signs and publishes.
|
||||
group: build-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
PRELOAD_CACHE_VERSION: v1
|
||||
PRELOAD_CACHE_VERSION: v2
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -34,12 +36,18 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: No private key in the tree
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -140,6 +148,8 @@ jobs:
|
||||
label="${BUILD_REF} at ${BUILD_SHA:0:7}"
|
||||
fi
|
||||
echo "label=$label" >> "$GITHUB_OUTPUT"
|
||||
echo "DROIDDECK_SOURCE_COMMIT=${PR_HEAD_SHA:-$BUILD_SHA}" >> "$GITHUB_ENV"
|
||||
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
|
||||
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cache the preloads
|
||||
@@ -160,7 +170,7 @@ jobs:
|
||||
D=app/src/main/assets/linuxfs
|
||||
mkdir -p "$D"
|
||||
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
|
||||
-pthread -std=c++17 -static-libstdc++ -static-libgcc \
|
||||
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
|
||||
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
@@ -270,6 +280,12 @@ jobs:
|
||||
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
|
||||
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# The static C++ runtime stays private: every session process preloads this library,
|
||||
# and an exported personality routine takes over other programs' unwinding (shadPS4
|
||||
# aborted in pthread_exit).
|
||||
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
|
||||
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
||||
fi
|
||||
SESSION=$(syms "$D/libblsession.so")
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs; do
|
||||
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
|
||||
@@ -295,6 +311,12 @@ jobs:
|
||||
set -o pipefail
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
- name: Check the native dependency closure
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -365,7 +387,7 @@ jobs:
|
||||
success)
|
||||
echo "### Build passed"
|
||||
echo
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. After approval, a maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. A maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
|
||||
;;
|
||||
failure)
|
||||
echo "### ERROR: Build failed"
|
||||
@@ -418,5 +440,139 @@ jobs:
|
||||
uses: ./.github/workflows/sign.yml
|
||||
with:
|
||||
environment: signing-main
|
||||
variants: '["standard"]'
|
||||
variants: ${{ needs.build.outputs.variants }}
|
||||
secrets: inherit
|
||||
|
||||
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
|
||||
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
|
||||
# Discord's builds channel linking to it. The release is made with a token of the org's
|
||||
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
|
||||
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
|
||||
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
|
||||
announce:
|
||||
needs: [build, sign]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
env:
|
||||
CI_REPO: Droid-Deck/DroidDeck-CI
|
||||
KEEP: 10
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-signed-*
|
||||
merge-multiple: true
|
||||
path: signed
|
||||
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
|
||||
# What this build is, for the top of the release notes: the merged PR's title and the first
|
||||
# paragraph of its description, or the commit's subject for a push made without a PR.
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
||||
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
number=$(jq -r .number <<<"$pr")
|
||||
title=$(jq -r .title <<<"$pr")
|
||||
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
|
||||
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
||||
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
||||
{
|
||||
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
|
||||
if [ -n "$summary" ]; then
|
||||
echo
|
||||
echo "$summary"
|
||||
fi
|
||||
} > notes-head.md
|
||||
else
|
||||
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
|
||||
echo "**Pushed to main** without a PR - $subject" > notes-head.md
|
||||
fi
|
||||
cat notes-head.md
|
||||
|
||||
- name: Publish to DroidDeck-CI
|
||||
id: publish
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag=main-${GITHUB_SHA::7}
|
||||
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
|
||||
rm signed/SHA256SUMS-*.txt
|
||||
notes="$(cat notes-head.md)
|
||||
|
||||
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
\`\`\`
|
||||
$(cat signed/SHA256SUMS.txt)
|
||||
\`\`\`"
|
||||
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
|
||||
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
|
||||
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
|
||||
# Keep the newest main builds by publication time. GitHub App releases can share createdAt.
|
||||
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
|
||||
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
|
||||
while IFS= read -r old; do
|
||||
# Never remove this run's release, even if the API returns an unexpected ordering.
|
||||
if [ "$old" = "$tag" ]; then
|
||||
continue
|
||||
fi
|
||||
# Another main publish may have removed the same old release concurrently.
|
||||
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
|
||||
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
|
||||
echo "::error::Could not delete old release $old"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
|
||||
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
|
||||
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
|
||||
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
|
||||
echo "release=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
|
||||
echo "Published $url"
|
||||
|
||||
- name: Post to Discord
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
||||
exit 0
|
||||
fi
|
||||
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
|
||||
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
|
||||
# A merge commit's subject is "Merge pull request #N from ..."; its body's first line is
|
||||
# the PR's title, which says more.
|
||||
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
|
||||
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
|
||||
if [ -n "$pr" ]; then
|
||||
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
|
||||
else
|
||||
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
|
||||
fi
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
@@ -0,0 +1,39 @@
|
||||
name: PR Release Cleanup
|
||||
run-name: remove PR #${{ github.event.pull_request.number }}'s test build
|
||||
|
||||
# A merged or closed PR's test build leaves DroidDeck-CI, so the app's Updates page stops offering it
|
||||
# (a merged fix is in the next Nightly). pull_request_target for the publisher app's secrets; nothing
|
||||
# from the PR is checked out or run.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [ closed ]
|
||||
branches: [ main ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete the test build
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="pr-$PR_NUMBER"
|
||||
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
|
||||
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
echo "Deleted $tag"
|
||||
else
|
||||
echo "No test build for PR #$PR_NUMBER"
|
||||
fi
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: Open, approved pull request
|
||||
description: Open pull request
|
||||
required: true
|
||||
type: number
|
||||
|
||||
@@ -26,8 +26,16 @@ jobs:
|
||||
run_id: ${{ steps.gate.outputs.run_id }}
|
||||
head_sha: ${{ steps.gate.outputs.head_sha }}
|
||||
label: ${{ steps.gate.outputs.label }}
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
steps:
|
||||
- name: Check the PR, review, and build
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Every variant a main build ships, so a PR build can be tried under any of them.
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
- name: Check the PR and build
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -52,24 +60,11 @@ jobs:
|
||||
any(.pull_requests[]?; .number == $number and .head.sha == $head and .base.sha == $base)
|
||||
' <<<"$run" >/dev/null
|
||||
|
||||
approved=$(gh api --paginate --slurp \
|
||||
"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" |
|
||||
jq -r --arg head "$head" '[.[][] | select(.commit_id == $head)] |
|
||||
group_by(.user.login) | map(max_by(.id)) | .[] |
|
||||
select(.state == "APPROVED") | .user.login')
|
||||
maintainer_approved=false
|
||||
while IFS= read -r reviewer; do
|
||||
[[ -n "$reviewer" ]] || continue
|
||||
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$reviewer/permission" --jq .permission)
|
||||
[[ "$permission" == write || "$permission" == admin ]] && maintainer_approved=true
|
||||
done <<<"$approved"
|
||||
[[ "$maintainer_approved" == true ]] || { echo '::error::A maintainer must approve this commit'; exit 1; }
|
||||
|
||||
sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
|
||||
tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)
|
||||
stable_tag=$(gh api repos/The412Banner/DroidDeck/releases/latest --jq .tag_name)
|
||||
stable_tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
|
||||
mkdir -p "$RUNNER_TEMP/stable" "$RUNNER_TEMP/pr"
|
||||
gh release download "$stable_tag" -R The412Banner/DroidDeck \
|
||||
gh release download "$stable_tag" -R "$GITHUB_REPOSITORY" \
|
||||
-p "DroidDeck-$stable_tag.apk" -D "$RUNNER_TEMP/stable"
|
||||
gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D "$RUNNER_TEMP/pr"
|
||||
mapfile -t apks < <(find "$RUNNER_TEMP/pr" -type f -name '*.apk')
|
||||
@@ -79,8 +74,9 @@ jobs:
|
||||
}
|
||||
stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk")
|
||||
candidate=$(package_line "${apks[0]}")
|
||||
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" == "$stable" ]] || {
|
||||
echo "::error::PR package/versionCode '$candidate' differs from stable '$stable'"; exit 1;
|
||||
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ &&
|
||||
${candidate##* } -ge ${stable##* } ]] || {
|
||||
echo "::error::PR package/versionCode '$candidate' cannot install over stable '$stable'"; exit 1;
|
||||
}
|
||||
|
||||
current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
@@ -88,9 +84,6 @@ jobs:
|
||||
.state == "open" and .draft == false and .base.ref == "main" and
|
||||
.head.sha == $head and .base.sha == $base
|
||||
' <<<"$current" >/dev/null
|
||||
[[ $(gh pr view "$PR_NUMBER" -R "$GITHUB_REPOSITORY" --json reviewDecision --jq .reviewDecision) == APPROVED ]] || {
|
||||
echo '::error::The current PR revision still needs approval'; exit 1;
|
||||
}
|
||||
{
|
||||
echo "run_id=$run_id"
|
||||
echo "head_sha=$head"
|
||||
@@ -102,7 +95,7 @@ jobs:
|
||||
uses: ./.github/workflows/sign.yml
|
||||
with:
|
||||
environment: signing-main
|
||||
variants: '["standard"]'
|
||||
variants: ${{ needs.authorize.outputs.variants }}
|
||||
run_id: ${{ needs.authorize.outputs.run_id }}
|
||||
label: ${{ needs.authorize.outputs.label }}
|
||||
artifact: droiddeck-pr-signed
|
||||
@@ -114,37 +107,71 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-pr-signed-standard
|
||||
pattern: droiddeck-pr-signed-*
|
||||
merge-multiple: true
|
||||
path: signed
|
||||
- name: Publish to DroidDeck-CI when configured
|
||||
# What this build is, for the top of the release notes: the PR's title and the first
|
||||
# paragraph of its description.
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
LABEL: ${{ needs.authorize.outputs.label }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$GH_TOKEN" ]]; then
|
||||
echo '::warning::No CI token; download the signed APK from this run'
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
title=$(jq -r .title <<<"$pr")
|
||||
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
||||
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
||||
{
|
||||
echo "**Not merged** - a test build of open [PR #$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
if [ -n "$summary" ]; then
|
||||
echo
|
||||
echo "$summary"
|
||||
fi
|
||||
} > notes-head.md
|
||||
cat notes-head.md
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Publish to DroidDeck-CI
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=Droid-Deck/DroidDeck-CI
|
||||
tag="pr-$PR_NUMBER"
|
||||
apk="DroidDeck-$LABEL.apk"
|
||||
sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1)
|
||||
notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
|
||||
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
|
||||
rm signed/SHA256SUMS-*.txt
|
||||
notes="$(cat notes-head.md)
|
||||
|
||||
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
|
||||
Commit: $HEAD_SHA
|
||||
APK SHA-256: $sha"
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
\`\`\`
|
||||
$(cat signed/SHA256SUMS.txt)
|
||||
\`\`\`"
|
||||
if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then
|
||||
gh release create "$tag" -R "$repo" --target main --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
|
||||
fi
|
||||
gh release upload "$tag" "signed/$apk" -R "$repo" --clobber
|
||||
gh release upload "$tag" signed/* -R "$repo" --clobber
|
||||
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
|
||||
# Drop the previous build's files: anything this build did not just upload.
|
||||
keep=$(cd signed && ls | jq -R . | jq -sc .)
|
||||
gh api "repos/$repo/releases/tags/$tag" |
|
||||
jq -r --arg keep "$apk" '.assets[] | select(.name | endswith(".apk")) | select(.name != $keep) | .id' |
|
||||
jq -r --argjson keep "$keep" '.assets[] | select(.name as $n | $keep | index($n) | not) | .id' |
|
||||
while IFS= read -r id; do
|
||||
gh api --method DELETE "repos/$repo/releases/assets/$id" >/dev/null
|
||||
done
|
||||
@@ -35,6 +35,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; }
|
||||
name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle)
|
||||
[[ $VERSION == "$name" || $VERSION == "$name"-* ]] || {
|
||||
echo "::error::version '$VERSION' does not match versionName '$name' in app/build.gradle: bump versionName and versionCode first"; exit 1;
|
||||
}
|
||||
runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \
|
||||
--status success --json databaseId,event,headSha -L 20)
|
||||
id=$(jq -r --arg sha "$GITHUB_SHA" \
|
||||
|
||||
@@ -12,6 +12,7 @@ captures/
|
||||
# copies cannot drift.
|
||||
app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
@@ -28,3 +29,7 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
!keystore/testkey.p12
|
||||
droiddeck-release-key/
|
||||
password.txt
|
||||
__pycache__/
|
||||
|
||||
# Local signing with the release key (tools/build_local.sh); holds a password.
|
||||
.signing.env
|
||||
@@ -1,13 +1,26 @@
|
||||
# DroidDeck
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="artwork/droiddeck-banner-dark.svg">
|
||||
<img alt="DroidDeck" src="artwork/droiddeck-banner-light.svg" width="100%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
Run Valve's native ARM64 Steam client on an Adreno Android device. Steam runs in a Linux runtime under proot, with gamescope and an in-app Vulkan compositor. Windows games use Valve's ARM64 Proton and FEX. A desktop with LXQt, Firefox, and emulators is also available.
|
||||
DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
|
||||
|
||||
<p align="center"><a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a></p>
|
||||
|
||||
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
|
||||
|
||||
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
|
||||
|
||||
## Community
|
||||
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, nightly builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
|
||||
|
||||
## Build
|
||||
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
|
||||
@@ -18,4 +31,4 @@ 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). 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). 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. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
+37
-3
@@ -49,6 +49,20 @@ if (!(treeState in ['clean', 'dirty'])) {
|
||||
buildLabel = "${buildOrigin} ${treeState} · ${buildLabel}"
|
||||
def escapedBuildLabel = buildLabel.replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
// What the in-app updater compares against the published builds: the commit this build is of (a PR
|
||||
// build's head, not the merge CI makes of it), its PR, and whether CI made it at all.
|
||||
def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getOrNull()
|
||||
?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
|
||||
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
|
||||
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
|
||||
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
|
||||
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
|
||||
.findAll { it.trim() && !it.startsWith('#') }
|
||||
.collect { def f = it.trim().split(/\s+/); "${f[1]}=${f[2] == '-' ? '' : f[2]}" }
|
||||
.join(';')
|
||||
|
||||
android {
|
||||
namespace 'com.droiddeck.launcher'
|
||||
compileSdk 34
|
||||
@@ -63,9 +77,14 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 8
|
||||
versionName "0.1.7"
|
||||
versionCode 9
|
||||
versionName "0.2.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
@@ -121,6 +140,10 @@ android {
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
// Kadb's Bouncy Castle artifacts repeat the same multi-release JAR manifest.
|
||||
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
|
||||
}
|
||||
jniLibs {
|
||||
// The PulseAudio daemon is executed from the native library directory and its
|
||||
// libraries are read out of the apk by the class loader: both want the old,
|
||||
@@ -136,6 +159,11 @@ android {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
@@ -148,7 +176,7 @@ android {
|
||||
dependencies {
|
||||
// The runtime tarball is a zstd-compressed tar.
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.20'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
|
||||
// on-screen pad are Views underneath it, which is what they are in every Compose app.
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
@@ -160,6 +188,12 @@ dependencies {
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
// ADB-over-Wi-Fi pairing and authenticated shell client. Users still approve pairing from
|
||||
// Android's Wireless debugging screen; DroidDeck only runs its one fixed setting command.
|
||||
implementation 'com.flyfishxu:kadb-android:1.2.1'
|
||||
// Android terminal view/emulator used by the in-session secondary-display Linux shell.
|
||||
implementation 'com.termux.termux-app:terminal-view:0.118.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
@@ -21,6 +22,8 @@
|
||||
relay helper opens the input stream under this uid, and PulseAudio exposes it to the
|
||||
Steam client as DirectAudioMic. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
@@ -47,7 +50,8 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorLandscape">
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -124,6 +128,13 @@
|
||||
android:name=".session.SessionService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false" />
|
||||
<!-- Holds the process in the foreground while the Linux runtime downloads and unpacks. -->
|
||||
<service
|
||||
android:name=".runtime.RuntimeInstallService"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_alpha.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_beta.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_chain.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_common.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_delta.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// See lsfg_dll.h. PE resource walk + on-device SPIR-V cache for the Lossless
|
||||
// Scaling frame-generation chain.
|
||||
//
|
||||
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), LSFG port credited to
|
||||
// Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk
|
||||
// (PancakeTAS). Bannerlator is GPL-3.0.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
|
||||
// by @maxjivi05. GPL-3.0-or-later.
|
||||
|
||||
#include "lsfg_dll.h"
|
||||
#include "lsfg_dxbc.h"
|
||||
@@ -478,20 +478,6 @@ DllStatus validateDll(const std::string& dllPath) {
|
||||
return status;
|
||||
}
|
||||
|
||||
Variant dllVariant(const std::string& dllPath, bool preferFp16) {
|
||||
if (dllPath.empty()) return Variant::None;
|
||||
|
||||
PeImage img;
|
||||
if (!img.open(dllPath)) return Variant::None;
|
||||
|
||||
ResourceTable table;
|
||||
if (parseResources(img, table) != DllStatus::Ok) return Variant::None;
|
||||
|
||||
Variant v = selectVariant(table, preferFp16);
|
||||
if (v == Variant::None && hasBaseChain(table)) v = Variant::DxbcTranslated;
|
||||
return v;
|
||||
}
|
||||
|
||||
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16) {
|
||||
if (dllPath.empty() || cachePath.empty()) return DllStatus::NotInstalled;
|
||||
|
||||
@@ -547,22 +533,6 @@ DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, b
|
||||
return DllStatus::Ok;
|
||||
}
|
||||
|
||||
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
|
||||
bool& outMatches) {
|
||||
outMatches = false;
|
||||
if (cachePath.empty() || dllPath.empty()) return DllStatus::CacheUnusable;
|
||||
|
||||
CacheHeader header{};
|
||||
if (!readCacheHeader(cachePath, header)) return DllStatus::CacheUnusable;
|
||||
|
||||
PeImage img;
|
||||
if (!img.open(dllPath)) return DllStatus::NotInstalled;
|
||||
|
||||
outMatches = header.sourceSize == (uint64_t)img.size()
|
||||
&& header.sourceHash == fnv1a64(img.data(), img.size());
|
||||
return DllStatus::Ok;
|
||||
}
|
||||
|
||||
DllStatus cacheVariant(const std::string& cachePath, Variant& outVariant) {
|
||||
outVariant = Variant::None;
|
||||
CacheHeader header{};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// ship as RCDATA resources inside Lossless.dll from the user's own paid copy
|
||||
// of Lossless Scaling (Steam app 993090). Every implementation - upstream
|
||||
// lsfg-vk, LSFG-Android, eden, WinNative - requires the user to supply their
|
||||
// own DLL and extracts the resources on device. Bannerlator does the same, and
|
||||
// own DLL and extracts the resources on device. DroidDeck does the same, and
|
||||
// bundles nothing.
|
||||
//
|
||||
// The DLL is mmap'd READ-ONLY and parsed as data. It is never loaded as a
|
||||
@@ -26,9 +26,9 @@
|
||||
// Both producers fill the same shader-id -> SPIR-V words map, so everything
|
||||
// downstream of this file is source-agnostic.
|
||||
//
|
||||
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), whose LSFG port is
|
||||
// credited to Camille LaVey / the Eden Emulator Project, and which follows
|
||||
// upstream lsfg-vk (PancakeTAS). Bannerlator is GPL-3.0; see THIRD-PARTY-LICENSES.md.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
|
||||
// by @maxjivi05. GPL-3.0-or-later.
|
||||
// ============================================================================
|
||||
|
||||
#include <cstdint>
|
||||
@@ -98,18 +98,11 @@ const char* variantName(Variant v);
|
||||
// reported as valid - the translator handles it.
|
||||
DllStatus validateDll(const std::string& dllPath);
|
||||
|
||||
// Which producer would be used for this DLL.
|
||||
Variant dllVariant(const std::string& dllPath, bool preferFp16);
|
||||
|
||||
// Parse the DLL, produce all 25 SPIR-V modules, and write them to cachePath
|
||||
// (via temp file + rename, so a failed build cannot leave a half-written
|
||||
// cache behind). Records which producer ran in the cache header.
|
||||
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16);
|
||||
|
||||
// Is the cache current for this DLL? Compares source size + content hash.
|
||||
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
|
||||
bool& outMatches);
|
||||
|
||||
// Load a previously built cache.
|
||||
DllStatus loadModules(const std::string& cachePath, ModuleSet& outSet);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// See lsfg_dxbc.h. DXBC -> SPIR-V via DXVK's vendored translator.
|
||||
//
|
||||
// Follows upstream lsfg-vk's src/extract/trans.cpp, including its
|
||||
// encounter-order binding renumber. Derived from WinNative's lsfg_dxbc.cpp
|
||||
// (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, vendored
|
||||
// under cpp/thirdparty/dxbc.
|
||||
// encounter-order binding renumber. Ported to WinNative (lsfg_dxbc.cpp) and
|
||||
// DroidDeck by @maxjivi05 from the work of Camille LaVey / the Eden Emulator
|
||||
// Project (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed,
|
||||
// vendored under cpp/thirdparty/dxbc.
|
||||
|
||||
#include "lsfg_dxbc.h"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// See lsfg_engine.h.
|
||||
//
|
||||
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
|
||||
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
|
||||
// @maxjivi05, where it runs on gamescope's output in the compositor.
|
||||
|
||||
#include "lsfg_engine.h"
|
||||
|
||||
@@ -269,7 +270,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
|
||||
// nothing. Copy only when generating, on the one priming frame that
|
||||
// precedes generation, or while the governor is warming up towards a
|
||||
// probe - so the ring is populated by the time it is needed.
|
||||
const bool needHistory = generations > 0 || primeHistory_
|
||||
const bool needHistory = generations > 0 || primeHistory_ || followEveryFrame()
|
||||
|| (governorEnabled_ && governor_.wantsHistory());
|
||||
if (needHistory) {
|
||||
copyPresentedFrame(cmd, source, chain_->Input(count), VkExtent2D{width, height});
|
||||
@@ -284,7 +285,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
|
||||
// regardless of the game's own settings, and it fed straight back into the
|
||||
// governor: the chain made the source rate collapse, the governor saw the
|
||||
// collapse and refused to generate, and refusing did not stop the chain.
|
||||
if (warm_ && generations > 0) chain_->DispatchShared(cmd, count);
|
||||
if (warm_ && (generations > 0 || followEveryFrame())) chain_->DispatchShared(cmd, count);
|
||||
}
|
||||
|
||||
void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t targetIndex,
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
// unavoidable cost of interpolating rather than extrapolating; no placement of
|
||||
// this code changes it.
|
||||
//
|
||||
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
|
||||
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
|
||||
// @maxjivi05, where it runs on gamescope's output in the compositor.
|
||||
// ============================================================================
|
||||
|
||||
#include <cstdint>
|
||||
@@ -109,6 +110,10 @@ private:
|
||||
// generation last stopped.
|
||||
bool historyFresh() const { return haveCopied_ && lastCopiedCount_ + 1 == frameCount_; }
|
||||
|
||||
// With a target rate the pacer plans 0..3 generations frame by frame, so the ring and the
|
||||
// shared chain have to follow every frame it keeps warm, not just the ones that generate.
|
||||
bool followEveryFrame() const { return warm_ && pacer_.Config().target_rate != 0; }
|
||||
|
||||
Device device_{};
|
||||
std::string cachePath_;
|
||||
std::unique_ptr<LsfgShaders> shaders_;
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_gamma.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_generate.hpp"
|
||||
#include "lsfg_dll.h"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -27,12 +27,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeValidateDll(JNIEnv* env, jclass
|
||||
return (jint)lsfg::validateDll(toStdString(env, dllPath));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeDllVariant(JNIEnv* env, jclass, jstring dllPath,
|
||||
jboolean preferFp16) {
|
||||
return (jint)lsfg::dllVariant(toStdString(env, dllPath), preferFp16 == JNI_TRUE);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, jstring dllPath,
|
||||
jstring cachePath, jboolean preferFp16) {
|
||||
@@ -40,16 +34,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass,
|
||||
preferFp16 == JNI_TRUE);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheMatchesSource(JNIEnv* env, jclass,
|
||||
jstring cachePath,
|
||||
jstring dllPath) {
|
||||
bool matches = false;
|
||||
const lsfg::DllStatus status = lsfg::cacheMatchesSource(toStdString(env, cachePath),
|
||||
toStdString(env, dllPath), matches);
|
||||
return (jboolean)(status == lsfg::DllStatus::Ok && matches);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheVariant(JNIEnv* env, jclass, jstring cachePath) {
|
||||
lsfg::Variant variant = lsfg::Variant::None;
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_mipmaps.hpp"
|
||||
#include "lsfg_dll.h"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_pacer.hpp"
|
||||
|
||||
@@ -22,6 +21,8 @@ constexpr float SOURCE_SMOOTHING = 0.15f;
|
||||
constexpr float SOURCE_STALE_SECONDS = 0.5f;
|
||||
constexpr float DISCONTINUITY_SECONDS = 0.25f;
|
||||
constexpr float HEADROOM_EPSILON = 0.02f;
|
||||
// A target is aimed just under, so rounding never puts output past the panel's refresh rate.
|
||||
constexpr float TARGET_MARGIN = 0.1f;
|
||||
constexpr float CREDIT_EPSILON = 1.0e-4f;
|
||||
constexpr float SOURCE_ACCUM_FLOOR = 0.01f;
|
||||
constexpr uint32_t MIN_RATE_SAMPLES = 12;
|
||||
@@ -117,6 +118,7 @@ LsfgPlan LsfgPacer::Plan(size_t capacity, uint64_t source_frames) {
|
||||
if (target_rate > 0.0f && config.refresh_rate > 0.0f) {
|
||||
target_rate = std::min(target_rate, config.refresh_rate);
|
||||
}
|
||||
if (target_rate > 0.0f) target_rate -= TARGET_MARGIN;
|
||||
|
||||
if (target_rate == 0.0f) {
|
||||
// No explicit target rate: the multiplier is the multiplier, full stop.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_shaders.hpp"
|
||||
#include "lsfg_common.hpp"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// ============================================================================
|
||||
// lsfg_vkd - the Vulkan entry points the LSFG chain calls, as a single global.
|
||||
//
|
||||
// Two dispatch styles meet here. Bannerlator's compositor resolves Vulkan
|
||||
// Two dispatch styles meet here. DroidDeck's compositor resolves Vulkan
|
||||
// through a per-context VkTable (`vk_`), because an adrenotools-loaded driver
|
||||
// lives in an isolated linker namespace and shares no global symbols with the
|
||||
// system loader. The LSFG chain, ported from eden by way of WinNative, calls
|
||||
// system loader. The LSFG chain, ported from Eden by way of WinNative, calls
|
||||
// through a file-scope `vkd`. Rather than rewrite ~2,300 lines of proven chain
|
||||
// code to thread a table through every call, this shim exposes exactly the 34
|
||||
// entry points the chain uses and is filled from the renderer's own table when
|
||||
|
||||
@@ -13,11 +13,13 @@ add_library(bannerwayland SHARED
|
||||
src/ahb_swapchain.c
|
||||
src/waylandcomp_jni.c
|
||||
src/banner_ext.c
|
||||
src/wl_dmabuf.c
|
||||
src/wl_clipboard.c
|
||||
src/wl_text_input.c
|
||||
src/wl_toplevel_icon.c
|
||||
src/wl_color_mgmt.c
|
||||
src/hdr_compose.c
|
||||
src/blend_pass.c
|
||||
generated/color-management-v1-protocol.c
|
||||
generated/xdg-shell-protocol.c
|
||||
generated/linux-dmabuf-v1-protocol.c
|
||||
@@ -33,6 +35,9 @@ add_library(bannerwayland SHARED
|
||||
src/framegen_bridge.c
|
||||
src/framegen_jni.c
|
||||
)
|
||||
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
|
||||
target_compile_options(bannerwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
|
||||
target_include_directories(bannerwayland PRIVATE
|
||||
generated
|
||||
prebuilt/include
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#version 450
|
||||
// Wayland buffers carry premultiplied alpha; the pipeline blends ONE, ONE_MINUS_SRC_ALPHA.
|
||||
layout(binding = 0) uniform sampler2D Surface;
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
outColor = texture(Surface, uv);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#version 450
|
||||
// One surface as a quad: dst = the target rectangle in NDC, src = the buffer rectangle in UV.
|
||||
layout(push_constant) uniform PC { vec4 dst; vec4 src; } pc;
|
||||
layout(location = 0) out vec2 uv;
|
||||
|
||||
void main() {
|
||||
vec2 t = vec2(gl_VertexIndex & 1, gl_VertexIndex >> 1);
|
||||
gl_Position = vec4(mix(pc.dst.xy, pc.dst.zw, t), 0.0, 1.0);
|
||||
uv = mix(pc.src.xy, pc.src.zw, t);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 1115.1.0
|
||||
#pragma once
|
||||
const uint32_t blend_frag_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x00000014,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x00000009,0x00000011,0x00030010,
|
||||
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
|
||||
0x00000000,0x00050005,0x00000009,0x4374756f,0x726f6c6f,0x00000000,0x00040005,0x0000000d,
|
||||
0x66727553,0x00656361,0x00030005,0x00000011,0x00007675,0x00040047,0x00000009,0x0000001e,
|
||||
0x00000000,0x00040047,0x0000000d,0x00000021,0x00000000,0x00040047,0x0000000d,0x00000022,
|
||||
0x00000000,0x00040047,0x00000011,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,
|
||||
0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,
|
||||
0x00000004,0x00040020,0x00000008,0x00000003,0x00000007,0x0004003b,0x00000008,0x00000009,
|
||||
0x00000003,0x00090019,0x0000000a,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,
|
||||
0x00000001,0x00000000,0x0003001b,0x0000000b,0x0000000a,0x00040020,0x0000000c,0x00000000,
|
||||
0x0000000b,0x0004003b,0x0000000c,0x0000000d,0x00000000,0x00040017,0x0000000f,0x00000006,
|
||||
0x00000002,0x00040020,0x00000010,0x00000001,0x0000000f,0x0004003b,0x00000010,0x00000011,
|
||||
0x00000001,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,
|
||||
0x0004003d,0x0000000b,0x0000000e,0x0000000d,0x0004003d,0x0000000f,0x00000012,0x00000011,
|
||||
0x00050057,0x00000007,0x00000013,0x0000000e,0x00000012,0x0003003e,0x00000009,0x00000013,
|
||||
0x000100fd,0x00010038
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
/* Alpha composition: one surface drawn source-over onto the scene - see blend_pass.h. Compositor thread. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "blend_pass.h"
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* banner_log, vkp_gpu_name */
|
||||
#include <string.h>
|
||||
|
||||
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
|
||||
* and committed next to the GLSL: the NDK build compiles no shaders. */
|
||||
#include "blend_vert.h"
|
||||
#include "blend_frag.h"
|
||||
|
||||
struct pc_blend { float dst[4]; float src[4]; };
|
||||
|
||||
static VkDevice g_dev;
|
||||
static int g_ready; /* 0 = not built, 1 = ok, -1 = unavailable (said once) */
|
||||
static VkSampler g_sampler;
|
||||
static VkDescriptorSetLayout g_dsl;
|
||||
static VkPipelineLayout g_pl;
|
||||
static VkDescriptorPool g_dpool;
|
||||
static VkShaderModule g_vert, g_frag;
|
||||
static unsigned g_frame = 1;
|
||||
|
||||
/* One render pass + pipeline per target format: the screen swapchain's, the scene image's, the HDR
|
||||
* composition's. */
|
||||
#define NFMT 4
|
||||
static struct { VkFormat fmt; VkRenderPass rp; VkPipeline pipe; } g_out[NFMT];
|
||||
|
||||
#define NSRC 32
|
||||
static struct { VkImage img; VkImageView view; VkDescriptorSet ds; unsigned frame; } g_src[NSRC];
|
||||
#define NDST 12
|
||||
static struct { VkImage img; VkFormat fmt; int w, h; VkImageView view; VkFramebuffer fb; unsigned frame; } g_dst[NDST];
|
||||
|
||||
void blendp_bind_device(VkDevice dev) { g_dev = dev; }
|
||||
|
||||
void blendp_begin_frame(void) { g_frame++; }
|
||||
|
||||
static void src_release(int i) {
|
||||
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
|
||||
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
|
||||
memset(&g_src[i], 0, sizeof(g_src[i]));
|
||||
}
|
||||
|
||||
static void dst_release(int i) {
|
||||
if (g_dst[i].fb) g_vk.DestroyFramebuffer(g_dev, g_dst[i].fb, NULL);
|
||||
if (g_dst[i].view) g_vk.DestroyImageView(g_dev, g_dst[i].view, NULL);
|
||||
memset(&g_dst[i], 0, sizeof(g_dst[i]));
|
||||
}
|
||||
|
||||
void blendp_forget_image(VkImage img) {
|
||||
if (!g_dev || !img) return;
|
||||
for (int i = 0; i < NSRC; i++) if (g_src[i].img == img) src_release(i);
|
||||
for (int i = 0; i < NDST; i++) if (g_dst[i].img == img) dst_release(i);
|
||||
}
|
||||
|
||||
static VkShaderModule shader(const uint32_t *code, size_t size) {
|
||||
VkShaderModule m = VK_NULL_HANDLE;
|
||||
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
|
||||
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return m;
|
||||
}
|
||||
|
||||
/* The target keeps what the blits put there (LOAD) and stays in TRANSFER_DST_OPTIMAL for the blits
|
||||
* that follow. The dependencies order the pass between them. */
|
||||
static VkRenderPass make_rp(VkFormat fmt) {
|
||||
VkAttachmentDescription att = {.format = fmt, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
|
||||
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
|
||||
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
|
||||
.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL};
|
||||
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
|
||||
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
|
||||
.pColorAttachments = &ref};
|
||||
VkSubpassDependency deps[2] = {
|
||||
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT},
|
||||
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT}};
|
||||
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
|
||||
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
|
||||
.dependencyCount = 2, .pDependencies = deps};
|
||||
VkRenderPass rp = VK_NULL_HANDLE;
|
||||
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &rp) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return rp;
|
||||
}
|
||||
|
||||
static VkPipeline make_pipe(VkRenderPass rp) {
|
||||
VkPipelineShaderStageCreateInfo st[2] = {
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT,
|
||||
.module = g_vert, .pName = "main"},
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT,
|
||||
.module = g_frag, .pName = "main"}};
|
||||
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
|
||||
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
|
||||
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
|
||||
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
|
||||
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
|
||||
.dynamicStateCount = 2, .pDynamicStates = dyn};
|
||||
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
|
||||
.viewportCount = 1, .scissorCount = 1};
|
||||
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
|
||||
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
|
||||
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
|
||||
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
|
||||
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
|
||||
/* The target's alpha is left alone: the screen is opaque whatever is drawn onto it. */
|
||||
VkPipelineColorBlendAttachmentState ba = {
|
||||
.blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
|
||||
.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_ADD,
|
||||
.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
|
||||
.alphaBlendOp = VK_BLEND_OP_ADD,
|
||||
.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT};
|
||||
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
|
||||
.attachmentCount = 1, .pAttachments = &ba};
|
||||
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
|
||||
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
|
||||
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
|
||||
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
|
||||
.renderPass = rp, .subpass = 0};
|
||||
VkPipeline p = VK_NULL_HANDLE;
|
||||
if (g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void objects_destroy(void) {
|
||||
if (g_vert) g_vk.DestroyShaderModule(g_dev, g_vert, NULL);
|
||||
if (g_frag) g_vk.DestroyShaderModule(g_dev, g_frag, NULL);
|
||||
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
|
||||
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
|
||||
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
|
||||
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
|
||||
g_vert = g_frag = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
|
||||
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
static int objects_ensure(void) {
|
||||
if (g_ready) return g_ready == 1 ? 0 : -1;
|
||||
g_ready = -1;
|
||||
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
|
||||
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
|
||||
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
|
||||
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
|
||||
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
|
||||
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
|
||||
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
|
||||
.bindingCount = 1, .pBindings = &b};
|
||||
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
|
||||
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT, .offset = 0, .size = sizeof(struct pc_blend)};
|
||||
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
|
||||
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
|
||||
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
|
||||
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, NSRC};
|
||||
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
|
||||
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, .maxSets = NSRC,
|
||||
.poolSizeCount = 1, .pPoolSizes = &ps};
|
||||
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
|
||||
if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail;
|
||||
if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail;
|
||||
g_ready = 1;
|
||||
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
|
||||
vkp_gpu_name());
|
||||
return 0;
|
||||
fail:
|
||||
objects_destroy();
|
||||
banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
|
||||
"are drawn opaque this session");
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int out_slot(VkFormat fmt) {
|
||||
int i;
|
||||
for (i = 0; i < NFMT && g_out[i].pipe; i++) if (g_out[i].fmt == fmt) return i;
|
||||
if (i == NFMT) return -1;
|
||||
VkRenderPass rp = make_rp(fmt);
|
||||
VkPipeline pipe = rp ? make_pipe(rp) : VK_NULL_HANDLE;
|
||||
if (!pipe) {
|
||||
if (rp) g_vk.DestroyRenderPass(g_dev, rp, NULL);
|
||||
return -1;
|
||||
}
|
||||
g_out[i].fmt = fmt; g_out[i].rp = rp; g_out[i].pipe = pipe;
|
||||
return i;
|
||||
}
|
||||
|
||||
static VkImageView make_view(VkImage img, VkFormat fmt) {
|
||||
VkImageView v = VK_NULL_HANDLE;
|
||||
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
|
||||
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = fmt,
|
||||
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
|
||||
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return v;
|
||||
}
|
||||
|
||||
/* The slot to recycle: a free one, else the one unused for longest - never one this command buffer
|
||||
* already refers to. -1 = every slot is in this frame. */
|
||||
#define OLDEST(arr, n, out) do { \
|
||||
unsigned age_ = 0; \
|
||||
(out) = -1; \
|
||||
for (int k_ = 0; k_ < (n); k_++) { \
|
||||
if (!(arr)[k_].img) { (out) = k_; break; } \
|
||||
if ((arr)[k_].frame != g_frame && g_frame - (arr)[k_].frame > age_) { age_ = g_frame - (arr)[k_].frame; (out) = k_; } \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static VkDescriptorSet src_ensure(VkImage img, VkFormat fmt, VkImageLayout layout) {
|
||||
int i;
|
||||
for (i = 0; i < NSRC; i++)
|
||||
if (g_src[i].img == img) { g_src[i].frame = g_frame; return g_src[i].ds; }
|
||||
OLDEST(g_src, NSRC, i);
|
||||
if (i < 0) return VK_NULL_HANDLE;
|
||||
src_release(i);
|
||||
if (!(g_src[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
|
||||
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
|
||||
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
|
||||
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &g_src[i].ds) != VK_SUCCESS) {
|
||||
g_src[i].ds = VK_NULL_HANDLE;
|
||||
src_release(i);
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = g_src[i].view, .imageLayout = layout};
|
||||
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = g_src[i].ds, .dstBinding = 0,
|
||||
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.pImageInfo = &ii};
|
||||
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
|
||||
g_src[i].img = img; g_src[i].frame = g_frame;
|
||||
return g_src[i].ds;
|
||||
}
|
||||
|
||||
static VkFramebuffer dst_ensure(VkImage img, VkFormat fmt, int w, int h, VkRenderPass rp) {
|
||||
int i;
|
||||
for (i = 0; i < NDST; i++) {
|
||||
if (g_dst[i].img != img) continue;
|
||||
if (g_dst[i].fmt == fmt && g_dst[i].w == w && g_dst[i].h == h) { g_dst[i].frame = g_frame; return g_dst[i].fb; }
|
||||
if (g_dst[i].frame == g_frame) return VK_NULL_HANDLE;
|
||||
break;
|
||||
}
|
||||
if (i == NDST) OLDEST(g_dst, NDST, i);
|
||||
if (i < 0) return VK_NULL_HANDLE;
|
||||
dst_release(i);
|
||||
if (!(g_dst[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
|
||||
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = rp,
|
||||
.attachmentCount = 1, .pAttachments = &g_dst[i].view, .width = (uint32_t)w,
|
||||
.height = (uint32_t)h, .layers = 1};
|
||||
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_dst[i].fb) != VK_SUCCESS) {
|
||||
g_dst[i].fb = VK_NULL_HANDLE;
|
||||
dst_release(i);
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
g_dst[i].img = img; g_dst[i].fmt = fmt; g_dst[i].w = w; g_dst[i].h = h; g_dst[i].frame = g_frame;
|
||||
return g_dst[i].fb;
|
||||
}
|
||||
|
||||
static void src_layout(VkCommandBuffer cmd, VkImage img, VkImageLayout from, VkImageLayout to) {
|
||||
int sampling = to == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
VkImageMemoryBarrier b = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = from, .newLayout = to,
|
||||
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||
.image = img, .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1},
|
||||
.srcAccessMask = sampling ? VK_ACCESS_TRANSFER_READ_BIT : VK_ACCESS_SHADER_READ_BIT,
|
||||
.dstAccessMask = sampling ? VK_ACCESS_SHADER_READ_BIT : VK_ACCESS_TRANSFER_READ_BIT};
|
||||
g_vk.CmdPipelineBarrier(cmd, sampling ? VK_PIPELINE_STAGE_TRANSFER_BIT : VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
||||
sampling ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
0, 0, NULL, 0, NULL, 1, &b);
|
||||
}
|
||||
|
||||
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
|
||||
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit) {
|
||||
if (!src || !dst || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0 || objects_ensure() != 0) return -1;
|
||||
int slot = out_slot(dst_fmt);
|
||||
if (slot < 0) return -1;
|
||||
VkImageLayout sampled = src_general ? VK_IMAGE_LAYOUT_GENERAL : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
VkDescriptorSet ds = src_ensure(src, src_fmt, sampled);
|
||||
if (!ds) return -1;
|
||||
VkFramebuffer fb = dst_ensure(dst, dst_fmt, dst_w, dst_h, g_out[slot].rp);
|
||||
if (!fb) return -1;
|
||||
|
||||
const VkOffset3D *d = blit->dstOffsets, *s = blit->srcOffsets;
|
||||
int x0 = d[0].x < d[1].x ? d[0].x : d[1].x, x1 = d[0].x < d[1].x ? d[1].x : d[0].x;
|
||||
int y0 = d[0].y < d[1].y ? d[0].y : d[1].y, y1 = d[0].y < d[1].y ? d[1].y : d[0].y;
|
||||
if (x0 < 0) x0 = 0;
|
||||
if (y0 < 0) y0 = 0;
|
||||
if (x1 > dst_w) x1 = dst_w;
|
||||
if (y1 > dst_h) y1 = dst_h;
|
||||
if (x1 <= x0 || y1 <= y0) return 0;
|
||||
|
||||
struct pc_blend pc = {
|
||||
.dst = {2.0f * d[0].x / dst_w - 1.0f, 2.0f * d[0].y / dst_h - 1.0f,
|
||||
2.0f * d[1].x / dst_w - 1.0f, 2.0f * d[1].y / dst_h - 1.0f},
|
||||
.src = {(float)s[0].x / src_w, (float)s[0].y / src_h, (float)s[1].x / src_w, (float)s[1].y / src_h}};
|
||||
|
||||
if (!src_general) src_layout(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, sampled);
|
||||
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_out[slot].rp,
|
||||
.framebuffer = fb,
|
||||
.renderArea = {{x0, y0}, {(uint32_t)(x1 - x0), (uint32_t)(y1 - y0)}}};
|
||||
VkViewport vp = {0, 0, (float)dst_w, (float)dst_h, 0, 1};
|
||||
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
|
||||
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
|
||||
g_vk.CmdSetScissor(cmd, 0, 1, &rpi.renderArea);
|
||||
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_out[slot].pipe);
|
||||
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
|
||||
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
|
||||
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
|
||||
g_vk.CmdEndRenderPass(cmd);
|
||||
if (!src_general) src_layout(cmd, src, sampled, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#ifndef BLEND_PASS_H
|
||||
#define BLEND_PASS_H
|
||||
/*
|
||||
* Alpha composition. The scene is composed with blits, which replace what is under them; a surface
|
||||
* with translucent pixels (gamescope's overlay and notification planes over a game, a toolkit's
|
||||
* shadowed popup) is drawn through this pass instead, source-over with premultiplied alpha.
|
||||
*
|
||||
* Compositor thread. Objects are built on the first use; if the driver refuses them the pass is
|
||||
* unavailable for the session (logged once) and the caller keeps blitting.
|
||||
*/
|
||||
#define VK_USE_PLATFORM_ANDROID_KHR
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
/* Called from dev_init (device up). */
|
||||
void blendp_bind_device(VkDevice dev);
|
||||
/* A source or target image is about to be destroyed: drop what is cached for it. Frames are fenced,
|
||||
* so nothing submitted still uses it. */
|
||||
void blendp_forget_image(VkImage img);
|
||||
/* A new command buffer is being recorded: what the previous one used may be recycled again. */
|
||||
void blendp_begin_frame(void);
|
||||
|
||||
/* Record one surface over `dst`. `dst` is in TRANSFER_DST_OPTIMAL before and after. `src` was created
|
||||
* with SAMPLED usage and is in GENERAL (src_general) or TRANSFER_SRC_OPTIMAL, and is left there.
|
||||
* `blit` is the draw as the blit path maps it. 0 = recorded, -1 = unavailable (nothing recorded). */
|
||||
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
|
||||
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,55 @@
|
||||
// 1115.1.0
|
||||
#pragma once
|
||||
const uint32_t blend_vert_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x0000003a,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0008000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000c,0x0000001b,0x00000031,
|
||||
0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,0x00000000,0x00030005,
|
||||
0x00000009,0x00000074,0x00060005,0x0000000c,0x565f6c67,0x65747265,0x646e4978,0x00007865,
|
||||
0x00060005,0x00000019,0x505f6c67,0x65567265,0x78657472,0x00000000,0x00060006,0x00000019,
|
||||
0x00000000,0x505f6c67,0x7469736f,0x006e6f69,0x00070006,0x00000019,0x00000001,0x505f6c67,
|
||||
0x746e696f,0x657a6953,0x00000000,0x00070006,0x00000019,0x00000002,0x435f6c67,0x4470696c,
|
||||
0x61747369,0x0065636e,0x00070006,0x00000019,0x00000003,0x435f6c67,0x446c6c75,0x61747369,
|
||||
0x0065636e,0x00030005,0x0000001b,0x00000000,0x00030005,0x0000001d,0x00004350,0x00040006,
|
||||
0x0000001d,0x00000000,0x00747364,0x00040006,0x0000001d,0x00000001,0x00637273,0x00030005,
|
||||
0x0000001f,0x00006370,0x00030005,0x00000031,0x00007675,0x00040047,0x0000000c,0x0000000b,
|
||||
0x0000002a,0x00030047,0x00000019,0x00000002,0x00050048,0x00000019,0x00000000,0x0000000b,
|
||||
0x00000000,0x00050048,0x00000019,0x00000001,0x0000000b,0x00000001,0x00050048,0x00000019,
|
||||
0x00000002,0x0000000b,0x00000003,0x00050048,0x00000019,0x00000003,0x0000000b,0x00000004,
|
||||
0x00030047,0x0000001d,0x00000002,0x00050048,0x0000001d,0x00000000,0x00000023,0x00000000,
|
||||
0x00050048,0x0000001d,0x00000001,0x00000023,0x00000010,0x00040047,0x00000031,0x0000001e,
|
||||
0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,
|
||||
0x00000020,0x00040017,0x00000007,0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,
|
||||
0x00000007,0x00040015,0x0000000a,0x00000020,0x00000001,0x00040020,0x0000000b,0x00000001,
|
||||
0x0000000a,0x0004003b,0x0000000b,0x0000000c,0x00000001,0x0004002b,0x0000000a,0x0000000e,
|
||||
0x00000001,0x00040017,0x00000015,0x00000006,0x00000004,0x00040015,0x00000016,0x00000020,
|
||||
0x00000000,0x0004002b,0x00000016,0x00000017,0x00000001,0x0004001c,0x00000018,0x00000006,
|
||||
0x00000017,0x0006001e,0x00000019,0x00000015,0x00000006,0x00000018,0x00000018,0x00040020,
|
||||
0x0000001a,0x00000003,0x00000019,0x0004003b,0x0000001a,0x0000001b,0x00000003,0x0004002b,
|
||||
0x0000000a,0x0000001c,0x00000000,0x0004001e,0x0000001d,0x00000015,0x00000015,0x00040020,
|
||||
0x0000001e,0x00000009,0x0000001d,0x0004003b,0x0000001e,0x0000001f,0x00000009,0x00040020,
|
||||
0x00000020,0x00000009,0x00000015,0x0004002b,0x00000006,0x00000029,0x00000000,0x0004002b,
|
||||
0x00000006,0x0000002a,0x3f800000,0x00040020,0x0000002e,0x00000003,0x00000015,0x00040020,
|
||||
0x00000030,0x00000003,0x00000007,0x0004003b,0x00000030,0x00000031,0x00000003,0x00050036,
|
||||
0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000008,
|
||||
0x00000009,0x00000007,0x0004003d,0x0000000a,0x0000000d,0x0000000c,0x000500c7,0x0000000a,
|
||||
0x0000000f,0x0000000d,0x0000000e,0x0004006f,0x00000006,0x00000010,0x0000000f,0x0004003d,
|
||||
0x0000000a,0x00000011,0x0000000c,0x000500c3,0x0000000a,0x00000012,0x00000011,0x0000000e,
|
||||
0x0004006f,0x00000006,0x00000013,0x00000012,0x00050050,0x00000007,0x00000014,0x00000010,
|
||||
0x00000013,0x0003003e,0x00000009,0x00000014,0x00050041,0x00000020,0x00000021,0x0000001f,
|
||||
0x0000001c,0x0004003d,0x00000015,0x00000022,0x00000021,0x0007004f,0x00000007,0x00000023,
|
||||
0x00000022,0x00000022,0x00000000,0x00000001,0x00050041,0x00000020,0x00000024,0x0000001f,
|
||||
0x0000001c,0x0004003d,0x00000015,0x00000025,0x00000024,0x0007004f,0x00000007,0x00000026,
|
||||
0x00000025,0x00000025,0x00000002,0x00000003,0x0004003d,0x00000007,0x00000027,0x00000009,
|
||||
0x0008000c,0x00000007,0x00000028,0x00000001,0x0000002e,0x00000023,0x00000026,0x00000027,
|
||||
0x00050051,0x00000006,0x0000002b,0x00000028,0x00000000,0x00050051,0x00000006,0x0000002c,
|
||||
0x00000028,0x00000001,0x00070050,0x00000015,0x0000002d,0x0000002b,0x0000002c,0x00000029,
|
||||
0x0000002a,0x00050041,0x0000002e,0x0000002f,0x0000001b,0x0000001c,0x0003003e,0x0000002f,
|
||||
0x0000002d,0x00050041,0x00000020,0x00000032,0x0000001f,0x0000000e,0x0004003d,0x00000015,
|
||||
0x00000033,0x00000032,0x0007004f,0x00000007,0x00000034,0x00000033,0x00000033,0x00000000,
|
||||
0x00000001,0x00050041,0x00000020,0x00000035,0x0000001f,0x0000000e,0x0004003d,0x00000015,
|
||||
0x00000036,0x00000035,0x0007004f,0x00000007,0x00000037,0x00000036,0x00000036,0x00000002,
|
||||
0x00000003,0x0004003d,0x00000007,0x00000038,0x00000009,0x0008000c,0x00000007,0x00000039,
|
||||
0x00000001,0x0000002e,0x00000034,0x00000037,0x00000038,0x0003003e,0x00000031,0x00000039,
|
||||
0x000100fd,0x00010038
|
||||
};
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <time.h>
|
||||
#include <stdarg.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <sys/timerfd.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysmacros.h>
|
||||
@@ -51,6 +52,7 @@
|
||||
#include "effects_chain.h"
|
||||
#include "banner_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "compositor_internal.h"
|
||||
|
||||
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
|
||||
@@ -161,21 +163,9 @@ static int log_budget(void) {
|
||||
|
||||
static struct wl_display *g_display;
|
||||
|
||||
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
|
||||
struct client_info {
|
||||
struct wl_client *client;
|
||||
struct wl_listener destroy;
|
||||
pid_t pid;
|
||||
char name[64];
|
||||
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
|
||||
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
|
||||
unsigned asked_feedback : 1, shm_gl_said : 1;
|
||||
unsigned dmabuf_buffers, shm_frames;
|
||||
struct client_info *next;
|
||||
};
|
||||
static struct client_info *g_clients;
|
||||
|
||||
static struct client_info *client_info_of(struct wl_client *client) {
|
||||
struct client_info *client_info_of(struct wl_client *client) {
|
||||
for (struct client_info *ci = g_clients; ci; ci = ci->next)
|
||||
if (ci->client == client) return ci;
|
||||
return NULL;
|
||||
@@ -248,6 +238,8 @@ struct surface {
|
||||
struct dmabuf_buffer *dmabuf_buf; /* its imported image, kept until replaced or the surface goes */
|
||||
struct wl_listener dmabuf_destroy;
|
||||
int buf_w, buf_h, has_content;
|
||||
int buf_alpha;
|
||||
int opaque[4], pending_opaque[4], pending_opaque_set; /* x, y, w, h; w = 0: none */
|
||||
int src_set, dst_set;
|
||||
float src[4];
|
||||
int dst[2];
|
||||
@@ -258,6 +250,10 @@ struct surface {
|
||||
int releases_pending; /* FPS limiter: buffers of this surface still to be released */
|
||||
|
||||
enum surface_role role;
|
||||
/* A role-less surface's last buffer, held unreleased: it may become the pointer's image
|
||||
* (wl_pointer.set_cursor after the commit, which is wlroots' order). */
|
||||
struct wl_resource *idle_buffer;
|
||||
struct wl_listener idle_buffer_destroy;
|
||||
struct wl_resource *xdg_surface, *xdg_toplevel;
|
||||
struct wl_resource *viewport;
|
||||
|
||||
@@ -439,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
|
||||
* delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier
|
||||
* limit had spaced out, and the game's first frames after that waited on empty slots. */
|
||||
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
|
||||
int limit = g_fps_limit;
|
||||
if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
int64_t interval = 1000000000LL / limit, now = now_ns();
|
||||
const int limit = g_fps_limit;
|
||||
/* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for
|
||||
* frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced
|
||||
* buffer straight back let it draw as fast as the GPU allowed and everything above the refresh
|
||||
* rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05).
|
||||
* One release per refresh is the back-pressure X11 gives, and the screen shows the same frames
|
||||
* either way. */
|
||||
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
|
||||
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
int64_t now = now_ns();
|
||||
int64_t at = s->next_release_ns + interval;
|
||||
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
|
||||
if (at < now) at = now;
|
||||
@@ -472,6 +475,12 @@ static struct seat_pointer g_ptrs[MAX_PTRS];
|
||||
static int g_nptrs;
|
||||
static struct seat_keyboard g_kbs[MAX_PTRS];
|
||||
static int g_nkbs;
|
||||
/* Modifier state in the keymap's real-modifier bits (Shift=0x1, Lock=0x2, Control=0x4, Mod1=0x8,
|
||||
* Mod4=0x40). A client of the wayland backend - gamescope, labwc - takes modifiers only from
|
||||
* wl_keyboard.modifiers and ignores what a Shift key event does to its xkb state, so without this
|
||||
* every capital arrived lowercase. */
|
||||
static uint32_t g_mods_depressed, g_mods_locked;
|
||||
static uint32_t g_mod_keys_held; /* one bit per modifier key below, so a repeat press is harmless */
|
||||
static struct seat_touch g_touches[MAX_PTRS];
|
||||
static int g_ntouches;
|
||||
|
||||
@@ -486,6 +495,7 @@ static int g_ntouches;
|
||||
#define CURSOR_MAX_PX (256 * 256)
|
||||
static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct surface *g_cursor_surface; /* compositor thread only */
|
||||
static struct surface *g_cursor_shown; /* compositor thread: whose image g_cursor_px holds */
|
||||
static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */
|
||||
static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */
|
||||
static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */
|
||||
@@ -493,6 +503,14 @@ static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */
|
||||
static int g_cursor_hidden = 1; /* the client asked for no pointer */
|
||||
static int g_cursor_serial; /* bumped on every change; the app polls it */
|
||||
|
||||
/* The last image again, after a hide: the same surface set as the pointer once more with nothing
|
||||
* newly committed to it (wlroots re-sets its cursor surface each time the pointer enters). */
|
||||
static void cursor_publish_shown(void) {
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
if (g_cursor_w > 0) { g_cursor_hidden = 0; g_cursor_serial++; } /* serial: the hotspot may be new */
|
||||
pthread_mutex_unlock(&g_cursor_lock);
|
||||
}
|
||||
|
||||
static void cursor_publish_hidden(void) {
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; }
|
||||
@@ -500,7 +518,7 @@ static void cursor_publish_hidden(void) {
|
||||
}
|
||||
|
||||
static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t stride, int hx, int hy) {
|
||||
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
|
||||
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
for (int y = 0; y < h; y++)
|
||||
memcpy(&g_cursor_px[y * w], src + (size_t)y * stride, (size_t)w * 4);
|
||||
@@ -513,13 +531,23 @@ static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t strid
|
||||
|
||||
static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
|
||||
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
|
||||
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
|
||||
int32_t stride = wl_shm_buffer_get_stride(shm);
|
||||
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
|
||||
/* libwayland only promises stride >= width; each row copied here is width * 4 bytes, so a
|
||||
* shorter stride would read past the end of the client's pool. */
|
||||
if ((int64_t)stride < (int64_t)w * 4) return;
|
||||
wl_shm_buffer_begin_access(shm);
|
||||
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h,
|
||||
(size_t)wl_shm_buffer_get_stride(shm), hx, hy);
|
||||
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, (size_t)stride, hx, hy);
|
||||
wl_shm_buffer_end_access(shm);
|
||||
}
|
||||
|
||||
/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below
|
||||
* copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to
|
||||
* change the cursor. A torn read is only a missed or an extra snapshot. */
|
||||
int banner_cursor_serial(void) {
|
||||
return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED);
|
||||
}
|
||||
|
||||
/* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...].
|
||||
* Returns the number of ints written, or 0 if out is too small. */
|
||||
int banner_cursor_snapshot(int *out, int cap) {
|
||||
@@ -557,40 +585,7 @@ static void constraints_focus_entered(struct wl_resource *target, struct wl_clie
|
||||
|
||||
/* ------------------------------------------------------------------ dmabuf buffers */
|
||||
|
||||
#define FOURCC(a, b, c, d) \
|
||||
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
|
||||
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
|
||||
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
|
||||
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
|
||||
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
|
||||
#define MOD_LINEAR VKP_MOD_LINEAR
|
||||
#define MOD_INVALID VKP_MOD_INVALID
|
||||
#define MAX_PLANES 4
|
||||
|
||||
struct dmabuf_params {
|
||||
int fd[MAX_PLANES];
|
||||
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
|
||||
uint64_t modifier[MAX_PLANES];
|
||||
int n_planes;
|
||||
};
|
||||
struct dmabuf_buffer {
|
||||
int fd[MAX_PLANES];
|
||||
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
|
||||
int n_planes;
|
||||
int32_t width, height;
|
||||
uint32_t format;
|
||||
uint64_t modifier;
|
||||
struct vkp_image *img; /* imported once, reused for every frame */
|
||||
int import_failed;
|
||||
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
|
||||
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
|
||||
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
|
||||
* until the surface commits something newer, so the picture never blinks to black. */
|
||||
int refs;
|
||||
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
|
||||
};
|
||||
|
||||
static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
|
||||
void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
|
||||
if (!b || --b->refs > 0) return;
|
||||
vkp_image_destroy(b->img);
|
||||
for (int i = 0; i < b->n_planes; i++)
|
||||
@@ -601,7 +596,7 @@ static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
|
||||
static void dbuf_buffer_destroy_req(struct wl_client *c, struct wl_resource *r) {
|
||||
wl_resource_destroy(r);
|
||||
}
|
||||
static const struct wl_buffer_interface dbuf_buffer_impl = {
|
||||
const struct wl_buffer_interface dbuf_buffer_impl = {
|
||||
.destroy = dbuf_buffer_destroy_req,
|
||||
};
|
||||
|
||||
@@ -743,6 +738,7 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
|
||||
wl_buffer_send_release(buffer);
|
||||
s->buf_w = w;
|
||||
s->buf_h = h;
|
||||
s->buf_alpha = wl_shm_buffer_get_format(shm) == WL_SHM_FORMAT_ARGB8888;
|
||||
s->has_content = s->shm_img != NULL;
|
||||
g_stat_shm++;
|
||||
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
|
||||
@@ -794,6 +790,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
}
|
||||
s->buf_w = b->width;
|
||||
s->buf_h = b->height;
|
||||
s->buf_alpha = (b->format & 0xff) == 'A';
|
||||
s->has_content = b->img != NULL;
|
||||
g_stat_dmabuf++;
|
||||
const int announced_now = !s->announced_vulkan;
|
||||
@@ -965,13 +962,94 @@ static void surface_frame(struct wl_client *c, struct wl_resource *r, uint32_t c
|
||||
wl_resource_set_implementation(callback, NULL, NULL, frame_callback_destroy);
|
||||
wl_list_insert(s->pending_frames.prev, wl_resource_get_link(callback));
|
||||
}
|
||||
/* The bounding box serves pointer confinement. The exact flag also tells opaque-region handling
|
||||
* whether the bounding box itself is the whole region. */
|
||||
struct region { int set, exact; int x, y, w, h; };
|
||||
|
||||
/* A wl_surface's opaque region is pending until its next commit. An exact region that covers
|
||||
* the surface lets the blit path keep handling an otherwise alpha-capable buffer. */
|
||||
static void surface_set_opaque(struct wl_client *c, struct wl_resource *r,
|
||||
struct wl_resource *region) {}
|
||||
struct wl_resource *region) {
|
||||
struct surface *s = wl_resource_get_user_data(r);
|
||||
struct region *rg = region ? wl_resource_get_user_data(region) : NULL;
|
||||
memset(s->pending_opaque, 0, sizeof(s->pending_opaque));
|
||||
s->pending_opaque_set = 1;
|
||||
if (rg && rg->set && rg->exact) {
|
||||
s->pending_opaque[0] = rg->x;
|
||||
s->pending_opaque[1] = rg->y;
|
||||
s->pending_opaque[2] = rg->w;
|
||||
s->pending_opaque[3] = rg->h;
|
||||
}
|
||||
struct client_info *ci = client_info_of(c);
|
||||
if (ci) ci->declares_opaque = 1;
|
||||
}
|
||||
static void surface_set_input(struct wl_client *c, struct wl_resource *r,
|
||||
struct wl_resource *region) {}
|
||||
|
||||
static void send_toplevel_configure(struct surface *s);
|
||||
|
||||
/* The pointer image from a cursor surface's buffer: wl_shm is copied, a dma-buf (labwc on a GPU
|
||||
* renderer) is read back once the client's render into it is done - its implicit fence, as the
|
||||
* zero-copy path waits for it (ahb_swapchain_present). */
|
||||
static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) {
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
|
||||
if (shm) {
|
||||
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
|
||||
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
|
||||
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
|
||||
if (!db->img && !db->import_failed) {
|
||||
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
|
||||
db->stride[0], db->offset[0]);
|
||||
if (!db->img) db->import_failed = 1;
|
||||
}
|
||||
if (!db->img) return;
|
||||
struct pollfd p = {.fd = db->fd[0], .events = POLLIN};
|
||||
int r;
|
||||
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
|
||||
if (vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) != 0) return;
|
||||
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
|
||||
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
g_cursor_shown = s;
|
||||
}
|
||||
|
||||
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
|
||||
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
|
||||
wl_list_remove(&s->idle_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
s->idle_buffer = NULL;
|
||||
}
|
||||
|
||||
/* Only what could be a pointer image is held; anything larger goes straight back. */
|
||||
static void surface_hold_idle(struct surface *s, struct wl_resource *buffer) {
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
|
||||
int64_t px = db ? (int64_t)db->width * db->height
|
||||
: shm ? (int64_t)wl_shm_buffer_get_width(shm) * wl_shm_buffer_get_height(shm) : 0;
|
||||
if (px <= 0 || px > CURSOR_MAX_PX) { wl_buffer_send_release(buffer); return; }
|
||||
s->idle_buffer = buffer;
|
||||
s->idle_buffer_destroy.notify = on_idle_buffer_destroyed;
|
||||
wl_resource_add_destroy_listener(buffer, &s->idle_buffer_destroy);
|
||||
}
|
||||
|
||||
/* Lets go of the held buffer; release = give it back to the client (not while it is going away). */
|
||||
static struct wl_resource *surface_take_idle(struct surface *s) {
|
||||
struct wl_resource *buffer = s->idle_buffer;
|
||||
if (!buffer) return NULL;
|
||||
wl_list_remove(&s->idle_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
s->idle_buffer = NULL;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static void surface_drop_idle(struct surface *s) {
|
||||
struct wl_resource *buffer = surface_take_idle(s);
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
}
|
||||
|
||||
static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
struct surface *s = wl_resource_get_user_data(r);
|
||||
struct surface *child;
|
||||
@@ -995,6 +1073,10 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
memcpy(s->dst, s->pending_dst, sizeof(s->dst));
|
||||
s->pending_dst_set = 0;
|
||||
}
|
||||
if (s->pending_opaque_set) {
|
||||
memcpy(s->opaque, s->pending_opaque, sizeof(s->opaque));
|
||||
s->pending_opaque_set = 0;
|
||||
}
|
||||
|
||||
if (s->pending_attach) {
|
||||
struct wl_resource *buffer = s->pending_buffer;
|
||||
@@ -1011,27 +1093,19 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
s->pending_buffer = NULL;
|
||||
s->pending_attach = 0;
|
||||
|
||||
surface_drop_idle(s); /* replaced, whatever the surface is now */
|
||||
if (s->role == ROLE_CURSOR) {
|
||||
/* The client's pointer image, copied out for the app's overlay (see cursor_publish_*)
|
||||
* rather than composited, so it survives the zero-copy and HDR layer paths. */
|
||||
if (!buffer) {
|
||||
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
|
||||
} else if (shm) {
|
||||
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
|
||||
} else if (db && db->n_planes >= 1 && db->width * db->height <= CURSOR_MAX_PX) {
|
||||
if (!db->img && !db->import_failed) {
|
||||
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
|
||||
db->stride[0], db->offset[0]);
|
||||
if (!db->img) db->import_failed = 1;
|
||||
}
|
||||
if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0)
|
||||
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
|
||||
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
|
||||
} else {
|
||||
cursor_publish_buffer(s, buffer);
|
||||
wl_buffer_send_release(buffer);
|
||||
}
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
} else if (s->role == ROLE_NONE) {
|
||||
/* Role-less surface: never drawn. */
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
/* Role-less surface: never drawn, but its buffer is kept for a set_cursor to come. */
|
||||
if (buffer) surface_hold_idle(s, buffer);
|
||||
} else if (db) {
|
||||
take_dmabuf(s, db, buffer);
|
||||
} else if (shm) {
|
||||
@@ -1103,6 +1177,8 @@ static void surface_resource_destroy(struct wl_resource *r) {
|
||||
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL;
|
||||
touch_cancel_surface(s);
|
||||
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);
|
||||
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;
|
||||
@@ -1139,24 +1215,29 @@ static void surface_resource_destroy(struct wl_resource *r) {
|
||||
|
||||
/* ------------------------------------------------------------------ wl_region */
|
||||
|
||||
/* A region is kept as the bounding box of its rectangles: enough for pointer confinement,
|
||||
* where winewayland sends one rectangle (the ClipCursor area). Subtractions are ignored. */
|
||||
struct region { int set; int x, y, w, h; };
|
||||
|
||||
static void region_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void region_add(struct wl_client *c, struct wl_resource *r,
|
||||
int32_t x, int32_t y, int32_t w, int32_t h) {
|
||||
struct region *rg = wl_resource_get_user_data(r);
|
||||
if (!rg || w <= 0 || h <= 0) return;
|
||||
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = 1; return; }
|
||||
int x2 = rg->x + rg->w > x + w ? rg->x + rg->w : x + w;
|
||||
int y2 = rg->y + rg->h > y + h ? rg->y + rg->h : y + h;
|
||||
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = rg->exact = 1; return; }
|
||||
long long x2 = (long long)rg->x + rg->w, y2 = (long long)rg->y + rg->h;
|
||||
long long nx2 = (long long)x + w, ny2 = (long long)y + h;
|
||||
int inside = x >= rg->x && y >= rg->y && nx2 <= x2 && ny2 <= y2;
|
||||
int around = x <= rg->x && y <= rg->y && nx2 >= x2 && ny2 >= y2;
|
||||
if (!inside && !around) rg->exact = 0;
|
||||
if (nx2 > x2) x2 = nx2;
|
||||
if (ny2 > y2) y2 = ny2;
|
||||
if (x < rg->x) rg->x = x;
|
||||
if (y < rg->y) rg->y = y;
|
||||
rg->w = x2 - rg->x; rg->h = y2 - rg->y;
|
||||
rg->w = (int)(x2 - rg->x > INT32_MAX ? INT32_MAX : x2 - rg->x);
|
||||
rg->h = (int)(y2 - rg->y > INT32_MAX ? INT32_MAX : y2 - rg->y);
|
||||
}
|
||||
static void region_subtract(struct wl_client *c, struct wl_resource *r,
|
||||
int32_t x, int32_t y, int32_t w, int32_t h) {}
|
||||
int32_t x, int32_t y, int32_t w, int32_t h) {
|
||||
struct region *rg = wl_resource_get_user_data(r);
|
||||
if (rg && w > 0 && h > 0) rg->exact = 0;
|
||||
}
|
||||
static void region_resource_destroy(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
|
||||
static const struct wl_region_interface region_impl = {
|
||||
.destroy = region_destroy,
|
||||
@@ -1179,6 +1260,7 @@ static void compositor_create_surface(struct wl_client *c, struct wl_resource *r
|
||||
wl_list_init(&s->child_link);
|
||||
wl_list_init(&s->toplevel_link);
|
||||
wl_list_init(&s->pending_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
wl_list_insert(&g_surfaces, &s->link);
|
||||
wl_resource_set_implementation(s->resource, &surface_impl, s, surface_resource_destroy);
|
||||
}
|
||||
@@ -1570,319 +1652,6 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
|
||||
wl_resource_set_implementation(r, &desktop_impl, NULL, NULL);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
|
||||
|
||||
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
|
||||
dmabuf_buffer_unref(wl_resource_get_user_data(r));
|
||||
}
|
||||
|
||||
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
|
||||
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
if (plane >= MAX_PLANES) { close(fd); return; }
|
||||
if (p->fd[plane] >= 0) close(p->fd[plane]);
|
||||
p->fd[plane] = fd;
|
||||
p->offset[plane] = offset;
|
||||
p->stride[plane] = stride;
|
||||
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
|
||||
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
|
||||
}
|
||||
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
|
||||
if (!b) return NULL;
|
||||
b->n_planes = p->n_planes;
|
||||
b->refs = 1; /* the wl_buffer resource's */
|
||||
b->width = w; b->height = h; b->format = format;
|
||||
b->modifier = p->modifier[0];
|
||||
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
|
||||
for (int i = 0; i < p->n_planes; i++) {
|
||||
b->fd[i] = p->fd[i];
|
||||
b->offset[i] = p->offset[i];
|
||||
b->stride[i] = p->stride[i];
|
||||
p->fd[i] = -1; /* ownership moves to the buffer */
|
||||
}
|
||||
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
|
||||
if (!buf) {
|
||||
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
|
||||
free(b);
|
||||
wl_client_post_no_memory(c);
|
||||
return NULL;
|
||||
}
|
||||
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
|
||||
struct client_info *ci = client_info_of(c);
|
||||
if (ci) ci->dmabuf_buffers++;
|
||||
return buf;
|
||||
}
|
||||
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
|
||||
uint32_t format, uint32_t flags) {
|
||||
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
|
||||
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
|
||||
else zwp_linux_buffer_params_v1_send_failed(r);
|
||||
}
|
||||
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
|
||||
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
|
||||
params_do_create(c, r, buffer_id, w, h, format, flags);
|
||||
}
|
||||
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
|
||||
.destroy = params_destroy,
|
||||
.add = params_add,
|
||||
.create = params_create,
|
||||
.create_immed = params_create_immed,
|
||||
};
|
||||
static void params_resource_destroy(struct wl_resource *r) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
if (!p) return;
|
||||
for (int i = 0; i < MAX_PLANES; i++)
|
||||
if (p->fd[i] >= 0) close(p->fd[i]);
|
||||
free(p);
|
||||
}
|
||||
|
||||
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
|
||||
struct dmabuf_params *p = calloc(1, sizeof(*p));
|
||||
if (!p) { wl_client_post_no_memory(c); return; }
|
||||
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
|
||||
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
|
||||
wl_resource_get_version(r), id);
|
||||
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(pr, ¶ms_impl, p, params_resource_destroy);
|
||||
}
|
||||
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
|
||||
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
struct wl_resource *surface);
|
||||
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
|
||||
.destroy = dmabuf_destroy,
|
||||
.create_params = dmabuf_create_params,
|
||||
.get_default_feedback = dmabuf_get_default_feedback,
|
||||
.get_surface_feedback = dmabuf_get_surface_feedback,
|
||||
};
|
||||
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
|
||||
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
|
||||
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
|
||||
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
|
||||
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
|
||||
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
|
||||
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
|
||||
* takes the first format it sees - gets exactly the table it always had. */
|
||||
#define DMABUF_NFMT_MAX 6
|
||||
#define DMABUF_NFMT_SDR 4
|
||||
#define DMABUF_NMOD 4
|
||||
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
|
||||
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
|
||||
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
|
||||
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
|
||||
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
|
||||
static int g_dmabuf_fmts_ready;
|
||||
static void dmabuf_build_feedback(void);
|
||||
|
||||
static void dmabuf_build_formats(void) {
|
||||
char line[320];
|
||||
int pos = 0, compressed = 0;
|
||||
g_dmabuf_fmts_ready = 1;
|
||||
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
|
||||
for (int f = 0; f < g_dmabuf_nfmt; f++) {
|
||||
uint64_t got[DMABUF_NMOD];
|
||||
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
|
||||
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
|
||||
* we advertised before this table existed. */
|
||||
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
|
||||
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
|
||||
if (got[i] == MOD_LINEAR) continue;
|
||||
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
|
||||
g_dmabuf_fmts[f].mods[k++] = got[i];
|
||||
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
|
||||
}
|
||||
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
|
||||
g_dmabuf_fmts[f].n = k;
|
||||
uint32_t fmt = g_dmabuf_fmts[f].fmt;
|
||||
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
|
||||
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
|
||||
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
|
||||
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
|
||||
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
|
||||
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
|
||||
}
|
||||
banner_log("dmabuf", "formats: %s%s", line,
|
||||
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
|
||||
if (g_ubwc && !compressed)
|
||||
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
|
||||
"game swapchains stay linear", vkp_gpu_name());
|
||||
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
|
||||
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
|
||||
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
|
||||
uint64_t got[DMABUF_NMOD];
|
||||
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
|
||||
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
|
||||
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
|
||||
"imports XB30 %s", vkp_gpu_name(),
|
||||
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
|
||||
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
|
||||
}
|
||||
dmabuf_build_feedback();
|
||||
}
|
||||
|
||||
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
|
||||
*
|
||||
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
|
||||
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
|
||||
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
|
||||
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
|
||||
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
|
||||
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
|
||||
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
|
||||
*
|
||||
* What a client needs from us is one tranche describing "everything the compositor can import":
|
||||
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
|
||||
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
|
||||
|
||||
#ifndef MFD_CLOEXEC
|
||||
#define MFD_CLOEXEC 0x0001U
|
||||
#endif
|
||||
#ifndef MFD_ALLOW_SEALING
|
||||
#define MFD_ALLOW_SEALING 0x0002U
|
||||
#endif
|
||||
#ifndef F_ADD_SEALS
|
||||
#define F_ADD_SEALS 1033
|
||||
#define F_SEAL_SEAL 0x0001
|
||||
#define F_SEAL_SHRINK 0x0002
|
||||
#define F_SEAL_GROW 0x0004
|
||||
#define F_SEAL_WRITE 0x0008
|
||||
#endif
|
||||
|
||||
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
|
||||
|
||||
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
|
||||
static size_t g_fmt_table_size;
|
||||
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
|
||||
static dev_t g_main_device; /* the render node clients should allocate on */
|
||||
|
||||
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
|
||||
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
|
||||
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
|
||||
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
|
||||
static dev_t dmabuf_render_node(void) {
|
||||
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
|
||||
struct stat st;
|
||||
if (g_no_render_node) return 0;
|
||||
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
|
||||
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void dmabuf_build_feedback(void) {
|
||||
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
|
||||
int n = 0;
|
||||
|
||||
g_main_device = dmabuf_render_node();
|
||||
for (int f = 0; f < g_dmabuf_nfmt; f++)
|
||||
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
|
||||
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
|
||||
entries[n].format = g_dmabuf_fmts[f].fmt;
|
||||
entries[n].pad = 0;
|
||||
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
|
||||
n++;
|
||||
}
|
||||
if (!n) return;
|
||||
|
||||
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
|
||||
MFD_CLOEXEC | MFD_ALLOW_SEALING);
|
||||
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
|
||||
size_t size = (size_t)n * sizeof(entries[0]);
|
||||
if (write(fd, entries, size) != (ssize_t)size) {
|
||||
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
/* The client mmaps this read-only and trusts it not to change under it. */
|
||||
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
|
||||
g_fmt_table_fd = fd;
|
||||
g_fmt_table_size = size;
|
||||
g_fmt_table_n = (uint16_t)n;
|
||||
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
|
||||
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
|
||||
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
|
||||
* one it fell back to a software path that draws nothing here (black window, sound plays). */
|
||||
if (!g_main_device)
|
||||
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
|
||||
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
|
||||
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
|
||||
: "this device gives apps no display (DRM) device (/dev/dri)");
|
||||
}
|
||||
|
||||
/* One tranche: our device, every pair in the table, no scanout flag. */
|
||||
static void dmabuf_feedback_send(struct wl_resource *fb) {
|
||||
struct wl_array dev, idx;
|
||||
uint16_t *ind;
|
||||
|
||||
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
|
||||
|
||||
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
|
||||
|
||||
wl_array_init(&dev);
|
||||
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
|
||||
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
|
||||
wl_array_release(&dev);
|
||||
|
||||
wl_array_init(&idx);
|
||||
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
|
||||
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
|
||||
wl_array_release(&idx);
|
||||
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
|
||||
zwp_linux_dmabuf_feedback_v1_send_done(fb);
|
||||
}
|
||||
|
||||
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
|
||||
wl_resource_destroy(r);
|
||||
}
|
||||
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
|
||||
.destroy = dmabuf_feedback_destroy,
|
||||
};
|
||||
|
||||
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
|
||||
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
|
||||
wl_resource_get_version(parent), id);
|
||||
if (!fb) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
|
||||
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
|
||||
dmabuf_feedback_send(fb);
|
||||
}
|
||||
|
||||
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
|
||||
struct client_info *ci = client_info_of(c);
|
||||
if (ci) ci->asked_feedback = 1;
|
||||
dmabuf_new_feedback(c, r, id);
|
||||
}
|
||||
/* Per-surface feedback would let us hint a different tranche for a window on its own display
|
||||
* layer; we have nothing better to say per surface, so it is the default one. */
|
||||
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
struct wl_resource *surface) {
|
||||
dmabuf_new_feedback(c, r, id);
|
||||
}
|
||||
|
||||
static void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
|
||||
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
|
||||
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
|
||||
/* From version 4 the format and modifier events are gone: the client asks for feedback
|
||||
* instead, and sending both would only confuse it about which list is authoritative. */
|
||||
if (ver >= 4) return;
|
||||
for (int f = 0; f < g_dmabuf_nfmt; f++) {
|
||||
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
|
||||
if (ver >= 3)
|
||||
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
|
||||
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
|
||||
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
|
||||
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ wl_output */
|
||||
|
||||
/* wl_output v3+ has a request (release); without an implementation the first client to send it
|
||||
@@ -1942,6 +1711,17 @@ static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, i
|
||||
g_hdr_unimported_below = dl->n;
|
||||
}
|
||||
|
||||
/* A client that describes opaque regions can intentionally leave part of an alpha buffer see-through.
|
||||
* Gamescope uses this for its full-size Steam notification and overlay planes. */
|
||||
static int surface_translucent(const struct surface *s, int w, int h) {
|
||||
if (!s->buf_alpha) return 0;
|
||||
const struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
|
||||
if (!ci || !ci->declares_opaque) return 0;
|
||||
const int *o = s->opaque;
|
||||
return !(o[2] > 0 && o[0] <= 0 && o[1] <= 0 &&
|
||||
(long long)o[0] + o[2] >= w && (long long)o[1] + o[3] >= h);
|
||||
}
|
||||
|
||||
static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy) {
|
||||
struct vkp_image *img = surface_image(s);
|
||||
float sx = 0, sy = 0, sw = (float)s->buf_w, sh = (float)s->buf_h;
|
||||
@@ -1961,7 +1741,8 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy)
|
||||
dl->d = d;
|
||||
dl->cap = cap;
|
||||
}
|
||||
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh};
|
||||
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh,
|
||||
surface_translucent(s, dw, dh)};
|
||||
s->drawn = 1;
|
||||
}
|
||||
|
||||
@@ -2032,6 +1813,7 @@ static int on_frame_timer(void *data) {
|
||||
static int layer_candidate(const struct draw_list *dl, int scene_w, int scene_h) {
|
||||
for (int i = dl->n - 1; i >= 0 && i >= dl->n - 2; i--) {
|
||||
const struct vkp_draw *d = &dl->d[i];
|
||||
if (d->blend) continue; /* the translucent plane must not replace the opaque game layer */
|
||||
if (!vkp_image_is_dmabuf(d->img)) continue;
|
||||
if (d->dx != 0 || d->dy != 0 || d->dw != scene_w || d->dh != scene_h) continue;
|
||||
if (d->sx != 0 || d->sy != 0 || (int)d->sw != vkp_image_width(d->img) ||
|
||||
@@ -2370,7 +2152,7 @@ static void render_scene(void) {
|
||||
* its own layer, cropped and placed by the display. */
|
||||
int go[8], ov = 0;
|
||||
if (over == 1 && li >= 0 && vkp_map_draw(&dl.d[li + 1], go))
|
||||
ov = sc_layer_present_overlay(dl.d[li + 1].img, go) == 0 ? 1 : -1;
|
||||
ov = sc_layer_present_overlay(dl.d[li + 1].img, go, dl.d[li + 1].blend) == 0 ? 1 : -1;
|
||||
if (ov <= 0) sc_layer_hide_overlay();
|
||||
if (ov < 0) { /* the overlay layer refused it: draw the whole scene the old way */
|
||||
sc_layer_hide();
|
||||
@@ -2496,6 +2278,16 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
|
||||
g_cursor_surface = s;
|
||||
g_cursor_hx = hx;
|
||||
g_cursor_hy = hy;
|
||||
/* Its content may already be committed: wlroots' Wayland backend attaches and commits the
|
||||
* cursor surface first and sets it as the pointer after, and sets it again with nothing new
|
||||
* committed each time the pointer re-enters. */
|
||||
struct wl_resource *buffer = surface_take_idle(s);
|
||||
if (buffer) {
|
||||
cursor_publish_buffer(s, buffer);
|
||||
wl_buffer_send_release(buffer);
|
||||
} else if (g_cursor_shown == s) {
|
||||
cursor_publish_shown();
|
||||
}
|
||||
}
|
||||
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_pointer_interface pointer_impl = {
|
||||
@@ -2643,8 +2435,8 @@ static void keyboard_focus(struct wl_resource *target) {
|
||||
if (sk->focus == target) continue;
|
||||
sk->focus = target;
|
||||
wl_keyboard_send_enter(sk->kb, wl_display_next_serial(g_display), target, &keys);
|
||||
/* Baseline modifiers = none; Shift/Ctrl arrive as their own key events. */
|
||||
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display), 0, 0, 0, 0);
|
||||
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
|
||||
g_mods_depressed, 0, g_mods_locked, 0);
|
||||
}
|
||||
wl_array_release(&keys);
|
||||
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
|
||||
@@ -3341,7 +3133,34 @@ static void scroll_event(int steps) {
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* Folds a key into the modifier state; returns whether the state changed. */
|
||||
static int update_modifiers(uint32_t evdev, int pressed) {
|
||||
static const struct { uint32_t evdev, mask; } mod_keys[] = {
|
||||
{ 42, 0x1 }, { 54, 0x1 }, /* Shift L/R */
|
||||
{ 29, 0x4 }, { 97, 0x4 }, /* Control L/R */
|
||||
{ 56, 0x8 }, { 100, 0x8 }, /* Alt L/R, both Mod1 in this keymap */
|
||||
{ 125, 0x40 }, { 126, 0x40 }, /* Super L/R */
|
||||
};
|
||||
uint32_t depressed = g_mods_depressed, locked = g_mods_locked;
|
||||
if (evdev == 58) { /* Caps Lock toggles on press */
|
||||
if (pressed) locked ^= 0x2;
|
||||
} else {
|
||||
int i, n = (int)(sizeof(mod_keys) / sizeof(mod_keys[0]));
|
||||
for (i = 0; i < n && mod_keys[i].evdev != evdev; i++) {}
|
||||
if (i == n) return 0;
|
||||
if (pressed) g_mod_keys_held |= 1u << i; else g_mod_keys_held &= ~(1u << i);
|
||||
depressed = 0;
|
||||
for (int j = 0; j < n; j++) if (g_mod_keys_held & (1u << j)) depressed |= mod_keys[j].mask;
|
||||
}
|
||||
if (depressed == g_mods_depressed && locked == g_mods_locked) return 0;
|
||||
g_mods_depressed = depressed;
|
||||
g_mods_locked = locked;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void key_event(uint32_t evdev, int pressed) {
|
||||
/* Tracked even with nothing to deliver to, so a Shift released unseen is not left held. */
|
||||
int mods_changed = update_modifiers(evdev, pressed);
|
||||
/* Keys go to the program window the user last clicked (else the topmost non-shell window),
|
||||
* never to Wine's desktop surface: winewayland hands each key to the hwnd of the surface that
|
||||
* holds keyboard focus, and a key handed to explorer's desktop hwnd is queued to explorer's
|
||||
@@ -3361,9 +3180,13 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
struct seat_keyboard *sk;
|
||||
if (!keyboard_for(client)) return;
|
||||
keyboard_focus(target->resource);
|
||||
for_each_keyboard_of(client, sk)
|
||||
for_each_keyboard_of(client, sk) {
|
||||
wl_keyboard_send_key(sk->kb, wl_display_next_serial(g_display), now_ms(), evdev,
|
||||
pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED);
|
||||
if (mods_changed)
|
||||
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
|
||||
g_mods_depressed, 0, g_mods_locked, 0);
|
||||
}
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf
|
||||
* buffer records, the per-client bookkeeping and the few functions and globals both sides use.
|
||||
* Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI.
|
||||
*/
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include <android/log.h>
|
||||
#include <wayland-server.h>
|
||||
#include "vk_present.h"
|
||||
|
||||
#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden")))
|
||||
|
||||
#ifndef WLOGE
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ dmabuf buffers */
|
||||
|
||||
#define FOURCC(a, b, c, d) \
|
||||
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
|
||||
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
|
||||
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
|
||||
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
|
||||
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
|
||||
#define MOD_LINEAR VKP_MOD_LINEAR
|
||||
#define MOD_INVALID VKP_MOD_INVALID
|
||||
#define MAX_PLANES 4
|
||||
|
||||
struct dmabuf_params {
|
||||
int fd[MAX_PLANES];
|
||||
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
|
||||
uint64_t modifier[MAX_PLANES];
|
||||
int n_planes;
|
||||
};
|
||||
struct dmabuf_buffer {
|
||||
int fd[MAX_PLANES];
|
||||
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
|
||||
int n_planes;
|
||||
int32_t width, height;
|
||||
uint32_t format;
|
||||
uint64_t modifier;
|
||||
struct vkp_image *img; /* imported once, reused for every frame */
|
||||
int import_failed;
|
||||
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
|
||||
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
|
||||
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
|
||||
* until the surface commits something newer, so the picture never blinks to black. */
|
||||
int refs;
|
||||
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
|
||||
};
|
||||
|
||||
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
|
||||
struct client_info {
|
||||
struct wl_client *client;
|
||||
struct wl_listener destroy;
|
||||
pid_t pid;
|
||||
char name[64];
|
||||
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
|
||||
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
|
||||
unsigned asked_feedback : 1, shm_gl_said : 1;
|
||||
/* Clients that describe opaque regions can use alpha outside those regions. Wine windows
|
||||
* never describe them, and their unused alpha channel must not make them translucent. */
|
||||
unsigned declares_opaque : 1;
|
||||
unsigned dmabuf_buffers, shm_frames;
|
||||
struct client_info *next;
|
||||
};
|
||||
|
||||
/* ---- compositor.c */
|
||||
COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client);
|
||||
COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b);
|
||||
COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl;
|
||||
extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
|
||||
extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */
|
||||
|
||||
/* ---- wl_dmabuf.c */
|
||||
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
|
||||
COMPOSITOR_INTERNAL void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id);
|
||||
@@ -19,6 +19,7 @@ void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(pri
|
||||
static _Atomic int g_kind = VKP_FG_ENGINE_LSFG;
|
||||
static _Atomic int g_armed;
|
||||
static _Atomic int g_mult = 2;
|
||||
static _Atomic int g_target; /* LSFG adaptive fps, 0 = fixed multiplier */
|
||||
static _Atomic int g_model = 3;
|
||||
static _Atomic int g_preset = 2;
|
||||
static _Atomic int g_cfg_dirty = 1;
|
||||
@@ -51,6 +52,7 @@ static struct {
|
||||
|
||||
/* ---- engine + generation ring (compositor thread) ------------------------------------ */
|
||||
static int g_engine_kind = -1; /* kind of the running engine, -1 = none */
|
||||
static int g_engine_cache_gen = -1; /* the LSFG cache it loaded */
|
||||
static int g_engine_ok;
|
||||
/* The engine of this kind (with this cache) could not start or build its chain; not retried
|
||||
* every frame - a new engine selection or a new cache clears it. */
|
||||
@@ -92,15 +94,20 @@ 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;
|
||||
atomic_store(&g_kind, kind);
|
||||
atomic_store(&g_failed_kind, -1);
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_armed(int armed, int multiplier) {
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps) {
|
||||
if (target_fps < 0) target_fps = 0;
|
||||
/* Adaptive sizes the ring and the swapchain for the most it may ever generate. */
|
||||
if (target_fps) multiplier = VKP_FG_MAX_GENERATIONS + 1;
|
||||
if (multiplier < 2) multiplier = 2;
|
||||
if (multiplier > VKP_FG_MAX_GENERATIONS + 1) multiplier = VKP_FG_MAX_GENERATIONS + 1;
|
||||
if (atomic_exchange(&g_mult, multiplier) != multiplier) atomic_store(&g_cfg_dirty, 1);
|
||||
if (atomic_exchange(&g_target, target_fps) != target_fps) atomic_store(&g_cfg_dirty, 1);
|
||||
atomic_store(&g_armed, armed ? 1 : 0);
|
||||
LOGD("framegen: set_armed(%d, x%d)", armed, multiplier);
|
||||
LOGD("framegen: set_armed(%d, x%d, target %d fps)", armed, multiplier, target_fps);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path) {
|
||||
@@ -285,7 +292,9 @@ int vkp_framegen_extra_images(void) {
|
||||
}
|
||||
|
||||
static int ensure_engine(int kind) {
|
||||
if (g_engine_ok && g_engine_kind == kind) return 1;
|
||||
const int cache_gen = atomic_load(&g_cache_gen);
|
||||
if (g_engine_ok && g_engine_kind == kind && (kind != VKP_FG_ENGINE_LSFG || g_engine_cache_gen == cache_gen))
|
||||
return 1;
|
||||
if (start_failed_for(kind)) return 0;
|
||||
if (g_engine_ok) { destroy_ring(); fge_stop(); g_engine_ok = 0; g_engine_kind = -1; }
|
||||
char *path = NULL;
|
||||
@@ -293,7 +302,7 @@ static int ensure_engine(int kind) {
|
||||
if (g_cache_path) path = strdup(g_cache_path);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (kind == VKP_FG_ENGINE_LSFG && !path) {
|
||||
FGLOG("LSFG Native can't start: no shader cache (import Lossless.dll in Settings)");
|
||||
FGLOG("LSFG Native can't start: no shader cache from Lossless.dll");
|
||||
mark_failed(kind);
|
||||
return 0;
|
||||
}
|
||||
@@ -305,29 +314,39 @@ static int ensure_engine(int kind) {
|
||||
mark_failed(kind);
|
||||
return 0;
|
||||
}
|
||||
g_engine_ok = 1; g_engine_kind = kind;
|
||||
g_engine_ok = 1; g_engine_kind = kind; g_engine_cache_gen = cache_gen;
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
g_built_w = g_built_h = 0;
|
||||
FGLOG("%s engine ready (%s)", fge_engine_name(kind), fge_build_info());
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int g_logged_mult;
|
||||
/* "x3 (2 interpolated frames per game frame)" or "adaptive to 90 fps (up to 3 ...)". */
|
||||
static const char *describe_mode(char *buf, size_t n, int mult, int target) {
|
||||
if (target) snprintf(buf, n, "adaptive to %d fps (up to %d interpolated frames per game frame)", target, mult - 1);
|
||||
else snprintf(buf, n, "x%d (%d interpolated frame%s per game frame)", mult, mult - 1, mult == 2 ? "" : "s");
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int g_logged_mult, g_logged_target;
|
||||
static void log_arm_transition(int armed, int kind, int mult) {
|
||||
const int target = atomic_load(&g_target);
|
||||
char mode[96];
|
||||
if (armed == g_was_armed) {
|
||||
if (armed && mult != g_logged_mult) {
|
||||
g_logged_mult = mult;
|
||||
FGLOG("%s multiplier changed to x%d (%d interpolated frames per game frame)", fge_engine_name(kind), mult, mult - 1);
|
||||
if (armed && (mult != g_logged_mult || target != g_logged_target)) {
|
||||
g_logged_mult = mult; g_logged_target = target;
|
||||
FGLOG("%s now %s", fge_engine_name(kind), describe_mode(mode, sizeof(mode), mult, target));
|
||||
}
|
||||
return;
|
||||
}
|
||||
g_was_armed = armed;
|
||||
if (armed) {
|
||||
g_logged_mult = mult;
|
||||
g_logged_mult = mult; g_logged_target = target;
|
||||
float flow, hz;
|
||||
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
|
||||
FGLOG("%s x%d armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
|
||||
"with the interpolated frames ahead of them", fge_engine_name(kind), mult, (double)flow, (double)hz);
|
||||
FGLOG("%s %s armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
|
||||
"with the interpolated frames ahead of them", fge_engine_name(kind),
|
||||
describe_mode(mode, sizeof(mode), mult, target), (double)flow, (double)hz);
|
||||
} else {
|
||||
FGLOG("frame generation off (%llu frames generated this session)", (unsigned long long)g_total_generated);
|
||||
g_generating_logged = 0;
|
||||
@@ -348,7 +367,8 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
if (atomic_exchange(&g_cfg_dirty, 0)) {
|
||||
float flow, hz;
|
||||
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
|
||||
fge_configure((uint32_t)mult, flow, hz, atomic_load(&g_model), atomic_load(&g_preset));
|
||||
fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz,
|
||||
atomic_load(&g_model), atomic_load(&g_preset));
|
||||
}
|
||||
if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) {
|
||||
if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) {
|
||||
@@ -426,8 +446,9 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
0, 0, NULL, 0, NULL, nb, bars);
|
||||
if (!g_generating_logged) {
|
||||
g_generating_logged = 1;
|
||||
FGLOG("generating: %s x%d at %dx%d (%u interpolated frame%s per game frame%s)",
|
||||
fge_engine_name(kind), mult, w, h, n_gen, n_gen == 1 ? "" : "s",
|
||||
char mode[96];
|
||||
FGLOG("generating: %s %s at %dx%d%s", fge_engine_name(kind),
|
||||
describe_mode(mode, sizeof(mode), mult, atomic_load(&g_target)), w, h,
|
||||
fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? ", FP16: the HDR picture" : "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,10 @@ enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
|
||||
|
||||
/* ---- app controls (any thread) ------------------------------------------------------- */
|
||||
void vkp_framegen_set_engine(int kind);
|
||||
/* multiplier 2..4; armed = 0 stops generating (the ring and the engine stay for a re-arm). */
|
||||
void vkp_framegen_set_armed(int armed, int multiplier);
|
||||
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (LsfgNative.ensureCache). */
|
||||
/* multiplier 2..4, or target_fps > 0 for LSFG's adaptive mode (up to 4x, as the target needs);
|
||||
* armed = 0 stops generating (the ring and the engine stay for a re-arm). */
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
|
||||
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (Lossless.sync); a new path reloads the engine. */
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path);
|
||||
/* Flow scale 0.25..1.0 and the panel's real refresh rate (the pacer never generates above it). */
|
||||
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
|
||||
|
||||
@@ -197,11 +197,11 @@ int fge_unavailable(void) {
|
||||
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
|
||||
}
|
||||
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset) {
|
||||
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);
|
||||
else if (g_lsfg) g_lsfg->configure(multiplier, 0, flow_scale, refresh_hz);
|
||||
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) {
|
||||
|
||||
@@ -45,8 +45,9 @@ int fge_start(int kind, const char *cache_path);
|
||||
void fge_stop(void);
|
||||
/* The engine came up but later found it cannot generate (chain build failed). */
|
||||
int fge_unavailable(void);
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset);
|
||||
/* target_fps > 0: LSFG picks 0..3 generations per frame to reach it (Win-FG ignores it). */
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
|
||||
int model, int perf_preset);
|
||||
/* Build/rebuild the chain for this extent; the guest extent for the flow pyramid is the same
|
||||
* (the Wayland scene is the game's own size in fullscreen). 1 = chain valid. */
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt);
|
||||
|
||||
@@ -17,8 +17,9 @@ int fge_format_ok(VkFormat fmt) { (void)fmt; return 0; }
|
||||
int fge_start(int kind, const char *cache_path) { (void)kind; (void)cache_path; return -1; }
|
||||
void fge_stop(void) {}
|
||||
int fge_unavailable(void) { return 1; }
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, int perf_preset) {
|
||||
(void)multiplier; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset) {
|
||||
(void)multiplier; (void)target_fps; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
|
||||
}
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { (void)w; (void)h; (void)fmt; return 0; }
|
||||
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
|
||||
|
||||
@@ -14,9 +14,9 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JN
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz,
|
||||
jboolean armed, jint multiplier) {
|
||||
jboolean armed, jint multiplier, jint targetFps) {
|
||||
(void)env; (void)clazz;
|
||||
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier);
|
||||
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier, (int)targetFps);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
|
||||
@@ -85,6 +85,7 @@ static struct {
|
||||
|
||||
#define ASC_VISIBILITY_HIDE 0
|
||||
#define ASC_VISIBILITY_SHOW 1
|
||||
#define ASC_TRANSPARENCY_TRANSLUCENT 1
|
||||
#define ASC_TRANSPARENCY_OPAQUE 2
|
||||
|
||||
static int load_api(void) {
|
||||
@@ -766,7 +767,8 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
|
||||
|
||||
/* The transaction that puts pool slot `idx` of layer `l` on screen at `r`, as `color` (NULL = no
|
||||
* description). 0 = applied. */
|
||||
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color) {
|
||||
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color,
|
||||
int translucent) {
|
||||
struct slot *s = &l->slots[idx];
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
if (!tx) return -1;
|
||||
@@ -774,9 +776,8 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b
|
||||
if (old) api.setZOrder(tx, l->sc, l->z);
|
||||
/* The blit was waited for on the CPU, so no acquire fence is needed (-1). */
|
||||
api.setBuffer(tx, l->sc, s->ahb, -1);
|
||||
/* The compositor composes with blits, which overwrite: nothing is ever alpha-blended on the
|
||||
* copy path either, so every layer is opaque and the picture matches it pixel for pixel. */
|
||||
api.setBufferTransparency(tx, l->sc, ASC_TRANSPARENCY_OPAQUE);
|
||||
api.setBufferTransparency(tx, l->sc,
|
||||
translucent ? ASC_TRANSPARENCY_TRANSLUCENT : ASC_TRANSPARENCY_OPAQUE);
|
||||
apply_geometry(tx, l, r);
|
||||
apply_frame_rate(tx, l);
|
||||
apply_colour(tx, l, color);
|
||||
@@ -883,7 +884,7 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru
|
||||
int idx = take_free_slot(l, sw, sh, AHB_RGBA8, &wait_fd);
|
||||
if (idx < 0) { log_drop(l); return 0; }
|
||||
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, r, color) != 0) return -1;
|
||||
if (present_slot(l, idx, r, color, 0) != 0) return -1;
|
||||
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8);
|
||||
if (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
@@ -908,7 +909,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
|
||||
int idx = take_free_slot(l, rw, rh, AHB_RGBA8, &wait_fd);
|
||||
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
|
||||
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, r, NULL) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
|
||||
if (present_slot(l, idx, r, NULL, 0) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
|
||||
if (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
banner_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
|
||||
@@ -950,7 +951,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
|
||||
}
|
||||
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
|
||||
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, r, tm ? NULL : color) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
|
||||
if (present_slot(l, idx, r, tm ? NULL : color, 0) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
|
||||
if (color && color->dataspace)
|
||||
banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
|
||||
static int said = -1;
|
||||
@@ -1031,7 +1032,7 @@ int sc_layer_overlay_affordable(void) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent) {
|
||||
struct layer *l = layer_of(SC_LAYER_OVERLAY);
|
||||
if (!src || !geo) return -1;
|
||||
/* The caller decides with sc_layer_overlay_affordable() BEFORE it commits the game to a layer;
|
||||
@@ -1046,7 +1047,7 @@ int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
|
||||
/* The window is copied into the layer buffer 1:1; `geo` crops it and places it, so the layer
|
||||
* is exactly the window's rectangle on screen and nothing else is blended anywhere. */
|
||||
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, geo, NULL) != 0) return -1;
|
||||
if (present_slot(l, idx, geo, NULL, translucent) != 0) return -1;
|
||||
if (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
log_layer_count();
|
||||
|
||||
@@ -150,8 +150,9 @@ int sc_layer_overlay_affordable(void);
|
||||
|
||||
/* OVERLAY layer: show `src` (one window's imported frame) above the game layer, at the placement
|
||||
* `geo` = {src x0,y0,x1,y1 in image pixels, dst x0,y0,x1,y1 in output pixels} from vkp_map_draw.
|
||||
* `translucent` preserves a client's alpha over the game for Steam notifications and overlays.
|
||||
* 0 = shown or dropped, -1 = unavailable (the caller must fall back to the copy path). */
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]);
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
|
||||
|
||||
/* The scene is not a single fullscreen window this frame: hide every layer that is up. */
|
||||
void sc_layer_hide(void);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "effects_chain.h"
|
||||
#include "framegen_bridge.h"
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
#include "banner_color.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -32,6 +33,8 @@ struct vkp_image {
|
||||
void *map; /* shm images: persistently mapped linear memory */
|
||||
VkDeviceSize offset, row_pitch;
|
||||
int in_general; /* shm images: moved from PREINITIALIZED to GENERAL */
|
||||
VkFormat fmt;
|
||||
int sampled; /* created with SAMPLED usage: the alpha pass can read it */
|
||||
};
|
||||
|
||||
static ANativeWindow *g_window; /* the window frames go to; compositor thread only */
|
||||
@@ -230,6 +233,7 @@ static void destroy_swapchain(void) {
|
||||
if (g_dev_state != 1) return;
|
||||
g_vk.DeviceWaitIdle(g_dev);
|
||||
reset_sync();
|
||||
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
|
||||
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
|
||||
g_swapchain = VK_NULL_HANDLE;
|
||||
g_swap_md_identity = 0; /* a new swapchain gets the metadata again */
|
||||
@@ -525,6 +529,7 @@ static int dev_init(void) {
|
||||
|
||||
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
|
||||
hdrc_bind_device(g_dev, &g_memprops);
|
||||
blendp_bind_device(g_dev);
|
||||
g_dev_state = 1;
|
||||
reset_sync();
|
||||
init_black_image();
|
||||
@@ -873,9 +878,8 @@ const char *vkp_modifier_name(uint64_t modifier) {
|
||||
return other;
|
||||
}
|
||||
|
||||
/* Can the driver create the image vkp_image_import_dmabuf() creates for this format+modifier
|
||||
* (2D, DRM_FORMAT_MODIFIER tiling, TRANSFER_SRC, dma-buf memory) - and import it? */
|
||||
static int modifier_importable(VkFormat fmt, uint64_t modifier) {
|
||||
/* Can the driver create and import this format/modifier with the requested image usage? */
|
||||
static int modifier_importable(VkFormat fmt, uint64_t modifier, VkImageUsageFlags usage) {
|
||||
if (!g_vk.GetPhysicalDeviceImageFormatProperties2) return 1; /* can't ask; the create will tell */
|
||||
VkPhysicalDeviceExternalImageFormatInfo ext = {
|
||||
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO,
|
||||
@@ -886,7 +890,7 @@ static int modifier_importable(VkFormat fmt, uint64_t modifier) {
|
||||
VkPhysicalDeviceImageFormatInfo2 info = {
|
||||
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2, .pNext = &mod,
|
||||
.format = fmt, .type = VK_IMAGE_TYPE_2D, .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT};
|
||||
.usage = usage};
|
||||
VkExternalImageFormatProperties extp = {.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES};
|
||||
VkImageFormatProperties2 props = {.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2, .pNext = &extp};
|
||||
if (g_vk.GetPhysicalDeviceImageFormatProperties2(g_pd, &info, &props) != VK_SUCCESS) return 0;
|
||||
@@ -914,7 +918,7 @@ int vkp_dmabuf_modifiers(uint32_t drm_format, uint64_t *out, int max) {
|
||||
if (m != VKP_MOD_LINEAR && m != VKP_MOD_QCOM_COMPRESSED) why = "unknown layout";
|
||||
else if (props[i].drmFormatModifierPlaneCount != 1) why = "not single-plane";
|
||||
else if (!(props[i].drmFormatModifierTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT)) why = "no blit source";
|
||||
else if (!modifier_importable(fmt, m)) why = "not importable as a dma-buf";
|
||||
else if (!modifier_importable(fmt, m, VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) why = "not importable as a dma-buf";
|
||||
if (why) {
|
||||
LOGI("dmabuf: %c%c%c%c %s reported by the driver but not advertised: %s",
|
||||
drm_format & 0xff, (drm_format >> 8) & 0xff, (drm_format >> 16) & 0xff,
|
||||
@@ -942,6 +946,10 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
struct vkp_image *img = calloc(1, sizeof(*img));
|
||||
if (!img) return NULL;
|
||||
img->w = w; img->h = h; img->dmabuf = 1; img->blit_dst = as_blit_dst ? 1 : 0;
|
||||
img->fmt = drm_to_vk(drm_format);
|
||||
const VkImageUsageFlags sampled_usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
|
||||
img->sampled = !as_blit_dst && g_vk.GetPhysicalDeviceImageFormatProperties2 &&
|
||||
modifier_importable(img->fmt, modifier, sampled_usage);
|
||||
|
||||
VkSubresourceLayout plane = {.offset = offset, .rowPitch = stride};
|
||||
VkImageDrmFormatModifierExplicitCreateInfoEXT modInfo = {
|
||||
@@ -952,10 +960,11 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT};
|
||||
VkImageCreateInfo ici = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &extImg,
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = drm_to_vk(drm_format), .extent = {w, h, 1},
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = img->fmt, .extent = {w, h, 1},
|
||||
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
|
||||
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT
|
||||
: img->sampled ? sampled_usage : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
|
||||
if (cr != VK_SUCCESS) {
|
||||
@@ -1008,18 +1017,35 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
return img;
|
||||
}
|
||||
|
||||
/* Linear wl_shm images can use the blend pass only if the driver samples them with a linear filter. */
|
||||
static int shm_sampled(void) {
|
||||
static int known;
|
||||
if (!known) {
|
||||
const VkFormatFeatureFlags need = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT |
|
||||
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
|
||||
VkFormatProperties2 fp = {.sType = VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_2};
|
||||
if (g_vk.GetPhysicalDeviceFormatProperties2)
|
||||
g_vk.GetPhysicalDeviceFormatProperties2(g_pd, VK_FORMAT_B8G8R8A8_UNORM, &fp);
|
||||
known = (fp.formatProperties.linearTilingFeatures & need) == need ? 1 : -1;
|
||||
}
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
struct vkp_image *vkp_image_create_shm(int w, int h) {
|
||||
if (w <= 0 || h <= 0 || dev_init() != 0) return NULL;
|
||||
|
||||
struct vkp_image *img = calloc(1, sizeof(*img));
|
||||
if (!img) return NULL;
|
||||
img->w = w; img->h = h;
|
||||
img->fmt = VK_FORMAT_B8G8R8A8_UNORM;
|
||||
img->sampled = shm_sampled();
|
||||
|
||||
VkImageCreateInfo ici = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
|
||||
.format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
|
||||
.format = img->fmt, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
|
||||
.samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | (img->sampled ? VK_IMAGE_USAGE_SAMPLED_BIT : 0),
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
|
||||
.initialLayout = VK_IMAGE_LAYOUT_PREINITIALIZED};
|
||||
if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return NULL; }
|
||||
|
||||
@@ -1062,6 +1088,7 @@ int vkp_image_height(const struct vkp_image *img) { return img ? img->h : 0; }
|
||||
void vkp_image_destroy(struct vkp_image *img) {
|
||||
if (!img) return;
|
||||
if (g_dev) {
|
||||
blendp_forget_image(img->image);
|
||||
if (img->map) g_vk.UnmapMemory(g_dev, img->mem);
|
||||
if (img->image) g_vk.DestroyImage(g_dev, img->image, NULL);
|
||||
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
|
||||
@@ -1111,8 +1138,21 @@ static int draw_to_scene_blit(const struct vkp_draw *d, int scene_w, int scene_h
|
||||
return map_draw(d, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, (float)scene_w, (float)scene_h, blit);
|
||||
}
|
||||
|
||||
/* A translucent draw needs source-over blending; every other draw keeps the existing blit path. */
|
||||
static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkImageBlit *blit, VkImage target,
|
||||
VkFormat target_fmt, int target_w, int target_h, VkFilter filter) {
|
||||
const struct vkp_image *im = d->img;
|
||||
if (d->blend && im->sampled &&
|
||||
blendp_draw(cmd, im->image, im->fmt, im->w, im->h, !im->dmabuf, target, target_fmt, target_w, target_h,
|
||||
blit) == 0)
|
||||
return;
|
||||
g_vk.CmdBlitImage(cmd, im->image, im->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, blit, filter);
|
||||
}
|
||||
|
||||
static void destroy_scene_image(void) {
|
||||
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
|
||||
if (g_scene.img) blendp_forget_image(g_scene.img);
|
||||
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
|
||||
if (g_scene.mem) g_vk.FreeMemory(g_dev, g_scene.mem, NULL);
|
||||
memset(&g_scene, 0, sizeof(g_scene));
|
||||
@@ -1150,6 +1190,7 @@ static int ensure_scene_image(int w, int h) {
|
||||
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
|
||||
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
|
||||
if (s->img) hdrc_forget_image(s->img);
|
||||
if (s->img) blendp_forget_image(s->img);
|
||||
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
|
||||
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
|
||||
memset(s, 0, sizeof(*s));
|
||||
@@ -1190,7 +1231,8 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
int scene_w, int scene_h, VkImage out, VkFormat out_fmt, enum hdrc_mode mode) {
|
||||
if (!hf || !hf->is_hdr || n <= 0) return -1;
|
||||
if (ensure_img(&g_mixed, scene_w, scene_h, HDR_FMT,
|
||||
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
|
||||
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
|
||||
"HDR composition (mixed)") != 0)
|
||||
return -1;
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
@@ -1214,9 +1256,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
|
||||
/* A blit converts UNORM to UNORM by value: 8-bit sRGB and 10-bit PQ both land unchanged. */
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
g_mixed.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
|
||||
record_draw(cmd, &draws[i], &blit, g_mixed.img, HDR_FMT, scene_w, scene_h, VK_FILTER_LINEAR);
|
||||
if (hf->is_hdr[i] && lowest_hdr < 0) lowest_hdr = i;
|
||||
}
|
||||
static int truncated_said;
|
||||
@@ -1249,6 +1289,7 @@ static void device_lost(const char *where) {
|
||||
vkp_framegen_device_lost();
|
||||
banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
|
||||
"restart the session", where);
|
||||
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
|
||||
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
|
||||
g_swapchain = VK_NULL_HANDLE;
|
||||
if (g_surface) g_vk.DestroySurfaceKHR(g_inst, g_surface, NULL);
|
||||
@@ -1547,6 +1588,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
g_vk.BeginCommandBuffer(cmd, &bi);
|
||||
blendp_begin_frame();
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
|
||||
/* Source images: take dmabufs from the client's queue family, move shm images to
|
||||
@@ -1589,8 +1631,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
free(bars);
|
||||
|
||||
/* Clear the composition target to black (the letterbox on the direct path, the desktop's
|
||||
* background on the pass) before the draws land on it. A draw that covers the whole scene
|
||||
* overwrites every pixel of its rectangle (blits never blend), so then only what lies outside it
|
||||
* background on the pass) before the draws land on it. An opaque draw that covers the whole scene
|
||||
* overwrites every pixel of its rectangle, so then only what lies outside it
|
||||
* needs black: the letterbox bars on the direct path, nothing at all on the pass (the scene image
|
||||
* is exactly the scene). Anything else - no such draw, the HDR composition - clears the whole
|
||||
* target as before. */
|
||||
@@ -1600,7 +1642,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
if (!(hf && pass)) {
|
||||
for (int i = n - 1; i >= 0 && !covered; i--) {
|
||||
const struct vkp_draw *d = &draws[i];
|
||||
if (!d->img || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w || d->dy + d->dh < scene_h) continue;
|
||||
if (!d->img || d->blend || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w ||
|
||||
d->dy + d->dh < scene_h) continue;
|
||||
covered = pass ? draw_to_scene_blit(d, scene_w, scene_h, &cover_blit) : draw_to_blit(d, &cover_blit);
|
||||
}
|
||||
}
|
||||
@@ -1633,10 +1676,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img) continue;
|
||||
if (pass ? !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit) : !draw_to_blit(&draws[i], &blit)) continue;
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit,
|
||||
pass ? VK_FILTER_LINEAR : blit_filter);
|
||||
record_draw(cmd, &draws[i], &blit, target, pass ? scene_fmt : g_swap_fmt,
|
||||
pass ? scene_w : (int)g_extent.width, pass ? scene_h : (int)g_extent.height,
|
||||
pass ? VK_FILTER_LINEAR : blit_filter);
|
||||
drawn++;
|
||||
}
|
||||
|
||||
@@ -2073,6 +2115,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
g_vk.BeginCommandBuffer(cmd, &bi);
|
||||
blendp_begin_frame();
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
|
||||
/* Same acquisition as vkp_render's compositor pass: the sources come from the client's queue
|
||||
@@ -2132,9 +2175,8 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
for (int i = 0; i < n; i++) {
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
g_scene.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
|
||||
record_draw(cmd, &draws[i], &blit, g_scene.img, SCENE_FMT, scene_w, scene_h,
|
||||
VK_FILTER_LINEAR);
|
||||
}
|
||||
if (vkp_effects_active()) vkp_effects_set_formats(VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ struct vkp_draw {
|
||||
struct vkp_image *img;
|
||||
float sx, sy, sw, sh;
|
||||
int dx, dy, dw, dh;
|
||||
int blend; /* translucent: composed over what is under it (blend_pass.h) */
|
||||
};
|
||||
|
||||
// The GPU the renderer runs on ("Adreno (TM) 750"), empty before the device is up.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
#include <jni.h>
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
@@ -25,6 +26,7 @@ extern int banner_wayland_run(void);
|
||||
extern void banner_wayland_send_pointer(int action, int x, int y);
|
||||
extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y);
|
||||
extern int banner_cursor_snapshot(int *out, int cap);
|
||||
extern int banner_cursor_serial(void);
|
||||
extern void banner_wayland_send_key(int evdev, int state);
|
||||
extern void banner_wayland_send_scene_input(int type, int a, int b);
|
||||
extern void banner_wayland_vsync(int64_t frame_time_ns);
|
||||
@@ -294,6 +296,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The serial nativeCursorSnapshot would return, without copying anything. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) {
|
||||
(void)env; (void)clazz;
|
||||
return banner_cursor_serial();
|
||||
}
|
||||
|
||||
/* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey(
|
||||
@@ -670,3 +679,29 @@ JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
|
||||
banner_host_text_delete(before, after);
|
||||
}
|
||||
|
||||
/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the
|
||||
* device's, not this process's: it outlives the fd and the app, so the caller clears it at stop
|
||||
* and again at every app start. No root; the kernel's thermal limits still apply. */
|
||||
extern void adrenotools_set_turbo(bool turbo);
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
(void)env; (void)clazz;
|
||||
adrenotools_set_turbo(on == JNI_TRUE);
|
||||
}
|
||||
|
||||
/* Raises one of our own processes or threads to `nice` with a bare setpriority(), never lowering
|
||||
* it. android.os.Process.setThreadPriority is not used for this: it also moves the thread between
|
||||
* scheduling groups by priority, and a guest process must stay where Android put the app. Returns
|
||||
* the nice value it is left at, or 100 when it could not be read. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv *env, jclass clazz, jint tid, jint nice) {
|
||||
(void)env; (void)clazz;
|
||||
errno = 0;
|
||||
int before = getpriority(PRIO_PROCESS, (id_t)tid);
|
||||
if (before == -1 && errno != 0) return 100;
|
||||
if (before > nice) setpriority(PRIO_PROCESS, (id_t)tid, nice);
|
||||
errno = 0;
|
||||
int after = getpriority(PRIO_PROCESS, (id_t)tid);
|
||||
return after == -1 && errno != 0 ? 100 : after;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* zwp_linux_dmabuf_v1: the buffer params a client builds a dma-buf wl_buffer from, the format and
|
||||
* modifier table the renderer can import, and dma-buf feedback (version 4). Split out of
|
||||
* compositor.c; the buffer records themselves are in compositor_internal.h.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysmacros.h>
|
||||
#include <wayland-server.h>
|
||||
|
||||
#include "linux-dmabuf-v1-server-protocol.h"
|
||||
#include "vk_present.h"
|
||||
#include "banner_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "compositor_internal.h"
|
||||
|
||||
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
|
||||
|
||||
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
|
||||
dmabuf_buffer_unref(wl_resource_get_user_data(r));
|
||||
}
|
||||
|
||||
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
|
||||
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
if (plane >= MAX_PLANES) { close(fd); return; }
|
||||
if (p->fd[plane] >= 0) close(p->fd[plane]);
|
||||
p->fd[plane] = fd;
|
||||
p->offset[plane] = offset;
|
||||
p->stride[plane] = stride;
|
||||
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
|
||||
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
|
||||
}
|
||||
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
|
||||
if (!b) return NULL;
|
||||
b->n_planes = p->n_planes;
|
||||
b->refs = 1; /* the wl_buffer resource's */
|
||||
b->width = w; b->height = h; b->format = format;
|
||||
b->modifier = p->modifier[0];
|
||||
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
|
||||
for (int i = 0; i < p->n_planes; i++) {
|
||||
b->fd[i] = p->fd[i];
|
||||
b->offset[i] = p->offset[i];
|
||||
b->stride[i] = p->stride[i];
|
||||
p->fd[i] = -1; /* ownership moves to the buffer */
|
||||
}
|
||||
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
|
||||
if (!buf) {
|
||||
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
|
||||
free(b);
|
||||
wl_client_post_no_memory(c);
|
||||
return NULL;
|
||||
}
|
||||
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
|
||||
struct client_info *ci = client_info_of(c);
|
||||
if (ci) ci->dmabuf_buffers++;
|
||||
return buf;
|
||||
}
|
||||
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
|
||||
uint32_t format, uint32_t flags) {
|
||||
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
|
||||
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
|
||||
else zwp_linux_buffer_params_v1_send_failed(r);
|
||||
}
|
||||
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
|
||||
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
|
||||
params_do_create(c, r, buffer_id, w, h, format, flags);
|
||||
}
|
||||
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
|
||||
.destroy = params_destroy,
|
||||
.add = params_add,
|
||||
.create = params_create,
|
||||
.create_immed = params_create_immed,
|
||||
};
|
||||
static void params_resource_destroy(struct wl_resource *r) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
if (!p) return;
|
||||
for (int i = 0; i < MAX_PLANES; i++)
|
||||
if (p->fd[i] >= 0) close(p->fd[i]);
|
||||
free(p);
|
||||
}
|
||||
|
||||
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
|
||||
struct dmabuf_params *p = calloc(1, sizeof(*p));
|
||||
if (!p) { wl_client_post_no_memory(c); return; }
|
||||
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
|
||||
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
|
||||
wl_resource_get_version(r), id);
|
||||
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(pr, ¶ms_impl, p, params_resource_destroy);
|
||||
}
|
||||
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
|
||||
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
struct wl_resource *surface);
|
||||
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
|
||||
.destroy = dmabuf_destroy,
|
||||
.create_params = dmabuf_create_params,
|
||||
.get_default_feedback = dmabuf_get_default_feedback,
|
||||
.get_surface_feedback = dmabuf_get_surface_feedback,
|
||||
};
|
||||
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
|
||||
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
|
||||
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
|
||||
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
|
||||
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
|
||||
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
|
||||
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
|
||||
* takes the first format it sees - gets exactly the table it always had. */
|
||||
#define DMABUF_NFMT_MAX 6
|
||||
#define DMABUF_NFMT_SDR 4
|
||||
#define DMABUF_NMOD 4
|
||||
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
|
||||
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
|
||||
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
|
||||
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
|
||||
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
|
||||
static int g_dmabuf_fmts_ready;
|
||||
static void dmabuf_build_feedback(void);
|
||||
|
||||
static void dmabuf_build_formats(void) {
|
||||
char line[320];
|
||||
int pos = 0, compressed = 0;
|
||||
g_dmabuf_fmts_ready = 1;
|
||||
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
|
||||
for (int f = 0; f < g_dmabuf_nfmt; f++) {
|
||||
uint64_t got[DMABUF_NMOD];
|
||||
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
|
||||
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
|
||||
* we advertised before this table existed. */
|
||||
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
|
||||
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
|
||||
if (got[i] == MOD_LINEAR) continue;
|
||||
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
|
||||
g_dmabuf_fmts[f].mods[k++] = got[i];
|
||||
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
|
||||
}
|
||||
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
|
||||
g_dmabuf_fmts[f].n = k;
|
||||
uint32_t fmt = g_dmabuf_fmts[f].fmt;
|
||||
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
|
||||
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
|
||||
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
|
||||
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
|
||||
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
|
||||
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
|
||||
}
|
||||
banner_log("dmabuf", "formats: %s%s", line,
|
||||
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
|
||||
if (g_ubwc && !compressed)
|
||||
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
|
||||
"game swapchains stay linear", vkp_gpu_name());
|
||||
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
|
||||
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
|
||||
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
|
||||
uint64_t got[DMABUF_NMOD];
|
||||
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
|
||||
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
|
||||
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
|
||||
"imports XB30 %s", vkp_gpu_name(),
|
||||
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
|
||||
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
|
||||
}
|
||||
dmabuf_build_feedback();
|
||||
}
|
||||
|
||||
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
|
||||
*
|
||||
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
|
||||
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
|
||||
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
|
||||
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
|
||||
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
|
||||
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
|
||||
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
|
||||
*
|
||||
* What a client needs from us is one tranche describing "everything the compositor can import":
|
||||
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
|
||||
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
|
||||
|
||||
#ifndef MFD_CLOEXEC
|
||||
#define MFD_CLOEXEC 0x0001U
|
||||
#endif
|
||||
#ifndef MFD_ALLOW_SEALING
|
||||
#define MFD_ALLOW_SEALING 0x0002U
|
||||
#endif
|
||||
#ifndef F_ADD_SEALS
|
||||
#define F_ADD_SEALS 1033
|
||||
#define F_SEAL_SEAL 0x0001
|
||||
#define F_SEAL_SHRINK 0x0002
|
||||
#define F_SEAL_GROW 0x0004
|
||||
#define F_SEAL_WRITE 0x0008
|
||||
#endif
|
||||
|
||||
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
|
||||
|
||||
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
|
||||
static size_t g_fmt_table_size;
|
||||
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
|
||||
dev_t g_main_device; /* the render node clients should allocate on */
|
||||
|
||||
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
|
||||
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
|
||||
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
|
||||
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
|
||||
static dev_t dmabuf_render_node(void) {
|
||||
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
|
||||
struct stat st;
|
||||
if (g_no_render_node) return 0;
|
||||
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
|
||||
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void dmabuf_build_feedback(void) {
|
||||
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
|
||||
int n = 0;
|
||||
|
||||
g_main_device = dmabuf_render_node();
|
||||
for (int f = 0; f < g_dmabuf_nfmt; f++)
|
||||
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
|
||||
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
|
||||
entries[n].format = g_dmabuf_fmts[f].fmt;
|
||||
entries[n].pad = 0;
|
||||
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
|
||||
n++;
|
||||
}
|
||||
if (!n) return;
|
||||
|
||||
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
|
||||
MFD_CLOEXEC | MFD_ALLOW_SEALING);
|
||||
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
|
||||
size_t size = (size_t)n * sizeof(entries[0]);
|
||||
if (write(fd, entries, size) != (ssize_t)size) {
|
||||
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
/* The client mmaps this read-only and trusts it not to change under it. */
|
||||
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
|
||||
g_fmt_table_fd = fd;
|
||||
g_fmt_table_size = size;
|
||||
g_fmt_table_n = (uint16_t)n;
|
||||
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
|
||||
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
|
||||
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
|
||||
* one it fell back to a software path that draws nothing here (black window, sound plays). */
|
||||
if (!g_main_device)
|
||||
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
|
||||
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
|
||||
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
|
||||
: "this device gives apps no display (DRM) device (/dev/dri)");
|
||||
}
|
||||
|
||||
/* One tranche: our device, every pair in the table, no scanout flag. */
|
||||
static void dmabuf_feedback_send(struct wl_resource *fb) {
|
||||
struct wl_array dev, idx;
|
||||
uint16_t *ind;
|
||||
|
||||
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
|
||||
|
||||
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
|
||||
|
||||
wl_array_init(&dev);
|
||||
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
|
||||
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
|
||||
wl_array_release(&dev);
|
||||
|
||||
wl_array_init(&idx);
|
||||
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
|
||||
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
|
||||
wl_array_release(&idx);
|
||||
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
|
||||
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
|
||||
zwp_linux_dmabuf_feedback_v1_send_done(fb);
|
||||
}
|
||||
|
||||
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
|
||||
wl_resource_destroy(r);
|
||||
}
|
||||
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
|
||||
.destroy = dmabuf_feedback_destroy,
|
||||
};
|
||||
|
||||
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
|
||||
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
|
||||
wl_resource_get_version(parent), id);
|
||||
if (!fb) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
|
||||
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
|
||||
dmabuf_feedback_send(fb);
|
||||
}
|
||||
|
||||
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
|
||||
struct client_info *ci = client_info_of(c);
|
||||
if (ci) ci->asked_feedback = 1;
|
||||
dmabuf_new_feedback(c, r, id);
|
||||
}
|
||||
/* Per-surface feedback would let us hint a different tranche for a window on its own display
|
||||
* layer; we have nothing better to say per surface, so it is the default one. */
|
||||
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
struct wl_resource *surface) {
|
||||
dmabuf_new_feedback(c, r, id);
|
||||
}
|
||||
|
||||
void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
|
||||
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
|
||||
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
|
||||
/* From version 4 the format and modifier events are gone: the client asks for feedback
|
||||
* instead, and sending both would only confuse it about which list is authoritative. */
|
||||
if (ver >= 4) return;
|
||||
for (int f = 0; f < g_dmabuf_nfmt; f++) {
|
||||
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
|
||||
if (ver >= 3)
|
||||
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
|
||||
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
|
||||
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
|
||||
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.Application
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.session.CrashHandler
|
||||
import com.droiddeck.launcher.session.GpuClockPin
|
||||
|
||||
/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */
|
||||
/**
|
||||
* Process-wide setup: the crash handler, so a session's folder is finished even when we die, and
|
||||
* the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's
|
||||
* sandboxes are started through (BwrapSpawner).
|
||||
*/
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs: a new install's first read decides it.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
|
||||
/**
|
||||
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
|
||||
* catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it.
|
||||
*/
|
||||
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
|
||||
var compCatalogAt by mutableStateOf(0L)
|
||||
var compProton by mutableStateOf<String?>(null)
|
||||
var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
|
||||
var compChecking by mutableStateOf(false)
|
||||
var compBusy by mutableStateOf<String?>(null)
|
||||
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
|
||||
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
|
||||
fun refreshComponents(snapshotFirst: Boolean = false) {
|
||||
Thread({
|
||||
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(activity) }
|
||||
runCatching { ComponentsManager.applyQueued(activity) }
|
||||
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
|
||||
ui.post {
|
||||
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
|
||||
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
|
||||
}
|
||||
}, "components").start()
|
||||
}
|
||||
|
||||
fun componentAction(label: String, work: () -> String) {
|
||||
if (compBusy != null) return
|
||||
compBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
compBusy = null
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshComponents()
|
||||
}
|
||||
}, "components-action").start()
|
||||
}
|
||||
|
||||
fun refreshComponentCatalog() {
|
||||
if (compChecking) return
|
||||
compChecking = true
|
||||
Thread({
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
|
||||
ui.post {
|
||||
compChecking = false
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
|
||||
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
}
|
||||
}, "components-catalog").start()
|
||||
}
|
||||
|
||||
fun downloadComponent(item: ComponentsManager.CatalogItem) {
|
||||
if (compDownloads.containsKey(item.file)) return
|
||||
compDownloads = compDownloads + (item.file to -1)
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
|
||||
"Stored ${pkg.version}"
|
||||
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
compDownloads = compDownloads - item.file
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshComponents()
|
||||
}
|
||||
}, "components-download").start()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MainActivity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
/**
|
||||
* Decky Loader's state in the launcher: what is installed, the releases on offer and an install
|
||||
* in progress. The launcher screen keeps one and shows it.
|
||||
*/
|
||||
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var deckyInstalled by mutableStateOf<String?>(null)
|
||||
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
|
||||
var deckyChecking by mutableStateOf(false)
|
||||
var deckyStage by mutableStateOf<String?>(null)
|
||||
var deckyPercent by mutableIntStateOf(-1)
|
||||
var deckySupervisor by mutableStateOf(false)
|
||||
var deckyReleaseRequest = 0
|
||||
|
||||
fun refreshDecky() {
|
||||
val request = ++deckyReleaseRequest
|
||||
deckyChecking = true
|
||||
Thread({
|
||||
val channels = runCatching { DeckyManager.releaseChannels(activity) }
|
||||
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
|
||||
ui.post {
|
||||
if (request == deckyReleaseRequest) {
|
||||
// Use the newest compatible stable build, or the newest compatible
|
||||
// prerelease when the fork has not published a stable ARM64 asset.
|
||||
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
|
||||
deckyChecking = false
|
||||
}
|
||||
}
|
||||
}, "decky-releases").start()
|
||||
}
|
||||
|
||||
fun installDecky(release: DeckyManager.Release) {
|
||||
if (deckyStage != null || SessionState.running) return
|
||||
deckyStage = "Starting…"; deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
|
||||
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "install-decky").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import com.droiddeck.launcher.gpu.DriverBundle
|
||||
import com.droiddeck.launcher.gpu.DriverPairs
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.gpu.TurnipReleases
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.ui.DriverRow
|
||||
import com.droiddeck.launcher.ui.GpuDriversState
|
||||
import com.droiddeck.launcher.ui.PairRow
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
|
||||
/**
|
||||
* The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and
|
||||
* - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the
|
||||
* Linux (runtime) and Android (display) lists as before, each picked on its own - except a
|
||||
* bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher
|
||||
* screen keeps one and hands it to the Components page's GPU drivers tab.
|
||||
*/
|
||||
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
|
||||
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
|
||||
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
|
||||
var releaseChecking by mutableStateOf(false)
|
||||
var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
private val releaseProgress = HashMap<String, Int>()
|
||||
var linuxSelected by mutableStateOf("")
|
||||
val gpu: GpuInfo = GpuInfo.detect()
|
||||
// Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made.
|
||||
var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO)
|
||||
var pairRows by mutableStateOf<List<PairRow>>(emptyList())
|
||||
/** The pair being installed, and how far along. */
|
||||
var pairBusy by mutableStateOf<String?>(null)
|
||||
var pairPercent by mutableIntStateOf(-1)
|
||||
/** What Auto last did or found, for the line under the pair in use. */
|
||||
var autoStatus by mutableStateOf("")
|
||||
/** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */
|
||||
var activeBundle by mutableStateOf<String?>(null)
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
|
||||
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
|
||||
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
)
|
||||
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
var androidSelected by mutableStateOf("")
|
||||
|
||||
fun refreshDrivers() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
val bundles = DriverBundle.all(activity)
|
||||
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
|
||||
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
|
||||
}
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
else bundleRow(id, linux = true) ?: DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
|
||||
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
|
||||
).joinToString(" · "),
|
||||
true, origin(id),
|
||||
)
|
||||
}
|
||||
linuxSelected = SessionPrefs.linuxDriver(activity)
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
add(DriverRow(
|
||||
TurnipDriver.AUTO, "Auto - picked by GPU",
|
||||
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
|
||||
false,
|
||||
))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
}
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(activity)
|
||||
activeBundle = DriverBundle.active(activity)?.label
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
}
|
||||
|
||||
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
|
||||
}
|
||||
|
||||
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
|
||||
fun refreshPairs() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val recommended = DriverPairs.recommendedKey(gpu, pairs)
|
||||
pairRows = pairs.map { p ->
|
||||
val bundle = p.bundle?.let { installedBundle(it) }
|
||||
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
|
||||
val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) }
|
||||
val mb = p.assets.sumOf { it.size } / 1_048_576.0
|
||||
PairRow(
|
||||
key = p.key, name = p.name, version = p.version,
|
||||
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
|
||||
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
|
||||
installed = displayId != null && linuxId != null,
|
||||
active = displayId != null && linuxId != null &&
|
||||
SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId,
|
||||
)
|
||||
}.sortedByDescending { it.recommended }
|
||||
}
|
||||
|
||||
fun setMode(auto: Boolean) {
|
||||
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
SessionPrefs.setGpuDriverMode(activity, mode)
|
||||
autoStatus = ""
|
||||
if (auto) ensureAuto(force = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto: make the recommended pair for this GPU the one installed and set. Looks online when
|
||||
* [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and
|
||||
* a new pair a day is plenty - otherwise works from the last check. Once per app start on its
|
||||
* own; the pair it replaces is removed, anything the user imported or picked is left alone.
|
||||
*/
|
||||
fun ensureAuto(force: Boolean) {
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
|
||||
return
|
||||
}
|
||||
releaseChecking = true
|
||||
autoStatus = "Checking for the latest drivers…"
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val key = DriverPairs.recommendedKey(gpu, pairs)
|
||||
val pair = pairs.firstOrNull { it.key == key }
|
||||
val row = pairRows.firstOrNull { it.key == key }
|
||||
when {
|
||||
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
}, "gpu-driver-auto").start()
|
||||
}
|
||||
|
||||
/** Manual: install (if need be) and set both halves of a pair. */
|
||||
fun selectPair(key: String) {
|
||||
if (pairBusy != null) return
|
||||
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
|
||||
installPair(pair, auto = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download what is missing of [pair], then set both halves - only once both are in, so a
|
||||
* failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the
|
||||
* ones the new pair replaces are removed.
|
||||
*/
|
||||
private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) {
|
||||
if (!pair.complete) return
|
||||
pairBusy = pair.key
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
var downloaded = false
|
||||
val result = runCatching {
|
||||
val zips = pair.assets
|
||||
val ids = zips.mapIndexed { i, asset ->
|
||||
installedIdOf(asset)
|
||||
?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true }
|
||||
}
|
||||
if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids
|
||||
}
|
||||
ui.post {
|
||||
pairBusy = null
|
||||
pairPercent = -1
|
||||
result.onSuccess { (displayId, linuxId) ->
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
|
||||
} else {
|
||||
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "driver pair ${pair.key}", e)
|
||||
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
|
||||
if (auto) autoStatus = why ?: "Download failed"
|
||||
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "gpu-driver-pair").start()
|
||||
}
|
||||
|
||||
/** The bundle a release bundle was downloaded as, while both its halves are installed. */
|
||||
private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? =
|
||||
TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) }
|
||||
|
||||
/** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */
|
||||
private fun installedIdOf(asset: TurnipReleases.Asset): String? = when {
|
||||
asset.bundle -> installedBundle(asset)?.id
|
||||
asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled)
|
||||
else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled)
|
||||
}
|
||||
|
||||
/**
|
||||
* One release asset, downloaded, checked and installed through the importer; returns its id,
|
||||
* a bundle's id for a bundle.
|
||||
*/
|
||||
private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String {
|
||||
var file: File? = null
|
||||
try {
|
||||
file = TurnipReleases.download(activity, asset, progress)
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = when {
|
||||
asset.bundle -> DriverBundle.install(activity, uri).id
|
||||
asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
|
||||
else -> TurnipDriver(activity).installFromZip(uri, asset.name)
|
||||
}
|
||||
TurnipReleases.recordDownload(activity, asset, id)
|
||||
return id
|
||||
} finally {
|
||||
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
|
||||
* library - then say what happened. A refusal's message is the user-facing reason. A bundle,
|
||||
* from any import, installs both halves and is set as both drivers at once. [linux] null (the
|
||||
* tab's own Import .zip) takes a single driver to whichever list its libc belongs in.
|
||||
*/
|
||||
fun importDriver(uri: Uri, linux: Boolean?) {
|
||||
val name = activity.displayNameOf(uri)
|
||||
Thread({
|
||||
var bundle: DriverBundle.Bundle? = null
|
||||
val problem = try {
|
||||
when {
|
||||
DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri)
|
||||
linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
linux == false -> TurnipDriver(activity).installFromZip(uri, name)
|
||||
else -> try {
|
||||
TurnipDriver(activity).installFromZip(uri, name)
|
||||
} catch (display: IllegalArgumentException) {
|
||||
try {
|
||||
LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
} catch (runtime: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "driver import", e)
|
||||
"Import failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: done,
|
||||
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "import-driver").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half
|
||||
* sets the bundle's other half in the other list as well.
|
||||
*/
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
setMode(false)
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
/** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */
|
||||
private fun useBundle(bundle: DriverBundle.Bundle): String {
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return "Using ${bundle.label} as both the runtime and display driver." +
|
||||
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
|
||||
* session never starts on a driver that is gone; a release download is forgotten, so the menu
|
||||
* offers it again.
|
||||
*/
|
||||
fun deleteDriver(id: String, linux: Boolean) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
if (bundle != null) {
|
||||
DriverBundle.remove(activity, bundle)
|
||||
TurnipReleases.forget(activity, bundle.id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
if (linux) {
|
||||
LinuxVulkanDriverManager(activity).removeDriver(id)
|
||||
if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "")
|
||||
} else {
|
||||
val td = TurnipDriver(activity)
|
||||
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
|
||||
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
/** The download entries and the refresh line, from what the last check found. */
|
||||
fun refreshReleaseRows() {
|
||||
val check = TurnipReleases.cached(activity)
|
||||
fun rows(linux: Boolean) = check?.assets.orEmpty()
|
||||
.filter { it.bundle || it.linux == linux }
|
||||
.filter { a -> installedIdOf(a) == null }
|
||||
.map { a ->
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
}
|
||||
linuxDownloads = rows(linux = true)
|
||||
androidDownloads = rows(linux = false)
|
||||
if (!releaseChecking) releaseStatus = when (check) {
|
||||
null -> "Not checked yet - tap refresh to look for new drivers"
|
||||
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
|
||||
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
|
||||
" · checked ${ago(check.checkedAt)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ago(t: Long): String {
|
||||
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
|
||||
return when {
|
||||
m < 1 -> "just now"
|
||||
m < 60 -> "$m min ago"
|
||||
m < 48 * 60 -> "${m / 60} h ago"
|
||||
else -> "${m / (24 * 60)} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
/** Only when the user taps refresh: nothing goes online on its own. */
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
}
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
}
|
||||
|
||||
/** Download one release driver and import it through the same importer a picked zip uses. */
|
||||
fun downloadReleaseDriver(assetName: String) {
|
||||
val asset = TurnipReleases.cached(activity)?.assets?.firstOrNull { it.name == assetName } ?: return
|
||||
if (releaseProgress.containsKey(assetName)) return
|
||||
releaseProgress[assetName] = 0
|
||||
refreshReleaseRows()
|
||||
Thread({
|
||||
val problem = try {
|
||||
installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } }
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
"Download failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "download-turnip").start()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MainActivity"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Context.displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -97,13 +97,11 @@ object HomeApp {
|
||||
val intent = Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(ComponentName(app.packageName, app.className))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (displayId == null) {
|
||||
context.startActivity(intent)
|
||||
return
|
||||
}
|
||||
// Keep the target app's task separate from DroidDeck. Without this, Android can put the
|
||||
// app on top of the caller's task and move the entire task to the chosen display.
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
|
||||
context.startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.ui.ProtonRow
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
/**
|
||||
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
|
||||
* removal in progress. The launcher screen keeps one and shows it.
|
||||
*/
|
||||
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
|
||||
var protonBusyId by mutableStateOf<String?>(null)
|
||||
var protonStage by mutableStateOf<String?>(null)
|
||||
var protonPercent by mutableIntStateOf(-1)
|
||||
|
||||
fun installProton(id: String) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
ProtonExtras.unqueue(activity, tool)
|
||||
protonBusyId = id
|
||||
protonStage = "Starting…"
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
val problem = ProtonExtras.install(activity, tool) { label, value ->
|
||||
ui.post { protonStage = label; protonPercent = value }
|
||||
}
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
protonPercent = -1
|
||||
refreshProtons()
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "install-proton-$id").start()
|
||||
}
|
||||
|
||||
fun removeProton(id: String) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
protonBusyId = id
|
||||
protonStage = "Removing ${tool.name}…"
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
ProtonExtras.remove(activity, tool)
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
refreshProtons()
|
||||
}
|
||||
}, "remove-proton-$id").start()
|
||||
}
|
||||
|
||||
fun refreshProtons() {
|
||||
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import android.hardware.input.InputManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.content.Intent
|
||||
@@ -23,16 +24,20 @@ import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
@@ -45,7 +50,6 @@ import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.input.TouchpadGestures
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import com.droiddeck.launcher.session.PerfHints
|
||||
import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -69,6 +73,8 @@ import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.ui.DRAWER_PAGES
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
@@ -88,6 +94,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private lateinit var loading: LoadingState
|
||||
private lateinit var hud: PerfHud
|
||||
private var padBridge: PadBridge? = null
|
||||
/** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
|
||||
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
|
||||
private fun updatePadMotion() {
|
||||
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
}
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var watching = true
|
||||
@@ -95,6 +112,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
|
||||
private var cursorVisible by mutableStateOf(false)
|
||||
/** The blue the front end flooded to on the way in; the loading screen opens on it (LaunchFlood). */
|
||||
private var floodColor by mutableStateOf<Int?>(null)
|
||||
private val cursorHide = Runnable { cursorVisible = false }
|
||||
/** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */
|
||||
private var lastPadInputMs = 0L
|
||||
@@ -140,6 +159,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
// Compose reads these; the activity writes them.
|
||||
private var drawerOpen by mutableStateOf(false)
|
||||
/** A stop growing out of the dialog's Stop (overlay px); the session ends once it covers the screen. */
|
||||
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
|
||||
/** Left behind that flood: the front end opens on its blue, so no sink of our own. */
|
||||
private var quitFlooded = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerPage by mutableIntStateOf(0)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
@@ -147,22 +172,43 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var fillScreen by mutableStateOf(true)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
|
||||
private var frameGenMultiplier by mutableStateOf(2)
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(-1)
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
|
||||
/** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */
|
||||
private var started = false
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private var secondScreenPresentation: SecondScreenPresentation? = null
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
followPanelRefresh(displayId)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's rate, followed for the rest of the session. It is read once at start, and a
|
||||
* panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode
|
||||
* asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and
|
||||
* its games paced against a vblank that no longer came. (WinNative 53836ca9.)
|
||||
*/
|
||||
private fun followPanelRefresh(displayId: Int) {
|
||||
if (!CompositorHost.isStarted) return
|
||||
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
|
||||
if (display.displayId != displayId) return
|
||||
val hz = display.refreshRate
|
||||
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
|
||||
}
|
||||
private var isHomeApp by mutableStateOf(false)
|
||||
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
|
||||
@@ -191,9 +237,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
|
||||
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this))
|
||||
// Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap
|
||||
// divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device
|
||||
// says why. The cap is the session's: an activity re-created mid-session keeps it.
|
||||
if (!SessionState.running) {
|
||||
SessionState.fpsLimit = SessionPrefs.fpsLimit(
|
||||
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
|
||||
goFullscreen()
|
||||
// The device's volume keys change the stream the session plays on (the relay and
|
||||
// PulseAudio are media playback); they are never forwarded to the guest.
|
||||
@@ -212,6 +264,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
padBridge = bridge
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
}
|
||||
SessionState.deckPadListener = deckPadListener
|
||||
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
|
||||
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
|
||||
bridge.setOnPlayerInput {
|
||||
@@ -228,12 +285,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
root.pointerIcon = noCursor
|
||||
surfaceView.pointerIcon = noCursor
|
||||
|
||||
loading = LoadingState(this)
|
||||
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
|
||||
if (!SessionState.running) {
|
||||
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
|
||||
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
|
||||
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
|
||||
if (needRuntime || needDesktop) installThenStart(needRuntime, needDesktop)
|
||||
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)
|
||||
}
|
||||
hud = PerfHud(this)
|
||||
hud.onPresentingWindowChanged = {
|
||||
@@ -246,121 +305,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
readPrefs()
|
||||
|
||||
// One Compose layer for every menu and overlay. Touches nothing in it consumes fall
|
||||
// through to the pad and the game underneath.
|
||||
sessionOverlay = ComposeView(this).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
|
||||
// Opening the drawer takes the controller away from the game: release its pad.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
padBridge?.releaseAll()
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val requested = sessionOverlay.requestFocus()
|
||||
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
|
||||
} else {
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
}
|
||||
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
|
||||
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
|
||||
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
|
||||
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
|
||||
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onClose = { pcKeyboardOpen = false },
|
||||
)
|
||||
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
|
||||
steam = SessionState.mode == SessionService.MODE_STEAM,
|
||||
title = if (SessionState.mode == SessionService.MODE_RUN)
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
|
||||
isHomeApp = isHomeApp,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
|
||||
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
|
||||
onFrameGenPick = { engine, multiplier ->
|
||||
FrameGen.set(this@SessionActivity, engine, multiplier)
|
||||
readPrefs()
|
||||
applyFrameGen()
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
|
||||
// The desktop with Steam's desktop client in it, in this session's place: the
|
||||
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
|
||||
// finish() would skip it).
|
||||
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
|
||||
drawerOpen = false
|
||||
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
|
||||
SessionService.stop(this@SessionActivity)
|
||||
}) else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
|
||||
onSuspendPolicy = { policy ->
|
||||
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
|
||||
suspendPolicy = policy
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
onLaunchAndroidApp = { app, displayId ->
|
||||
drawerOpen = false
|
||||
try {
|
||||
HomeApp.launch(this@SessionActivity, app, displayId)
|
||||
} catch (_: Exception) {
|
||||
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
|
||||
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
|
||||
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onBackground = { drawerOpen = false; moveTaskToBack(true) },
|
||||
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
|
||||
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
|
||||
onClose = { drawerOpen = false },
|
||||
))
|
||||
if (SessionState.suspended) SessionPausedOverlay {
|
||||
SessionService.resume(this@SessionActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
intent.getIntExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, 0).takeIf { it != 0 && loading.visible }?.let {
|
||||
floodColor = it
|
||||
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
|
||||
}
|
||||
sessionOverlay = createSessionOverlay()
|
||||
root.addView(sessionOverlay)
|
||||
setContentView(root)
|
||||
handleHomeGuideIntent(intent)
|
||||
|
||||
updateOnScreenControls()
|
||||
WaylandCompositor.setFirstFrameListener {
|
||||
SessionEvents.firstFrame()
|
||||
runOnUiThread {
|
||||
loading.visible = false
|
||||
hud.start()
|
||||
}
|
||||
}
|
||||
WaylandCompositor.setFirstFrameListener(firstFrameListener)
|
||||
SessionEvents.markReadyIfPossible()
|
||||
SessionState.endListener = endListener
|
||||
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
|
||||
@@ -369,6 +324,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun handleOnBackPressed() {
|
||||
// The PC keyboard closes first (B on a controller), then Back is the drawer again.
|
||||
when {
|
||||
::loading.isInitialized && loading.ended -> finish()
|
||||
drawerOpen -> drawerOpen = false
|
||||
pcKeyboardOpen -> pcKeyboardOpen = false
|
||||
else -> routeBackAction()
|
||||
@@ -378,6 +334,154 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
watchSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* One Compose layer for every menu and overlay. Touches nothing in it consumes fall through to
|
||||
* the pad and the game underneath.
|
||||
*/
|
||||
private fun createSessionOverlay(): ComposeView = ComposeView(this).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
if (loading.visible) LoadingOverlay(
|
||||
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
|
||||
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
|
||||
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
|
||||
onCancel = { SessionService.stop(this@SessionActivity); finish() },
|
||||
endedDetail = loading.endedDetail,
|
||||
onRetry = { retrySession() },
|
||||
onShareLogs = { shareCurrentSessionLogs() },
|
||||
onClose = { finish() },
|
||||
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
|
||||
onFlooded = {
|
||||
floodColor = null
|
||||
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
|
||||
},
|
||||
)
|
||||
// Opening the drawer takes the controller away from the game: release its pad.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
syncLossless()
|
||||
padBridge?.releaseAll()
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val requested = sessionOverlay.requestFocus()
|
||||
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
|
||||
} else {
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
}
|
||||
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
|
||||
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
|
||||
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
|
||||
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
|
||||
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onClose = { pcKeyboardOpen = false },
|
||||
)
|
||||
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
|
||||
steam = SessionState.mode == SessionService.MODE_STEAM,
|
||||
title = if (SessionState.mode == SessionService.MODE_RUN)
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
|
||||
isHomeApp = isHomeApp,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this@SessionActivity, mode)
|
||||
readPrefs()
|
||||
applyFrameGen()
|
||||
},
|
||||
onImportLossless = {
|
||||
pickLossless.launch(InAppFilePicker.buildIntent(
|
||||
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
|
||||
// The desktop with Steam's desktop client in it, in this session's place: the
|
||||
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
|
||||
// finish() would skip it).
|
||||
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
|
||||
drawerOpen = false
|
||||
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
|
||||
SessionService.stop(this@SessionActivity)
|
||||
}) else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
|
||||
onSuspendPolicy = { policy ->
|
||||
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
|
||||
suspendPolicy = policy
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
onLaunchAndroidApp = { app, displayId ->
|
||||
drawerOpen = false
|
||||
try {
|
||||
HomeApp.launch(this@SessionActivity, app, displayId)
|
||||
} catch (_: Exception) {
|
||||
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
|
||||
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
|
||||
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onBackground = { drawerOpen = false; moveTaskToBack(true) },
|
||||
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
|
||||
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
|
||||
onStopFrom = { onScreen ->
|
||||
drawerOpen = false
|
||||
if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) {
|
||||
if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() }
|
||||
} else {
|
||||
val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) }
|
||||
quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat())
|
||||
}
|
||||
},
|
||||
onClose = { drawerOpen = false },
|
||||
components = drawerComponents,
|
||||
onComponentsRefresh = { refreshDrawerComponents() },
|
||||
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
|
||||
))
|
||||
if (SessionState.suspended) SessionPausedOverlay(
|
||||
title = pausedTitle(),
|
||||
onResume = { SessionService.resume(this@SessionActivity) },
|
||||
)
|
||||
quitFrom?.let { from ->
|
||||
val error = androidx.compose.material3.MaterialTheme.colorScheme.error
|
||||
com.droiddeck.launcher.ui.LaunchFlood(
|
||||
from, onProgress = {},
|
||||
fromColors = error to androidx.compose.ui.graphics.lerp(error, androidx.compose.ui.graphics.Color.Black, 0.12f),
|
||||
cornerAtRest = 20.dp,
|
||||
) { finishFlooded() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshHomeApp() {
|
||||
isHomeApp = HomeApp.isDefault(this)
|
||||
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
|
||||
@@ -408,7 +512,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
performBackAction(double = false)
|
||||
}
|
||||
pendingBackAction = pending
|
||||
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
|
||||
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
|
||||
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
|
||||
}
|
||||
|
||||
private fun performBackAction(double: Boolean) {
|
||||
@@ -424,6 +529,62 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
|
||||
}
|
||||
|
||||
|
||||
/** Re-reads the Protons for the drawer's Components tab, first running swaps a closed game was holding back. */
|
||||
private fun refreshDrawerComponents() {
|
||||
Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
|
||||
uiHandler.post {
|
||||
drawerComponents = snap
|
||||
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "drawer-components").start()
|
||||
}
|
||||
|
||||
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
val snap = ComponentsManager.snapshot(this)
|
||||
val queued = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.queued
|
||||
val active = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.activeFile
|
||||
val build = snap.protons.firstOrNull { it.proton.id == protonId }?.proton?.version?.let(ComponentsManager::safeName)
|
||||
val current = active ?: "orig:$build"
|
||||
if (queued != null && value == current) {
|
||||
// Picking what is already in place again cancels the swap waiting for the game.
|
||||
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
|
||||
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
|
||||
else ComponentsManager.swap(this, protonId, value)
|
||||
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
uiHandler.post {
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshDrawerComponents()
|
||||
}
|
||||
}, "drawer-components-swap").start()
|
||||
}
|
||||
|
||||
/** The session this screen is bringing up: the live one when re-attached, else the intent's. */
|
||||
private fun loadingMode(): String =
|
||||
if (SessionState.running) SessionState.mode
|
||||
else intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
|
||||
|
||||
private fun pausedTitle(): String = when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> "The desktop is paused"
|
||||
SessionService.MODE_RUN -> (com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program") + " is paused"
|
||||
else -> "Steam is paused"
|
||||
}
|
||||
|
||||
private fun loadingTitle(): String = when (loadingMode()) {
|
||||
SessionService.MODE_DESKTOP ->
|
||||
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) "Starting Steam on the desktop"
|
||||
else "Starting the desktop"
|
||||
SessionService.MODE_RUN -> {
|
||||
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
"Starting " + (com.droiddeck.launcher.frontend.Library.nameForProgram(program) ?: "the program")
|
||||
}
|
||||
else -> "Starting Steam"
|
||||
}
|
||||
|
||||
private fun shareCurrentSessionLogs() {
|
||||
val folder = SessionPaths.current()
|
||||
if (folder == null || !folder.isDirectory) {
|
||||
@@ -452,9 +613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
fillScreen = SessionPrefs.forceFullscreen(this)
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
frameGenEngine = FrameGen.engine(this)
|
||||
frameGenMultiplier = FrameGen.multiplier(this)
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
@@ -471,17 +630,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* First Play (or Desktop) on a fresh install: the runtime, and for the desktop its package,
|
||||
* are downloaded and unpacked here, on the loading screen's own line and bar, and the session
|
||||
* starts when they are in. Nothing else changes.
|
||||
*
|
||||
* The first Steam session also gets Valve's ARM64 Proton here, before the client has ever
|
||||
* started, so the compatibility tool is installed and the default at sign-in with no install
|
||||
* dialog, download or client restart. Not being able to fetch it stops nothing: the session
|
||||
* then asks the client for it after sign-in, as it always has.
|
||||
*/
|
||||
private fun installThenStart(runtime: Boolean, desktop: Boolean) {
|
||||
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
|
||||
if (SessionState.stopRequested) return
|
||||
installingRuntime = true
|
||||
SessionState.installing = if (runtime) "runtime" else "desktop"
|
||||
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
|
||||
SessionEvents.transition(
|
||||
SessionPhase.INSTALLING_RUNTIME,
|
||||
if (runtime) "runtime.installing" else "desktop.installing",
|
||||
"${SessionState.installing}.installing",
|
||||
mapOf("component" to SessionState.installing),
|
||||
)
|
||||
loading.step = if (runtime) "downloading the Linux runtime" else "downloading the desktop"
|
||||
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
|
||||
else "downloading Proton Experimental (ARM64)"
|
||||
loading.percent = -1
|
||||
Thread({
|
||||
var failedComponent: String? = null
|
||||
@@ -496,6 +661,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
problem = installDesktop()
|
||||
if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop"
|
||||
}
|
||||
if (problem == null && proton && !SessionState.stopRequested) {
|
||||
SessionState.installing = "proton"
|
||||
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "proton.installing", mapOf("component" to "proton"))
|
||||
val protonProblem = installProton()
|
||||
if (protonProblem == null) SessionEvents.record("proton.ready")
|
||||
else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
|
||||
}
|
||||
uiHandler.post {
|
||||
installingRuntime = false
|
||||
SessionState.installing = null
|
||||
@@ -511,6 +683,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.fail(code, problem)
|
||||
collectStartArtifacts("session start failed")
|
||||
loading.showEnded(problem)
|
||||
focusEndedScreen()
|
||||
return@post
|
||||
}
|
||||
loading.percent = -1
|
||||
@@ -561,6 +734,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
|
||||
}
|
||||
|
||||
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
|
||||
private fun installProton(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
|
||||
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
|
||||
?: return "catalog unreachable"
|
||||
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
|
||||
}
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
if (installingRuntime || SessionState.stopRequested) return
|
||||
if (!SessionState.running) {
|
||||
@@ -626,12 +808,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
CompositorHost.startOrAttach(
|
||||
holder.surface, runtimeDir.path,
|
||||
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
|
||||
)
|
||||
if (!SessionState.running) SessionEvents.record("compositor.started")
|
||||
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
|
||||
// period, so a long frame raises CPU clocks now rather than after the load averages up.
|
||||
PerfHints.arm(this, refreshHz())
|
||||
// The service owns everything below the compositor. It is started whenever no session is
|
||||
// running - NOT only when the compositor was just started: the compositor lives for the
|
||||
// whole process, so the second Play after a session ended used to re-attach the Surface,
|
||||
@@ -717,9 +896,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return Pair(width and 1.inv(), height and 1.inv())
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
|
||||
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
|
||||
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
|
||||
*/
|
||||
private fun refreshHz(): Float {
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
|
||||
val hz = display?.refreshRate ?: 60f
|
||||
val wanted = window.attributes.preferredDisplayModeId
|
||||
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
?: display?.refreshRate ?: 60f
|
||||
return if (hz > 1f) hz else 60f
|
||||
}
|
||||
|
||||
@@ -731,10 +917,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val hz = refreshHz()
|
||||
Thread({
|
||||
val problem = FrameGen.apply(this, hz)
|
||||
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "frame-gen").start()
|
||||
}
|
||||
|
||||
/** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
Lossless.sync(this)
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread { lossless = state }
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen()
|
||||
}
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
// ── Session state ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Keeps the loading overlay current from the session log, and its clock moving. */
|
||||
@@ -791,19 +1002,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun showEnded(status: Int, hint: String?) {
|
||||
run {
|
||||
if (status != 0) {
|
||||
loading.showEnded(
|
||||
"The session ended ($status)\n${SessionState.logFile?.path ?: "-"}" +
|
||||
(if (hint != null) "\n\n$hint" else "")
|
||||
)
|
||||
// A moment on screen, so a failure is readable rather than a flash of black;
|
||||
// longer when there is advice to read.
|
||||
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (hint != null) 9000 else 4000)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
if (status == 0) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// It stays until the user leaves: a failure read against a timer is a failure not read.
|
||||
val what = when (SessionState.mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
|
||||
SessionService.MODE_DESKTOP -> "The desktop"
|
||||
else -> "Steam"
|
||||
}
|
||||
loading.showEnded(
|
||||
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
|
||||
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
|
||||
)
|
||||
focusEndedScreen()
|
||||
}
|
||||
|
||||
/** The ended screen's buttons take the pad: it is no longer driving a game. */
|
||||
private fun focusEndedScreen() {
|
||||
drawerOpen = false
|
||||
padBridge?.releaseAll()
|
||||
uiHandler.post { sessionOverlay.requestFocus() }
|
||||
}
|
||||
|
||||
/** The same start again, in a fresh activity: the ended one keeps no session to reuse. */
|
||||
private fun retrySession() {
|
||||
val again = Intent(intent)
|
||||
finish()
|
||||
startActivity(again)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,10 +1078,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
}
|
||||
// While the session starts, B is the loading screen's Cancel, as its corner says.
|
||||
if (fromController && event.keyCode == KeyEvent.KEYCODE_BUTTON_B && ::loading.isInitialized && loading.visible && !loading.ended && !drawerOpen) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
SessionService.stop(this)
|
||||
finish()
|
||||
}
|
||||
return true
|
||||
}
|
||||
// The ended screen is a page of buttons: the pad drives them, and B leaves.
|
||||
if (::loading.isInitialized && loading.ended) {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B || event.keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) finish()
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (drawerOpen) {
|
||||
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else 2) % 3
|
||||
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
return true
|
||||
@@ -1071,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
*/
|
||||
private fun syncClientCursor(): Boolean {
|
||||
if (!CompositorHost.isStarted) return false
|
||||
// Asked on every pointer move; the image is copied only when the client has changed it.
|
||||
if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false
|
||||
val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf)
|
||||
if (n < 6) return false
|
||||
val serial = cursorBuf[0]
|
||||
@@ -1112,11 +1357,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
|
||||
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
|
||||
// A choice kept while its display was away (asleep, waking): back as soon as it is.
|
||||
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
private fun selectSecondScreenDisplay(displayId: Int) {
|
||||
if (secondScreenDisplays.none { it.id == displayId }) return
|
||||
selectedSecondScreenDisplay = displayId
|
||||
SessionState.secondScreenDisplay = displayId
|
||||
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
@@ -1138,8 +1386,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun showSecondScreen(mode: SecondScreenMode) {
|
||||
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
|
||||
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
|
||||
closeSecondScreen(reset = true)
|
||||
refreshSecondScreenDisplays()
|
||||
// Not there right now - a panel still waking, say. The choice is kept; the display
|
||||
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
|
||||
closeSecondScreen(reset = false)
|
||||
return
|
||||
}
|
||||
var presentation = secondScreenPresentation
|
||||
@@ -1171,6 +1420,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (!presentation.isShowing) presentation.show()
|
||||
presentation.showMode(mode)
|
||||
secondScreenMode = mode
|
||||
SessionState.secondScreenMode = mode
|
||||
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
|
||||
closeSecondScreen(reset = true)
|
||||
@@ -1190,7 +1441,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun closeSecondScreen(reset: Boolean) {
|
||||
secondScreenPresentation?.closeControls()
|
||||
secondScreenPresentation = null
|
||||
if (reset) secondScreenMode = SecondScreenMode.NONE
|
||||
if (reset) {
|
||||
secondScreenMode = SecondScreenMode.NONE
|
||||
SessionState.secondScreenMode = SecondScreenMode.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
|
||||
@@ -1303,15 +1557,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
started = true
|
||||
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
||||
refreshSecondScreenDisplays()
|
||||
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
|
||||
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
SessionService.setActivityVisible(this, true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
started = false
|
||||
displayManager.unregisterDisplayListener(displayListener)
|
||||
closeSecondScreen(reset = true)
|
||||
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
|
||||
// onStart puts it back.
|
||||
closeSecondScreen(reset = false)
|
||||
SessionService.setActivityVisible(this, false)
|
||||
super.onStop()
|
||||
}
|
||||
@@ -1338,6 +1596,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
refreshHomeApp()
|
||||
if (intent?.action == SessionService.ACTION_RESUME) {
|
||||
intent.action = null
|
||||
@@ -1347,15 +1606,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
|
||||
onScreenControls?.reload()
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
// Do not carry transient session UI across an app/display transition. In particular, the
|
||||
// drawer's dim layer can otherwise remain over Steam when this activity returns.
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
|
||||
releaseDrawerDirection()
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
onScreenControls?.releaseAll()
|
||||
resumed = false
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
super.onPause()
|
||||
}
|
||||
@@ -1391,6 +1658,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
/** Ours, so a finishing activity never unhooks the one that replaced it. */
|
||||
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
|
||||
private val firstFrameListener = Runnable {
|
||||
SessionEvents.firstFrame()
|
||||
runOnUiThread {
|
||||
loading.visible = false
|
||||
hud.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Deliberately does NOT end the session: this activity can be destroyed while the user is
|
||||
@@ -1402,21 +1676,34 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = true)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
WaylandCompositor.setFirstFrameListener(null)
|
||||
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** The screen is all blue: the session stops and the front end opens on the same blue (FloodReturn). */
|
||||
private fun finishFlooded() {
|
||||
if (isFinishing) return
|
||||
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
|
||||
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
|
||||
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
|
||||
quitFlooded = true
|
||||
SessionService.stop(this)
|
||||
finish()
|
||||
}
|
||||
|
||||
/** Leaving the session sinks its surface back down onto the front end. */
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
if (quitFlooded) overridePendingTransition(0, 0)
|
||||
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionActivity"
|
||||
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
|
||||
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
|
||||
private const val CURSOR_PAD_HOLD_MS = 1200L
|
||||
private const val DRAWER_HAT_THRESHOLD = 0.5f
|
||||
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.ui.UpdatesActions
|
||||
import com.droiddeck.launcher.ui.UpdatesState
|
||||
import com.droiddeck.launcher.update.AppUpdates
|
||||
import com.droiddeck.launcher.update.SelfInstaller
|
||||
|
||||
/**
|
||||
* The Updates page's state: the channels' latest builds, the one followed, and an update being
|
||||
* downloaded or installed. Checked when the launcher comes to the front - opening the app, and
|
||||
* coming back from a session - at most once a minute.
|
||||
*/
|
||||
internal class UpdatesMenu(private val activity: Activity, private val ui: Handler) {
|
||||
// Read in start(): the activity has no context yet while its fields are made.
|
||||
var catalog by mutableStateOf<AppUpdates.Catalog?>(null)
|
||||
var follow by mutableStateOf(AppUpdates.Follow(AppUpdates.Channel.STABLE))
|
||||
var checking by mutableStateOf(false)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
var percent by mutableIntStateOf(-1)
|
||||
var askPermission by mutableStateOf(false)
|
||||
// An install waiting for the user to allow "Install unknown apps"; it goes on when they come back.
|
||||
private var pending: AppUpdates.Release? = null
|
||||
private var lastCheck = 0L
|
||||
private var request = 0
|
||||
|
||||
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
|
||||
|
||||
fun actions() = UpdatesActions(
|
||||
onCheck = { check(force = true) },
|
||||
onFollow = ::choose,
|
||||
onInstall = ::install,
|
||||
onAllowInstalls = {
|
||||
askPermission = false
|
||||
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
|
||||
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
|
||||
},
|
||||
onDismissPermission = { askPermission = false; pending = null },
|
||||
)
|
||||
|
||||
val hasUpdate: Boolean get() = AppUpdates.hasUpdate(catalog, follow)
|
||||
|
||||
/** On every return to the launcher: go on with an install the user just allowed, and check if due. */
|
||||
fun onResume() {
|
||||
pending?.let { r ->
|
||||
if (SelfInstaller.canInstall(activity)) { pending = null; install(r) }
|
||||
}
|
||||
if (!SessionState.running) check(force = false)
|
||||
}
|
||||
|
||||
fun check(force: Boolean) {
|
||||
if (checking || stage != null) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastCheck < 60_000) return
|
||||
lastCheck = now
|
||||
val id = ++request
|
||||
checking = true
|
||||
Thread({
|
||||
val result = runCatching { AppUpdates.refresh(activity) }
|
||||
ui.post {
|
||||
if (id != request) return@post
|
||||
checking = false
|
||||
result.onSuccess {
|
||||
catalog = it
|
||||
follow = AppUpdates.follow(activity, it)
|
||||
error = null
|
||||
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}
|
||||
}, "app-updates").start()
|
||||
}
|
||||
|
||||
private fun choose(f: AppUpdates.Follow) {
|
||||
AppUpdates.setFollow(activity, f)
|
||||
follow = f
|
||||
error = null
|
||||
}
|
||||
|
||||
private fun install(r: AppUpdates.Release) {
|
||||
if (stage != null) return
|
||||
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
|
||||
if (SessionState.running) { error = "Stop the running session first, then update"; return }
|
||||
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
|
||||
error = null
|
||||
stage = "Downloading"
|
||||
percent = 0
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
|
||||
ui.post { stage = "Installing"; percent = -1 }
|
||||
SelfInstaller.install(activity, file)
|
||||
}
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
|
||||
}, "app-update-install").start()
|
||||
}
|
||||
|
||||
/** PackageInstaller's answer: Android's confirm prompt to show, or why it failed. Success restarts the app. */
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
|
||||
stage = "Waiting for you to confirm"
|
||||
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> stage = null
|
||||
else -> {
|
||||
stage = null
|
||||
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** From onCreate: the last check, the channel followed, and PackageInstaller's answers. */
|
||||
fun start() {
|
||||
catalog = AppUpdates.cached(activity)
|
||||
follow = AppUpdates.follow(activity, catalog)
|
||||
val filter = IntentFilter(SelfInstaller.ACTION_STATUS)
|
||||
if (Build.VERSION.SDK_INT >= 33) activity.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
else activity.registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
fun unregister() {
|
||||
runCatching { activity.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.droiddeck.launcher.core;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Where an archive entry may land. An entry name ("../x", "/etc/x") or a symlink already written
|
||||
* by an earlier entry can point outside the directory being filled; the canonical path follows
|
||||
* both, so anything that does not resolve inside it is refused.
|
||||
*/
|
||||
public final class ArchivePaths {
|
||||
private ArchivePaths() {}
|
||||
|
||||
/**
|
||||
* The file for {@code name} under {@code destination}, or null when it would resolve outside
|
||||
* it. The destination itself counts as inside (a "./" entry).
|
||||
*/
|
||||
public static File inside(File destination, String name) throws IOException {
|
||||
String base = destination.getCanonicalPath();
|
||||
File file = new File(destination, name);
|
||||
String path = file.getCanonicalPath();
|
||||
if (path.equals(base) || path.startsWith(base + File.separator)) return file;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
@@ -114,19 +113,24 @@ object DeviceReport {
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
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 "")
|
||||
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
|
||||
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
|
||||
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: ""))
|
||||
val turnip = TurnipDriver(context)
|
||||
val androidChoice = SessionPrefs.androidDriver(context)
|
||||
k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice)
|
||||
k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim())
|
||||
k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", "))
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
val id = SessionPrefs.linuxDriver(context, m)
|
||||
run {
|
||||
val id = SessionPrefs.linuxDriver(context)
|
||||
val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)"
|
||||
else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() +
|
||||
lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } +
|
||||
if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]"
|
||||
k("Linux driver ($m)", label)
|
||||
k("Linux driver", label)
|
||||
}
|
||||
k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path)
|
||||
|
||||
@@ -145,9 +149,12 @@ object DeviceReport {
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
k("No GL error checks", SessionPrefs.noGlError(context))
|
||||
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
|
||||
k("Steam controller", SessionPrefs.steamController(context))
|
||||
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
|
||||
k("Skip xalia", SessionPrefs.noXalia(context))
|
||||
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
|
||||
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
@@ -162,21 +169,7 @@ object DeviceReport {
|
||||
})
|
||||
|
||||
h("Android process limits")
|
||||
// Android 12 kills "phantom" processes - the children an app forks itself rather than
|
||||
// starting through the framework - once there are more than a handful of them. A session is
|
||||
// nothing but those: proot, gamescope, Xwayland, the client, its helpers, Wine, FEX. Where
|
||||
// this is left on, the OS kills the session and nothing in our logs says why, because
|
||||
// nothing in the session did it. Some ROMs expose it in Developer options as a
|
||||
// "restrict child processes" switch; otherwise it is
|
||||
// adb shell settings put global settings_enable_monitor_phantom_procs false
|
||||
val phantom = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "settings_enable_monitor_phantom_procs")
|
||||
}.getOrNull()
|
||||
k("Phantom proc monitor", when (phantom?.lowercase()) {
|
||||
"false", "0" -> "disabled (good - the OS will not kill the session's children)"
|
||||
null, "" -> "not set (ROM default, usually ENABLED - see the note below)"
|
||||
else -> "ENABLED (the OS may kill the session with no log; turn off \"restrict child processes\")"
|
||||
})
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
/**
|
||||
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
|
||||
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
|
||||
* session's environment; the games under FEX inherit them from the client. The default is FEX's
|
||||
* own defaults - no variables at all - which is what every session has run on so far.
|
||||
*
|
||||
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
|
||||
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
|
||||
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
|
||||
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
|
||||
*/
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object FexPreset {
|
||||
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
|
||||
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
|
||||
|
||||
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
|
||||
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
|
||||
)
|
||||
|
||||
val all: List<Preset> = listOf(
|
||||
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
|
||||
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
|
||||
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
|
||||
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
|
||||
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
|
||||
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
|
||||
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
|
||||
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
|
||||
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
|
||||
)
|
||||
|
||||
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
|
||||
|
||||
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
|
||||
/** Game-launch variables in KEY=VALUE form. */
|
||||
fun env(id: String): List<String> = byId(id).env
|
||||
}
|
||||
@@ -104,6 +104,7 @@ public final class FileUtils {
|
||||
public static String sizeToString(long bytes) {
|
||||
if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30));
|
||||
if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20));
|
||||
if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10));
|
||||
return bytes + " B";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
object GameEnvironment {
|
||||
data class Config(
|
||||
val shared: Map<String, String?> = emptyMap(),
|
||||
val games: Map<String, Map<String, String?>> = emptyMap(),
|
||||
) {
|
||||
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
|
||||
|
||||
fun withEntries(scope: String, entries: Map<String, String?>): Config =
|
||||
if (scope.isEmpty()) copy(shared = entries.toMap())
|
||||
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
|
||||
}
|
||||
|
||||
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val unused = setOf(
|
||||
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
|
||||
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
|
||||
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
|
||||
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
|
||||
)
|
||||
|
||||
fun validName(name: String) = namePattern.matches(name)
|
||||
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
|
||||
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
|
||||
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
|
||||
|
||||
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
).apply {
|
||||
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object GameEnvironmentOptions {
|
||||
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
|
||||
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
|
||||
|
||||
val all = listOf(
|
||||
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
|
||||
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
|
||||
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
|
||||
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
|
||||
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
|
||||
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
|
||||
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
|
||||
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
|
||||
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
|
||||
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
|
||||
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
|
||||
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
|
||||
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
|
||||
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
|
||||
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
|
||||
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
|
||||
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
|
||||
)
|
||||
|
||||
fun find(name: String): Option? = all.firstOrNull { it.name == name }
|
||||
|
||||
fun toggle(value: String, choice: String): String {
|
||||
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
|
||||
if (!selected.remove(choice)) selected.add(choice)
|
||||
return selected.joinToString(",")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** File checksums as lowercase hex, and GitHub's release-asset digests, in one place. */
|
||||
object Hashes {
|
||||
private val GITHUB_SHA256 = Regex("(?i)sha256:[0-9a-f]{64}")
|
||||
|
||||
@JvmStatic
|
||||
fun sha256(file: File): String = hex(file, "SHA-256")
|
||||
|
||||
@JvmStatic
|
||||
fun sha512(file: File): String = hex(file, "SHA-512")
|
||||
|
||||
/** True when [digest] is GitHub's asset digest form, "sha256:" and 64 hex digits. */
|
||||
@JvmStatic
|
||||
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
|
||||
|
||||
/** The hex of a GitHub asset digest ("sha256:<hex>"), or null when [digest] is not one. */
|
||||
@JvmStatic
|
||||
fun githubSha256(digest: String?): String? = digest?.takeIf { isGithubSha256(it) }?.substringAfter(':')
|
||||
|
||||
private fun hex(file: File, algorithm: String): String {
|
||||
val digest = MessageDigest.getInstance(algorithm)
|
||||
file.inputStream().buffered(1 shl 16).use { input ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
digest.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,8 @@ object HostProcess {
|
||||
}
|
||||
}
|
||||
|
||||
private fun pidOf(process: Process): Int = try {
|
||||
@JvmStatic
|
||||
fun pidOf(process: Process): Int = try {
|
||||
val field = process.javaClass.getDeclaredField("pid")
|
||||
field.isAccessible = true
|
||||
val pid = field.getInt(process)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
|
||||
enum class PhantomProcessStatus {
|
||||
NOT_APPLICABLE,
|
||||
DISABLED,
|
||||
ENABLED,
|
||||
UNSET,
|
||||
UNREADABLE,
|
||||
}
|
||||
|
||||
object PhantomProcessLimit {
|
||||
private const val SETTING = "settings_enable_monitor_phantom_procs"
|
||||
|
||||
private const val MAX_PHANTOM = "2147483647"
|
||||
private const val PREFS = "phantom-process-limit"
|
||||
private const val PREF_ANDROID_12_OFF = "android12-off"
|
||||
|
||||
fun usesDeviceConfig(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk == Build.VERSION_CODES.S
|
||||
|
||||
fun shellCommands(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): List<String> = when {
|
||||
usesDeviceConfig(sdk) && enabled -> listOf(
|
||||
"device_config delete activity_manager max_phantom_processes",
|
||||
"device_config set_sync_disabled_for_tests none",
|
||||
)
|
||||
usesDeviceConfig(sdk) -> listOf(
|
||||
"device_config set_sync_disabled_for_tests persistent",
|
||||
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
|
||||
)
|
||||
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
|
||||
}
|
||||
|
||||
fun verifyCommand(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (usesDeviceConfig(sdk)) "device_config get activity_manager max_phantom_processes"
|
||||
else "settings get global $SETTING"
|
||||
|
||||
fun verified(output: String, enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): Boolean {
|
||||
val value = output.trim()
|
||||
return if (usesDeviceConfig(sdk)) (value == MAX_PHANTOM) != enabled
|
||||
else value == if (enabled) "true" else "false"
|
||||
}
|
||||
|
||||
fun adbCommand(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
shellCommands(enabled, sdk).joinToString(" && ") { "adb shell $it" }
|
||||
|
||||
fun adbCommand(): String = adbCommand(false)
|
||||
|
||||
fun rememberAndroid12(context: Context, off: Boolean) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(PREF_ANDROID_12_OFF, off).apply()
|
||||
}
|
||||
|
||||
fun read(context: Context, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
|
||||
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
|
||||
if (usesDeviceConfig(sdk)) {
|
||||
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
|
||||
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
|
||||
}
|
||||
val global = try {
|
||||
Settings.Global.getString(context.contentResolver, SETTING)
|
||||
} catch (_: Exception) {
|
||||
return PhantomProcessStatus.UNREADABLE
|
||||
}
|
||||
return status(global, systemProperty(OVERRIDE_PROPERTY))
|
||||
}
|
||||
|
||||
/**
|
||||
* The value Android itself acts on, in FeatureFlagUtils.isEnabled's order: the global setting
|
||||
* when it is set, otherwise the feature-flag override property. Developer options' "Disable
|
||||
* child process restrictions" writes only the property, so a device switched off there has no
|
||||
* global setting at all and must not be reported as unset.
|
||||
*/
|
||||
fun status(global: String?, override: String?): PhantomProcessStatus {
|
||||
val value = global?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
|
||||
?: override?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
|
||||
return when (value) {
|
||||
"false", "0" -> PhantomProcessStatus.DISABLED
|
||||
null -> PhantomProcessStatus.UNSET
|
||||
else -> PhantomProcessStatus.ENABLED
|
||||
}
|
||||
}
|
||||
|
||||
private const val OVERRIDE_PROPERTY = "persist.sys.fflag.override.$SETTING"
|
||||
|
||||
private fun systemProperty(name: String): String? = try {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java)
|
||||
.invoke(null, name) as? String
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun hasDeveloperToggle(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
|
||||
|
||||
fun blocksSteam(status: PhantomProcessStatus): Boolean =
|
||||
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
|
||||
|
||||
fun title(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
|
||||
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
|
||||
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
|
||||
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
|
||||
}
|
||||
|
||||
fun instructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
|
||||
}
|
||||
|
||||
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
|
||||
else -> instructions(status)
|
||||
}
|
||||
|
||||
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
|
||||
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
|
||||
|
||||
fun reportValue(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
|
||||
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
|
||||
PhantomProcessStatus.UNSET -> "not set (ROM default applies; DroidDeck requires an explicit disabled value)"
|
||||
PhantomProcessStatus.UNREADABLE -> "unreadable (DroidDeck could not verify the setting)"
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "not applicable before Android 12"
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,10 @@ public final class TarZst {
|
||||
destination.mkdirs();
|
||||
try (InputStream in = new ZstdCompressorInputStream(new BufferedInputStream(source, 1 << 16));
|
||||
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
|
||||
String base = destination.getCanonicalPath() + File.separator;
|
||||
TarArchiveEntry entry;
|
||||
while ((entry = tar.getNextTarEntry()) != null) {
|
||||
File file = new File(destination, entry.getName());
|
||||
if (!file.getCanonicalPath().startsWith(base)) continue;
|
||||
File file = ArchivePaths.inside(destination, entry.getName());
|
||||
if (file == null) continue;
|
||||
if (entry.isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
file.mkdirs();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import com.flyfishxu.kadb.Kadb
|
||||
import com.flyfishxu.kadb.cert.KadbCert
|
||||
import java.io.File
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.ArrayDeque
|
||||
|
||||
/** A narrowly scoped Wireless debugging client for the child-process setting repair. */
|
||||
object WirelessAdbFix {
|
||||
private const val CERT_FILE = "wireless-adb-cert.pem"
|
||||
private const val KEY_FILE = "wireless-adb-key.pem"
|
||||
private const val HOST_FILE = "wireless-adb-host.txt"
|
||||
private const val CONNECTION_FILE = "wireless-adb-connection.txt"
|
||||
const val LOOPBACK = "127.0.0.1"
|
||||
|
||||
private data class SavedConnection(val host: String, val port: Int?)
|
||||
|
||||
@Synchronized
|
||||
private fun loadOrCreateIdentity(context: Context) {
|
||||
val directory = context.noBackupFilesDir
|
||||
val cert = File(directory, CERT_FILE)
|
||||
val key = File(directory, KEY_FILE)
|
||||
if (cert.isFile && key.isFile) {
|
||||
KadbCert.set(cert.readBytes(), key.readBytes())
|
||||
return
|
||||
}
|
||||
|
||||
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
|
||||
KadbCert.set(identity.first, identity.second)
|
||||
writeAtomically(cert, identity.first)
|
||||
writeAtomically(key, identity.second)
|
||||
}
|
||||
|
||||
private fun writeAtomically(destination: File, contents: ByteArray) {
|
||||
val temporary = File(destination.parentFile, "${destination.name}.tmp")
|
||||
temporary.writeBytes(contents)
|
||||
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
|
||||
}
|
||||
|
||||
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.pair(host, port, pairingCode, "DroidDeck")
|
||||
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
}
|
||||
|
||||
private fun savedConnection(context: Context): SavedConnection? {
|
||||
val directory = context.applicationContext.noBackupFilesDir
|
||||
if (!File(directory, CERT_FILE).isFile || !File(directory, KEY_FILE).isFile) return null
|
||||
val host = File(directory, HOST_FILE).takeIf(File::isFile)?.readText()?.trim()
|
||||
?.takeIf(String::isNotEmpty) ?: return null
|
||||
val lines = File(directory, CONNECTION_FILE).takeIf(File::isFile)?.readLines()
|
||||
val port = lines?.takeIf { it.size == 2 && it[0] == host }
|
||||
?.get(1)?.toIntOrNull()?.takeIf { it in 1..65535 }
|
||||
return SavedConnection(host, port)
|
||||
}
|
||||
|
||||
/** A saved pairing skips the PIN; an unknown connection port can be filled in from Settings. */
|
||||
fun savedConnectionAddress(context: Context): String? = savedConnection(context)?.let { saved ->
|
||||
val host = if (':' in saved.host) "[${saved.host}]" else saved.host
|
||||
"$host:${saved.port ?: ""}"
|
||||
}
|
||||
|
||||
private fun saveConnection(context: Context, host: String, port: Int) {
|
||||
writeAtomically(
|
||||
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
|
||||
"$host\n$port".toByteArray(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Finds this paired device's separate TLS connection port from Android's ADB mDNS record. */
|
||||
fun isLocalAddress(address: InetAddress): Boolean = runCatching {
|
||||
if (address.isLoopbackAddress) return true
|
||||
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { network ->
|
||||
network.inetAddresses.toList().any { it.address.contentEquals(address.address) }
|
||||
}
|
||||
}.getOrDefault(address is Inet4Address)
|
||||
|
||||
fun localConnectPort(context: Context): Int? =
|
||||
tlsPortProperty()
|
||||
?: findConnectPort(context, LOOPBACK)
|
||||
|
||||
private fun tlsPortProperty(): Int? = runCatching {
|
||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
|
||||
.invoke(null, "service.adb.tls.port") as? String
|
||||
}.getOrNull()?.trim()?.toIntOrNull()?.takeIf { it in 1..65535 }
|
||||
|
||||
fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12): Int? {
|
||||
val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
val paired = runCatching { InetAddress.getByName(pairedHost) }.getOrNull() ?: return null
|
||||
val matches: (InetAddress) -> Boolean =
|
||||
if (paired.isLoopbackAddress) ::isLocalAddress else { address -> address.address.contentEquals(paired.address) }
|
||||
val resolvedPort = AtomicInteger(-1)
|
||||
val finished = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
val queue = ArrayDeque<NsdServiceInfo>()
|
||||
val seen = mutableSetOf<String>()
|
||||
var resolving = false
|
||||
val lock = Any()
|
||||
|
||||
lateinit var resolveNext: () -> Unit
|
||||
resolveNext = {
|
||||
val next = synchronized(lock) {
|
||||
if (finished.get() || resolving || queue.isEmpty()) null
|
||||
else queue.removeFirst().also { resolving = true }
|
||||
}
|
||||
if (next != null) {
|
||||
val listener = object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
synchronized(lock) { resolving = false }
|
||||
resolveNext()
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
val address = serviceInfo.host
|
||||
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
|
||||
resolvedPort.set(serviceInfo.port)
|
||||
latch.countDown()
|
||||
} else {
|
||||
synchronized(lock) { resolving = false }
|
||||
resolveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
runCatching { manager.resolveService(next, listener) }.onFailure {
|
||||
synchronized(lock) { resolving = false }
|
||||
resolveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val discoveryListener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String) = Unit
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType?.contains("_adb-tls-connect._tcp") == true) {
|
||||
synchronized(lock) {
|
||||
if (seen.add(serviceInfo.serviceName)) queue.addLast(serviceInfo)
|
||||
}
|
||||
resolveNext()
|
||||
}
|
||||
}
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) { latch.countDown() }
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
|
||||
}
|
||||
|
||||
try {
|
||||
manager.discoverServices("_adb-tls-connect._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
latch.await(timeoutSeconds, TimeUnit.SECONDS)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
} finally {
|
||||
runCatching { manager.stopServiceDiscovery(discoveryListener) }
|
||||
}
|
||||
return resolvedPort.get().takeIf { it in 1..65535 }
|
||||
}
|
||||
|
||||
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.create(host, port).use { adb ->
|
||||
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
|
||||
val change = adb.shell(command)
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
|
||||
}
|
||||
val result = adb.shell(PhantomProcessLimit.verifyCommand())
|
||||
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
|
||||
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
|
||||
}
|
||||
}
|
||||
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
|
||||
saveConnection(context, host, port)
|
||||
}
|
||||
|
||||
/** Tries the app's saved pairing without asking for a new pairing code. */
|
||||
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
|
||||
val appContext = context.applicationContext
|
||||
val saved = savedConnection(appContext)
|
||||
?: error("No Wireless debugging pairing is saved")
|
||||
val cachedPort = saved.port
|
||||
if (cachedPort != null) {
|
||||
val cachedResult = runCatching {
|
||||
setChildProcessLimit(appContext, saved.host, cachedPort, enabled)
|
||||
}
|
||||
if (cachedResult.isSuccess) return
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
if (discoveredPort != null && discoveredPort != cachedPort) {
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
} else {
|
||||
cachedResult.getOrThrow()
|
||||
}
|
||||
} else {
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
?: error("Enter the current Wireless debugging IP address & Port")
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.RemoteInput
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class WirelessAdbPairingService : Service() {
|
||||
sealed interface Stage {
|
||||
data object Idle : Stage
|
||||
data object Waiting : Stage
|
||||
data class CodeNeeded(val error: String? = null) : Stage
|
||||
data class Working(val step: String) : Stage
|
||||
data object Done : Stage
|
||||
data class Failed(val error: String) : Stage
|
||||
}
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var nsd: NsdManager? = null
|
||||
private var discovery: NsdManager.DiscoveryListener? = null
|
||||
private var pairingHost: String? = null
|
||||
private var pairingPort: Int? = null
|
||||
@Volatile private var working = false
|
||||
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_CODE -> {
|
||||
val code = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_CODE)
|
||||
?.filter(Char::isDigit)?.toString().orEmpty()
|
||||
onCode(code)
|
||||
}
|
||||
ACTION_CANCEL -> finish(Stage.Idle)
|
||||
else -> begin()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun begin() {
|
||||
pairingHost = null
|
||||
pairingPort = null
|
||||
working = false
|
||||
publish(Stage.Waiting)
|
||||
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
|
||||
startDiscovery()
|
||||
main.removeCallbacks(timeout)
|
||||
main.postDelayed(timeout, TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun startDiscovery() {
|
||||
stopDiscovery()
|
||||
val manager = getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
val listener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String) = Unit
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType?.contains(PAIRING_TYPE) != true) return
|
||||
runCatching { manager.resolveService(serviceInfo, resolveListener()) }
|
||||
}
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) = Unit
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
|
||||
}
|
||||
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
|
||||
.onSuccess { nsd = manager; discovery = listener }
|
||||
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
|
||||
}
|
||||
|
||||
private fun resolveListener() = object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
val address = serviceInfo.host ?: return
|
||||
if (!WirelessAdbFix.isLocalAddress(address)) return
|
||||
main.post {
|
||||
if (working || current.value == Stage.Done) return@post
|
||||
pairingHost = address.hostAddress
|
||||
pairingPort = serviceInfo.port
|
||||
if (current.value !is Stage.CodeNeeded) show(Stage.CodeNeeded())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCode(code: String) {
|
||||
val host = pairingHost
|
||||
val port = pairingPort
|
||||
if (working) return
|
||||
if (host == null || port == null) {
|
||||
show(Stage.Waiting)
|
||||
return
|
||||
}
|
||||
if (code.length != 6) {
|
||||
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
|
||||
return
|
||||
}
|
||||
working = true
|
||||
show(Stage.Working("Pairing…"))
|
||||
Thread({
|
||||
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
|
||||
if (paired.isFailure) {
|
||||
main.post {
|
||||
working = false
|
||||
pairingPort = null
|
||||
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
main.post {
|
||||
stopDiscovery()
|
||||
show(Stage.Working("Paired. Connecting…"))
|
||||
}
|
||||
val result = runCatching {
|
||||
val connectPort = WirelessAdbFix.localConnectPort(this)
|
||||
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
|
||||
main.post { show(Stage.Working("Applying the setting…")) }
|
||||
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
|
||||
}
|
||||
main.post {
|
||||
working = false
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
|
||||
}
|
||||
}, "wireless-adb-pairing").start()
|
||||
}
|
||||
|
||||
private fun show(stage: Stage) {
|
||||
publish(stage)
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(stage))
|
||||
}
|
||||
|
||||
private fun finish(stage: Stage) {
|
||||
main.removeCallbacks(timeout)
|
||||
stopDiscovery()
|
||||
publish(stage)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (stage is Stage.Done || stage is Stage.Failed) {
|
||||
manager?.notify(NOTIFICATION_ID, notification(stage))
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
manager?.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun stopDiscovery() {
|
||||
val listener = discovery ?: return
|
||||
runCatching { nsd?.stopServiceDiscovery(listener) }
|
||||
discovery = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
main.removeCallbacks(timeout)
|
||||
stopDiscovery()
|
||||
if (current.value.let { it is Stage.Waiting || it is Stage.CodeNeeded || it is Stage.Working }) publish(Stage.Idle)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun notification(stage: Stage): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Asks for the Wireless debugging pairing code while Settings is open"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
})
|
||||
val open = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
val cancel = PendingIntent.getService(this, 1,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentIntent(open)
|
||||
.setShowWhen(false)
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
when (stage) {
|
||||
Stage.Waiting, Stage.Idle -> builder
|
||||
.setContentTitle("Pair Wireless debugging")
|
||||
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
|
||||
.setStyle(Notification.BigTextStyle().bigText(
|
||||
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
is Stage.CodeNeeded -> {
|
||||
val reply = PendingIntent.getService(this, 2,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
|
||||
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
|
||||
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
|
||||
builder
|
||||
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setOngoing(true)
|
||||
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
}
|
||||
is Stage.Working -> builder
|
||||
.setContentTitle("Setting up Steam")
|
||||
.setContentText(stage.step)
|
||||
.setProgress(0, 0, true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
Stage.Done -> builder
|
||||
.setContentTitle("Steam is ready")
|
||||
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
|
||||
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
|
||||
.setAutoCancel(true)
|
||||
is Stage.Failed -> builder
|
||||
.setContentTitle("Wireless debugging setup stopped")
|
||||
.setContentText(stage.error)
|
||||
.setStyle(Notification.BigTextStyle().bigText(stage.error))
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "wireless-adb-pairing"
|
||||
private const val NOTIFICATION_ID = 3
|
||||
private const val PAIRING_TYPE = "_adb-tls-pairing._tcp"
|
||||
private const val ACTION_CODE = "com.droiddeck.launcher.action.WIRELESS_ADB_CODE"
|
||||
private const val ACTION_CANCEL = "com.droiddeck.launcher.action.WIRELESS_ADB_CANCEL"
|
||||
private const val KEY_CODE = "code"
|
||||
private const val TIMEOUT_MS = 5 * 60_000L
|
||||
|
||||
private val current = MutableStateFlow<Stage>(Stage.Idle)
|
||||
val stage: StateFlow<Stage> = current
|
||||
|
||||
private fun publish(stage: Stage) { current.value = stage }
|
||||
|
||||
fun start(context: Context) {
|
||||
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
|
||||
publish(Stage.Waiting)
|
||||
val intent = Intent(context, WirelessAdbPairingService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
if (current.value.let { it is Stage.Done || it is Stage.Failed || it is Stage.Idle }) {
|
||||
publish(Stage.Idle)
|
||||
return
|
||||
}
|
||||
context.startService(Intent(context, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL))
|
||||
}
|
||||
|
||||
fun notificationsEnabled(context: Context): Boolean =
|
||||
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() != false
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ fun CollapsibleRail(
|
||||
TextButton(onClick = link.onClick, modifier = Modifier.padding(start = 4.dp)) {
|
||||
Icon(link.icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(15.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(link.label, fontSize = 11.sp)
|
||||
Text(link.label, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ fun CollapsibleRail(
|
||||
Text(
|
||||
section.header,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
fontSize = 9.sp,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.6.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 10.dp, bottom = 2.dp),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import java.io.File
|
||||
|
||||
// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr).
|
||||
|
||||
// ── DOS file attributes (Read-only / Hidden) ──
|
||||
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
|
||||
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
|
||||
// app owns (container files) and the Wine DOS-attribute xattr.
|
||||
|
||||
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
|
||||
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
|
||||
|
||||
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
|
||||
|
||||
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
|
||||
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
|
||||
// disabled while Read-only keeps working.
|
||||
internal data class FileAttrState(
|
||||
val readOnly: Boolean,
|
||||
val hidden: Boolean,
|
||||
val hiddenSupported: Boolean,
|
||||
)
|
||||
|
||||
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
|
||||
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
|
||||
// prefix and stop at the first non-hex character.
|
||||
private fun parseDosAttrib(raw: ByteArray): Int {
|
||||
val s = String(raw, Charsets.US_ASCII).trim()
|
||||
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
|
||||
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
return digits.toIntOrNull(16) ?: 0
|
||||
}
|
||||
|
||||
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
|
||||
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
|
||||
internal fun readFileAttrs(file: File): FileAttrState {
|
||||
val path = file.absolutePath
|
||||
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
|
||||
val readOnly = runCatching {
|
||||
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
|
||||
}.getOrDefault(!file.canWrite())
|
||||
|
||||
var hidden = false
|
||||
var supported = true
|
||||
try {
|
||||
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
|
||||
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
|
||||
supported = e.errno == android.system.OsConstants.ENODATA
|
||||
} catch (e: Exception) {
|
||||
supported = false
|
||||
}
|
||||
return FileAttrState(readOnly, hidden, supported)
|
||||
}
|
||||
|
||||
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
|
||||
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
|
||||
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
|
||||
// somehow refused. Returns true on success.
|
||||
internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
|
||||
val path = file.absolutePath
|
||||
val mode = android.system.Os.stat(path).st_mode
|
||||
val writeBits = android.system.OsConstants.S_IWUSR or
|
||||
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
|
||||
val newMode = if (readOnly) mode and writeBits.inv()
|
||||
else mode or android.system.OsConstants.S_IWUSR
|
||||
android.system.Os.chmod(path, newMode)
|
||||
true
|
||||
}.getOrElse { file.setWritable(!readOnly, true) }
|
||||
|
||||
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
|
||||
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
|
||||
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
|
||||
// store the xattr, so the caller can disable just the Hidden toggle.
|
||||
internal fun setHidden(file: File, hidden: Boolean): Boolean = try {
|
||||
val path = file.absolutePath
|
||||
var attr = try {
|
||||
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
|
||||
}
|
||||
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
|
||||
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
|
||||
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import java.io.File
|
||||
|
||||
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
|
||||
class FileManagerActivity : ComponentActivity() {
|
||||
@@ -19,10 +20,11 @@ class FileManagerActivity : ComponentActivity() {
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen()
|
||||
FileManagerScreen(initialDir = initialDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,4 +34,8 @@ class FileManagerActivity : ComponentActivity() {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 1 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) recreate()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_START_DIR = "com.droiddeck.launcher.files.START_DIR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.ContentCut
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// The File Manager's rows, tiles, favourites and context menu.
|
||||
|
||||
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
|
||||
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
|
||||
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
|
||||
val white = Color(0xFFFFFFFF)
|
||||
return when {
|
||||
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
|
||||
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
|
||||
else -> Color(0xFF555555) to white // grey
|
||||
}
|
||||
}
|
||||
|
||||
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
|
||||
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
|
||||
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
|
||||
// the menu first, then runs its action.
|
||||
@Composable
|
||||
private fun FileContextMenuItems(
|
||||
file: File,
|
||||
isFavorite: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onProperties: () -> Unit,
|
||||
onDismissMenu: () -> Unit,
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select") },
|
||||
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onSelect() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
|
||||
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Properties") },
|
||||
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onToggleFavorite() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onRename() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCopy() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cut") },
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCut() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onDelete() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun FileItemRow(
|
||||
file: File,
|
||||
showActions: Boolean = true,
|
||||
compact: Boolean = false,
|
||||
selectionMode: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
onLongPress: () -> Unit = {},
|
||||
onToggleSelect: () -> Unit = {},
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean,
|
||||
onDismissMenu: () -> Unit,
|
||||
onSelect: () -> Unit = {},
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
isFavorite: Boolean = false,
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
|
||||
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.combinedClickable(
|
||||
// In selection mode a tap toggles instead of opening, so you can rattle through a
|
||||
// folder without long-pressing every single row.
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
when {
|
||||
// Show the executable's own embedded icon when we managed to extract one.
|
||||
exeIcon != null -> Image(
|
||||
bitmap = exeIcon!!,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isExe -> Icon(
|
||||
painter = painterResource(R.drawable.icon_menu_container),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isDir -> Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
|
||||
append(dateFormat.format(Date(file.lastModified())))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
if (showActions) Box {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
internal fun FavoritesList(
|
||||
currentDir: File,
|
||||
favTick: Int,
|
||||
onPinCurrent: () -> Unit,
|
||||
onJump: (File) -> Unit,
|
||||
onUnpin: (File) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val favorites = remember(favTick) {
|
||||
FavoritesStore.list(context).map(::File).filter { it.exists() }
|
||||
}
|
||||
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
|
||||
FavoritesStore.isFavorite(context, currentDir.absolutePath)
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onPinCurrent,
|
||||
enabled = !currentAlreadyPinned,
|
||||
) {
|
||||
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Pin current folder",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (favorites.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(favorites, key = { it.absolutePath }) { file ->
|
||||
val loc = remember(file.absolutePath) { describeLocation(file) }
|
||||
FavoriteCard(
|
||||
file = file,
|
||||
loc = loc,
|
||||
onJump = { onJump(file) },
|
||||
onUnpin = { onUnpin(file) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
|
||||
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
|
||||
// and the full display path; tapping jumps into it, the filled star unpins.
|
||||
@Composable
|
||||
private fun FavoriteCard(
|
||||
file: File,
|
||||
loc: FavLocation,
|
||||
onJump: () -> Unit,
|
||||
onUnpin: () -> Unit,
|
||||
) {
|
||||
val (badgeBg, badgeFg) = badgeColors(loc)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onJump),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Origin line: coloured drive badge + source description.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = loc.driveLabel,
|
||||
color = badgeFg,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(badgeBg)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (loc.storage) {
|
||||
FavStorage.INTERNAL -> "Internal storage"
|
||||
FavStorage.SD -> "SD card"
|
||||
FavStorage.OTHER -> "Storage"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = loc.displayPath,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onUnpin) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = "Remove from favorites",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
|
||||
*
|
||||
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
|
||||
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
|
||||
* reading details.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun FileGridTile(
|
||||
file: File,
|
||||
selectionMode: Boolean,
|
||||
selected: Boolean,
|
||||
onLongPress: () -> Unit,
|
||||
onToggleSelect: () -> Unit,
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean = false,
|
||||
onDismissMenu: () -> Unit = {},
|
||||
isFavorite: Boolean = false,
|
||||
onSelect: () -> Unit = {},
|
||||
onRename: () -> Unit = {},
|
||||
onCopy: () -> Unit = {},
|
||||
onCut: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
|
||||
Box {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
|
||||
isDir -> Icon(
|
||||
Icons.Filled.Folder, null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
Icons.Filled.InsertDriveFile, null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
}
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onToggleSelect() },
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
|
||||
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
|
||||
Text(
|
||||
file.name,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import java.io.File
|
||||
|
||||
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
|
||||
|
||||
/*
|
||||
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
|
||||
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
|
||||
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
|
||||
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
|
||||
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
|
||||
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
|
||||
* either app carries to the other.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What to do when a pasted item already exists at the destination.
|
||||
*
|
||||
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
|
||||
* directory and truncates existing files - but they mean different things to the user, so both are
|
||||
* offered and the wording is chosen per item type (files overwrite, folders merge).
|
||||
*/
|
||||
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
|
||||
|
||||
/**
|
||||
* Shortens a path from the LEFT, keeping whole segments.
|
||||
*
|
||||
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
|
||||
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
|
||||
*/
|
||||
internal fun elidePathStart(path: String, max: Int): String {
|
||||
if (path.length <= max) return path
|
||||
val parts = path.split('/').filter { it.isNotEmpty() }
|
||||
val out = StringBuilder()
|
||||
for (part in parts.asReversed()) {
|
||||
if (out.length + part.length + 1 > max - 2) break
|
||||
out.insert(0, "/$part")
|
||||
}
|
||||
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
|
||||
}
|
||||
|
||||
/** Folder-first ordering stays fixed across both sort directions. */
|
||||
internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
|
||||
val inner: Comparator<File> = when (sortBy) {
|
||||
"date" -> compareBy { it.lastModified() }
|
||||
// Directory length() is meaningless, so folders sort by name within the size ordering
|
||||
// instead of pretending to have one.
|
||||
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
|
||||
else -> compareBy { it.name.lowercase() }
|
||||
}
|
||||
val directed = if (desc) inner.reversed() else inner
|
||||
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
|
||||
}
|
||||
|
||||
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
|
||||
internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
|
||||
// True when [child] is [ancestor] itself or lives anywhere inside it.
|
||||
internal fun isWithin(child: File, ancestor: File): Boolean {
|
||||
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
|
||||
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
|
||||
return c == a || c.startsWith(a + File.separator)
|
||||
}
|
||||
|
||||
// ── Favorites: origin resolution ──
|
||||
|
||||
enum class FavStorage { INTERNAL, SD, OTHER }
|
||||
|
||||
data class FavLocation(
|
||||
val storage: FavStorage,
|
||||
val driveLabel: String, // "Internal", "SD card", or "Storage"
|
||||
val displayPath: String // the unix absolute path
|
||||
)
|
||||
|
||||
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
|
||||
fun describeLocation(file: File): FavLocation {
|
||||
val abs = file.absolutePath
|
||||
|
||||
val internal = "/storage/emulated/0"
|
||||
if (abs == internal || abs.startsWith("$internal/")) {
|
||||
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
|
||||
}
|
||||
|
||||
if (abs.startsWith("/storage/")) {
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") {
|
||||
return FavLocation(FavStorage.SD, "SD card", abs)
|
||||
}
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, "Storage", abs)
|
||||
}
|
||||
@@ -7,12 +7,7 @@ import androidx.compose.foundation.Image
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.ContentCut
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SdStorage
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Sort
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Unarchive
|
||||
import androidx.compose.material.icons.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Smartphone
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -95,217 +73,22 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/*
|
||||
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
|
||||
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
|
||||
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
|
||||
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
|
||||
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
|
||||
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
|
||||
* either app carries to the other.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What to do when a pasted item already exists at the destination.
|
||||
*
|
||||
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
|
||||
* directory and truncates existing files - but they mean different things to the user, so both are
|
||||
* offered and the wording is chosen per item type (files overwrite, folders merge).
|
||||
*/
|
||||
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
|
||||
|
||||
/**
|
||||
* Shortens a path from the LEFT, keeping whole segments.
|
||||
*
|
||||
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
|
||||
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
|
||||
*/
|
||||
private fun elidePathStart(path: String, max: Int): String {
|
||||
if (path.length <= max) return path
|
||||
val parts = path.split('/').filter { it.isNotEmpty() }
|
||||
val out = StringBuilder()
|
||||
for (part in parts.asReversed()) {
|
||||
if (out.length + part.length + 1 > max - 2) break
|
||||
out.insert(0, "/$part")
|
||||
}
|
||||
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
|
||||
}
|
||||
|
||||
/** Folder-first ordering stays fixed across both sort directions. */
|
||||
private fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
|
||||
val inner: Comparator<File> = when (sortBy) {
|
||||
"date" -> compareBy { it.lastModified() }
|
||||
// Directory length() is meaningless, so folders sort by name within the size ordering
|
||||
// instead of pretending to have one.
|
||||
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
|
||||
else -> compareBy { it.name.lowercase() }
|
||||
}
|
||||
val directed = if (desc) inner.reversed() else inner
|
||||
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
|
||||
}
|
||||
|
||||
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
|
||||
private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
|
||||
// True when [child] is [ancestor] itself or lives anywhere inside it.
|
||||
private fun isWithin(child: File, ancestor: File): Boolean {
|
||||
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
|
||||
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
|
||||
return c == a || c.startsWith(a + File.separator)
|
||||
}
|
||||
|
||||
// ── DOS file attributes (Read-only / Hidden) ──
|
||||
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
|
||||
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
|
||||
// app owns (container files) and the Wine DOS-attribute xattr.
|
||||
|
||||
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
|
||||
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
|
||||
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
|
||||
|
||||
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
|
||||
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
|
||||
// disabled while Read-only keeps working.
|
||||
private data class FileAttrState(
|
||||
val readOnly: Boolean,
|
||||
val hidden: Boolean,
|
||||
val hiddenSupported: Boolean,
|
||||
)
|
||||
|
||||
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
|
||||
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
|
||||
// prefix and stop at the first non-hex character.
|
||||
private fun parseDosAttrib(raw: ByteArray): Int {
|
||||
val s = String(raw, Charsets.US_ASCII).trim()
|
||||
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
|
||||
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
return digits.toIntOrNull(16) ?: 0
|
||||
}
|
||||
|
||||
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
|
||||
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
|
||||
private fun readFileAttrs(file: File): FileAttrState {
|
||||
val path = file.absolutePath
|
||||
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
|
||||
val readOnly = runCatching {
|
||||
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
|
||||
}.getOrDefault(!file.canWrite())
|
||||
|
||||
var hidden = false
|
||||
var supported = true
|
||||
try {
|
||||
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
|
||||
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
|
||||
supported = e.errno == android.system.OsConstants.ENODATA
|
||||
} catch (e: Exception) {
|
||||
supported = false
|
||||
}
|
||||
return FileAttrState(readOnly, hidden, supported)
|
||||
}
|
||||
|
||||
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
|
||||
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
|
||||
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
|
||||
// somehow refused. Returns true on success.
|
||||
private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
|
||||
val path = file.absolutePath
|
||||
val mode = android.system.Os.stat(path).st_mode
|
||||
val writeBits = android.system.OsConstants.S_IWUSR or
|
||||
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
|
||||
val newMode = if (readOnly) mode and writeBits.inv()
|
||||
else mode or android.system.OsConstants.S_IWUSR
|
||||
android.system.Os.chmod(path, newMode)
|
||||
true
|
||||
}.getOrElse { file.setWritable(!readOnly, true) }
|
||||
|
||||
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
|
||||
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
|
||||
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
|
||||
// store the xattr, so the caller can disable just the Hidden toggle.
|
||||
private fun setHidden(file: File, hidden: Boolean): Boolean = try {
|
||||
val path = file.absolutePath
|
||||
var attr = try {
|
||||
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
|
||||
}
|
||||
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
|
||||
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
|
||||
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
// ── Favorites: origin resolution ──
|
||||
|
||||
enum class FavStorage { INTERNAL, SD, OTHER }
|
||||
|
||||
data class FavLocation(
|
||||
val storage: FavStorage,
|
||||
val driveLabel: String, // "Internal", "SD card", or "Storage"
|
||||
val displayPath: String // the unix absolute path
|
||||
)
|
||||
|
||||
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
|
||||
fun describeLocation(file: File): FavLocation {
|
||||
val abs = file.absolutePath
|
||||
|
||||
val internal = "/storage/emulated/0"
|
||||
if (abs == internal || abs.startsWith("$internal/")) {
|
||||
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
|
||||
}
|
||||
|
||||
if (abs.startsWith("/storage/")) {
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") {
|
||||
return FavLocation(FavStorage.SD, "SD card", abs)
|
||||
}
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, "Storage", abs)
|
||||
}
|
||||
|
||||
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
|
||||
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
|
||||
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
|
||||
val white = Color(0xFFFFFFFF)
|
||||
return when {
|
||||
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
|
||||
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
|
||||
else -> Color(0xFF555555) to white // grey
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -1030,7 +813,7 @@ fun FileManagerScreen(
|
||||
// when it doesn't fit we drop the /storage/emulated/0 prefix, not the tail.
|
||||
text = elidePathStart(currentDir.absolutePath, 52),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
// fill = true: the path takes all remaining width, so the free-space figure
|
||||
// is pinned to the right edge instead of sliding around with the path length.
|
||||
@@ -1040,7 +823,7 @@ fun FileManagerScreen(
|
||||
Text(
|
||||
"${FileOps.formatBytes(freeSpace)} free",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
@@ -1414,675 +1197,3 @@ fun FileManagerScreen(
|
||||
} // end rail + content Row
|
||||
}
|
||||
}
|
||||
|
||||
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
|
||||
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
|
||||
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
|
||||
// the menu first, then runs its action.
|
||||
@Composable
|
||||
private fun FileContextMenuItems(
|
||||
file: File,
|
||||
isFavorite: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onProperties: () -> Unit,
|
||||
onDismissMenu: () -> Unit,
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select") },
|
||||
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onSelect() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
|
||||
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Properties") },
|
||||
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onToggleFavorite() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onRename() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCopy() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cut") },
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCut() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onDelete() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun FileItemRow(
|
||||
file: File,
|
||||
showActions: Boolean = true,
|
||||
compact: Boolean = false,
|
||||
selectionMode: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
onLongPress: () -> Unit = {},
|
||||
onToggleSelect: () -> Unit = {},
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean,
|
||||
onDismissMenu: () -> Unit,
|
||||
onSelect: () -> Unit = {},
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
isFavorite: Boolean = false,
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
|
||||
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.combinedClickable(
|
||||
// In selection mode a tap toggles instead of opening, so you can rattle through a
|
||||
// folder without long-pressing every single row.
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
when {
|
||||
// Show the executable's own embedded icon when we managed to extract one.
|
||||
exeIcon != null -> Image(
|
||||
bitmap = exeIcon!!,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isExe -> Icon(
|
||||
painter = painterResource(R.drawable.icon_menu_container),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isDir -> Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
|
||||
append(dateFormat.format(Date(file.lastModified())))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
if (showActions) Box {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
private fun FavoritesList(
|
||||
currentDir: File,
|
||||
favTick: Int,
|
||||
onPinCurrent: () -> Unit,
|
||||
onJump: (File) -> Unit,
|
||||
onUnpin: (File) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val favorites = remember(favTick) {
|
||||
FavoritesStore.list(context).map(::File).filter { it.exists() }
|
||||
}
|
||||
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
|
||||
FavoritesStore.isFavorite(context, currentDir.absolutePath)
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onPinCurrent,
|
||||
enabled = !currentAlreadyPinned,
|
||||
) {
|
||||
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Pin current folder",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (favorites.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(favorites, key = { it.absolutePath }) { file ->
|
||||
val loc = remember(file.absolutePath) { describeLocation(file) }
|
||||
FavoriteCard(
|
||||
file = file,
|
||||
loc = loc,
|
||||
onJump = { onJump(file) },
|
||||
onUnpin = { onUnpin(file) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
|
||||
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
|
||||
// and the full display path; tapping jumps into it, the filled star unpins.
|
||||
@Composable
|
||||
private fun FavoriteCard(
|
||||
file: File,
|
||||
loc: FavLocation,
|
||||
onJump: () -> Unit,
|
||||
onUnpin: () -> Unit,
|
||||
) {
|
||||
val (badgeBg, badgeFg) = badgeColors(loc)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onJump),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Origin line: coloured drive badge + source description.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = loc.driveLabel,
|
||||
color = badgeFg,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(badgeBg)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (loc.storage) {
|
||||
FavStorage.INTERNAL -> "Internal storage"
|
||||
FavStorage.SD -> "SD card"
|
||||
FavStorage.OTHER -> "Storage"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = loc.displayPath,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onUnpin) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = "Remove from favorites",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
|
||||
*
|
||||
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
|
||||
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
|
||||
* reading details.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FileGridTile(
|
||||
file: File,
|
||||
selectionMode: Boolean,
|
||||
selected: Boolean,
|
||||
onLongPress: () -> Unit,
|
||||
onToggleSelect: () -> Unit,
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean = false,
|
||||
onDismissMenu: () -> Unit = {},
|
||||
isFavorite: Boolean = false,
|
||||
onSelect: () -> Unit = {},
|
||||
onRename: () -> Unit = {},
|
||||
onCopy: () -> Unit = {},
|
||||
onCut: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
|
||||
Box {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
|
||||
isDir -> Icon(
|
||||
Icons.Filled.Folder, null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
Icons.Filled.InsertDriveFile, null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
}
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onToggleSelect() },
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
|
||||
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
|
||||
Text(
|
||||
file.name,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 11.sp,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
|
||||
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
|
||||
* immediately (off the main thread) and refreshes the listing via [onChanged].
|
||||
*
|
||||
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
|
||||
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
|
||||
* Read-only keeps working everywhere.
|
||||
*/
|
||||
@Composable
|
||||
private fun FilePropertiesDialog(
|
||||
file: File,
|
||||
onDismiss: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
|
||||
// Guards against a second toggle landing while the first is still being applied off-thread.
|
||||
var busy by remember(file.absolutePath) { mutableStateOf(false) }
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
|
||||
}
|
||||
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Properties") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// ── Basic info ──
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine(
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
|
||||
)
|
||||
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
|
||||
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
val state = attrs
|
||||
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
|
||||
AttributeToggleRow(
|
||||
label = "Read-only",
|
||||
description = "Stops games (and Wine) from overwriting or deleting this file.",
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
onChanged()
|
||||
} else {
|
||||
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
|
||||
val hiddenSupported = state?.hiddenSupported == true
|
||||
AttributeToggleRow(
|
||||
label = "Hidden",
|
||||
description = if (state != null && !hiddenSupported)
|
||||
"This storage can't store the hidden flag."
|
||||
else "Marks the file hidden in Windows (Wine's hidden attribute).",
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(hidden = want)
|
||||
onChanged()
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
)
|
||||
}
|
||||
|
||||
// One "label: value" line in the Properties info block.
|
||||
@Composable
|
||||
private fun PropertyLine(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.width(76.dp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A labelled attribute switch with a one-line description, used by the Properties sheet.
|
||||
@Composable
|
||||
private fun AttributeToggleRow(
|
||||
label: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// The File Manager's properties dialog.
|
||||
|
||||
/**
|
||||
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
|
||||
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
|
||||
* immediately (off the main thread) and refreshes the listing via [onChanged].
|
||||
*
|
||||
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
|
||||
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
|
||||
* Read-only keeps working everywhere.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FilePropertiesDialog(
|
||||
file: File,
|
||||
onDismiss: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
|
||||
// Guards against a second toggle landing while the first is still being applied off-thread.
|
||||
var busy by remember(file.absolutePath) { mutableStateOf(false) }
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
|
||||
}
|
||||
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Properties") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// ── Basic info ──
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine(
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
|
||||
)
|
||||
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
|
||||
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
val state = attrs
|
||||
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
|
||||
AttributeToggleRow(
|
||||
label = "Read-only",
|
||||
description = "Stops games (and Wine) from overwriting or deleting this file.",
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
onChanged()
|
||||
} else {
|
||||
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
|
||||
val hiddenSupported = state?.hiddenSupported == true
|
||||
AttributeToggleRow(
|
||||
label = "Hidden",
|
||||
description = if (state != null && !hiddenSupported)
|
||||
"This storage can't store the hidden flag."
|
||||
else "Marks the file hidden in Windows (Wine's hidden attribute).",
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(hidden = want)
|
||||
onChanged()
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
)
|
||||
}
|
||||
|
||||
// One "label: value" line in the Properties info block.
|
||||
@Composable
|
||||
private fun PropertyLine(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.width(76.dp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A labelled attribute switch with a one-line description, used by the Properties sheet.
|
||||
@Composable
|
||||
private fun AttributeToggleRow(
|
||||
label: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,15 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* The user's own Windows games, added to the Steam client's library.
|
||||
*
|
||||
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
|
||||
* is the .exe in it that looks most like the game - the one named after the folder, else the
|
||||
* largest, never an installer, redistributable or crash reporter - unless the user picked one
|
||||
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
|
||||
* with the appid the client itself would derive, so launching from the rail and from the
|
||||
* client's own library are the same thing. Any number of Games folders, from anywhere on the
|
||||
* device: each is bound into the session under /root/Games on its own.
|
||||
*/
|
||||
/** Windows game folders shared with the Steam session. */
|
||||
object AddedGames {
|
||||
private const val TAG = "AddedGames"
|
||||
|
||||
@@ -29,6 +20,7 @@ object AddedGames {
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
val steamAppId: Int? = null,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
@@ -96,12 +88,15 @@ object AddedGames {
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
for (root in roots(context)) {
|
||||
val dir = root.host
|
||||
val folders = roots(context).map { it.host } + listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
for (dir in folders.distinctBy { it.absolutePath }) {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) scanGame(context, folder, out)
|
||||
}
|
||||
return out
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
@@ -115,10 +110,23 @@ object AddedGames {
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
|
||||
val users = File(root, "config/loginusers.vdf").readText()
|
||||
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
|
||||
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
?: return@runCatching null
|
||||
val account = recent.groupValues[1].toLong() - 76561197960265728L
|
||||
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
|
||||
.optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -126,6 +134,7 @@ object AddedGames {
|
||||
games.forEachIndexed { i, g ->
|
||||
if (i > 0) json.append(',')
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
@@ -145,5 +154,5 @@ object AddedGames {
|
||||
return guestPath(context, file)
|
||||
}
|
||||
|
||||
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
private fun quote(s: String): String = JSONObject.quote(s)
|
||||
}
|
||||
@@ -14,7 +14,18 @@ import java.io.File
|
||||
*/
|
||||
object Library {
|
||||
/** [gameId] is what steam://rungameid/ takes: the appid for a Steam title, the shortcut id for an added game. */
|
||||
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong())
|
||||
/**
|
||||
* [art] is the portrait capsule; [hero] the wide banner Steam shows above a game's page, when
|
||||
* the client has cached one. [lastPlayed] is Steam's own "LastPlayed" (unix seconds), 0 = never.
|
||||
*/
|
||||
class SteamGame(
|
||||
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
|
||||
val hero: File? = null, val lastPlayed: Long = 0L,
|
||||
val gameFiles: File? = null, val protonPrefix: File? = null,
|
||||
)
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
|
||||
const val ADDED = "added"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
|
||||
@@ -55,16 +66,35 @@ object Library {
|
||||
4690330, // Legacy Steam Runtime
|
||||
)
|
||||
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
|
||||
private val STEAM_HEROES = listOf("library_hero.jpg")
|
||||
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 INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
|
||||
/** Steam's library roots visible to this launcher: its private default plus the selected library. */
|
||||
private fun steamLibraries(context: Context): List<Pair<File, String>> {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
return listOfNotNull(
|
||||
root to "internal",
|
||||
GameStorage.effective(context)?.let { File(it.path) to it.label },
|
||||
)
|
||||
}
|
||||
|
||||
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
|
||||
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
|
||||
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
|
||||
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first })
|
||||
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
|
||||
return roots.asSequence()
|
||||
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
|
||||
.firstOrNull { it.isDirectory }
|
||||
}
|
||||
|
||||
fun steamGames(context: Context): List<SteamGame> {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val libraries = listOfNotNull(
|
||||
root to "internal",
|
||||
GameStorage.effective(context)?.let { File(it.path) to it.label },
|
||||
)
|
||||
val libraries = steamLibraries(context)
|
||||
val out = LinkedHashMap<Int, SteamGame>()
|
||||
for ((library, label) in libraries) {
|
||||
val steamapps = File(library, "steamapps")
|
||||
@@ -77,19 +107,31 @@ object Library {
|
||||
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
// StateFlags 4 = fully installed; anything else is downloading, updating or broken.
|
||||
if (name.isEmpty() || flags and 4 == 0) return@forEach
|
||||
val art = steamCapsule(cache, appId)
|
||||
out[appId] = SteamGame(appId, name, art, label)
|
||||
val art = steamCacheImage(cache, appId, STEAM_CAPSULES)
|
||||
val hero = steamCacheImage(cache, appId, STEAM_HEROES)
|
||||
val lastPlayed = LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
|
||||
// App manifests are Valve KeyValues (VDF) files. installdir is one folder
|
||||
// below steamapps/common; only expose it when the directory exists and the
|
||||
// manifest value cannot escape that directory.
|
||||
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
|
||||
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
|
||||
out[appId] = SteamGame(
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library),
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.values.toList()
|
||||
}
|
||||
|
||||
/** Steam stores current library capsules inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCapsule(cache: File, appId: Int): File? {
|
||||
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
val appDir = File(cache, appId.toString())
|
||||
val dirs = listOf(appDir) + appDir.listFiles()
|
||||
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
|
||||
return STEAM_CAPSULES.asSequence()
|
||||
return names.asSequence()
|
||||
.flatMap { name -> dirs.asSequence().map { File(it, name) } }
|
||||
.firstOrNull { it.isFile && it.length() > 0L }
|
||||
}
|
||||
@@ -124,7 +166,13 @@ object Library {
|
||||
)
|
||||
|
||||
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
|
||||
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
|
||||
fun nameForProgram(program: String?): String? =
|
||||
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
|
||||
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
|
||||
} else specs.firstOrNull { it.program == program }?.name
|
||||
|
||||
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
|
||||
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Whether a program from the rail runs at the panel's own resolution rather than the session's
|
||||
@@ -278,7 +326,9 @@ object Library {
|
||||
"-C", "Dolphin.General.HotkeysRequireFocus=False", "-C", "Dolphin.Input.BackgroundInput=True",
|
||||
"-e", guestPath,
|
||||
)
|
||||
"cemu" -> listOf("-g", guestPath)
|
||||
// Full screen (LaunchSettings.cpp -f); the settings seed keeps the Getting Started
|
||||
// dialog away (bannerlator-pad-defaults).
|
||||
"cemu" -> listOf("-f", "-g", guestPath)
|
||||
// Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and
|
||||
// comes back (HK_FullscreenToggle, bannerlator-pad-defaults).
|
||||
"melonds" -> listOf("-f", guestPath)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* An all-in-one driver package: one zip carrying both halves of a matched pair, the display
|
||||
* (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another,
|
||||
* indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers):
|
||||
*
|
||||
* ```
|
||||
* manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}}
|
||||
* android/ meta.json (AdrenoTools) + libvulkan_freedreno.so
|
||||
* linux/ meta.json + libvulkan_freedreno.so
|
||||
* ```
|
||||
*
|
||||
* Installing one puts each half through its own importer, with that importer's checks, and
|
||||
* remembers the two as one bundle: picking either half sets both, and deleting either deletes both,
|
||||
* so a bundle is always used whole. Zips of the older kind, one driver each, never reach this.
|
||||
*/
|
||||
object DriverBundle {
|
||||
private const val TAG = "DriverBundle"
|
||||
private const val PREFS = "driver_bundles"
|
||||
private const val MANIFEST = "manifest.json"
|
||||
/** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */
|
||||
private const val MAX_BYTES = 512L shl 20
|
||||
|
||||
class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String)
|
||||
|
||||
/** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */
|
||||
class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) {
|
||||
/** "DD-Turnip 0.1.0". */
|
||||
val label: String get() = if (version.isEmpty()) name else "$name $version"
|
||||
}
|
||||
|
||||
/** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */
|
||||
fun parseManifest(json: JSONObject): Manifest? {
|
||||
val drivers = json.optJSONObject("drivers") ?: return null
|
||||
val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null
|
||||
val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null
|
||||
if (android == linux) return null
|
||||
return Manifest(
|
||||
json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"),
|
||||
json.optString("tag"), android, linux,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanPath(path: String): String? {
|
||||
val p = path.trim().trim('/')
|
||||
return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } }
|
||||
}
|
||||
|
||||
/** True when the zip at [uri] is a bundle rather than a single driver. */
|
||||
fun isBundle(context: Context, uri: Uri): Boolean = try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST }
|
||||
?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "reading $uri as a bundle", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes
|
||||
* the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip
|
||||
* is not a usable bundle, [IOException] on read failures.
|
||||
*/
|
||||
fun install(context: Context, uri: Uri): Bundle {
|
||||
val work = File(context.cacheDir, "bundle-${System.nanoTime()}")
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
var androidId: String? = null
|
||||
try {
|
||||
unzip(context, uri, work)
|
||||
val manifest = File(work, MANIFEST).takeIf { it.isFile }
|
||||
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
|
||||
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
|
||||
val androidDir = File(work, manifest.androidPath)
|
||||
val linuxDir = File(work, manifest.linuxPath)
|
||||
|
||||
val androidStage = td.newStagingDir()
|
||||
androidId = try {
|
||||
androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) }
|
||||
td.adopt(androidStage, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(androidStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val linuxStage = lm.newStagingDir()
|
||||
val linuxId = try {
|
||||
val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") }
|
||||
library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME))
|
||||
val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() }
|
||||
lm.adopt(linuxStage, library?.name, meta, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(linuxStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" })
|
||||
val saved = registry(context)
|
||||
var id = base
|
||||
var n = 2
|
||||
while (saved.contains(id)) id = "$base-${n++}"
|
||||
val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId)
|
||||
saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version)
|
||||
.put("android", androidId).put("linux", linuxId).toString()).apply()
|
||||
Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId")
|
||||
return bundle
|
||||
} catch (e: Exception) {
|
||||
androidId?.let { td.remove(it) }
|
||||
throw e
|
||||
} finally {
|
||||
FileUtils.delete(work)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unzip(context: Context, uri: Uri, dest: File) {
|
||||
val root = dest.canonicalFile
|
||||
var total = 0L
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
for (entry in generateSequence { zip.nextEntry }) {
|
||||
if (entry.isDirectory) continue
|
||||
val out = File(root, entry.name).canonicalFile
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
|
||||
out.parentFile?.mkdirs()
|
||||
out.outputStream().use { sink ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val r = zip.read(buf)
|
||||
if (r <= 0) break
|
||||
total += r
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
|
||||
sink.write(buf, 0, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("cannot open $uri")
|
||||
}
|
||||
|
||||
/** Every installed bundle whose two halves are both still there. */
|
||||
fun all(context: Context): List<Bundle> {
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
return registry(context).all.mapNotNull { (id, raw) ->
|
||||
runCatching {
|
||||
val j = JSONObject(raw as String)
|
||||
Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux"))
|
||||
}.getOrNull()
|
||||
}.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id }
|
||||
}
|
||||
|
||||
fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id }
|
||||
|
||||
/** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */
|
||||
fun containing(context: Context, driverId: String, linux: Boolean): Bundle? =
|
||||
all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId }
|
||||
|
||||
/** The bundle both drivers are set to right now, if they are one. */
|
||||
fun active(context: Context): Bundle? {
|
||||
val android = SessionPrefs.androidDriver(context)
|
||||
val linux = SessionPrefs.linuxDriver(context)
|
||||
return all(context).firstOrNull { it.androidId == android && it.linuxId == linux }
|
||||
}
|
||||
|
||||
/** Set both halves as the drivers in use. */
|
||||
fun select(context: Context, bundle: Bundle) {
|
||||
SessionPrefs.setAndroidDriver(context, bundle.androidId)
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
/** Delete both halves; a driver still set to either goes back to its default. */
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "")
|
||||
registry(context).edit().remove(bundle.id).apply()
|
||||
Log.i(TAG, "removed bundle ${bundle.id}")
|
||||
}
|
||||
|
||||
private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo.Family
|
||||
|
||||
/**
|
||||
* The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux
|
||||
* build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a
|
||||
* patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app
|
||||
* installs and selects both halves of one pair at once.
|
||||
*
|
||||
* - Banners-Turnip publishes each variant as a set in every release (`Turnip-<tag><variant>.zip`
|
||||
* and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the
|
||||
* newest release that carries both halves.
|
||||
* - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with
|
||||
* their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance).
|
||||
* - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the
|
||||
* newest bundle, and it can never be mixed.
|
||||
*/
|
||||
object DriverPairs {
|
||||
const val BANNER = "banner"
|
||||
const val BANNER_A8XX = "banner-a8xx"
|
||||
const val BANNER_710 = "banner-710-720-test"
|
||||
const val BANNER_ONEUI = "banner-8g2-oneui"
|
||||
const val WN_BALANCED = "wn-b"
|
||||
const val WN_PERFORMANCE = "wn-p"
|
||||
const val DD_TURNIP = "dd-turnip"
|
||||
/** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */
|
||||
fun ddTurnip(tag: String) = "$DD_TURNIP:$tag"
|
||||
|
||||
class DriverPair(
|
||||
val key: String,
|
||||
/** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */
|
||||
val name: String,
|
||||
/** The families the pair is built for; the list only offers it on those unless asked. */
|
||||
val families: Set<Family>,
|
||||
val display: TurnipReleases.Asset?,
|
||||
val linux: TurnipReleases.Asset?,
|
||||
/** Both halves in one zip, instead of [display] and [linux]. */
|
||||
val bundle: TurnipReleases.Asset? = null,
|
||||
) {
|
||||
val complete: Boolean get() = bundle != null || (display != null && linux != null)
|
||||
/** The zips to download for the pair: the bundle, or the two halves. */
|
||||
val assets: List<TurnipReleases.Asset> get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null)
|
||||
/** "r4 · 1.19 + 0.1.2": the build of each half. */
|
||||
val version: String
|
||||
get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") }
|
||||
fun suits(gpu: GpuInfo) = gpu.family in families ||
|
||||
(gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families))
|
||||
}
|
||||
|
||||
private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN)
|
||||
|
||||
private fun describe(key: String): Pair<String, Set<Family>> = when {
|
||||
key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO
|
||||
else -> describeFixed(key)
|
||||
}
|
||||
|
||||
private fun describeFixed(key: String): Pair<String, Set<Family>> = when (key) {
|
||||
BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX)
|
||||
BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX)
|
||||
BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW)
|
||||
BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX)
|
||||
WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO
|
||||
WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO
|
||||
else -> key to ALL_ADRENO
|
||||
}
|
||||
|
||||
/** Every pair the last release check found, in the order the list shows them. */
|
||||
fun from(check: TurnipReleases.Check?): List<DriverPair> {
|
||||
val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() }
|
||||
val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE)
|
||||
return assets.map { it.pair }.distinct()
|
||||
.sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } }
|
||||
.map { key ->
|
||||
val (name, families) = describe(key)
|
||||
val halves = assets.filter { it.pair == key }
|
||||
halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) }
|
||||
// The list keeps the newest release of each half; for Banners-Turnip the two halves
|
||||
// must come from the same release, so a set missing one half in its newest release
|
||||
// is offered incomplete rather than mixed with an older one.
|
||||
val display = halves.firstOrNull { !it.linux }
|
||||
val linux = halves.firstOrNull { it.linux }
|
||||
val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag
|
||||
DriverPair(key, name, families, display, if (sameRelease) linux else null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest
|
||||
* official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other
|
||||
* 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip
|
||||
* release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for
|
||||
* the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno).
|
||||
*/
|
||||
fun recommendedKey(gpu: GpuInfo, pairs: List<DriverPair>): String? = when (gpu.family) {
|
||||
Family.NOT_ADRENO -> null
|
||||
Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED
|
||||
Family.A7XX_LOW -> BANNER_710
|
||||
Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER
|
||||
Family.A6XX -> BANNER
|
||||
// KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one.
|
||||
Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER
|
||||
}
|
||||
|
||||
/** The complete DD-Turnip pair with the highest version, or null when none is listed. */
|
||||
fun newestDdTurnip(pairs: List<DriverPair>): String? = pairs
|
||||
.filter { it.key.startsWith("$DD_TURNIP:") && it.complete }
|
||||
.maxByOrNull { p ->
|
||||
Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured
|
||||
?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L
|
||||
}?.key
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
/**
|
||||
@@ -9,7 +10,7 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
*
|
||||
* Both engines generate on gamescope's final output inside our compositor, so they work for any
|
||||
* game with nothing changed in the runtime. Win-FG is our own and needs nothing; LSFG needs the
|
||||
* shader chain from the user's copy of Lossless Scaling (see [LsfgNative]). Every compositor
|
||||
* shader chain from the user's copy of Lossless Scaling (see [Lossless]). Every compositor
|
||||
* setter is a value store on the native side, so this can be applied before the compositor is
|
||||
* up and changed live from the main screen while a session runs.
|
||||
*/
|
||||
@@ -26,102 +27,77 @@ object FrameGen {
|
||||
private const val FLOW_SCALE_WINFG = 0.60f
|
||||
private const val FLOW_SCALE_LSFG = 0.80f
|
||||
|
||||
/** LSFG's adaptive targets: it generates as many frames as it takes to reach one. */
|
||||
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
|
||||
|
||||
private const val PREFS = "frame_gen"
|
||||
|
||||
fun engine(context: Context): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("engine", ENGINE_OFF) ?: ENGINE_OFF
|
||||
|
||||
fun multiplier(context: Context): Int =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt("multiplier", 2).coerceIn(2, 4)
|
||||
|
||||
fun set(context: Context, engine: String, multiplier: Int) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("engine", engine).putInt("multiplier", multiplier).apply()
|
||||
/** A fixed [multiplier] (2..4), or for LSFG a [target] frame rate (0 = fixed). */
|
||||
data class Mode(val engine: String, val multiplier: Int = 2, val target: Int = 0) {
|
||||
companion object {
|
||||
val OFF = Mode(ENGINE_OFF)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
|
||||
fun label(context: Context): String = when (engine(context)) {
|
||||
ENGINE_WINFG -> "Win-FG ${multiplier(context)}×"
|
||||
ENGINE_LSFG -> "LSFG ${multiplier(context)}×"
|
||||
else -> "Off"
|
||||
fun engine(context: Context): String = mode(context).engine
|
||||
|
||||
fun mode(context: Context): Mode {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val engine = prefs.getString("engine", ENGINE_OFF) ?: 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))
|
||||
}
|
||||
|
||||
fun set(context: Context, mode: Mode) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("engine", mode.engine).putInt("multiplier", mode.multiplier).putInt("target", mode.target).apply()
|
||||
}
|
||||
|
||||
/** "Off", "Win-FG 2×", "LSFG 3×", "LSFG Adaptive 90". */
|
||||
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_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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the saved setting into the compositor. Returns a message for the user when the
|
||||
* engine cannot run (LSFG without Lossless Scaling), or null.
|
||||
* engine cannot run (LSFG without Lossless Scaling), or null. Blocks while a new or updated
|
||||
* Lossless.dll is prepared.
|
||||
*/
|
||||
fun apply(context: Context, refreshHz: Float): String? {
|
||||
val engine = engine(context)
|
||||
val multiplier = multiplier(context)
|
||||
var problem: String? = null
|
||||
when (engine) {
|
||||
val mode = mode(context)
|
||||
when (mode.engine) {
|
||||
ENGINE_LSFG -> {
|
||||
val status = LsfgNative.ensureCache(context)
|
||||
if (status != LsfgNative.STATUS_OK) {
|
||||
problem = if (status == LsfgNative.STATUS_NOT_INSTALLED)
|
||||
"LSFG needs Lossless Scaling installed from the Steam client"
|
||||
else "LSFG: " + LsfgNative.statusName(status)
|
||||
Log.w(TAG, problem)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
val status = Lossless.sync(context)
|
||||
val cache = Lossless.cacheFile(context)
|
||||
if (status != LsfgNative.STATUS_OK || cache == null) {
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
val problem = context.getString(
|
||||
if (status == LsfgNative.STATUS_NOT_INSTALLED) R.string.lsfg_problem_missing else R.string.lsfg_problem_failed,
|
||||
)
|
||||
Log.w(TAG, "$problem (status $status)")
|
||||
return problem
|
||||
}
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_LSFG)
|
||||
WaylandCompositor.nativeSetLsfgCachePath(LsfgNative.cacheFile(context).absolutePath)
|
||||
WaylandCompositor.nativeSetLsfgCachePath(cache.absolutePath)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
|
||||
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)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
|
||||
}
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
}
|
||||
Log.i(TAG, "frame generation: $engine x$multiplier at ${refreshHz}Hz")
|
||||
return problem
|
||||
}
|
||||
|
||||
/**
|
||||
* Off, or an engine at 2x/3x/4x; saved on pick, then [onChanged]. The LSFG rows are greyed
|
||||
* and unpickable until Lossless Scaling is installed in the Steam client - the engine cannot
|
||||
* run without its DLL, so offering it would only produce a setting that silently does nothing.
|
||||
*/
|
||||
fun showPicker(activity: android.app.Activity, onChanged: () -> Unit) {
|
||||
val lsfgReady = LsfgNative.isInstalled(activity)
|
||||
val lsfgNote = if (lsfgReady) "" else " - install Lossless Scaling in Steam"
|
||||
val choices = listOf("Off",
|
||||
"Win-FG 2×", "Win-FG 3×", "Win-FG 4×",
|
||||
"LSFG 2×$lsfgNote", "LSFG 3×$lsfgNote", "LSFG 4×$lsfgNote")
|
||||
val current = when (engine(activity)) {
|
||||
ENGINE_WINFG -> multiplier(activity) - 1
|
||||
ENGINE_LSFG -> if (lsfgReady) multiplier(activity) + 2 else 0
|
||||
else -> 0
|
||||
}
|
||||
val adapter = object : android.widget.ArrayAdapter<String>(
|
||||
activity, android.R.layout.simple_list_item_single_choice, choices) {
|
||||
override fun isEnabled(position: Int) = lsfgReady || position < 4
|
||||
override fun areAllItemsEnabled() = lsfgReady
|
||||
override fun getView(position: Int, convertView: android.view.View?, parent: android.view.ViewGroup): android.view.View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
view.isEnabled = isEnabled(position)
|
||||
view.alpha = if (isEnabled(position)) 1f else 0.38f
|
||||
return view
|
||||
}
|
||||
}
|
||||
android.app.AlertDialog.Builder(activity)
|
||||
.setTitle(com.droiddeck.launcher.R.string.frame_gen_title)
|
||||
.setSingleChoiceItems(adapter, current) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> set(activity, ENGINE_OFF, 2)
|
||||
in 1..3 -> set(activity, ENGINE_WINFG, which + 1)
|
||||
else -> set(activity, ENGINE_LSFG, which - 2)
|
||||
}
|
||||
dialog.dismiss()
|
||||
onChanged()
|
||||
}
|
||||
.setNeutralButton(android.R.string.ok, null)
|
||||
.show()
|
||||
Log.i(TAG, "frame generation: $mode at ${refreshHz}Hz")
|
||||
return null
|
||||
}
|
||||
|
||||
private const val TAG = "FrameGen"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
|
||||
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
|
||||
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
|
||||
* may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
*/
|
||||
data class GpuInfo(
|
||||
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
|
||||
val name: String,
|
||||
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
|
||||
val model: Int,
|
||||
val family: Family,
|
||||
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
|
||||
val soc: String,
|
||||
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
|
||||
val oneUi8Gen2: Boolean,
|
||||
) {
|
||||
enum class Family(val label: String) {
|
||||
A8XX("Adreno 8xx"),
|
||||
/** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */
|
||||
A7XX_LOW("Adreno 710/720/722"),
|
||||
A7XX("Adreno 7xx"),
|
||||
A6XX("Adreno 6xx"),
|
||||
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
|
||||
ADRENO_UNKNOWN("Adreno"),
|
||||
NOT_ADRENO("Not an Adreno GPU"),
|
||||
}
|
||||
|
||||
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
|
||||
|
||||
val support: Support
|
||||
get() = when {
|
||||
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
|
||||
family == Family.A8XX -> Support.TESTED
|
||||
family == Family.A7XX && model >= 725 -> Support.TESTED
|
||||
else -> Support.UNTESTED
|
||||
}
|
||||
|
||||
/** One line for the device card and the system check. */
|
||||
val supportText: String
|
||||
get() = when (support) {
|
||||
Support.TESTED -> "Supported"
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
|
||||
else "Below tested hardware (Adreno 725 and newer): it may not run"
|
||||
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun detect(): GpuInfo {
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
|
||||
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
|
||||
!adreno -> Family.NOT_ADRENO
|
||||
model == 0 -> Family.ADRENO_UNKNOWN
|
||||
model >= 800 -> Family.A8XX
|
||||
model in listOf(710, 720, 722) -> Family.A7XX_LOW
|
||||
model >= 700 -> Family.A7XX
|
||||
model >= 600 -> Family.A6XX
|
||||
else -> Family.ADRENO_UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager {
|
||||
* Linux driver; {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installDriver(Uri zipUri, String displayName) throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
String soName = null;
|
||||
@@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager {
|
||||
}
|
||||
}
|
||||
// The zip's own freedreno_icd.aarch64.json is dropped on purpose: its
|
||||
// library_path is relative to itself, and we write an absolute one below.
|
||||
// library_path is relative to itself, and adopt() writes an absolute one.
|
||||
}
|
||||
}
|
||||
String id = adopt(tmpDir, soName, zipMeta, displayName);
|
||||
keep = true;
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in
|
||||
* the zip), write its manifest and meta.json, and move it in. Returns the id; the caller
|
||||
* deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
|
||||
try {
|
||||
if (soName == null) {
|
||||
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
|
||||
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
|
||||
@@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager {
|
||||
if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json");
|
||||
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir);
|
||||
return id;
|
||||
} catch (org.json.JSONException e) {
|
||||
throw new IOException("manifest write failed: " + e.getMessage());
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.security.DigestOutputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* LSFG's shader chain, from the user's own Lossless Scaling (Steam app 993090).
|
||||
*
|
||||
* The copy the Steam client installs, in any library it installs to, is taken by
|
||||
* itself and taken again whenever the client updates it. Once the session has seen the account
|
||||
* own the app, a Lossless.dll can also be imported by hand; it stays until the Steam copy changes
|
||||
* or another is imported. The DLL is copied in and its shaders are built into a cache named after
|
||||
* its contents, so a different DLL hands the compositor a different path and its engine reloads.
|
||||
* Everything here blocks: call it off the main thread.
|
||||
*/
|
||||
object Lossless {
|
||||
enum class Source { NONE, STEAM, IMPORT }
|
||||
|
||||
enum class ImportResult { UPDATED, UNCHANGED, NOT_OWNED, INVALID, FAILED }
|
||||
|
||||
data class State(val owned: Boolean, val ready: Boolean, val source: Source) {
|
||||
companion object {
|
||||
val NONE = State(owned = false, ready = false, source = Source.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "Lossless"
|
||||
private const val PREFS = "lossless"
|
||||
private const val KEY_OWNED = "owned"
|
||||
private const val KEY_ID = "id"
|
||||
private const val KEY_SOURCE = "source"
|
||||
private const val KEY_STEAM_STAMP = "steamStamp"
|
||||
private const val STEAM_DLL = "steamapps/common/Lossless Scaling/Lossless.dll"
|
||||
private const val UNCHANGED = -1
|
||||
|
||||
fun state(context: Context): State {
|
||||
val source = prefs(context).getString(KEY_SOURCE, null)
|
||||
?.let { name -> Source.entries.firstOrNull { it.name == name } } ?: Source.NONE
|
||||
return State(owned(context), cacheFile(context)?.isFile == true, source)
|
||||
}
|
||||
|
||||
/** Recorded once: by the session's ownership check (a flag in the guest's home) or a Steam copy. */
|
||||
fun owned(context: Context): Boolean {
|
||||
if (prefs(context).getBoolean(KEY_OWNED, false)) return true
|
||||
if (!ownedFlag(context).exists()) return false
|
||||
markOwned(context)
|
||||
return true
|
||||
}
|
||||
|
||||
fun cacheFile(context: Context): File? = prefs(context).getString(KEY_ID, null)?.let { cacheFor(context, it) }
|
||||
|
||||
/** Takes a new or updated Steam copy and rebuilds a missing or outdated cache. A [LsfgNative] status. */
|
||||
@Synchronized
|
||||
fun sync(context: Context): Int {
|
||||
val steam = steamDll(context)
|
||||
if (steam != null) {
|
||||
markOwned(context)
|
||||
val stamp = "${steam.length()}:${steam.lastModified()}"
|
||||
if (stamp != prefs(context).getString(KEY_STEAM_STAMP, null)) {
|
||||
val status = adopt(context, steam, Source.STEAM)
|
||||
if (status != LsfgNative.STATUS_OK && status != UNCHANGED) {
|
||||
Log.w(TAG, "Steam copy $steam not taken: ${statusName(status)}")
|
||||
}
|
||||
prefs(context).edit().putString(KEY_STEAM_STAMP, stamp).apply()
|
||||
}
|
||||
}
|
||||
return ensureBuilt(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun import(context: Context, dll: File): ImportResult {
|
||||
if (!owned(context)) return ImportResult.NOT_OWNED
|
||||
return when (adopt(context, dll, Source.IMPORT)) {
|
||||
UNCHANGED -> ImportResult.UNCHANGED
|
||||
LsfgNative.STATUS_OK -> ImportResult.UPDATED
|
||||
LsfgNative.STATUS_UNREADABLE_FILE, LsfgNative.STATUS_NOT_PORTABLE_EXECUTABLE,
|
||||
LsfgNative.STATUS_MISSING_SHADERS -> ImportResult.INVALID
|
||||
else -> ImportResult.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
fun message(context: Context, result: ImportResult): String = context.getString(
|
||||
when (result) {
|
||||
ImportResult.UPDATED -> R.string.lsfg_imported
|
||||
ImportResult.UNCHANGED -> R.string.lsfg_import_unchanged
|
||||
ImportResult.NOT_OWNED -> R.string.lsfg_import_not_owned
|
||||
ImportResult.INVALID -> R.string.lsfg_import_invalid
|
||||
ImportResult.FAILED -> R.string.lsfg_import_failed
|
||||
},
|
||||
)
|
||||
|
||||
private fun ensureBuilt(context: Context): Int {
|
||||
val id = prefs(context).getString(KEY_ID, null) ?: return LsfgNative.STATUS_NOT_INSTALLED
|
||||
val cache = cacheFor(context, id)
|
||||
if (usable(cache)) return LsfgNative.STATUS_OK
|
||||
val dll = storedDll(context)
|
||||
if (!dll.isFile) return LsfgNative.STATUS_NOT_INSTALLED
|
||||
return build(dll, cache)
|
||||
}
|
||||
|
||||
private fun adopt(context: Context, source: File, from: Source): Int {
|
||||
val staged = File(dir(context).apply { mkdirs() }, "Lossless.dll.staged")
|
||||
try {
|
||||
val id = copyHashing(source, staged)
|
||||
if (id == prefs(context).getString(KEY_ID, null) && usable(cacheFor(context, id))) return UNCHANGED
|
||||
val cache = cacheFor(context, id)
|
||||
var status = LsfgNative.nativeValidateDll(staged.path)
|
||||
if (status == LsfgNative.STATUS_OK) status = build(staged, cache)
|
||||
if (status != LsfgNative.STATUS_OK) {
|
||||
cache.delete()
|
||||
return status
|
||||
}
|
||||
if (!staged.renameTo(storedDll(context))) {
|
||||
cache.delete()
|
||||
return LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
}
|
||||
prefs(context).edit().putString(KEY_ID, id).putString(KEY_SOURCE, from.name).apply()
|
||||
removeStale(context, id)
|
||||
Log.i(TAG, "Lossless.dll from $source ($from) ready as $id")
|
||||
return LsfgNative.STATUS_OK
|
||||
} catch (e: java.io.IOException) {
|
||||
Log.w(TAG, "could not copy $source", e)
|
||||
return LsfgNative.STATUS_UNREADABLE_FILE
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "could not take $source", t)
|
||||
return LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
} finally {
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(dll: File, cache: File): Int = try {
|
||||
val status = LsfgNative.nativeBuildCache(dll.path, cache.path, true)
|
||||
Log.i(TAG, "shader cache ${cache.name}: ${statusName(status)}" +
|
||||
if (status == LsfgNative.STATUS_OK) " (${LsfgNative.nativeVariantName(LsfgNative.nativeCacheVariant(cache.path))})" else "")
|
||||
status
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "shader cache ${cache.name}", t)
|
||||
LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
}
|
||||
|
||||
private fun usable(cache: File): Boolean = cache.isFile &&
|
||||
runCatching { LsfgNative.nativeCacheVariant(cache.path) != LsfgNative.VARIANT_NONE }.getOrDefault(false)
|
||||
|
||||
private fun copyHashing(source: File, target: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
source.inputStream().use { input ->
|
||||
DigestOutputStream(target.outputStream(), digest).use { output -> input.copyTo(output) }
|
||||
}
|
||||
return digest.digest().take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun removeStale(context: Context, keep: String) {
|
||||
File(context.filesDir, "lsfg-native").deleteRecursively()
|
||||
dir(context).listFiles { f -> f.name.startsWith("shaders-") && f.name != cacheFor(context, keep).name }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** The newest copy in any library the client installs to: its own, the card's, the Games folders. */
|
||||
private fun steamDll(context: Context): File? {
|
||||
val libraries = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
|
||||
GameStorage.effective(context)?.path?.let(::File),
|
||||
) + SessionPrefs.addedGamesDirs(context).map(::File)
|
||||
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
private fun markOwned(context: Context) {
|
||||
if (!prefs(context).getBoolean(KEY_OWNED, false)) prefs(context).edit().putBoolean(KEY_OWNED, true).apply()
|
||||
}
|
||||
|
||||
private fun statusName(status: Int): String =
|
||||
runCatching { LsfgNative.nativeStatusName(status) }.getOrDefault("status $status")
|
||||
|
||||
private fun ownedFlag(context: Context) = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned")
|
||||
private fun dir(context: Context) = File(context.filesDir, "lsfg")
|
||||
private fun storedDll(context: Context) = File(dir(context), "Lossless.dll")
|
||||
private fun cacheFor(context: Context, id: String) = File(dir(context), "shaders-$id.cache")
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -1,99 +1,30 @@
|
||||
package com.droiddeck.launcher.gpu;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* LSFG frame generation's shader chain: extracted from the user's own copy of Lossless Scaling
|
||||
* and cached on device.
|
||||
*
|
||||
* <p>The 25 compute shaders are not redistributable. They live as resources inside the user's
|
||||
* paid {@code Lossless.dll} (Steam app 993090), which in this app the user installs from the
|
||||
* Steam client itself - so the DLL sits in the runtime's own Steam library. It is mapped
|
||||
* read-only and parsed as data, never loaded or executed. Extraction is slow enough to be worth
|
||||
* doing once (the DXBC chain is translated to SPIR-V), so the result is cached, keyed on the
|
||||
* DLL's size and hash. Call {@link #ensureCache} off the main thread.
|
||||
*
|
||||
* <p>Ported from Bannerlator's LsfgNative; the native side is the same lsfg_jni.cpp compiled into
|
||||
* the compositor library.
|
||||
* The native half of LSFG's shader cache (lsfg_jni.cpp, compiled into the compositor library):
|
||||
* {@code Lossless.dll} is mapped read-only and parsed as data, never loaded or executed, and its
|
||||
* DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block.
|
||||
*/
|
||||
public final class LsfgNative {
|
||||
private static final String TAG = "LsfgNative";
|
||||
|
||||
final class LsfgNative {
|
||||
static { System.loadLibrary("bannerwayland"); }
|
||||
|
||||
private LsfgNative() {}
|
||||
|
||||
// Mirrors lsfg::DllStatus.
|
||||
public static final int STATUS_OK = 0;
|
||||
public static final int STATUS_NOT_INSTALLED = 1;
|
||||
public static final int STATUS_UNREADABLE_FILE = 2;
|
||||
public static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
|
||||
public static final int STATUS_MISSING_SHADERS = 4;
|
||||
public static final int STATUS_TRANSLATION_FAILED = 5;
|
||||
public static final int STATUS_CACHE_UNUSABLE = 6;
|
||||
static final int STATUS_OK = 0;
|
||||
static final int STATUS_NOT_INSTALLED = 1;
|
||||
static final int STATUS_UNREADABLE_FILE = 2;
|
||||
static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
|
||||
static final int STATUS_MISSING_SHADERS = 4;
|
||||
static final int STATUS_TRANSLATION_FAILED = 5;
|
||||
static final int STATUS_CACHE_UNUSABLE = 6;
|
||||
|
||||
private static native int nativeValidateDll(String dllPath);
|
||||
private static native int nativeDllVariant(String dllPath, boolean preferFp16);
|
||||
private static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
|
||||
private static native boolean nativeCacheMatchesSource(String cachePath, String dllPath);
|
||||
private static native int nativeCacheVariant(String cachePath);
|
||||
private static native String nativeStatusName(int status);
|
||||
private static native String nativeVariantName(int variant);
|
||||
// Mirrors lsfg::Variant.
|
||||
static final int VARIANT_NONE = 0;
|
||||
|
||||
/** Where Lossless Scaling lands when installed from the client inside the runtime. */
|
||||
public static File losslessDll(Context context) {
|
||||
return new File(LinuxRuntime.rootDir(context),
|
||||
"root/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll");
|
||||
}
|
||||
|
||||
/** Where the translated SPIR-V chain is cached. */
|
||||
public static File cacheFile(Context context) {
|
||||
return new File(context.getFilesDir(), "lsfg-native/shaders.cache");
|
||||
}
|
||||
|
||||
public static boolean isInstalled(Context context) {
|
||||
return losslessDll(context).isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a usable cache exists for the installed DLL, building it if the DLL is new or
|
||||
* changed. Returns a {@code STATUS_*} code; {@link #STATUS_OK} means the engine can start.
|
||||
*/
|
||||
public static int ensureCache(Context context) {
|
||||
File dll = losslessDll(context);
|
||||
if (!dll.isFile()) return STATUS_NOT_INSTALLED;
|
||||
File cache = cacheFile(context);
|
||||
try {
|
||||
if (cache.isFile() && nativeCacheMatchesSource(cache.getPath(), dll.getPath())) {
|
||||
return STATUS_OK;
|
||||
}
|
||||
int status = nativeValidateDll(dll.getPath());
|
||||
if (status != STATUS_OK) {
|
||||
Log.w(TAG, "Lossless.dll rejected: " + statusName(status));
|
||||
return status;
|
||||
}
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
cache.getParentFile().mkdirs();
|
||||
status = nativeBuildCache(dll.getPath(), cache.getPath(), true);
|
||||
Log.i(TAG, "cache build: " + statusName(status)
|
||||
+ (status == STATUS_OK ? " (" + nativeVariantName(nativeCacheVariant(cache.getPath())) + ")" : ""));
|
||||
return status;
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "ensureCache", t);
|
||||
return STATUS_CACHE_UNUSABLE;
|
||||
}
|
||||
}
|
||||
|
||||
public static String statusName(int status) {
|
||||
try {
|
||||
return nativeStatusName(status);
|
||||
} catch (Throwable t) {
|
||||
return "status " + status;
|
||||
}
|
||||
}
|
||||
static native int nativeValidateDll(String dllPath);
|
||||
static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
|
||||
static native int nativeCacheVariant(String cachePath);
|
||||
static native String nativeStatusName(int status);
|
||||
static native String nativeVariantName(int variant);
|
||||
}
|
||||
@@ -179,10 +179,7 @@ public final class TurnipDriver {
|
||||
* {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installFromZip(Uri zipUri, String displayName) throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
try (InputStream is = context.getContentResolver().openInputStream(zipUri);
|
||||
@@ -198,26 +195,44 @@ public final class TurnipDriver {
|
||||
Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
String id = adopt(tmpDir, displayName);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver (meta.json and its library, flat) and move it in as a new driver.
|
||||
* Returns the id; the caller deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String displayName) throws IOException {
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
}
|
||||
|
||||
private static JSONObject readMetaFile(File metaFile) {
|
||||
try {
|
||||
return new JSONObject(FileUtils.readString(metaFile));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONArray
|
||||
@@ -27,11 +28,21 @@ object TurnipReleases {
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
|
||||
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String)
|
||||
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
|
||||
/**
|
||||
* [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux
|
||||
* build of the same pair are made to run together. "" for a list cached before pairs were kept.
|
||||
*/
|
||||
/** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
|
||||
val sha256: String = "", val pair: String = "", val bundle: Boolean = false)
|
||||
/** [latest] = each source's newest release tag, for the refresh line. */
|
||||
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
|
||||
/** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */
|
||||
private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?)
|
||||
|
||||
private val SOURCES = listOf(
|
||||
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
|
||||
@@ -42,12 +53,14 @@ object TurnipReleases {
|
||||
if (variant.endsWith("-Wayland")) return@Source null
|
||||
val linux = variant.endsWith("-Linux")
|
||||
variant = variant.removeSuffix("-Linux")
|
||||
linux to when {
|
||||
val label = when {
|
||||
variant.isEmpty() -> "Adreno 6xx/7xx"
|
||||
variant == "-A8xx" -> "Adreno 8xx"
|
||||
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
|
||||
variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI"
|
||||
else -> variant.trimStart('-')
|
||||
}
|
||||
Kind(linux, label, "banner" + variant.lowercase())
|
||||
},
|
||||
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
|
||||
// b = Balanced, p = Performance, Axxx = every Adreno.
|
||||
@@ -55,7 +68,13 @@ object TurnipReleases {
|
||||
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
|
||||
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
|
||||
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
|
||||
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
|
||||
Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2])
|
||||
},
|
||||
// DD-Turnip-v<ver>.zip: DroidDeck's own all-in-one build, both halves in one zip, published
|
||||
// to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair.
|
||||
Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag ->
|
||||
if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null
|
||||
Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -88,12 +107,19 @@ object TurnipReleases {
|
||||
for (j in 0 until list.length()) {
|
||||
val a = list.getJSONObject(j)
|
||||
val name = a.getString("name")
|
||||
val (linux, label) = src.classify(name, tag) ?: continue
|
||||
val kind = src.classify(name, tag) ?: continue
|
||||
// A bundle is only offered from an official release, never a pre-release.
|
||||
if (kind.bundle && r.optBoolean("prerelease")) continue
|
||||
val linux = kind.linux
|
||||
val label = kind.label
|
||||
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
|
||||
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
|
||||
// Newest first: the first release carrying a variant is the one offered.
|
||||
if (!seen.add("$linux|$label")) continue
|
||||
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
|
||||
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
|
||||
.put("linux", linux).put("label", label))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
|
||||
.put("bundle", kind.bundle))
|
||||
}
|
||||
}
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
@@ -131,7 +157,8 @@ object TurnipReleases {
|
||||
for (i in 0 until list.length()) {
|
||||
val a = list.getJSONObject(i)
|
||||
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""),
|
||||
a.optBoolean("bundle", false)))
|
||||
}
|
||||
val latest = ArrayList<Pair<String, String>>()
|
||||
val l = json.optJSONArray("latest") ?: JSONArray()
|
||||
@@ -167,6 +194,7 @@ object TurnipReleases {
|
||||
|
||||
/** Download an asset into the cache; the caller imports it and deletes the file. */
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val c = URL(asset.url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
@@ -191,6 +219,9 @@ object TurnipReleases {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
throw IOException("Checksum mismatch - the download was discarded")
|
||||
}
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(target)
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.onSignal
|
||||
import com.droiddeck.launcher.ui.LocalPalette
|
||||
|
||||
class ControllerEditorActivity : ComponentActivity() {
|
||||
@@ -47,7 +48,7 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
|
||||
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text("Drag to move", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
EditorButton("Reset", false) { controls.resetLayout() }
|
||||
EditorButton("Cancel", false) { finish() }
|
||||
EditorButton("Save", true) {
|
||||
@@ -67,7 +68,7 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
val pal = LocalPalette.current
|
||||
Text(
|
||||
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (primary) Color.White else MaterialTheme.colorScheme.onBackground,
|
||||
color = if (primary) pal.onSignal else MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.clip(RoundedCornerShape(50))
|
||||
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
|
||||
.clickable(onClick = onClick)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
/**
|
||||
* The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have:
|
||||
* the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the
|
||||
* pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report.
|
||||
*
|
||||
* Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its
|
||||
* position, a pad clicked also reports full pressure.
|
||||
*/
|
||||
object DeckControls {
|
||||
/** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */
|
||||
const val L4 = 1
|
||||
const val R4 = 2
|
||||
const val L5 = 4
|
||||
const val R5 = 8
|
||||
private const val LEFT_TOUCH = 16
|
||||
private const val RIGHT_TOUCH = 32
|
||||
private const val LEFT_CLICK = 64
|
||||
private const val RIGHT_CLICK = 128
|
||||
|
||||
/** The pad the Deck controller is made from (PadBridge's slot). */
|
||||
private const val SLOT = 0
|
||||
|
||||
private var controls = 0
|
||||
private val pads = ShortArray(4)
|
||||
private val pressure = ShortArray(2)
|
||||
|
||||
@Synchronized
|
||||
fun setGrip(bit: Int, down: Boolean) {
|
||||
set(bit, down)
|
||||
publish()
|
||||
}
|
||||
|
||||
/** A finger on (or off) one trackpad at x, y in -1..1, y up. */
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
|
||||
val at = if (right) 2 else 0
|
||||
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
|
||||
pads[at] = if (touching) axis(x) else 0
|
||||
pads[at + 1] = if (touching) axis(y) else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) {
|
||||
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
|
||||
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
/** Everything let go - for when the panel goes away with a finger still on it. */
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
|
||||
controls = 0
|
||||
pads.fill(0)
|
||||
pressure.fill(0)
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun set(bit: Int, on: Boolean) {
|
||||
controls = if (on) controls or bit else controls and bit.inv()
|
||||
}
|
||||
|
||||
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
|
||||
}
|
||||
@@ -67,6 +67,9 @@ public class FakeInputWriter {
|
||||
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
|
||||
* button is not, because PadState keeps 10 and 11 for the triggers. */
|
||||
private static final int SNAPSHOT_IDX_MODE = 10;
|
||||
/** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key;
|
||||
* libfakeinput's Deck controller reads it from here. */
|
||||
private static final int SNAPSHOT_IDX_QAM = 11;
|
||||
|
||||
private static final int EVENT_SIZE = 24;
|
||||
static final int MAX_EVENTS_PER_UPDATE = 32;
|
||||
@@ -92,6 +95,18 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
private static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
private static final int IMU_OFFSET = RING_SIZE;
|
||||
private static final int IMU_MAGIC = 0x31554D49; // IMU1
|
||||
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
|
||||
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
|
||||
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
|
||||
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
|
||||
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
|
||||
private static final int RING_FILE_SIZE = RING_SIZE + 64;
|
||||
|
||||
private static final Object RING_LOCK = new Object();
|
||||
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
|
||||
@@ -110,6 +125,7 @@ public class FakeInputWriter {
|
||||
private volatile boolean destroyed = false;
|
||||
|
||||
private final boolean[] prevButtonStates = new boolean[12];
|
||||
private boolean prevQam;
|
||||
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
|
||||
private int prevTriggerL, prevTriggerR;
|
||||
private int prevHatX, prevHatY;
|
||||
@@ -217,30 +233,40 @@ public class FakeInputWriter {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(IMU_OFFSET, 0);
|
||||
data.putLong(IMU_SEQ_OFFSET, 0L);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
}
|
||||
|
||||
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
|
||||
// re-checks it there: a writer that looked the slot up just before this ran finds it gone.
|
||||
private static void releaseRingSlotLocked(int slot) {
|
||||
RingSlot ringSlot = RING_SLOTS[slot];
|
||||
if (ringSlot == null) {
|
||||
return;
|
||||
}
|
||||
ringSlot.data = null;
|
||||
if (ringSlot.ringChannel != null) {
|
||||
try {
|
||||
ringSlot.ringChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
synchronized (ringSlot) {
|
||||
ringSlot.data = null;
|
||||
if (ringSlot.ringChannel != null) {
|
||||
try {
|
||||
ringSlot.ringChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
ringSlot.ringChannel = null;
|
||||
}
|
||||
ringSlot.ringChannel = null;
|
||||
}
|
||||
if (ringSlot.ringRaf != null) {
|
||||
try {
|
||||
ringSlot.ringRaf.close();
|
||||
} catch (IOException ignored) {
|
||||
if (ringSlot.ringRaf != null) {
|
||||
try {
|
||||
ringSlot.ringRaf.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
ringSlot.ringRaf = null;
|
||||
}
|
||||
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
|
||||
ringSlot.ringFile.delete();
|
||||
}
|
||||
ringSlot.ringRaf = null;
|
||||
}
|
||||
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
|
||||
ringSlot.ringFile.delete();
|
||||
}
|
||||
RING_SLOTS[slot] = null;
|
||||
}
|
||||
@@ -289,9 +315,9 @@ public class FakeInputWriter {
|
||||
FileChannel channel = null;
|
||||
try {
|
||||
raf = new RandomAccessFile(ringFile, "rw");
|
||||
raf.setLength(RING_SIZE);
|
||||
raf.setLength(RING_FILE_SIZE);
|
||||
channel = raf.getChannel();
|
||||
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE);
|
||||
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
|
||||
initializeRingHeader(data);
|
||||
|
||||
RingSlot ringSlot = new RingSlot();
|
||||
@@ -332,6 +358,9 @@ public class FakeInputWriter {
|
||||
return false;
|
||||
}
|
||||
synchronized (ringSlot) {
|
||||
if (ringSlot.data == null) {
|
||||
return false;
|
||||
}
|
||||
if (!ringSlot.active) {
|
||||
if (ringSlot.everActivated) {
|
||||
ringSlot.generation++;
|
||||
@@ -381,6 +410,9 @@ public class FakeInputWriter {
|
||||
ByteBuffer source = this.buffer.duplicate();
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) {
|
||||
return false;
|
||||
}
|
||||
long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET);
|
||||
int sourceLimit = source.limit();
|
||||
while (source.remaining() >= EVENT_SIZE) {
|
||||
@@ -416,6 +448,9 @@ public class FakeInputWriter {
|
||||
buttons |= (1 << i);
|
||||
}
|
||||
}
|
||||
if (this.prevQam) {
|
||||
buttons |= (1 << SNAPSHOT_IDX_QAM);
|
||||
}
|
||||
long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET);
|
||||
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress
|
||||
nativeStoreFence();
|
||||
@@ -434,6 +469,58 @@ public class FakeInputWriter {
|
||||
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck
|
||||
* controller's axes and units (see PadMotion). Safe from any thread.
|
||||
*/
|
||||
public static void writeMotion(int slot, short[] accel, short[] gyro) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
long seq = ring.getLong(IMU_SEQ_OFFSET);
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress
|
||||
nativeStoreFence();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]);
|
||||
ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]);
|
||||
}
|
||||
ring.putInt(IMU_OFFSET, IMU_MAGIC);
|
||||
nativeStoreFence();
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
|
||||
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
|
||||
* {@code pressure} left, right, already in the Deck's units. Safe from any thread.
|
||||
*/
|
||||
public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
long seq = ring.getLong(IMU_SEQ_OFFSET);
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 1);
|
||||
nativeStoreFence();
|
||||
for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]);
|
||||
for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]);
|
||||
ring.putInt(DECK_CONTROLS_OFFSET, controls);
|
||||
ring.putInt(IMU_OFFSET, IMU_MAGIC);
|
||||
nativeStoreFence();
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
private static short clampShort(int value) {
|
||||
if (value > Short.MAX_VALUE) {
|
||||
return Short.MAX_VALUE;
|
||||
@@ -512,6 +599,11 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
if (prevQam) {
|
||||
prevQam = false;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Zero all axes
|
||||
if (prevThumbLX != 0) {
|
||||
prevThumbLX = 0;
|
||||
@@ -620,6 +712,11 @@ public class FakeInputWriter {
|
||||
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
|
||||
writeButton(SNAPSHOT_IDX_MODE,
|
||||
state.isDown(PadState.GUIDE));
|
||||
boolean qam = state.isDown(PadState.QAM);
|
||||
if (forceResend || qam != prevQam) {
|
||||
prevQam = qam;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Sticks
|
||||
int lx = (int) (state.leftX * 32767);
|
||||
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user