mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-21 22:54:48 -04:00
Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c74bac96e | ||
|
|
1330fe4d6c | ||
|
|
4df0e012fd | ||
|
|
66b71b486f | ||
|
|
6782e5a717 | ||
|
|
e1a80c1751 | ||
|
|
1f29cd92a5 | ||
|
|
4f30356c9b | ||
|
|
baebf50197 | ||
|
|
01674b7c6f | ||
|
|
728bfa2043 | ||
|
|
c4de482b6a | ||
|
|
55697535a1 | ||
|
|
65733c23a1 | ||
|
|
fd51b86060 | ||
|
|
d6e4ab1824 | ||
|
|
566c7d703a | ||
|
|
d7eb3256a5 | ||
|
|
cc8b743ecb | ||
|
|
45422b739b | ||
|
|
f0e8ef1274 | ||
|
|
ca1ceb5e28 | ||
|
|
bb6d6fee97 | ||
|
|
14db12290e | ||
|
|
1e476539df | ||
|
|
3c8e370744 | ||
|
|
3fcd01f536 | ||
|
|
0e2ee35b49 | ||
|
|
27920711f0 | ||
|
|
d4c16b5b59 | ||
|
|
e392364e30 | ||
|
|
3cdb77fd65 | ||
|
|
a91be68992 | ||
|
|
e16f351540 | ||
|
|
efdab60b22 | ||
|
|
8dc24f244c | ||
|
|
b565c4a6e5 | ||
|
|
a999fbdb38 | ||
|
|
52eee568f5 | ||
|
|
05cebd4a4f | ||
|
|
05284b0dd6 | ||
|
|
8565acf12b | ||
|
|
941a6c3ef9 | ||
|
|
fe84c050ad | ||
|
|
f13c885fec | ||
|
|
8ee1cbe3eb | ||
|
|
6c5d6cda73 | ||
|
|
2725ee4512 | ||
|
|
7cc1c93775 | ||
|
|
38c9b733b5 | ||
|
|
27e5f86793 | ||
|
|
a6331e44ae | ||
|
|
f53b844add | ||
|
|
fb34098006 | ||
|
|
c729f4c8e7 | ||
|
|
9f343278c3 | ||
|
|
6beeb29486 | ||
|
|
ade2e982b6 | ||
|
|
8a61a1898b | ||
|
|
dfa3b12f48 | ||
|
|
93382ff241 | ||
|
|
24b74d8346 | ||
|
|
9ed25d8ae5 | ||
|
|
b4b31d5a08 | ||
|
|
f394906726 | ||
|
|
b50dfa76d1 | ||
|
|
3f81ddff9b | ||
|
|
d1dec8d7a4 | ||
|
|
e60097e5cb | ||
|
|
7c7bf2dcbe | ||
|
|
75593cfd29 | ||
|
|
00d0f8cd2b | ||
|
|
faf4b4e8b5 | ||
|
|
5a61d37c5d | ||
|
|
25079ab819 | ||
|
|
736b7bed5e | ||
|
|
a3f4c911e5 | ||
|
|
caa7682c6f | ||
|
|
e217cbe66d | ||
|
|
4914b2cc69 | ||
|
|
051b9a320e | ||
|
|
6ecd6fa1d0 | ||
|
|
0f4e83eefd | ||
|
|
d62525f5af | ||
|
|
71057fefe1 | ||
|
|
f4d2c37fbd | ||
|
|
ffd9f92d7b | ||
|
|
213d110766 | ||
|
|
1a8c91b49b | ||
|
|
9644a4cc7f | ||
|
|
24c250fdb4 | ||
|
|
d4636afa97 | ||
|
|
32930e704e | ||
|
|
a8b18947a1 | ||
|
|
7c422577b6 | ||
|
|
1cc1ee994d | ||
|
|
d8d375cb9a | ||
|
|
49934ce933 | ||
|
|
221644e8be | ||
|
|
5179b54745 | ||
|
|
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 | ||
|
|
8f36c4e37f | ||
|
|
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 | ||
|
|
c104e1c276 |
No files matched your search
+179
-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
|
||||
@@ -352,6 +374,7 @@ jobs:
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
BUILD_OUTCOME: ${{ steps.build.outcome }}
|
||||
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
MARK="<!-- droiddeck-pr-build -->"
|
||||
@@ -365,7 +388,15 @@ 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."
|
||||
echo
|
||||
if [ "$SAME_REPO" = true ]; then
|
||||
echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that."
|
||||
else
|
||||
echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing."
|
||||
fi
|
||||
echo
|
||||
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
|
||||
;;
|
||||
failure)
|
||||
echo "### ERROR: Build failed"
|
||||
@@ -418,5 +449,147 @@ 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.
|
||||
Commit: $GITHUB_SHA
|
||||
VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
\`\`\`
|
||||
$(cat signed/SHA256SUMS.txt)
|
||||
\`\`\`"
|
||||
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
|
||||
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
|
||||
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
|
||||
# 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"
|
||||
|
||||
catalog:
|
||||
needs: announce
|
||||
if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }}
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,49 @@
|
||||
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 Preview). 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:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
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
|
||||
found=0
|
||||
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
|
||||
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"
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
|
||||
|
||||
catalog:
|
||||
needs: cleanup
|
||||
if: ${{ always() }}
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
secrets: inherit
|
||||
@@ -1,75 +1,130 @@
|
||||
name: PR Release Publish
|
||||
run-name: sign PR #${{ inputs.pr_number }} with the release key
|
||||
run-name: "sign PR #${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} with the release key"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: Open, approved pull request
|
||||
description: Open pull request number
|
||||
required: true
|
||||
type: number
|
||||
type: string
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
branches: [main]
|
||||
# A PR from a branch of this repo is signed on its own after each green build: only people who
|
||||
# can push here can change it. A fork's PR waits for a maintainer's publish-test label.
|
||||
workflow_run:
|
||||
workflows: [Build APK]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-release-${{ inputs.pr_number }}
|
||||
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
github.event.workflow_run.pull_requests[0] != null)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
pr_number: ${{ steps.gate.outputs.pr_number }}
|
||||
run_id: ${{ steps.gate.outputs.run_id }}
|
||||
head_sha: ${{ steps.gate.outputs.head_sha }}
|
||||
base_sha: ${{ steps.gate.outputs.base_sha }}
|
||||
version_code: ${{ steps.gate.outputs.version_code }}
|
||||
label: ${{ steps.gate.outputs.label }}
|
||||
skip: ${{ steps.gate.outputs.skip }}
|
||||
automatic: ${{ steps.gate.outputs.automatic }}
|
||||
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 requester, PR, and build
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REQUESTER: ${{ github.actor }}
|
||||
PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
BUILT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid PR number '$PR_NUMBER'"; exit 1; }
|
||||
# The automatic path stands down quietly, never failing: a draft, a PR labelled
|
||||
# no-test-build, or a build that a later push has already overtaken.
|
||||
skip() { echo "::notice::Not publishing PR #$PR_NUMBER: $1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
if [ "$EVENT_NAME" = workflow_run ]; then
|
||||
echo "automatic=true" >> "$GITHUB_OUTPUT"
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
[ "$(jq -r .state <<<"$pr")" = open ] || skip "it is not open"
|
||||
[ "$(jq -r .draft <<<"$pr")" = false ] || skip "it is a draft"
|
||||
[ "$(jq -r .base.ref <<<"$pr")" = main ] || skip "it does not target main"
|
||||
[ "$(jq -r .head.repo.full_name <<<"$pr")" = "$GITHUB_REPOSITORY" ] || skip "it comes from a fork"
|
||||
jq -e 'any(.labels[]; .name == "no-test-build")' <<<"$pr" >/dev/null && skip "it is labelled no-test-build"
|
||||
[ "$(jq -r .head.sha <<<"$pr")" = "$BUILT_SHA" ] || skip "a newer push is building"
|
||||
fi
|
||||
if [ "$EVENT_NAME" = pull_request_target ]; then
|
||||
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$REQUESTER/permission" --jq .permission)
|
||||
case "$permission" in
|
||||
admin|maintain|write) ;;
|
||||
*) echo "::error::@$REQUESTER does not have permission to publish release-signed test builds"; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null
|
||||
head=$(jq -r '.head.sha' <<<"$pr")
|
||||
base=$(jq -r '.base.sha' <<<"$pr")
|
||||
|
||||
run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \
|
||||
--event pull_request --commit "$head" \
|
||||
--json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 |
|
||||
jq -r --arg head "$head" '[.[] | select(.headSha == $head)] |
|
||||
sort_by([.createdAt, .attempt]) | last |
|
||||
select(.status == "completed" and .conclusion == "success") | .databaseId // empty')
|
||||
[[ -n "$run_id" ]] || { echo '::error::The latest PR build is not green'; exit 1; }
|
||||
# The label is often added while the PR's build is still running, or just after a push
|
||||
# before it has started: wait for the latest build of the head commit to finish (a PR
|
||||
# build takes about 4 minutes) rather than fail and need the label added again.
|
||||
run_id=
|
||||
for attempt in $(seq 1 60); do
|
||||
latest=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \
|
||||
--event pull_request --commit "$head" \
|
||||
--json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 |
|
||||
jq -c --arg head "$head" '[.[] | select(.headSha == $head)] |
|
||||
sort_by([.createdAt, .attempt]) | last // empty')
|
||||
if [[ -n "$latest" && $(jq -r .status <<<"$latest") == completed ]]; then
|
||||
conclusion=$(jq -r .conclusion <<<"$latest")
|
||||
[[ "$conclusion" == success ]] || { echo "::error::The latest PR build is not green ($conclusion)"; exit 1; }
|
||||
run_id=$(jq -r .databaseId <<<"$latest")
|
||||
break
|
||||
fi
|
||||
status=not-started
|
||||
[[ -z "$latest" ]] || status=$(jq -r .status <<<"$latest")
|
||||
echo "Waiting for the PR build of ${head:0:7} to finish ($status)"
|
||||
sleep 30
|
||||
done
|
||||
[[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 30 minutes'; exit 1; }
|
||||
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id")
|
||||
jq -e --arg head "$head" --arg base "$base" --argjson number "$PR_NUMBER" '
|
||||
.event == "pull_request" and .head_sha == $head and .conclusion == "success" and
|
||||
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 +134,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,12 +144,12 @@ 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 "pr_number=$PR_NUMBER"
|
||||
echo "run_id=$run_id"
|
||||
echo "head_sha=$head"
|
||||
echo "base_sha=$base"
|
||||
echo "version_code=${candidate##* }"
|
||||
echo "label=PR.$PR_NUMBER-${head:0:7}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -102,11 +158,12 @@ 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
|
||||
secrets: inherit
|
||||
if: ${{ needs.authorize.outputs.skip != 'true' }}
|
||||
|
||||
publish:
|
||||
needs: [authorize, sign]
|
||||
@@ -114,37 +171,174 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
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 }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
id: publish
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
LABEL: ${{ needs.authorize.outputs.label }}
|
||||
BASE_SHA: ${{ needs.authorize.outputs.base_sha }}
|
||||
VERSION_CODE: ${{ needs.authorize.outputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$GH_TOKEN" ]]; then
|
||||
echo '::warning::No CI token; download the signed APK from this run'
|
||||
exit 0
|
||||
fi
|
||||
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"
|
||||
Base: $BASE_SHA
|
||||
VersionCode: $VERSION_CODE
|
||||
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)
|
||||
\`\`\`"
|
||||
first=false
|
||||
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
|
||||
first=true
|
||||
fi
|
||||
gh release upload "$tag" "signed/$apk" -R "$repo" --clobber
|
||||
echo "first=$first" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
|
||||
published=$(gh release view "$tag" -R "$repo" --json tagName,assets)
|
||||
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<<"$published" >/dev/null
|
||||
release="$GITHUB_SERVER_URL/$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=$release" >> "$GITHUB_OUTPUT"
|
||||
echo "apk=$GITHUB_SERVER_URL/$repo/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Whether the PR's branch is in this repo
|
||||
id: same-repo
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
run: |
|
||||
head_repo=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.repo.full_name)
|
||||
echo "same=$([ "$head_repo" = "$GITHUB_REPOSITORY" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Clear the publish request label
|
||||
if: ${{ github.event_name == 'pull_request_target' && steps.publish.outcome == 'success' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
run: |
|
||||
gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/publish-test"
|
||||
|
||||
- name: Report the signed build on the PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
SAME_REPO: ${{ steps.same-repo.outputs.same }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short=${HEAD_SHA:0:7}
|
||||
mark='<!-- droiddeck-pr-release -->'
|
||||
if [ "$SAME_REPO" = true ]; then
|
||||
NEXT='Each green build of a new push is signed and published here on its own. Label the PR `no-test-build` to stop that.'
|
||||
else
|
||||
NEXT='Pushes to this PR do not re-sign automatically. After a new green build, a maintainer can add the `publish-test` label again.'
|
||||
fi
|
||||
body=$(cat <<EOF
|
||||
$mark
|
||||
## Signed test build
|
||||
|
||||
A release-signed build of this PR at \`$short\` is ready. It installs over Stable or Preview and keeps app data.
|
||||
|
||||
[Download the standard APK]($APK) · [All variants and checksums]($RELEASE)
|
||||
|
||||
It is also available in **DroidDeck → Updates → Test builds → PR #$PR_NUMBER**.
|
||||
|
||||
$NEXT
|
||||
EOF
|
||||
)
|
||||
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
|
||||
if [ -n "$id" ]; then
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
|
||||
fi
|
||||
|
||||
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
|
||||
# only a PR's first test build is, and later ones update its release quietly.
|
||||
- name: Post to Discord
|
||||
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
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
|
||||
short=${HEAD_SHA:0:7}
|
||||
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
|
||||
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$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"
|
||||
|
||||
catalog:
|
||||
needs: publish
|
||||
if: ${{ always() && needs.publish.result != 'skipped' }}
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
secrets: inherit
|
||||
@@ -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" \
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Update app catalog
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published, edited, deleted, unpublished]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: update-app-catalog
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
catalog:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Always use the current trusted generator, even when a release event points at an older tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Regenerate catalog.json
|
||||
env:
|
||||
SOURCE_GITHUB_TOKEN: ${{ github.token }}
|
||||
CI_GITHUB_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/release/update_catalog.py
|
||||
@@ -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).
|
||||
@@ -29,3 +30,6 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
droiddeck-release-key/
|
||||
password.txt
|
||||
__pycache__/
|
||||
|
||||
# Local signing with the release key (tools/build_local.sh); holds a password.
|
||||
.signing.env
|
||||
@@ -5,14 +5,22 @@
|
||||
</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/Droid-Deck/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, Preview 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`.
|
||||
@@ -23,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.
|
||||
+33
-3
@@ -49,6 +49,22 @@ 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'
|
||||
// The release key's certificate: a build signed with it, from CI or a computer, updates in place.
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
// 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 +79,15 @@ 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 "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
@@ -140,6 +162,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
|
||||
@@ -152,7 +179,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')
|
||||
@@ -169,4 +196,7 @@ dependencies {
|
||||
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'
|
||||
}
|
||||
@@ -22,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" />
|
||||
@@ -48,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" />
|
||||
@@ -125,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,24 @@
|
||||
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)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(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
|
||||
@@ -90,6 +94,35 @@ 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(); openDeckRing("deck pad changed") } }
|
||||
|
||||
/**
|
||||
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
|
||||
* it starts. The ring behind the Deck (libfakeinput's /dev/hidraw16) opens on the pad's first
|
||||
* input otherwise - on a phone with nothing attached, the first touch of the on-screen
|
||||
* controls, seconds after the client had looked, found no controller and never set the touch
|
||||
* mode, so its interface took a finger as a mouse. Opened as soon as the session settles on a
|
||||
* Deck instead.
|
||||
*/
|
||||
private fun openDeckRing(why: String) {
|
||||
val deck = SessionState.deckPad
|
||||
val opened = if (deck) padBridge?.start() else null
|
||||
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
|
||||
null -> ", left for first input"
|
||||
true -> ", ring open"
|
||||
false -> ", ring NOT open"
|
||||
})
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -97,6 +130,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
|
||||
@@ -142,6 +177,10 @@ 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)
|
||||
@@ -151,22 +190,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())
|
||||
@@ -177,15 +237,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* press, and a user without one must not be left with no way to answer Big Picture.
|
||||
*/
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
updateOnScreenControls()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
updateOnScreenControls()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
|
||||
SessionEvents.begin(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
|
||||
// A Flatpak app or AppImage is named by its arguments, which the name lookup reads here.
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
|
||||
}
|
||||
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
@@ -195,9 +264,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.
|
||||
@@ -216,6 +291,12 @@ 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
|
||||
openDeckRing("activity created")
|
||||
// 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 {
|
||||
@@ -232,7 +313,7 @@ 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 &&
|
||||
@@ -252,130 +333,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,
|
||||
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() },
|
||||
)
|
||||
// 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 },
|
||||
components = drawerComponents,
|
||||
onComponentsRefresh = { refreshDrawerComponents() },
|
||||
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
|
||||
))
|
||||
if (SessionState.suspended) SessionPausedOverlay(
|
||||
title = pausedTitle(),
|
||||
onResume = { 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.
|
||||
@@ -384,6 +352,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()
|
||||
@@ -393,6 +362,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()
|
||||
@@ -423,7 +540,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) {
|
||||
@@ -496,7 +614,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun shareCurrentSessionLogs() {
|
||||
val folder = SessionPaths.current()
|
||||
// Once a session has ended its folder has been handed on (SessionPaths.take), and the
|
||||
// ended screen's Share logs is for that one.
|
||||
val folder = SessionPaths.current() ?: SessionPaths.lastEnded()
|
||||
if (folder == null || !folder.isDirectory) {
|
||||
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
@@ -523,9 +643,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)
|
||||
@@ -595,6 +713,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.fail(code, problem)
|
||||
collectStartArtifacts("session start failed")
|
||||
loading.showEnded(problem)
|
||||
focusEndedScreen()
|
||||
return@post
|
||||
}
|
||||
loading.percent = -1
|
||||
@@ -719,12 +838,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,
|
||||
@@ -810,9 +926,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
|
||||
}
|
||||
|
||||
@@ -824,10 +947,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. */
|
||||
@@ -884,19 +1032,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -952,6 +1116,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
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) {
|
||||
@@ -1172,6 +1344,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]
|
||||
@@ -1213,11 +1387,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)
|
||||
}
|
||||
|
||||
@@ -1239,8 +1416,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
|
||||
@@ -1272,6 +1450,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)
|
||||
@@ -1291,7 +1471,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. */
|
||||
@@ -1373,6 +1556,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
|
||||
/** Which controller the session has, so a pad report says what was plugged in and when. */
|
||||
private fun logInputDevice(what: String, deviceId: Int) {
|
||||
val d = InputDevice.getDevice(deviceId) ?: return
|
||||
if (!PadBridge.isFromController(d)) return
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "controller %s: \"%s\" (%04x:%04x, id %d, sources 0x%x)",
|
||||
what, d.name, d.vendorId, d.productId, d.id, d.sources))
|
||||
}
|
||||
|
||||
private fun updateOnScreenControls() {
|
||||
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
|
||||
.takeIf { it.isFile }
|
||||
@@ -1404,15 +1595,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()
|
||||
}
|
||||
@@ -1439,6 +1634,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
|
||||
@@ -1448,15 +1644,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()
|
||||
}
|
||||
@@ -1492,6 +1696,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
|
||||
@@ -1503,21 +1714,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,142 @@
|
||||
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 }
|
||||
AppUpdates.installBlock(r)?.let { error = it; 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;
|
||||
}
|
||||
}
|
||||
@@ -113,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)
|
||||
|
||||
@@ -144,11 +149,15 @@ 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("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
k("Microphone", SessionPrefs.micEnabled(context))
|
||||
k("On-screen controls", SessionPrefs.oscMode(context))
|
||||
@@ -161,11 +170,11 @@ object DeviceReport {
|
||||
})
|
||||
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context.contentResolver)))
|
||||
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",
|
||||
"droiddeck-osc", "droiddeck-no-pad", "droiddeck-pad-log",
|
||||
"droiddeck-osc", "droiddeck-no-pad",
|
||||
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
|
||||
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
|
||||
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
@@ -14,27 +14,87 @@ enum class PhantomProcessStatus {
|
||||
}
|
||||
|
||||
object PhantomProcessLimit {
|
||||
const val ADB_COMMAND = "adb shell settings put global settings_enable_monitor_phantom_procs false"
|
||||
const val SHELL_COMMAND = "settings put global settings_enable_monitor_phantom_procs false"
|
||||
private const val SETTING = "settings_enable_monitor_phantom_procs"
|
||||
|
||||
fun adbCommand(enabled: Boolean): String =
|
||||
"adb shell settings put global $SETTING ${if (enabled) "true" else "false"}"
|
||||
private const val MAX_PHANTOM = "2147483647"
|
||||
private const val PREFS = "phantom-process-limit"
|
||||
private const val PREF_ANDROID_12_OFF = "android12-off"
|
||||
|
||||
fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
|
||||
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
|
||||
val value = try {
|
||||
Settings.Global.getString(resolver, SETTING)
|
||||
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 when (value?.trim()?.lowercase()) {
|
||||
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
|
||||
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
|
||||
|
||||
@@ -47,20 +107,20 @@ object PhantomProcessLimit {
|
||||
}
|
||||
|
||||
fun instructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.UNSET -> "This setting is unset, so the ROM default applies and may enforce the limit. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not read the setting. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
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 -> "Turn off “Restrict child processes” in Developer options."
|
||||
PhantomProcessStatus.UNSET -> "The ROM default may enforce this limit. Explicitly turn off “Restrict child processes” in Developer options."
|
||||
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not check this setting. Turn off “Restrict child processes” in Developer options."
|
||||
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)"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,7 +6,9 @@ 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
|
||||
@@ -19,6 +21,7 @@ object WirelessAdbFix {
|
||||
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?)
|
||||
|
||||
@@ -76,9 +79,27 @@ object WirelessAdbFix {
|
||||
}
|
||||
|
||||
/** 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 expectedAddress = runCatching { InetAddress.getByName(pairedHost).address }.getOrNull() ?: return null
|
||||
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)
|
||||
@@ -101,8 +122,8 @@ object WirelessAdbFix {
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
val address = serviceInfo.host?.address
|
||||
if (address != null && address.contentEquals(expectedAddress) && finished.compareAndSet(false, true)) {
|
||||
val address = serviceInfo.host
|
||||
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
|
||||
resolvedPort.set(serviceInfo.port)
|
||||
latch.countDown()
|
||||
} else {
|
||||
@@ -148,14 +169,16 @@ object WirelessAdbFix {
|
||||
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.create(host, port).use { adb ->
|
||||
val value = if (enabled) "true" else "false"
|
||||
val change = adb.shell("settings put global settings_enable_monitor_phantom_procs $value")
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
|
||||
val result = adb.shell("settings get global settings_enable_monitor_phantom_procs")
|
||||
check(result.exitCode == 0 && result.output.trim() == value) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 = 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
|
||||
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 = 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
|
||||
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 = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 12.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,26 +1,19 @@
|
||||
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"
|
||||
private const val LIBRARY = "/mnt/droiddeck-sd"
|
||||
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
@@ -29,6 +22,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
|
||||
}
|
||||
@@ -73,7 +67,7 @@ object AddedGames {
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/")
|
||||
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -96,12 +90,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>) {
|
||||
@@ -113,12 +110,26 @@ object AddedGames {
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + 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 +137,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 +157,5 @@ object AddedGames {
|
||||
return guestPath(context, file)
|
||||
}
|
||||
|
||||
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
private fun quote(s: String): String = JSONObject.quote(s)
|
||||
}
|
||||
@@ -21,7 +21,11 @@ object Library {
|
||||
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). */
|
||||
@@ -66,14 +70,31 @@ object Library {
|
||||
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val 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")
|
||||
@@ -89,7 +110,17 @@ object Library {
|
||||
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
|
||||
out[appId] = SteamGame(appId, name, art, label, hero = hero, lastPlayed = lastPlayed)
|
||||
// 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()
|
||||
@@ -135,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
|
||||
@@ -289,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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -45,11 +45,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
|
||||
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
|
||||
|
||||
fun contains(x: Float, y: Float, r: Float): Boolean {
|
||||
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f && abs(dy) <= halfH + r * 0.25f
|
||||
val hit = r * 1.25f
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
|
||||
val hit = r * 1.25f + padding
|
||||
return dx * dx + dy * dy <= hit * hit
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
for (control in controls) {
|
||||
if (!isVisible(control)) continue
|
||||
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
|
||||
val held = control.pressedBy != -1
|
||||
val radius = control.radius
|
||||
if (control.stick >= 0) {
|
||||
@@ -426,10 +427,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
val index = event.actionIndex
|
||||
val control = controlAt(event.getX(index), event.getY(index)) ?: return false
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
|
||||
if (control.pressedBy != -1) return true
|
||||
control.pressedBy = event.getPointerId(index)
|
||||
if (control.stick >= 0) {
|
||||
control.clicked = settings.stickClick && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
val adaptive = settings.adaptiveSticks
|
||||
control.ax = if (adaptive) event.getX(index) else control.cx
|
||||
control.ay = if (adaptive) event.getY(index) else control.cy
|
||||
@@ -468,7 +472,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
for (control in controls) {
|
||||
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
if (control.pressedBy != -1) changed = true
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked) 0L else event.eventTime
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
|
||||
control.pressedBy = -1
|
||||
control.clicked = false
|
||||
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
|
||||
@@ -505,7 +509,25 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
}
|
||||
|
||||
private fun controlAt(x: Float, y: Float): Control? =
|
||||
controls.firstOrNull { isVisible(it) && it.contains(x, y, it.radius) }
|
||||
controls.firstOrNull {
|
||||
isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
}
|
||||
|
||||
private fun adaptiveStickAt(x: Float, y: Float): Control? {
|
||||
if (!settings.adaptiveSticks || editing || buttonsOnly) return null
|
||||
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
|
||||
if (controls.any { it.stick < 0 && isVisible(it) && it.contains(x, y, it.radius, 5f) }) return null
|
||||
return controls.filter {
|
||||
val dx = x - it.cx
|
||||
val dy = y - it.cy
|
||||
val reach = it.radius * 1.4f * sqrt(1.5f)
|
||||
it.stick >= 0 && isVisible(it) && it.pressedBy == -1 && dx * dx + dy * dy <= reach * reach
|
||||
}.minByOrNull {
|
||||
val dx = x - it.cx
|
||||
val dy = y - it.cy
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
|
||||
private fun isVisible(control: Control): Boolean = when {
|
||||
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
|
||||
@@ -574,6 +596,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
controls.forEach {
|
||||
it.pressedBy = -1
|
||||
it.clicked = false
|
||||
it.lastUp = 0L
|
||||
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
|
||||
it.kx = 0f; it.ky = 0f
|
||||
}
|
||||
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user