Merge branch 'main' into stretch-to-panel

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-01 12:41:57 -04:00
commit d659a03467
118 files changed
+5493 -1028

No files matched your search

+18 -1
View File
@@ -374,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 -->"
@@ -387,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. 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"
@@ -515,6 +524,8 @@ jobs:
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:
@@ -576,3 +587,9 @@ jobs:
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
+19 -9
View File
@@ -2,14 +2,16 @@ name: PR Release Cleanup
run-name: remove PR #${{ github.event.pull_request.number }}'s test build
# A merged or closed PR's test build leaves DroidDeck-CI, so the app's Updates page stops offering it
# (a merged fix is in the next Nightly). pull_request_target for the publisher app's secrets; nothing
# (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: {}
permissions:
contents: read
pull-requests: read
jobs:
cleanup:
@@ -30,10 +32,18 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
tag="pr-$PR_NUMBER"
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
echo "Deleted $tag"
else
echo "No test build for PR #$PR_NUMBER"
fi
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
+185 -18
View File
@@ -1,31 +1,52 @@
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 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:
- uses: actions/checkout@v4
@@ -35,25 +56,64 @@ jobs:
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
- name: Check the PR and build
- 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
@@ -85,8 +145,11 @@ jobs:
.head.sha == $head and .base.sha == $base
' <<<"$current" >/dev/null
{
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"
@@ -100,6 +163,7 @@ jobs:
label: ${{ needs.authorize.outputs.label }}
artifact: droiddeck-pr-signed
secrets: inherit
if: ${{ needs.authorize.outputs.skip != 'true' }}
publish:
needs: [authorize, sign]
@@ -107,7 +171,8 @@ jobs:
permissions:
actions: read
contents: read
pull-requests: read
issues: write
pull-requests: write
steps:
- uses: actions/download-artifact@v4
with:
@@ -119,7 +184,7 @@ jobs:
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr_number }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
@@ -143,10 +208,13 @@ jobs:
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Publish to DroidDeck-CI
id: publish
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ inputs.pr_number }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
BASE_SHA: ${{ needs.authorize.outputs.base_sha }}
VERSION_CODE: ${{ needs.authorize.outputs.version_code }}
run: |
set -euo pipefail
repo=Droid-Deck/DroidDeck-CI
@@ -157,15 +225,20 @@ jobs:
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
Commit: $HEAD_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
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.
@@ -175,3 +248,97 @@ jobs:
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
+42
View File
@@ -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
+8 -2
View File
@@ -7,14 +7,20 @@
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. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
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`.
@@ -25,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.
+3
View File
@@ -55,6 +55,8 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO
?: 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.
@@ -83,6 +85,7 @@ android {
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}\""
+195 -17
View File
@@ -8,6 +8,7 @@
* a game become more of these nodes instead of vanishing.
*/
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <climits>
#include <cstdint>
@@ -38,6 +39,9 @@
#include <sys/inotify.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <time.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
@@ -295,21 +299,52 @@ static void controller_fork_child() {
namespace Logger {
int log_enabled;
// FAKE_EVDEV_LOG_FILE: the session's pad.log, shared by every process the library is loaded in
// (the client, each game), so a pad report has the guest side in one file of the session folder.
// Opened with the raw syscall: open() is this library's own hook.
static int log_fd = -1;
static char log_name[17];
void init() {
log_enabled = getenv("FAKE_EVDEV_LOG") && atoi(getenv("FAKE_EVDEV_LOG"));
const char *path = getenv("FAKE_EVDEV_LOG_FILE");
if (log_enabled && path && *path)
log_fd = static_cast<int>(syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644));
if (prctl(PR_GET_NAME, log_name) != 0) strcpy(log_name, "?");
}
void log(const char *message, ...) {
if (!log_enabled)
return;
if (log_fd < 0) {
va_list args;
va_start(args, message);
vfprintf(stderr, message, args);
va_end(args);
std::cerr.flush();
return;
}
// One write per line: O_APPEND keeps lines from different processes whole.
char line[512];
struct timespec now;
clock_gettime(CLOCK_REALTIME, &now);
struct tm local;
localtime_r(&now.tv_sec, &local);
int n = snprintf(line, sizeof(line), "%02d:%02d:%02d.%03ld %d %s: ", local.tm_hour, local.tm_min,
local.tm_sec, now.tv_nsec / 1000000, getpid(), log_name);
va_list args;
va_start(args, message);
vfprintf(stderr, message, args);
if (n > 0 && n < static_cast<int>(sizeof(line)))
n += vsnprintf(line + n, sizeof(line) - n, message, args);
va_end(args);
std::cerr.flush();
if (n <= 0) return;
if (n >= static_cast<int>(sizeof(line))) {
n = sizeof(line) - 1;
line[n - 1] = '\n';
}
ssize_t ignored = write(log_fd, line, n);
(void)ignored;
}
} // namespace Logger
@@ -649,31 +684,43 @@ open_fake_input_ring(const char *event, int flags) {
// does not exist here, so the game was left with no controller at all. With
// FAKE_EVDEV_STEAM_VIRTUAL set the pad carries the virtual gamepad's identity for every process
// but the client's own, which must keep seeing the pad as the physical one it reads.
__attribute__((visibility("hidden"))) static bool presents_steam_virtual() {
// Whether this process is the Steam client itself. Asked once: a process never becomes another.
__attribute__((visibility("hidden"))) static bool is_steam_client() {
static int known;
if (known == 0) {
const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL");
char exe[PATH_MAX];
ssize_t length = enabled && atoi(enabled) ? readlink("/proc/self/exe", exe, sizeof(exe) - 1) : -1;
ssize_t length = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
bool client = false;
if (length > 0) {
exe[length] = '\0';
const char *name = strrchr(exe, '/');
client = !strncmp(name ? name + 1 : exe, "steam", 5);
}
known = length > 0 && !client ? 1 : -1;
known = client ? 1 : -1;
}
return known == 1;
}
// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine places a
// gamepad's axes by their position among the ones it advertises, so the triggers have to sit
__attribute__((visibility("hidden"))) static bool presents_steam_virtual() {
static int known;
if (known == 0) {
const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL");
known = enabled && atoi(enabled) && !is_steam_client() ? 1 : -1;
}
return known == 1;
}
// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine and SDL place
// a gamepad's axes by their position among the ones it advertises, so the triggers have to sit
// where that pad has them: as ABS_GAS and ABS_BRAKE they follow the sticks, the right stick
// lands on the left trigger, and a released right trigger reads as the right stick held up.
// A pad made through /dev/uinput is that pad for real, whoever reads it.
// lands on the left trigger, and a released right trigger reads as the right stick held up. That
// is every reader but the Steam client, which maps the pad from what it finds (controller.txt)
// and has always been shown ABS_GAS/ABS_BRAKE: a game Steam Input is off for reads this pad
// itself (the Xbox 360 setting), and so do the desktop's programs. A pad made through
// /dev/uinput is that pad for real, whoever reads it.
__attribute__((visibility("hidden"))) static bool
presents_xbox_triggers(const FakeController &fake) {
return fake.uinput || presents_steam_virtual();
return fake.uinput || !is_steam_client();
}
__attribute__((visibility("hidden"))) static uint16_t
@@ -1256,6 +1303,14 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() {
return known == 1;
}
// What the client starts with, so a pad log says whether it was offered a Deck at all.
__attribute__((constructor)) static void log_client_start() {
config();
if (process_is_steam_client())
Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str());
}
__attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) {
return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH);
}
@@ -1364,12 +1419,16 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg)
__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
if (!process_is_steam_client()) {
// Only the client reads the Deck; a game sees Steam Input's virtual pad instead.
Logger::log("deck: %s refused: not the Steam client\n", DECK_HIDRAW_PATH);
errno = ENOENT;
return -1;
}
std::string ring_path = get_ring_path_for_slot(0);
int ring_fd = ring_path.empty() ? -1 : my_open(ring_path.c_str(), O_RDONLY | O_CLOEXEC);
if (ring_fd < 0) {
Logger::log("deck: %s refused: ring %s not openable (%s)\n", DECK_HIDRAW_PATH,
ring_path.empty() ? "(none configured)" : ring_path.c_str(), strerror(errno));
errno = ENODEV;
return -1;
}
@@ -1382,12 +1441,15 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, ring_fd, 0);
syscall(SYS_close, ring_fd);
if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast<FakeInputRingHeader *>(mapping))) {
Logger::log("deck: %s refused: ring %s %s\n", DECK_HIDRAW_PATH, ring_path.c_str(),
mapping == MAP_FAILED ? "could not be mapped" : "has no valid header");
if (mapping != MAP_FAILED) munmap(mapping, mapping_size);
errno = ENODEV;
return -1;
}
int pair[2];
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) {
Logger::log("deck: %s refused: socketpair failed (%s)\n", DECK_HIDRAW_PATH, strerror(errno));
munmap(mapping, mapping_size);
return -1;
}
@@ -1426,8 +1488,18 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
// into /sys, so those paths are said to be sysfs; nothing else is touched.
static constexpr long SYSFS_FS_MAGIC = 0x62656572;
// FAKE_DECK_SYSFS_LISTING: stand-ins for the listings of /sys, /sys/class and /sys/bus
// (SteamDeckPad.listingDir), for a client that is not allowed to list the real ones.
__attribute__((visibility("hidden"))) static const char *deck_listing_dir() {
static const char *dir = getenv("FAKE_DECK_SYSFS_LISTING");
return dir && *dir ? dir : nullptr;
}
__attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) {
return path && (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17));
if (!path) return false;
if (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)) return true;
const char *listing = deck_listing_dir();
return listing && !strncmp(path, listing, strlen(listing));
}
__attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) {
@@ -1439,17 +1511,32 @@ __attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) {
return is_deck_sysfs_path(path);
}
// Once per process: proof the client's enumeration reached the Deck's sysfs at all. No line here
// and no open of /dev/hidraw16 means the client never looked; a line here and no open means it
// looked and turned the device down.
__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *how, int result) {
static std::atomic<bool> noted{false};
if (!noted.exchange(true))
Logger::log("deck: sysfs checked (%s%s)\n", how, result == 0 ? "" : ", failed");
}
EXPORT int fstatfs(int fd, struct statfs *buf) {
static auto real = reinterpret_cast<int (*)(int, struct statfs *)>(dlsym(RTLD_NEXT, "fstatfs"));
int result = real(fd, buf);
if (result == 0 && fake_deck_enabled() && is_deck_sysfs_fd(fd)) buf->f_type = SYSFS_FS_MAGIC;
if (fake_deck_enabled() && is_deck_sysfs_fd(fd)) {
note_deck_sysfs("fstatfs", result);
if (result == 0) buf->f_type = SYSFS_FS_MAGIC;
}
return result;
}
EXPORT int statfs(const char *path, struct statfs *buf) {
static auto real = reinterpret_cast<int (*)(const char *, struct statfs *)>(dlsym(RTLD_NEXT, "statfs"));
int result = real(path, buf);
if (result == 0 && fake_deck_enabled() && is_deck_sysfs_path(path)) buf->f_type = SYSFS_FS_MAGIC;
if (fake_deck_enabled() && is_deck_sysfs_path(path)) {
note_deck_sysfs(path, result);
if (result == 0) buf->f_type = SYSFS_FS_MAGIC;
}
return result;
}
@@ -1471,6 +1558,15 @@ __attribute__((visibility("hidden"))) static int copy_ioctl_string(ioctl_request
}
// Feature reports carry the report id (0) first, then the Valve message: type, length, payload.
// The client repeats some feature reports (settings, rumble) for as long as it has the pad; each
// is logged the first time and then at 2, 4, 8... so the log shows what it asked and how often
// without a line per report.
__attribute__((visibility("hidden"))) static void log_deck_feature(const char *what, uint8_t feature) {
static std::atomic<unsigned> counts[2][256];
unsigned n = ++counts[what[0] == 's'][feature];
if ((n & (n - 1)) == 0) Logger::log("deck: feature 0x%02x %s (x%u)\n", feature, what, n);
}
__attribute__((visibility("hidden"))) static int
deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
size_t size = _IOC_SIZE(op);
@@ -1484,7 +1580,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8;
send_vibration(left, right, left || right ? 1000 : 0, 0);
}
Logger::log("deck: feature 0x%02x set\n", buf[1]);
log_deck_feature("set", buf[1]);
return static_cast<int>(size);
}
uint8_t reply[DECK_REPORT_BYTES + 1] = {};
@@ -1514,7 +1610,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
reply[1] = deck.pending_feature;
break;
}
Logger::log("deck: feature 0x%02x read\n", deck.pending_feature);
log_deck_feature("read", deck.pending_feature);
size_t length = std::min(size, sizeof(reply));
memcpy(buf, reply, length);
return static_cast<int>(length);
@@ -1558,6 +1654,80 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) {
}
}
// A device scan that fails on a phone and not on the Thor: libudev gives up its whole scan on any
// sysfs directory it cannot read for a reason other than its absence, and a phone's SELinux policy
// denies directories a permissive device lets through. The client's /sys failures that are not
// ENOENT are logged, each path once, so pad.log names the directory that stopped the scan.
__attribute__((visibility("hidden"))) static std::string note_sys_failure(int dirfd, const char *pathname) {
int saved_errno = errno;
std::string path;
if (saved_errno == ENOENT || !pathname || !process_is_steam_client()) {
errno = saved_errno;
return path;
}
if (pathname[0] == '/' || dirfd == AT_FDCWD) {
path = pathname;
} else {
char link[64], dir[PATH_MAX];
snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd);
ssize_t length = readlink(link, dir, sizeof(dir) - 1);
if (length <= 0) {
errno = saved_errno;
return path;
}
path.assign(dir, length);
path += '/';
path += pathname;
}
if (path.compare(0, 5, "/sys/") != 0 && path != "/sys") {
errno = saved_errno;
return {};
}
static std::mutex mutex;
static std::vector<std::string> *seen = new std::vector<std::string>();
bool first = false;
{
std::lock_guard<std::mutex> guard(mutex);
if (seen->size() < 64 && std::find(seen->begin(), seen->end(), path) == seen->end()) {
seen->push_back(path);
first = true;
}
}
if (first) Logger::log("sysfs: %s not readable (%s)\n", path.c_str(), strerror(saved_errno));
errno = saved_errno;
return path;
}
// An enforcing SELinux policy (every retail phone; the Thor only with it forced on) refuses an app
// a listing of /sys, /sys/class and /sys/bus. libudev lists /sys/class to find the subsystems to
// descend into and gives up its whole scan when it cannot, so the client never reached hidraw and
// found no Deck. The client is listed the stand-ins instead - hidraw under class - and opens
// /sys/class/hidraw itself, which is ours and readable. Only on that refusal, only for the client.
__attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path) {
const char *listing = deck_listing_dir();
if (!path || !listing || errno != EACCES || !fake_deck_enabled() || !process_is_steam_client()) return {};
std::string p(path);
while (p.size() > 1 && p.back() == '/') p.pop_back();
int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1;
if (which < 0) return {};
static const char *const names[] = {"sys", "class", "bus"};
static std::atomic<int> logged{0};
if (!(logged.fetch_or(1 << which) & (1 << which)))
Logger::log("sysfs: %s listed from the Deck's stand-in\n", p.c_str());
return std::string(listing) + "/" + names[which];
}
EXPORT DIR *opendir(const char *name) {
static auto real = reinterpret_cast<DIR *(*)(const char *)>(dlsym(RTLD_NEXT, "opendir"));
DIR *dir = real(name);
if (!dir) {
note_sys_failure(AT_FDCWD, name);
std::string listing = deck_listing_for(name);
if (!listing.empty()) dir = real(listing.c_str());
}
return dir;
}
EXPORT int open(const char *pathname, int flags, ...) {
va_list va;
mode_t mode;
@@ -1621,6 +1791,10 @@ EXPORT int open(const char *pathname, int flags, ...) {
fd = my_open(pathname, flags, mode);
else
fd = my_open(pathname, flags);
if (fd < 0) {
std::string listing = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str());
if (!listing.empty()) fd = my_open(listing.c_str(), flags);
}
if (fake_path)
free(fake_path);
@@ -1691,6 +1865,10 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
fd = my_openat(dirfd, pathname, flags, mode);
else
fd = my_openat(dirfd, pathname, flags);
if (fd < 0) {
std::string listing = deck_listing_for(note_sys_failure(dirfd, pathname).c_str());
if (!listing.empty()) fd = my_openat(AT_FDCWD, listing.c_str(), flags);
}
if (fake_path)
free(fake_path);
@@ -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
+3 -4
View File
@@ -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"
+3 -4
View File
@@ -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
+3 -33
View File
@@ -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{};
+4 -11
View File
@@ -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);
+4 -3
View File
@@ -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,
+7 -2
View File
@@ -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 -2
View File
@@ -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
@@ -541,6 +541,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int 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) {
@@ -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
@@ -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;
}
@@ -3,15 +3,22 @@ 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, and
* the socket Flatpak's sandboxes are started through (BwrapSpawner).
* 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)
}
}
@@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
var compProton by mutableStateOf<String?>(null)
var compComp by mutableStateOf("dxvk")
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())
@@ -10,6 +10,10 @@ 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
@@ -17,11 +21,16 @@ 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 Linux and Android driver menus: the drivers each can pick from, the release downloads the
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
* rows to the mode settings dialog.
* 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())
@@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
var linuxSteam by mutableStateOf("")
var linuxDesktop by mutableStateOf("")
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 DriverRow(
else bundleRow(id, linux = true) ?: DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
@@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
linuxSelected = SessionPrefs.linuxDriver(activity)
mode = SessionPrefs.gpuDriverMode(activity)
val td = TurnipDriver(activity)
val auto = td.autoId()
androidRows = buildList {
@@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
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.
* 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) {
fun importDriver(uri: Uri, linux: Boolean?) {
val name = activity.displayNameOf(uri)
Thread({
var bundle: DriverBundle.Bundle? = null
val problem = try {
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
else TurnipDriver(activity).installFromZip(uri, name)
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
@@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
"Import failed: ${e.message}"
}
ui.post {
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
android.widget.Toast.makeText(
activity, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
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)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
}
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)
@@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
/** The download entries and the refresh line, from what the last check found. */
fun refreshReleaseRows() {
val check = TurnipReleases.cached(activity)
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.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])
@@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
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"
@@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
@@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(activity, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else TurnipDriver(activity).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(activity, asset, id)
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}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
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 it in the menu",
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
@@ -1,5 +1,6 @@
package com.droiddeck.launcher
import androidx.compose.foundation.layout.fillMaxSize
import android.Manifest
import android.app.ActivityOptions
import android.content.Intent
@@ -27,7 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
@@ -100,11 +101,13 @@ class MainActivity : ComponentActivity() {
private var percent by mutableIntStateOf(-1)
private var failed by mutableStateOf(false)
private var frameGenLabel by mutableStateOf("Off")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
@@ -132,6 +135,7 @@ class MainActivity : ComponentActivity() {
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var gamescopeRealtime by mutableStateOf(false)
private var gpuClockPin by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
@@ -157,6 +161,12 @@ class MainActivity : ComponentActivity() {
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
}
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
@@ -166,6 +176,26 @@ class MainActivity : ComponentActivity() {
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
private fun syncLossless() {
Thread({
Lossless.sync(this)
val state = Lossless.state(this)
ui.post { 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)
ui.post {
lossless = state
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "lossless-import").start()
}
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
@@ -235,6 +265,7 @@ class MainActivity : ComponentActivity() {
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
@@ -317,6 +348,7 @@ class MainActivity : ComponentActivity() {
updates.start()
setContent {
DroidDeckTheme(theme) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
@@ -335,8 +367,8 @@ class MainActivity : ComponentActivity() {
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
@@ -412,10 +444,13 @@ class MainActivity : ComponentActivity() {
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
onRuntime = { onRuntimeButton() },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this, engine, multiplier)
onFrameGenPick = { mode ->
FrameGen.set(this, mode)
frameGenLabel = FrameGen.label(this)
},
onImportLossless = {
pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title)))
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
@@ -441,6 +476,16 @@ class MainActivity : ComponentActivity() {
}
}, "share-logs").start()
},
onClearLogs = {
Thread({
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
ui.post {
android.widget.Toast.makeText(this,
if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.",
android.widget.Toast.LENGTH_SHORT).show()
}
}, "clear-logs").start()
},
onOffline = {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
@@ -566,6 +611,7 @@ class MainActivity : ComponentActivity() {
}
}
}
}
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
@@ -589,6 +635,7 @@ class MainActivity : ComponentActivity() {
if (!SessionState.running) Thread({
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
SessionArtifacts.prune(this)
}, "finish-abandoned").start()
}
@@ -625,11 +672,11 @@ class MainActivity : ComponentActivity() {
}
}
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
}
@@ -646,8 +693,11 @@ class MainActivity : ComponentActivity() {
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
syncLossless()
// Opening the app and coming back from a session both land here.
updates.onResume()
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
drivers.ensureAuto(force = false)
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
@@ -774,14 +824,16 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
focusComponentsContent = focusContent
if (tab != null) components.compComp = tab
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
}
private fun importComponent(uri: Uri) {
@@ -816,6 +868,22 @@ class MainActivity : ComponentActivity() {
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
onPair = { key -> drivers.selectPair(key) },
onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() },
// Picking one driver on its own is Manual: Auto would put its pair back.
onSelectLinux = { id -> drivers.selectDriver(id, linux = true) },
onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
),
)
}
@@ -882,10 +950,8 @@ class MainActivity : ComponentActivity() {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason,
linuxRows = drivers.linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
@@ -901,13 +967,12 @@ class MainActivity : ComponentActivity() {
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
@@ -918,15 +983,8 @@ class MainActivity : ComponentActivity() {
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRefreshReleases = { drivers.checkLatestTurnip() },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -959,6 +1017,7 @@ class MainActivity : ComponentActivity() {
steamDeckMode = on
steamChannel = SessionPrefs.steamChannel(this)
},
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
@@ -992,6 +1051,7 @@ class MainActivity : ComponentActivity() {
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
@@ -1000,6 +1060,7 @@ class MainActivity : ComponentActivity() {
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
@@ -1045,12 +1106,14 @@ class MainActivity : ComponentActivity() {
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
refreshAddedGames()
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
@@ -1097,6 +1160,7 @@ class MainActivity : ComponentActivity() {
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
gpuClockPin = SessionPrefs.gpuClockPin(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
@@ -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,6 +24,7 @@ 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
@@ -32,9 +34,10 @@ 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
@@ -95,7 +98,25 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
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). */
@@ -169,9 +190,11 @@ 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)
@@ -186,7 +209,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
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())
@@ -197,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()
@@ -215,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: 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.
@@ -241,6 +296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
(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 {
@@ -315,6 +371,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
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)
@@ -336,6 +393,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// 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()
@@ -359,8 +417,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
@@ -369,11 +427,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
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)
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,
@@ -445,6 +507,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
}
}
}
private fun refreshHomeApp() {
@@ -551,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
@@ -578,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)
@@ -775,7 +838,7 @@ 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")
// The service owns everything below the compositor. It is started whenever no session is
@@ -884,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. */
@@ -1256,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]
@@ -1466,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 }
@@ -91,6 +91,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
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
@@ -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)
@@ -169,7 +174,7 @@ object DeviceReport {
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)")
@@ -12,6 +12,8 @@ import java.util.zip.CRC32
/** 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,
@@ -65,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
}
@@ -108,7 +110,8 @@ 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
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
@@ -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,62 +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 = label(engine(context), multiplier(context))
fun engine(context: Context): String = mode(context).engine
fun label(engine: String, multiplier: Int): String = when (engine) {
ENGINE_WINFG -> "Win-FG $multiplier×"
ENGINE_LSFG -> "LSFG $multiplier×"
else -> "Off"
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
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));
@@ -29,12 +29,20 @@ object TurnipReleases {
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
/** [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 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.
@@ -45,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.
@@ -58,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)
},
)
@@ -91,14 +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("sha256", sha))
.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))
@@ -136,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.optString("sha256", "")))
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()
@@ -66,6 +66,16 @@ public final class PadBridge {
private volatile Runnable onPlayerInput;
private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean();
// What reached the pad over the last stats window, so a report of "the pad does nothing" or
// "touch and the pad don't work together" shows in the app log whether input arrived, from
// which device, and whether it got to the ring. Logged only for a window that had input.
private static final long STATS_WINDOW_MS = 10_000;
private int statButtons, statAxes, statOnScreen, statDropped;
private String statDevice;
private boolean statsScheduled;
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
private int lastDeviceId = Integer.MIN_VALUE;
public PadBridge(File fakeInputDir) {
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
}
@@ -134,6 +144,7 @@ public final class PadBridge {
/** @return true when the event was a pad button and has been consumed. */
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isFromController(event.getDevice())) return false;
noteDevice(event.getDevice());
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
@@ -161,6 +172,8 @@ public final class PadBridge {
default: return false;
}
if (pressed) notePlayerInput();
statButtons++;
scheduleStats();
publish();
return true;
}
@@ -169,6 +182,9 @@ public final class PadBridge {
public synchronized boolean onMotionEvent(MotionEvent event) {
if (!isFromController(event.getDevice())) return false;
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
noteDevice(event.getDevice());
statAxes++;
scheduleStats();
state.leftX = axis(event, MotionEvent.AXIS_X);
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
// pad reports as "down" is what the client is told.
@@ -203,6 +219,8 @@ public final class PadBridge {
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
notePlayerInput();
statOnScreen++;
scheduleStats();
publish();
}
@@ -279,8 +297,39 @@ public final class PadBridge {
publish();
}
/** Per event, so the common case - the same pad as last time - is a single compare. */
private void noteDevice(InputDevice device) {
if (device.getId() == lastDeviceId) return;
lastDeviceId = device.getId();
statDevice = device.getName();
if (seenDevices.add(device.getId())) {
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
open ? "open" : "not open yet", SessionState.getDeckPad()));
}
}
private void scheduleStats() {
if (statsScheduled) return;
statsScheduled = true;
mainHandler.postDelayed(this::logStats, STATS_WINDOW_MS);
}
private synchronized void logStats() {
statsScheduled = false;
Log.i(TAG, "[input] last 10 s: pad " + statButtons + " buttons, " + statAxes + " axis events"
+ (statDevice != null ? " (\"" + statDevice + "\")" : "")
+ ", on-screen " + statOnScreen
+ (statDropped > 0 ? ", " + statDropped + " NOT delivered (ring closed)" : "")
+ "; ring " + (open ? "open" : "closed") + ", deck pad " + SessionState.getDeckPad());
statButtons = statAxes = statOnScreen = statDropped = 0;
}
private void publish() {
if (!open && !start()) return;
if (!open && !start()) {
statDropped++;
return;
}
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
if (!guide && !qam) {
@@ -5,6 +5,7 @@ import android.os.Environment
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
@@ -16,13 +17,15 @@ object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
* says also goes to Download/DroidDeck/tools/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
try {
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
} catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
@@ -18,11 +18,15 @@ import kotlin.math.abs
* from Android's own battery API - the same on every device - into a directory the session binds
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
*
* Steam's Quick Access Menu takes its time estimates from neither: it reads the files in `/run/vpower` (as on
* SteamOS), and with those absent showed "?h ?m" for projected battery life and for time to full.
* The same estimates are written there too, into [vpowerDir] in the guest's own `/run`.
*
* Values: charge in µAh from the charge counter; energy in µWh from charge × voltage; power in µW
* from |current| × voltage; time to empty from charge ÷ |average current| while discharging. Where
* a device reports no current, the client still gets status and percentage.
*/
class BatteryComponent(val dir: File) : SessionPart() {
class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart() {
@Volatile private var running = false
private var thread: Thread? = null
@@ -42,6 +46,8 @@ class BatteryComponent(val dir: File) : SessionPart() {
running = false
thread?.interrupt()
thread = null
// Stale estimates would outlive the session in the rootfs; Steam falls back to "?h ?m".
for (name in VPOWER_FILES) File(vpowerDir, name).delete()
}
private fun write() {
@@ -115,6 +121,17 @@ class BatteryComponent(val dir: File) : SessionPart() {
for ((k, v) in attrs) uevent.append("POWER_SUPPLY_").append(k.uppercase()).append('=').append(v).append('\n')
put(File(bat, "uevent"), uevent.toString())
}
val plugged = (sticky?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0) != 0
vpowerDir.mkdirs()
val vpower = linkedMapOf(
"ac_status" to if (plugged) "Connected" else "Disconnected",
"battery_percent" to "%.2f".format(java.util.Locale.ROOT, pct.toDouble()),
"battery_status" to statusText,
// Steam reads a separate file for each direction; -1 = no estimate.
"secs_until_battery_full" to (if (timeToFullS > 0) timeToFullS else -1L).toString(),
"secs_until_shutdown_request" to (if (timeToEmptyS > 0) timeToEmptyS else -1L).toString(),
)
for ((k, v) in vpower) put(File(vpowerDir, k), v + "\n")
} catch (t: Throwable) {
Log.w(TAG, "battery: could not write", t)
}
@@ -130,5 +147,9 @@ class BatteryComponent(val dir: File) : SessionPart() {
companion object {
private const val TAG = "SessionService"
private const val PERIOD_MS = 5_000L
private val VPOWER_FILES = listOf(
"ac_status", "battery_percent", "battery_status",
"secs_until_battery_full", "secs_until_shutdown_request",
)
}
}
@@ -0,0 +1,70 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
/**
* The GPU held at its top clock for a session, without root: KGSL's power-control property turned
* off on /dev/kgsl-3d0, the node the driver already opens (adrenotools' "turbo"). Left to itself the
* governor lets the GPU sit low between bursts, and a frame that lands on a low clock is a hitch;
* Bannerlator measured the clock going from 231 to 1000 MHz with this set. It costs power and heat,
* so it is opt-in, and the kernel's thermal limits still apply on top of it.
*
* The property belongs to the device, not to this process, and a killed app cannot unwind it: it
* is cleared when a session stops, and at the next app start when a marker file says this app set
* it and never cleared it - only then, so another app's own setting is not undone.
* (Bannerlator, PerfGpuTurbo.)
*/
object GpuClockPin {
private const val TAG = "GpuClockPin"
private const val KGSL_NODE = "/dev/kgsl-3d0"
private const val MARKER = "gpu-clock-pinned"
@Volatile
private var pinned = false
private val supported: Boolean by lazy { File(KGSL_NODE).exists() }
/** At a session's start: pins the clock when the user asked for it. The line is for the log. */
@Synchronized
fun start(context: Context): String {
if (!SessionPrefs.gpuClockPin(context)) return "gpu clock: governed"
if (!supported) return "gpu clock: no $KGSL_NODE, left governed"
return try {
File(context.filesDir, MARKER).createNewFile()
WaylandCompositor.nativeSetGpuTurbo(true)
pinned = true
"gpu clock: held at its top"
} catch (t: Throwable) {
Log.w(TAG, "pin", t)
"gpu clock: pin failed"
}
}
/** At a session's end. */
@Synchronized
fun stop(context: Context) {
if (pinned) clear(context)
}
/** At the app's start: a process killed with the clock pinned left only the marker behind. */
@Synchronized
fun clearLeftover(context: Context) {
if (File(context.filesDir, MARKER).exists()) clear(context)
}
private fun clear(context: Context) {
if (supported) {
try {
WaylandCompositor.nativeSetGpuTurbo(false)
} catch (t: Throwable) {
Log.w(TAG, "clear", t)
}
}
pinned = false
File(context.filesDir, MARKER).delete()
}
}
@@ -8,7 +8,9 @@ import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.util.Locale
@@ -86,20 +88,22 @@ class PerfHud(context: Context) {
}
private fun line(base: Float): String {
val engine = FrameGen.engine(context)
if (engine == FrameGen.ENGINE_OFF) return fps(base)
val name = if (engine == FrameGen.ENGINE_LSFG) "LSFG" else "Win-FG"
val multiplier = FrameGen.multiplier(context)
val mode = FrameGen.mode(context)
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
val name = if (lsfg) "LSFG" else "Win-FG"
val label = FrameGen.label(context, mode)
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 }
return when {
problem == 1 -> "$name: unsupported"
problem == 2 -> "$name: engine failed"
problem == 1 -> context.getString(R.string.hud_fg_unsupported, name)
problem == 2 -> context.getString(R.string.hud_fg_failed, name)
stats != null && stats[3] > 1f -> {
val source = if (stats[2] > 1f) stats[2] else base
String.format(Locale.US, "%s %d× %.0f → %.0f fps", name, multiplier, source, stats[3])
String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3])
}
else -> String.format(Locale.US, "%s %d× %s · FG starting", name, multiplier, fps(base))
else -> context.getString(R.string.hud_fg_starting, label, fps(base))
}
}
@@ -17,7 +17,8 @@ import android.util.Log
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
* the game;
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
* 120 Hz for the menu, not only when a game votes for it;
* 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode
* the cap divides evenly, so every capped frame is shown for the same number of refreshes;
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
* its boost on loading screens or menus keeps it up.
*
@@ -27,7 +28,7 @@ import android.util.Log
object PerfMode {
private const val TAG = "PerfMode"
fun apply(a: Activity): String {
fun apply(a: Activity, fpsLimit: Int = 0): String {
val parts = ArrayList<String>(2)
// The fastest display mode at the panel's current size.
@@ -35,10 +36,12 @@ object PerfMode {
val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay
if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else {
val cur = display.mode
val best = display.supportedModes
val sameSize = display.supportedModes
.filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight }
.maxByOrNull { it.refreshRate }
if (best != null && best.modeId != cur.modeId && best.refreshRate > cur.refreshRate + 0.5f) {
val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate }
?: sameSize.maxByOrNull { it.refreshRate }
if (best != null && best.modeId != cur.modeId &&
(best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) {
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
} else "display ${cur.refreshRate.toInt()} Hz"
@@ -64,4 +67,11 @@ object PerfMode {
return parts.joinToString(" · ")
}
/** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
if (fpsLimit <= 0) return true
val ratio = hz / fpsLimit
return ratio >= 0.98f && Math.abs(ratio - Math.round(ratio)) < 0.02f
}
}
@@ -66,7 +66,7 @@ object SessionArtifacts {
fun scrubOlder(context: Context) {
val current = SessionPaths.current()
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
f.isDirectory && f.name.startsWith("session-") && f != current && !File(f, SCRUBBED_MARKER).exists()
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
} ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
@@ -124,8 +124,8 @@ object SessionArtifacts {
fun finishAbandoned(context: Context) {
val parent = LinuxRuntime.debugLogDir()
val abandoned = parent.listFiles { f ->
f.isDirectory && f.name.startsWith("session-") && !File(f, COMPLETE_MARKER).exists()
}?.sortedBy { it.name } ?: return
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
}?.sortedWith(SessionPaths.chronological) ?: return
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
@@ -157,5 +157,37 @@ object SessionArtifacts {
}
}
/**
* Keeps the newest [SessionPaths.KEEP_SESSIONS] session folders and deletes the rest: a few
* days of testing left hundreds, and the one a report needed was lost among them. Only folders
* that are finished; the session in progress is never touched. Runs at app start after
* [finishAbandoned], on its worker thread.
*/
@Synchronized
fun prune(context: Context) {
val current = SessionPaths.current()
val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() }
val old = finished.dropLast(SessionPaths.KEEP_SESSIONS)
old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}")
}
/**
* Every session folder but the one in progress, and the one-off command logs (tools/, or loose
* beside the folders from before it): the Setup page's Clear logs. Returns how many session
* folders went.
*/
@Synchronized
fun clearAll(context: Context): Int {
val current = SessionPaths.current()
val gone = SessionPaths.sessionFolders(context).filter { it != current }
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
// Before tools/, those logs sat loose beside the session folders.
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
Log.i(TAG, "cleared ${gone.size} session folder(s)")
return gone.size
}
private fun now(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
}
@@ -11,11 +11,12 @@ object SessionEvents {
private const val TAG = "SessionEvents"
private val lock = Any()
fun begin(context: Context, mode: String) {
/** [label] names the session's folder for what is being run (SessionPaths.label). */
fun begin(context: Context, mode: String, label: String? = null) {
// A runtime-install failure can leave the folder claimed before the service ever starts.
// Keep that folder, but give the next attempt its own session directory.
SessionPaths.take()
val dir = SessionPaths.beginOrCurrent(context)
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
synchronized(SessionState) {
SessionState.phase = SessionPhase.PREPARING
SessionState.mode = mode
@@ -17,10 +17,7 @@ import java.util.zip.ZipOutputStream
*/
object SessionLogShare {
/** The newest session folder in either place logs are written, or null if there is none. */
fun latest(context: Context): File? =
listOf(LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
.flatMap { dir -> dir.listFiles { f -> f.isDirectory && f.name.startsWith("session-") }?.toList() ?: emptyList() }
.maxByOrNull { it.name }
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
/** Builds the zip (blocking). Returns null when there is no session to share. */
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
@@ -36,21 +33,40 @@ object SessionLogShare {
// session pass yet, and the redactor changes nothing in a line that is already clean.
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f ->
z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path))
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
}
return zip
}
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
/**
* The client's own logs as they stand, for a session shared while it runs. The session script
* copies them into steam/ only as it exits, so a zip made from the drawer had none - and
* controller.txt (which pad the client opened, the touch mode it set) is what a controller or
* touch report needs most. Only for the running session: an older folder would get this
* session's logs. The same files the script copies; nothing holding credentials is in logs/.
*/
private fun liveSteamLogs(context: Context, folder: File): List<File> {
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
}
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
fun shareIntent(context: Context, zip: File): Intent {
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
val send = Intent(Intent.ACTION_SEND)
@@ -8,18 +8,24 @@ import java.util.Date
import java.util.Locale
/**
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded:
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
* named for the day, that day's session number and what was run, so the one a report is about can
* be picked out without opening any:
*
* ```
* Download/DroidDeck/session-20260921-161256/
* Download/DroidDeck/2026-09-30-03-steam/
* device.txt what this device is, and every setting the session ran with
* session.log the guest session: proot, gamescope, the client's stdout
* wayland.log the app's compositor
* steam.log the Steam client's own log, scrubbed (Steam mode)
* steam/ the rest of the client's logs, scrubbed (Steam mode)
* desktop.log labwc, the panel and the programs on it (desktop mode)
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
* ```
*
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
* pruned with the rest.
*
* The folder is claimed by whoever starts first - the activity starts the compositor before the
* service starts the session - so both write into the same one, and a recreated activity (a
* foldable opening mid-session) joins the folder in progress instead of opening another.
@@ -27,24 +33,44 @@ import java.util.Locale
object SessionPaths {
private const val TAG = "SessionPaths"
/** How many session folders are kept; older ones go at the next app start. */
const val KEEP_SESSIONS = 30
/** Beside the session folders: the logs of one-off commands (GuestCommand's logName). */
const val TOOLS_DIR = "tools"
/** `2026-09-30-03-steam`: day, that day's session number, what was run. */
private val NAMED = Regex("""^(\d{4})-(\d{2})-(\d{2})-(\d{2,})-.+$""")
/** `session-20260930-180642`, from before. */
private val LEGACY = Regex("""^session-(\d{8})-(\d{6})(-\d+)?$""")
@Volatile
private var dir: File? = null
@Volatile
private var ended: File? = null
/**
* The folder for the session now starting, or the one already in progress. With logs turned
* off on the main screen the folder lives in the app's cache instead of Downloads - the
* scripts and the compositor still need somewhere to write - and [release] deletes it.
* [label] says what is being run (see [label]); without one it comes from the session's state.
*/
@Synchronized
fun beginOrCurrent(context: Context): File {
fun beginOrCurrent(context: Context, label: String? = null): File {
dir?.let { if (it.isDirectory) return it }
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context)
else File(context.cacheDir, "session-logs")
val baseName = "session-$stamp"
var made = File(parent, baseName)
var suffix = 2
while (made.exists()) made = File(parent, "$baseName-${suffix++}")
val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
// The day's highest number plus one, not a count: pruning removes the oldest folders, and
// a count would hand out a number already in use.
val last = parent.listFiles()?.mapNotNull { f ->
NAMED.matchEntire(f.name)?.takeIf { f.name.startsWith("$day-") }?.groupValues?.get(4)?.toIntOrNull()
}?.maxOrNull() ?: 0
val what = slug(label ?: label(SessionState.mode, SessionState.program))
var number = last + 1
var made = File(parent, "$day-${"%02d".format(Locale.US, number)}-$what")
while (made.exists()) made = File(parent, "$day-${"%02d".format(Locale.US, ++number)}-$what")
if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made")
dir = made
Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)")
@@ -53,6 +79,9 @@ object SessionPaths {
fun current(): File? = dir
/** The folder of the session that ended last, while it is still there: what its Share logs sends. */
fun lastEnded(): File? = ended?.takeIf { it.isDirectory }
/**
* The ending session takes its folder with it: the next session (one that replaces this one
* in place) then claims a folder of its own instead of sharing, and this one's collector
@@ -60,9 +89,10 @@ object SessionPaths {
*/
@Synchronized
fun take(): File? {
val ended = dir
val taken = dir
dir = null
return ended
if (taken != null) ended = taken
return taken
}
fun file(name: String): File? = dir?.let { File(it, name) }
@@ -78,4 +108,39 @@ object SessionPaths {
com.droiddeck.launcher.core.FileUtils.delete(ended)
}
}
/**
* What a session's folder is named for: Steam, the desktop, or the program started from the
* rail by its own name (`2026-10-01-02-retroarch`).
*/
fun label(mode: String, program: String?): String = when (mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(program)
?: program?.substringAfterLast('/')
?: mode
SessionService.MODE_DESKTOP -> "desktop"
else -> mode
}
/** Whether [f] is a session's folder, in either naming. */
fun isSessionFolder(f: File): Boolean =
f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name))
/**
* Oldest first: by day, then by when in the day. A folder from before the new naming sorts
* ahead of a new one from the same day, which it always predates.
*/
val chronological: Comparator<File> = compareBy { f ->
NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" }
?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" }
?: f.name
}
/** Every session folder in the places logs are written, oldest first. */
fun sessionFolders(context: Context): List<File> =
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
.sortedWith(chronological)
internal fun slug(text: String): String =
text.lowercase(Locale.US).replace(Regex("[^a-z0-9]+"), "-").trim('-').take(24).trim('-').ifEmpty { "session" }
}
@@ -44,12 +44,23 @@ object SessionPrefs {
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
/**
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
* in one - a choice kept apart from every other session's, where it stays on by default.
*/
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
fun setHudEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hud", on).apply()
prefs(context).edit().putBoolean(hudKey(context), on).apply()
}
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
private fun mangoappSession(context: Context) =
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
@@ -101,15 +112,52 @@ object SessionPrefs {
}
/**
* The imported glibc Turnip a mode draws with inside the runtime, keyed by
* SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the
* driver built into the runtime. Resolved by LinuxVulkanDriver at session start.
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
* session's choice, the one nearly everyone set, carries over.
*/
fun linuxDriver(context: Context, mode: String): String =
prefs(context).getString("linuxDriver.$mode", "") ?: ""
fun linuxDriver(context: Context): String =
prefs(context).getString("linuxDriver", null)
?: prefs(context).getString("linuxDriver.steam", null)
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
fun setLinuxDriver(context: Context, mode: String, id: String) {
prefs(context).edit().putString("linuxDriver.$mode", id).apply()
fun setLinuxDriver(context: Context, id: String) {
prefs(context).edit().putString("linuxDriver", id).apply()
}
const val GPU_DRIVERS_AUTO = "auto"
const val GPU_DRIVERS_MANUAL = "manual"
/**
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
* installs; see settleGpuDriverMode.
*/
fun gpuDriverMode(context: Context): String =
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
fun setGpuDriverMode(context: Context, mode: String) {
prefs(context).edit().putString("gpuDriverMode", mode).apply()
}
/**
* Auto arrived after people had picked drivers by hand: an install that chose either driver
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
*/
fun settleGpuDriverMode(context: Context) {
val p = prefs(context)
if (p.contains("gpuDriverMode")) return
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
}
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
fun gpuAutoInstalled(context: Context): Set<String> =
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
}
/**
@@ -241,6 +289,13 @@ object SessionPrefs {
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
* but correct. Max's advice for devices whose kernels "don't work well with it".
*/
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
fun setGpuClockPin(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
}
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
fun setProotNoSeccomp(context: Context, on: Boolean) {
@@ -298,10 +353,30 @@ object SessionPrefs {
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** Runs the SteamOS gamepad client with its Quick Access performance controls. */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On for new installs (settleDeckModeDefault). */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
/**
* Deck mode became the default for new installs; an install from before keeps what it ran with
* (off), so an update never changes its interface or restarts the client on its own. Run once at
* process start, before anything reads or writes these prefs: a new install has neither prefs
* nor a runtime yet. The answer is written down, so it is decided once.
*/
fun settleDeckModeDefault(context: Context) {
val p = prefs(context)
if (p.contains("steamDeckMode")) return
val existing = p.all.isNotEmpty() || java.io.File(context.filesDir, "linuxfs").exists()
p.edit().putBoolean("steamDeckMode", !existing).apply()
}
/**
* Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws
* through it. Off is the way out where Valve's mangoapp crashes (one Turnip build did).
*/
fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true)
fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() }
/**
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
@@ -494,6 +569,22 @@ object SessionPrefs {
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
}
/**
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
* -r (what the client and its games see as the display's rate), the compositor's buffer release
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
* does. Applies next session.
*/
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
fun setFpsLimit(context: Context, mode: String, fps: Int) {
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
}
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
/**
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
@@ -514,7 +605,7 @@ object SessionPrefs {
// ── Game storage ────────────────────────────────────────────────────────────────────────
/**
* A second Steam library on this device: the folder bound at /mnt/bannerlator-sd and
* A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and
* registered with the client, which then asks where to install every game and shows both
* on its Storage page. "" = automatic: the SD card when one is in the phone (the default,
* so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal
@@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.input.FakeInputWriter
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
@@ -269,6 +270,8 @@ class SessionService : Service() {
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
Log.i(TAG, GpuClockPin.start(this))
val size = SessionState.outputSize
val guest = ArrayList<String>()
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
@@ -287,7 +290,7 @@ class SessionService : Service() {
guest.add("DXVK_HDR=1")
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
}
guest.add("BL_FPS=0")
guest.add("BL_FPS=" + SessionState.fpsLimit)
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
@@ -296,7 +299,7 @@ class SessionService : Service() {
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
@@ -314,6 +317,7 @@ class SessionService : Service() {
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
guest.add("BL_ADDED_GAMES=" + listing.path)
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1")
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
// Where the guest leaves a request for another session (the desktop's Steam launchers).
@@ -435,6 +439,7 @@ class SessionService : Service() {
}
sessionPid = pid
if (pid > 1) {
raiseTracer(pid, gen)
SessionEvents.guestStarted(pid)
} else {
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
@@ -498,15 +503,12 @@ class SessionService : Service() {
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
// build, and with two drivers every emulator compiled its shaders twice - once per way of
// starting it (RPCS3's 6650 interpreter variants on each first boot).
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
// An imported glibc Turnip, when one is set (by the user, or by Auto): the session script
// checks the manifest and its library from inside and points the loader at it with
// VK_DRIVER_FILES, so the runtime's own driver above stays untouched and is what a bad
// import falls back to. One driver for every session: the driver's shader cache is keyed on
// its build, and with one per mode every emulator compiled its shaders twice.
val linuxDriverId = SessionPrefs.linuxDriver(this)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
@@ -527,7 +529,14 @@ class SessionService : Service() {
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
// file cache is an open and every store an open and a rename, each a proot stop (a rename
// two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is
// opened once and read and written with pread/pwrite, which proot never sees. Mesa removes
// the old folder itself once it has gone a week untouched.
guest.add("MESA_DISK_CACHE_DATABASE=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
@@ -608,7 +617,7 @@ class SessionService : Service() {
}
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File, sessionDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
// Virtual pads a client made last session (event16 and up, and their hidden rings) are
// not there any more; a client that crashed never took its own down.
@@ -634,11 +643,15 @@ class SessionService : Service() {
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
logControllersAtStart()
if (uinput) {
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1")
if (SessionState.deckPad) {
guest.add("FAKE_EVDEV_DECK=1")
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
}
} else if (SessionState.mode == MODE_STEAM) {
// Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity -
// for games the client starts only. Everywhere else (the desktop, a program from the
@@ -651,9 +664,11 @@ class SessionService : Service() {
// The client reads a Deck controller through SDL's HIDAPI; a hint in the environment
// outranks the client's own. Nothing else is shown the Deck (libfakeinput).
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
// The guest side of the pads (libfakeinput: which pads were opened, the Deck's hidraw and why
// it was refused) in pad.log beside the session's other logs, so it is in every shared zip.
// Setup-time lines only, nothing per input event.
guest.add("FAKE_EVDEV_LOG=1")
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
SessionState.fakeInputDir = fakeInputDir
}
@@ -666,7 +681,11 @@ class SessionService : Service() {
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
// of our own, written from Android's battery API every few seconds, is bound over it.
val battery = BatteryComponent(File(filesDir, "session/sys/power_supply"))
// Steam's time estimates come from /run/vpower instead, written straight into the rootfs.
val battery = BatteryComponent(
File(filesDir, "session/sys/power_supply"),
File(LinuxRuntime.rootDir(this), "run/vpower"),
)
battery.attach(this)
components.add(battery)
binds.add(battery.dir.path + ":/sys/class/power_supply")
@@ -689,9 +708,12 @@ class SessionService : Service() {
if (library != null) {
val problem = GameStorage.prepare(library.path)
if (problem == null) {
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
binds.add("${library.path}:/mnt/droiddeck-sd")
// Links, prefixes and Steam entries made before the rename still name the old path.
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
binds.add("${library.path}:/mnt/bannerlator-sd")
Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")")
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")")
} else {
Log.w(TAG, "game storage: $problem; internal only this session")
}
@@ -967,6 +989,31 @@ class SessionService : Service() {
}
}
/**
* proot is one thread, and every syscall it traps anywhere in the session - a path lookup in
* Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to
* be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6,
* under the compositor's -8. After a moment, not at once: the first guest process is forked by
* proot itself and would inherit the value, and with it everything the session starts.
*/
private fun raiseTracer(pid: Int, gen: Int) {
Thread({
try {
Thread.sleep(2000)
} catch (e: InterruptedException) {
return@Thread
}
if (gen != sessionGen || sessionPid != pid) return@Thread
val nice = try {
WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE)
} catch (t: Throwable) {
Log.w(TAG, "tracer priority", t)
return@Thread
}
Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)")
}, "tracer-priority").start()
}
private fun stopSession(status: Int) {
synchronized(stopLock) {
if (!SessionState.running) return
@@ -975,6 +1022,7 @@ class SessionService : Service() {
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
GpuClockPin.stop(this)
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
suspendOperationPending = false
launchWatcher?.stopWatching()
@@ -1149,8 +1197,22 @@ class SessionService : Service() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
}
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
private fun logControllersAtStart() {
val pads = ArrayList<String>()
for (id in android.view.InputDevice.getDeviceIds()) {
val d = android.view.InputDevice.getDevice(id) ?: continue
if (!com.droiddeck.launcher.input.PadBridge.isFromController(d)) continue
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
}
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
}
companion object {
private const val TAG = "SessionService"
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
private const val TRACER_NICE = -6
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
@@ -1176,7 +1238,6 @@ class SessionService : Service() {
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
@@ -95,6 +95,10 @@ object SessionState {
@Volatile
var refreshHz: Float = 60f
/** The session's frame cap (SessionPrefs.fpsLimit), fixed when it starts; 0 = none. */
@Volatile
var fpsLimit: Int = 0
/** The compositor has presented a frame, so the loading panel is behind us for this session. */
@Volatile
var firstFrameSeen = false
@@ -23,7 +23,11 @@ import java.io.File
* - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the
* runtime already binds there for the GPU);
* - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without
* which libudev's enumeration leaves a device node out.
* which libudev's enumeration leaves a device node out;
* - [listingDir] - stand-ins for the listings of `/sys`, `/sys/class` and `/sys/bus`. An app under
* an enforcing SELinux policy (every retail phone) may not list those directories, and libudev
* abandons its whole scan when it cannot, so the client never reached `hidraw`; libfakeinput
* lists these to the client instead when the real listing is refused (FAKE_DECK_SYSFS_LISTING).
*
* Symlinks are absolute guest paths; proot resolves them inside the session.
*/
@@ -47,6 +51,9 @@ object SteamDeckPad {
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
fun prepare(context: Context, sessionRoot: File): List<String> {
val base = File(sessionRoot, "sys/deck")
@@ -89,6 +96,11 @@ object SteamDeckPad {
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
// Only the names matter: libudev reads a listing for the subsystems to descend into and
// then opens /sys/class/hidraw itself, which is the bind above.
val listing = listingDir(sessionRoot)
for (name in listOf("sys/bus", "sys/class", "sys/devices", "class/hidraw", "bus")) File(listing, name).mkdirs()
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
@@ -102,6 +102,9 @@ fun ComponentsPage(
onImport: () -> Unit,
onBack: () -> Unit,
requestInitialFocus: Boolean = true,
/** The GPU drivers tab ([GPU_TAB]): what it shows and does. */
gpu: GpuDriversState = GpuDriversState(),
gpuActions: GpuDriversActions = GpuDriversActions(),
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -112,23 +115,55 @@ fun ComponentsPage(
var about by remember { mutableStateOf(false) }
var protonMenu by remember { mutableStateOf(false) }
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
var driverPage by remember { mutableStateOf<String?>(null) }
val gpuTab = comp == GPU_TAB
when (driverPage) {
"rt" -> {
DriverPage(
title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.",
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false,
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
)
return
}
"panel" -> {
DriverPage(
title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.",
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…",
canRestore = gpu.canRestoreBundled,
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
onBack = { driverPage = null },
)
return
}
}
val views = snapshot?.protons ?: emptyList()
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
val label = ComponentsManager.LABEL[comp] ?: comp
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked"
val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked"
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
// ---- title, and the one thing that goes online -----------------------------------------
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Text(
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
maxLines = 1, modifier = Modifier.weight(1f),
maxLines = 1,
)
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
// The status gives way to the title, not the other way round.
if (!narrow) Text(
nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
) else Spacer(Modifier.weight(1f))
if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
}
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -138,7 +173,7 @@ fun ComponentsPage(
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
if (view != null) Box {
if (view != null && !gpuTab) Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
@@ -152,11 +187,12 @@ fun ComponentsPage(
}
}
}
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
val tabs = listOf(GPU_TAB) + comps
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
}
// ---- what is in use -----------------------------------------------------------------------
if (view != null) {
if (view != null && !gpuTab) {
val st = view.components.getValue(comp)
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
Row(
@@ -179,6 +215,12 @@ fun ComponentsPage(
// ---- the lists ------------------------------------------------------------------------------
when {
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
Row(modifier = Modifier.padding(top = 14.dp)) {
SmallButton("Import .zip", onClick = gpuActions.onImportZip)
}
}
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
@@ -304,7 +346,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp))
.glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) {
@@ -327,7 +369,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean =
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
.glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
@@ -369,7 +411,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE)
.glideBorder(hot, ROW_SHAPE, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.controllerConfirm(onClick = onSelect)
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
@@ -398,7 +440,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE)
.background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE)
.glideBorder(delHot, ROW_SHAPE, colors.error)
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
.controllerConfirm(onClick = onDelete),
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
@@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** The Components page's tab for the GPU drivers, before the Proton components' own. */
const val GPU_TAB = "gpu"
@@ -92,7 +92,7 @@ fun DriverPage(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp))
.background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp))
.glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line)
.focusRequester(refreshFocus)
.hoverable(refreshSrc)
.clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh),
@@ -141,6 +141,7 @@ fun DriverPage(
text = {
Text(
if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it."
else if (row.tag == DriverRow.BUNDLE) "It is one half of an Android + Linux bundle, so its runtime and display drivers are both removed. A driver in use goes back to the default."
else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "",
fontSize = 13.sp,
)
@@ -173,7 +174,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape),
.glideBorder(hot, shape, pal.signal),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -209,7 +210,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp))
.background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp))
.glideBorder(delHot, RoundedCornerShape(10.dp), colors.error)
.focusRequester(delFocus)
.focusProperties { left = mainFocus }
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete),
@@ -229,7 +230,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color,
modifier = modifier.clip(shape)
.background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (hot) color else Color.Transparent, shape)
.glideBorder(hot, shape, color)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
)
@@ -0,0 +1,253 @@
package com.droiddeck.launcher.ui
import android.view.View
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.abs
// One focus ring for a whole screen that slides from control to control. When focus moves, the
// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so
// the ring stretches like a drop and snaps into the new control's shape.
/** A control that can show the ring: where it is and what its ring looks like there. */
internal class GlideSource {
var coords: LayoutCoordinates? = null
var shape: Shape by mutableStateOf<Shape>(androidx.compose.ui.graphics.RectangleShape)
var color by mutableStateOf(Color.Transparent)
var width by mutableStateOf(2.dp)
}
internal class FocusGlide(val view: View) {
var host: LayoutCoordinates? = null
/** Controls that are hot, the latest last: focus and a hovering pointer can each hold one. */
val hot = mutableStateListOf<GlideSource>()
/** Bumped when the current control moves (a scroll, a relayout). */
var moves by mutableIntStateOf(0)
val current: GlideSource? get() = hot.lastOrNull()
fun claim(s: GlideSource) { hot.remove(s); hot.add(s) }
fun release(s: GlideSource) { hot.remove(s) }
fun moved(s: GlideSource) { if (hot.lastOrNull() === s) moves++ }
/** [s]'s bounds in the host, cut to what is on screen; null when it is not laid out or is scrolled away. */
fun boundsOf(s: GlideSource): Rect? {
val h = host ?: return null
val c = s.coords ?: return null
if (!h.isAttached || !c.isAttached) return null
val b = h.localBoundingBoxOf(c, clipBounds = true)
return if (b.width < 1f || b.height < 1f) null else b
}
}
internal val LocalFocusGlide = staticCompositionLocalOf<FocusGlide?> { null }
private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float =
when (val o = shape.createOutline(size, dir, density)) {
is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x
is Outline.Rectangle -> 0f
is Outline.Generic -> minOf(size.width, size.height) / 2f
}
/**
* A control's outline: [restColor] at [restWidth] always, and [hotColor] at [hotWidth] while [hot].
* Under a [FocusGlideHost] the hot outline is the host's one sliding ring; elsewhere (a dialog, a
* popup, a screen with no host) it is drawn here, as a plain border.
*/
@Composable
internal fun Modifier.glideBorder(
hot: Boolean,
shape: Shape,
hotColor: Color,
restColor: Color = Color.Transparent,
hotWidth: Dp = 2.dp,
restWidth: Dp = 1.dp,
): Modifier {
val glide = LocalFocusGlide.current
if (glide == null || glide.view !== LocalView.current) {
return border(if (hot) hotWidth else restWidth, if (hot) hotColor else restColor, shape)
}
val src = remember { GlideSource() }
src.shape = shape
src.color = hotColor
src.width = hotWidth
DisposableEffect(glide, hot) {
if (hot) glide.claim(src)
onDispose { glide.release(src) }
}
val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this
return base.onGloballyPositioned { src.coords = it; glide.moved(src) }
}
/** Leading edge on a focus move: quick and a little loose. */
private fun lead(): AnimationSpec<Float> = Motion.sp(0.62f, 700f)
/** Trailing edge: slower, settles without a bounce to speak of. */
private fun trail(): AnimationSpec<Float> = Motion.sp(0.78f, 360f)
/** Following a control that moves under the ring (a scroll): tight, no wobble. */
private fun follow(): AnimationSpec<Float> = Motion.sp(1f, 2400f)
/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */
private const val SETTLE_MS = 450L
/**
* Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the
* host's space, so put the host inside anything that moves as a whole (a sheet that slides in).
*/
@Composable
internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val view = LocalView.current
val density = LocalDensity.current
val dir = LocalLayoutDirection.current
val glide = remember(view) { FocusGlide(view) }
val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom
val corner = remember { Animatable(0f) }
val alpha = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val jobs = remember { arrayOfNulls<Job>(5) }
val cur = glide.current
var lastColor by remember { mutableStateOf(Color.Transparent) }
if (cur != null) lastColor = cur.color
val color by animateColorAsState(cur?.color ?: lastColor, Motion.tw(160), label = "glideColor")
var width by remember { mutableStateOf(2.dp) }
if (cur != null) width = cur.width
LaunchedEffect(glide) {
var shown: GlideSource? = null
var movedAt = 0L
var dirSpecs: List<AnimationSpec<Float>> = List(4) { follow() }
var delays = LongArray(4)
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, wait: Long) {
jobs[i]?.cancel()
jobs[i] = scope.launch {
if (wait > 0) delay(Motion.ms(wait.toInt()).toLong())
edges[i].animateTo(v, spec)
}
}
fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) {
val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size
val r = cornerOf(s.shape, size, dir, density)
jobs[4]?.cancel()
jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) }
}
snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) ->
if (src == null) {
// Focus passing between two controls can let go of one a moment before the next
// takes it: wait a beat, so that reads as a move and not a fade out and back in.
delay(60)
scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
shown = null
return@collectLatest
}
var first = src !== shown
val start = System.nanoTime()
var last: Rect? = null
while (true) {
val b = glide.boundsOf(src)
if (b == null) {
if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
} else if (first) {
val prev = shown
val wasShowing = prev != null && alpha.targetValue > 0f
shown = src
first = false
if (!wasShowing) {
// Appearing: a hair bigger, pulled in onto the control as it fades up.
val out = with(density) { 3.dp.toPx() }
val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out)
jobs.forEach { it?.cancel() }
scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join()
dirSpecs = List(4) { Motion.sp(0.6f, 700f) }
delays = LongArray(4)
cornerTo(src, b, snap = true)
scope.launch { alpha.animateTo(1f, Motion.tw(140)) }
} else {
// Moving: the edges on the side it is heading lead.
val dx = b.center.x - (edges[0].value + edges[2].value) / 2f
val dy = b.center.y - (edges[1].value + edges[3].value) / 2f
val horizontal = abs(dx) >= abs(dy)
val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1)
val trailIdx = (leadIdx + 2) % 4
dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } }
delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L }
cornerTo(src, b, snap = false)
scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
}
movedAt = System.nanoTime()
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) }
last = b
} else if (b != last) {
// It moved under the ring: keep the stretch while the move is fresh, else follow.
val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) }
if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
last = b
}
// Layer animations (a tile growing, a row sliding) move it without a relayout, so
// look again each frame for a moment after anything changes.
if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break
withFrameNanos { }
}
}
}
Box(
modifier
.onGloballyPositioned { glide.host = it }
.drawWithContent {
drawContent()
val a = alpha.value
if (a <= 0.01f) return@drawWithContent
val w = width.toPx()
val l = edges[0].value; val t = edges[1].value; val r = edges[2].value; val b = edges[3].value
val sw = (r - l - w).coerceAtLeast(0f)
val sh = (b - t - w).coerceAtLeast(0f)
val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f)
drawRoundRect(
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
)
},
) {
CompositionLocalProvider(LocalFocusGlide provides glide) { content() }
}
}
@@ -1,22 +1,48 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
/**
* The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup,
* a game's launch settings and the session drawer, so they say the same thing.
* a game's launch settings and the session drawer, so they say the same thing. Importing a
* Lossless.dll is offered once the account is known to own Lossless Scaling.
*/
@Composable
internal fun FrameGenMenu(
host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean,
onPick: (engine: String, multiplier: Int) -> Unit,
host: MenuHost, mode: FrameGen.Mode, lossless: Lossless.State,
onPick: (FrameGen.Mode) -> Unit,
onImportLossless: () -> Unit,
) {
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam"
fun pick(e: String, m: Int) { onPick(e, m); host.open = null }
MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) }
val context = LocalContext.current
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = stringResource(R.string.frame_gen_title)) { firstItemFocus ->
val need = when {
lossless.ready -> null
lossless.owned -> stringResource(R.string.lsfg_needs_install)
else -> stringResource(R.string.lsfg_needs_steam)
}
@Composable
fun item(m: FrameGen.Mode, enabled: Boolean = true, detail: String? = null, focus: FocusRequester? = null) =
MenuItem(FrameGen.label(context, m), checked = mode == m, enabled = enabled, detail = detail, focusRequester = focus) {
onPick(m); host.open = null
}
item(FrameGen.Mode.OFF, focus = firstItemFocus)
for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m))
val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } +
FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) }
lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) }
if (lossless.owned) {
val using = when (lossless.source) {
Lossless.Source.STEAM -> stringResource(R.string.lsfg_using_steam)
Lossless.Source.IMPORT -> stringResource(R.string.lsfg_using_import)
Lossless.Source.NONE -> null
}
MenuItem(stringResource(R.string.lsfg_import), checked = false, detail = using) { host.open = null; onImportLossless() }
}
}
}
@@ -118,13 +118,12 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift")
val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev")
val pal = LocalPalette.current
val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing")
Column(
modifier = track
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) }
.clip(Shape12)
.background(colors.surface)
.border(2.5.dp, ring, Shape12)
.glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp)
.alpha(if (t.dim && !hot) 0.55f else 1f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
) {
@@ -403,7 +403,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -220,7 +220,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}")
.clip(Shape12)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent)
.border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12)
.glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
.controllerConfirm(onClick = onLaunch)
.padding(horizontal = 10.dp, vertical = 6.dp),
@@ -201,7 +201,6 @@ private fun RailItem(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing")
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
Box(
contentAlignment = Alignment.Center,
@@ -211,7 +210,7 @@ private fun RailItem(
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
.background(fill)
.border(2.dp, ring, Shape14)
.glideBorder(focused, Shape14, pal.signal)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier),
@@ -248,7 +247,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
.width(if (iconOnly) 52.dp else 80.dp)
.clip(Shape14)
.background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f))
.border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14)
.glideBorder(focused, Shape14, pal.signal)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
.semantics { contentDescription = "Resume $name" }
@@ -68,6 +68,7 @@ import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
@@ -91,9 +92,8 @@ class FrontEndState(
val steamGames: List<Library.SteamGame>,
val emulators: List<Library.Emulator>,
val running: String?,
val frameGenEngine: String = FrameGen.ENGINE_OFF,
val frameGenMultiplier: Int = 2,
val lsfgReady: Boolean = false,
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
val lossless: Lossless.State = Lossless.State.NONE,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
val isHomeApp: Boolean = false,
@@ -139,7 +139,8 @@ class FrontEndActions(
val onInstallPackage: (String) -> Unit,
val onRemovePackage: (String) -> Unit,
val onRuntime: () -> Unit,
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
val onFrameGenPick: (FrameGen.Mode) -> Unit,
val onImportLossless: () -> Unit,
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: (focusContent: Boolean) -> Unit,
@@ -152,6 +153,7 @@ class FrontEndActions(
val onBrowseFiles: (File) -> Unit = {},
val onLogs: () -> Unit,
val onShareLogs: () -> Unit = {},
val onClearLogs: () -> Unit = {},
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
@@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInputModeManager
@@ -73,6 +74,7 @@ import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.R
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
@@ -151,7 +153,9 @@ internal fun SetupPanel(
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val gpuOk = remember { DeviceSupport.adreno() }
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
val gpuName = remember { DeviceSupport.gpuName() }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
@@ -193,8 +197,16 @@ internal fun SetupPanel(
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
if (gpuOk) "Device supported" else "GPU not supported",
if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen",
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "Device supported"
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "Untested GPU"
else -> "GPU not supported"
},
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "${gpu.name} · $gpuName"
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "${gpu.name}: ${gpu.supportText.replaceFirstChar { it.lowercase() }}"
else -> "Steam draws with an Adreno driver; $gpuName may show a black screen"
},
)
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
@@ -269,7 +281,7 @@ internal fun SetupPanel(
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow("Frame generation", "Select the frame generation mode") {
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
@@ -277,6 +289,7 @@ internal fun SetupPanel(
}
ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() }
ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs)
ActionRow("Saved session logs", "The newest ${com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS} are kept", "Clear logs", a.onClearLogs)
ToggleRow(
host, "offline", "Offline mode",
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
@@ -370,7 +383,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
.controllerConfirm(onClick = t.onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
@@ -383,7 +396,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
@Composable
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick)
FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless)
}
/**
@@ -399,7 +412,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } }
add { m ->
Box(m) {
SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
host.open = if (host.open == "fg") null else "fg"
}
FrameGenMenu(s, a, host)
@@ -425,14 +438,13 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge")
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = modifier.paneItem(id)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, edge, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
@@ -116,7 +116,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un
modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp)
.clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape)
.glideBorder(hot, shape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(start = 8.dp, end = 16.dp),
@@ -180,7 +180,7 @@ internal fun PrimaryButton(
.shine(hot, 0.45f)
// The grow and shine alone barely show on the light fill: outline it when a
// controller is on it, as the other controls are.
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
.glideBorder(hot, Shape12, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
onClick()
@@ -209,11 +209,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge")
val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill")
Box(
contentAlignment = Alignment.Center,
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12)
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
@@ -228,10 +227,9 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit)
val hot = rememberHot(src)
val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12)
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick),
) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) }
}
@@ -0,0 +1,312 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.vector.ImageVector
import androidx.compose.ui.semantics.Role
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
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
class PairRow(
val key: String,
val name: String,
val version: String,
val detail: String,
val recommended: Boolean,
/** Built for this GPU's family; the others only show when asked. */
val suits: Boolean,
val complete: Boolean,
val installed: Boolean,
val active: Boolean,
)
class GpuDriversState(
val gpuName: String = "",
val gpuFamily: String = "",
val soc: String = "",
val supportText: String = "",
val supported: Boolean = true,
val unsupported: Boolean = false,
val auto: Boolean = true,
val pairs: List<PairRow> = emptyList(),
/** The pair key being installed, and how far along (-1 = unknown). */
val busy: String? = null,
val percent: Int = -1,
val autoStatus: String = "",
val releaseStatus: String = "",
val checking: Boolean = false,
// Advanced: each driver on its own.
val linuxRows: List<DriverRow> = emptyList(),
val linuxSelected: String = "",
val androidRows: List<DriverRow> = emptyList(),
val androidSelected: String = "",
val linuxDownloads: List<DownloadRow> = emptyList(),
val androidDownloads: List<DownloadRow> = emptyList(),
val canRestoreBundled: Boolean = false,
/** The Android + Linux bundle both drivers are set to ("DD-Turnip 0.1.0"), or null. */
val activeBundle: String? = null,
) {
/** The pair both drivers are set to, if they make one. */
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
}
class GpuDriversActions(
val onAuto: (Boolean) -> Unit = {},
val onPair: (String) -> Unit = {},
val onRefresh: () -> Unit = {},
val onSelectLinux: (String) -> Unit = {},
val onSelectAndroid: (String) -> Unit = {},
val onRemoveLinux: (String) -> Unit = {},
val onRemoveAndroid: (String) -> Unit = {},
val onDownloadDriver: (String) -> Unit = {},
val onImportLinux: () -> Unit = {},
val onImportAndroid: () -> Unit = {},
/** The tab's own import: a bundle, or a single driver of either kind. */
val onImportZip: () -> Unit = {},
val onRestoreBundled: () -> Unit = {},
)
/**
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
* two lists each on their own, and imports, are under Advanced.
*/
@Composable
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var showAll by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
DeviceCard(s)
if (!s.unsupported) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) }
ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
}
if (s.auto) AutoCard(s)
else {
val shown = s.pairs.filter { it.suits || showAll }
if (shown.isEmpty()) Text(
if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
)
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
if (s.pairs.any { !it.suits }) FocusText(
if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal,
) { showAll = !showAll }
}
}
SettingsGroup("Advanced") {
val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." }
SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") {
DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") }
}
SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") {
DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") }
}
}
}
}
/**
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
*/
@Composable
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var open by remember { mutableStateOf(false) }
Box {
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
for (row in rows) DropdownMenuItem(
leadingIcon = { Radio(row.id == selected) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
)
HorizontalDivider(color = pal.line)
DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
}
}
}
@Composable
private fun DeviceCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when {
s.unsupported -> pal.error
s.supported -> pal.good
else -> AttentionAmber
}
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
}
Column(Modifier.weight(1f)) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
}
}
}
/** What Auto has set and what it is doing. */
@Composable
private fun AutoCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val active = s.activePair
val recommended = s.pairs.firstOrNull { it.recommended }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
) {
Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
Text(
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck",
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
)
Text(
if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair."
else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.",
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
if (s.busy != null) {
LinearProgressIndicator(
progress = { (s.percent.coerceAtLeast(0)) / 100f },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
)
}
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
}
}
@Composable
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
.clip(Shape14).background(colors.surface).background(fill)
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
Column(Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Radio(selected)
}
}
@Composable
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val usable = enabled && p.complete && !p.active
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (p.active) pal.signal.copy(alpha = 0.08f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
.clip(Shape12).background(colors.surface).background(fill)
.border(if (hot) 2.dp else 1.dp, if (hot || p.active) pal.signal else pal.line, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(enabled = usable, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Radio(p.active)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (p.recommended) Text(
"RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
when {
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
}
p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
!p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant)
p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
}
@Composable
private fun Radio(selected: Boolean) {
val pal = LocalPalette.current
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
}
}
@@ -33,6 +33,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
const val BUNDLED = "BUNDLED"
const val DOWNLOADED = "DOWNLOADED"
const val IMPORTED = "IMPORTED"
/** One half of an Android + Linux bundle: picked and deleted together with the other. */
const val BUNDLE = "ANDROID + LINUX"
}
}
@@ -47,10 +49,10 @@ class ModeSettings(
val shapeMode: String,
val hdr: Boolean,
val hdrReason: String?,
val linuxRows: List<DriverRow>,
val linuxSelected: String,
val androidRows: List<DriverRow>,
val androidSelected: String,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
val gpuDrivers: String = "Auto",
/** Frames per second the session is capped at; 0 = none. */
val fpsLimit: Int = 0,
val touchMode: String,
val suspendPolicy: String,
/** Steam only. */
@@ -71,6 +73,8 @@ class ModeSettings(
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
val mangoapp: Boolean = true,
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
val steamController: String? = null,
/** Steam only: start a Steam session when DroidDeck opens. */
@@ -80,12 +84,6 @@ class ModeSettings(
val addedGames: List<AddedGameRow> = emptyList(),
val addedGamesArt: Boolean = true,
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
val linuxDownloads: List<DownloadRow> = emptyList(),
val androidDownloads: List<DownloadRow> = emptyList(),
val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers",
val releaseChecking: Boolean = false,
/** A bundled display driver was deleted: the page offers to restore it. */
val canRestoreBundled: Boolean = false,
/** Steam only: Decky Loader is managed from the Steam session settings. */
val deckyInstalled: String? = null,
val deckyLatestRelease: DeckyManager.Release? = null,
@@ -105,16 +103,9 @@ class ModeSettingsActions(
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
val onShape: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
val onSelectLinux: (String) -> Unit,
val onImportLinux: () -> Unit,
val onRemoveLinux: (String) -> Unit,
val onRefreshReleases: () -> Unit = {},
/** Asset name of the release driver to download. */
val onDownloadDriver: (String) -> Unit = {},
val onRestoreBundled: () -> Unit = {},
val onSelectAndroid: (String) -> Unit,
val onImportAndroid: () -> Unit,
val onRemoveAndroid: (String) -> Unit,
/** Opens the GPU drivers on the Components page: they are shared by every session. */
val onGpuDrivers: () -> Unit = {},
val onFpsLimit: (Int) -> Unit = {},
val onTouch: (String) -> Unit,
val onSuspendPolicy: (String) -> Unit,
val onOsc: (String) -> Unit,
@@ -130,6 +121,7 @@ class ModeSettingsActions(
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onPickAddedGamesDir: () -> Unit = {},
@@ -149,51 +141,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
// The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display).
// Coming back restores this page as it was left: the same scroll position, and controller focus
// on the driver box that opened the page.
var driverPage by remember { mutableStateOf<String?>(null) }
var returnTo by remember { mutableStateOf<String?>(null) }
val pageScroll = androidx.compose.foundation.rememberScrollState()
val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() }
val displayChip = remember { androidx.compose.ui.focus.FocusRequester() }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
fun openDriverPage(key: String) { returnTo = key; driverPage = key }
androidx.compose.runtime.LaunchedEffect(driverPage) {
if (driverPage == null) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once; back
// from a driver page, it returns to the driver box that opened it.
androidx.compose.runtime.withFrameNanos { }
val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip }
runCatching { target.requestFocus() }
}
}
when (driverPage) {
"rt" -> {
DriverPage(
title = "Runtime driver",
hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads,
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false,
onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases,
onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null },
)
return
}
"panel" -> {
DriverPage(
title = "Display driver",
hint = "Used by the compositor in both modes. Restart the app to apply.",
rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads,
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…",
canRestore = s.canRestoreBundled,
onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases,
onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled,
onBack = { driverPage = null },
)
return
}
androidx.compose.runtime.LaunchedEffect(Unit) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
androidx.compose.runtime.withFrameNanos { }
runCatching { firstChip.requestFocus() }
}
SettingsPage(
host,
@@ -219,6 +173,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.",
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
ChoiceRow(
host, "fps", "Frame limit", "Applies next session.",
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
note = "Caps the whole session. The screen switches to a rate the cap divides evenly.",
onPick = a.onFpsLimit,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
@@ -234,17 +194,8 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
SettingsGroup("Drivers") {
SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") {
ValueChip(
s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false,
modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip),
) { openDriverPage("rt") }
}
SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") {
ValueChip(
s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false,
modifier = androidx.compose.ui.Modifier.focusRequester(displayChip),
) { openDriverPage("panel") }
SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
}
}
SettingsGroup(if (steam) "Touch & controls" else "Touch") {
@@ -361,6 +312,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
"Enables Steam's Deck interface and Quick Access performance overlay controls. Applies next session.",
s.steamDeckMode, onChange = a.onSteamDeckMode,
)
if (s.steamDeckMode) ToggleRow(
host, "mangoapp", "Performance overlay",
"The Quick Access Menu's frame-rate and usage overlay. Turn off if games crash or go black with Deck mode on. Applies next session.",
s.mangoapp, onChange = a.onMangoapp,
)
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
if (s.steamDeckMode) SettingsRow("Client branch", "Steam Deck public beta: Deck mode needs it, so Steam doesn't keep offering an update") {}
else ChoiceRow(
@@ -202,12 +202,11 @@ private fun RowScope.KeyCap(
else -> Color.White.copy(alpha = 0.07f)
}, label = "keyFill",
)
val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f)
Box(
contentAlignment = Alignment.Center,
modifier = modifier.weight(weight).fillMaxHeight()
.background(fill, RoundedCornerShape(7.dp))
.border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp))
.glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f))
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown().consume()
@@ -244,7 +243,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) {
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxHeight()
.background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp))
.border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp))
.glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f))
.clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.padding(horizontal = 12.dp),
) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) }
@@ -38,6 +38,7 @@ fun PerformancePage(
noGlError: Boolean,
noXalia: Boolean,
gamescopeRealtime: Boolean,
gpuClockPin: Boolean,
prootNoSeccomp: Boolean,
guestHostname: String,
phantomWarning: String?,
@@ -48,6 +49,7 @@ fun PerformancePage(
onNoGlError: (Boolean) -> Unit,
onNoXalia: (Boolean) -> Unit,
onGamescopeRealtime: (Boolean) -> Unit,
onGpuClockPin: (Boolean) -> Unit,
onProotNoSeccomp: (Boolean) -> Unit,
onGuestHostname: (String) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
@@ -102,6 +104,13 @@ fun PerformancePage(
gamescopeRealtime, onChange = onGamescopeRealtime,
)
}
SettingsGroup("GPU") {
ToggleRow(
host, "gpuclock", "Hold the GPU at its top clock",
"Adreno only. Fewer hitches from the clock ramping up, at the cost of battery and heat. Released when the session ends.",
gpuClockPin, onChange = onGpuClockPin,
)
}
SettingsGroup("Session fixes") {
ToggleRow(
host, "sysmem", "Turnip: sysmem rendering",
@@ -1,6 +1,28 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.draw.alpha
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
@@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@@ -93,9 +113,11 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.input.SecondScreenDisplay
@@ -137,9 +159,8 @@ class DrawerActions(
val isHomeApp: Boolean,
val androidApps: List<HomeApp.LaunchableApp>,
val hudOn: Boolean,
val frameGenEngine: String,
val frameGenMultiplier: Int,
val lsfgReady: Boolean,
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val suspendPolicy: String,
val backActionsInverted: Boolean,
@@ -153,7 +174,8 @@ class DrawerActions(
val secondScreenDisplays: List<SecondScreenDisplay>,
val selectedSecondScreenDisplay: Int,
val onHud: (Boolean) -> Unit,
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
val onFrameGenPick: (FrameGen.Mode) -> Unit,
val onImportLossless: () -> Unit,
/** The Android keyboard (text, turned into key presses). */
val onKeyboard: () -> Unit,
/** The on-screen PC keyboard: real keys, Esc, F1-F12, Ctrl, Alt... */
@@ -195,6 +217,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
@@ -240,10 +264,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)),
exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)),
) {
FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) {
Column(
modifier = Modifier
.fillMaxHeight()
.width(sheetWidth)
.fillMaxSize()
.background(pal.background.copy(alpha = 0.97f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.focusGroup()
@@ -255,7 +279,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) {
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold,
color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true }
StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true }
}
if (a.onSteamMenu != null && a.onQam != null) {
val qamInteraction = remember { MutableInteractionSource() }
@@ -323,11 +347,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier)
SettingsRow("Frame generation", null, highlighted = fgOpen) {
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
Box {
ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" }
FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick)
FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless)
}
}
}
@@ -437,6 +461,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
} }
}
StopConfirm(
open = confirmStop,
pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } },
controllerActive = controllerActive,
onCancel = { confirmStop = false },
onStop = { onScreen ->
confirmStop = false
val from = a.onStopFrom
if (from != null) from(onScreen) else a.onStop()
},
)
} }
}
}
@@ -457,44 +493,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
onDismiss = { appToChooseDisplay = null },
)
}
if (confirmStop) {
val cancelFocus = remember { FocusRequester() }
val cancel = { confirmStop = false }
// Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken
// against the screen and the session's overlay converts them back.
var stopOnScreen by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val stop = {
confirmStop = false
val from = a.onStopFrom
if (from != null) from(stopOnScreen) else a.onStop()
}
LaunchedEffect(controllerActive) {
if (controllerActive) {
androidx.compose.runtime.withFrameNanos { }
runCatching { cancelFocus.requestFocus() }
}
}
AlertDialog(
onDismissRequest = cancel,
modifier = Modifier.controllerBack(onBack = cancel),
title = { Text("Stop session?") },
confirmButton = {
val dialogView = LocalView.current
TextButton(
onClick = stop,
modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c ->
val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) }
stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat())
},
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text("Stop") }
},
dismissButton = {
TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus)
.controllerConfirm(onClick = cancel)) { Text("Cancel") }
},
)
}
}
@Composable
@@ -524,7 +522,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" }
.clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape)
.clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(start = 12.dp, end = 14.dp),
@@ -549,7 +547,7 @@ private fun QuickAction(
val shape = RoundedCornerShape(12.dp)
val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(interactionSource)
.clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onConfirm ?: onClick)
@@ -580,7 +578,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(44.dp).clip(ring)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ring)
.glideBorder(hot, ring, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = description },
@@ -639,7 +637,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp))
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = "${drawerPageTitles[index]} page" }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
@@ -788,3 +786,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
/** Room between the Stop pill and the box that steps out under it: the pull. */
private val StepPull = 6.dp
private val StepBoxCorner = 16.dp
private val StepFillet = 10.dp
private val StepBoxMaxWidth = 264.dp
/**
* The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge
* and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and
* the inside corner where the pull meets the box is rounded the other way. While the box is still
* no wider than the pill it is just the pill, stretched down.
*/
private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) {
path.reset()
val r = p.right; val tl = p.left; val tt = p.top
val pb = maxOf(bottom, p.bottom)
val l = minOf(left, tl)
val pt = minOf(pullTop, pb)
val sw = tl - l
val h = (pb - pt).coerceAtLeast(0f)
val rt = minOf(p.height / 2f, p.width / 2f)
val rbr = minOf(box, (pb - tt) / 2f)
val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f)
val rpl = minOf(box, sw / 2f, h / 2f)
val f = minOf(fillet, sw / 2f)
val yl = minOf(pt + rpl, pb - rbl)
fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) {
if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false)
}
path.moveTo(tl + rt, tt)
path.lineTo(r - rt, tt)
corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt)
path.lineTo(r, pb - rbr)
corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb)
path.lineTo(l + rbl, pb)
corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl)
path.lineTo(l, yl)
corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt)
path.lineTo(tl - f, pt)
corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f)
path.lineTo(tl, tt + rt)
corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt)
path.close()
}
/**
* Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width
* drops out of it and a wider box steps out to the left under it for the choices, right edges
* flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill,
* width first. [onStop] gets Stop's bounds on screen for the flood.
*/
@Composable
private fun StopConfirm(
open: Boolean,
pill: Rect?,
controllerActive: Boolean,
onCancel: () -> Unit,
onStop: (Rect?) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val density = LocalDensity.current
val left = remember { Animatable(0f) }
val bottom = remember { Animatable(0f) }
val tint = remember { Animatable(0f) }
val dim = remember { Animatable(0f) }
val items = remember { List(3) { Animatable(0f) } }
// Composed from the first open until it has folded back into the pill.
var shown by remember { mutableStateOf(false) }
var grown by remember { mutableStateOf(false) }
var content by remember { mutableStateOf<IntSize?>(null) }
val cancelFocus = remember { FocusRequester() }
var stopFocused by remember { mutableStateOf(false) }
var cancelFocused by remember { mutableStateOf(false) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val view = LocalView.current
if (!open && !shown) return
val pull = with(density) { StepPull.toPx() }
LaunchedEffect(open, pill, content) {
val p = pill ?: return@LaunchedEffect
val c = content ?: return@LaunchedEffect
val boxLeft = (p.right - c.width).coerceAtLeast(0f)
val boxBottom = p.bottom + pull + c.height
if (open) {
shown = true
if (!grown) {
left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f)
items.forEach { it.snapTo(0f) }
grown = true
}
coroutineScope {
launch { dim.animateTo(1f, Motion.tw(260)) }
launch { tint.animateTo(1f, Motion.tw(200)) }
launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) }
launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) }
items.forEachIndexed { i, a ->
launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) }
}
if (controllerActive) launch {
delay(Motion.ms(300).toLong())
withFrameNanos { }
runCatching { cancelFocus.requestFocus() }
}
}
} else if (grown) {
coroutineScope {
items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) }
launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) }
launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) }
launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) }
}
grown = false
shown = false
content = null
}
}
val p = pill ?: return
Box(Modifier.fillMaxSize()) {
// The rest of the sheet dims behind it, and a tap there cancels.
Box(
Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel),
)
val path = remember { Path() }
val boxCorner = with(density) { StepBoxCorner.toPx() }
val fillet = with(density) { StepFillet.toPx() }
val rim = with(density) { 1.4.dp.toPx() }
val restFill = colors.surface
val hotFill = lerp(colors.surface, colors.error, 0.22f)
Canvas(Modifier.fillMaxSize()) {
if (!grown) return@Canvas
stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet)
drawPath(path, lerp(hotFill, restFill, tint.value))
drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim))
}
// The pill's label rides on top, as the pull's handle.
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) }
.size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() })
.padding(start = 12.dp, end = 14.dp),
) {
Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp))
Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
}
val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right)
Column(
modifier = Modifier
.offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) }
.width(with(density) { maxW.toDp() })
.onSizeChanged { if (content != it) content = it }
.controllerBack(onCancel)
.onPreviewKeyEvent { e ->
// Keep a controller in here, as the dialog's own window used to.
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (e.key) {
Key.DirectionUp, Key.DirectionDown -> true
Key.DirectionLeft -> cancelFocused
Key.DirectionRight -> stopFocused
else -> false
}
}
.padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp),
) {
Text(
"Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() },
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
modifier = Modifier.fillMaxWidth().padding(top = 14.dp),
) {
ConfirmChoice(
"Cancel", danger = false, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() }
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused },
onClick = onCancel,
)
ConfirmChoice(
"Stop", danger = true, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() }
.onFocusChanged { stopFocused = it.isFocused }
.onGloballyPositioned { stopCoords = it },
) {
val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) }
onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat()))
}
}
}
}
}
/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */
@Composable
private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(20.dp)
val accent = if (danger) colors.error else pal.signal
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 40.dp).clip(shape)
.background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent)
.glideBorder(hot, shape, accent)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp),
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground)
}
}
@@ -148,7 +148,7 @@ internal fun TabStrip(
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(tabShape)
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape)
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 16.dp, vertical = 12.dp),
@@ -297,7 +297,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val pal = LocalPalette.current
val edge by animateColorAsState(if (open || hot) pal.signal else pal.line2, Motion.tw(200), label = "chipEdge")
val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
@@ -306,7 +305,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
.heightIn(min = 44.dp)
.clip(RoundedCornerShape(10.dp))
.background(colors.surfaceVariant)
.border(1.dp, edge, RoundedCornerShape(10.dp))
.glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
@@ -400,7 +399,6 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack")
val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge")
val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob")
val flip = { onChange(!checked) }
val shape = RoundedCornerShape(99.dp)
@@ -417,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
) {
Box(
Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track)
.border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape),
.glideBorder(hot, shape, if (checked) colors.onBackground else pal.signal, if (checked) pal.signal else pal.line2),
) {
Box(
Modifier.padding(4.dp).size(22.dp)
@@ -411,7 +411,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -449,7 +449,7 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.paneItem("pill:$label").clip(shape)
.background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 9.dp),
@@ -478,7 +478,7 @@ internal fun InstalledAppsGrid(a: FrontEndActions) {
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}")
.clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -536,7 +536,7 @@ internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) {
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14)
.glideBorder(hot, Shape14, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -112,10 +112,15 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
if (LocalNarrowPane.current) {
StatusPanel(s, u, ua, me)
Box(Modifier.height(18.dp))
ChannelLabel()
ChannelPicker(u, ua)
} else Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
} else {
// The label sits over both columns, so the status card lines up with the first channel.
ChannelLabel()
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
}
}
}
}
@@ -140,20 +145,21 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
val pal = LocalPalette.current
val catalog = u.catalog
val release = catalog?.let { AppUpdates.release(it, u.follow) }
val offer = if (me.ci) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null
val offer = if (me.updatable) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null
val name = channelName(u.follow)
val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH)
val installBlock = release?.let { AppUpdates.installBlock(it, me) }
class Look(val tint: Color, val status: String, val headline: String, val detail: String?)
val look = when {
!me.ci -> Look(colors.onSurfaceVariant, "Local build", "Built on a computer",
"It's signed differently from the builds here, so Android won't update it in place. Uninstall it to switch.")
!me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place",
"This copy isn't signed with DroidDeck's release key, so Android won't install the builds here over it. Uninstall it to switch.")
catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null)
offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null)
offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null)
offer == Offer.AHEAD -> Look(pal.signal, "Ahead of Stable", "You're ahead of Stable",
"This build is newer than the last Stable release. You'll move onto Stable with its next one.")
offer == Offer.GONE -> Look(AttentionAmber, "Test ended", "This test has ended",
"Its fix was merged or dropped. Follow Nightly to keep getting the newest fixes.")
"Its fix was merged or dropped. Follow Preview to keep getting the newest fixes.")
else -> Look(pal.good, "Up to date", "You have the latest $name", null)
}
Column(
@@ -169,8 +175,11 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
ReleaseNotes(changeTitle(release!!.title.ifBlank { release.tag }), release.summary, release.tag)
}
if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
if (installBlock != null && (offered || offer == Offer.AHEAD)) {
Text(installBlock, fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
}
if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp))
val installable = release?.apk != null && !s.sessionRunning
val installable = release?.apk != null && installBlock == null && !s.sessionRunning
val button = release?.apk?.size?.takeIf { it > 0 }?.let { " · ${megabytes(it)}" }.orEmpty()
Box(Modifier.padding(top = 14.dp)) {
when {
@@ -181,8 +190,9 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
}
offer == Offer.UPDATE -> PrimaryButton("Update$button", enabled = installable, main = true) { ua.onInstall(release!!) }
offer == Offer.SWITCH -> PrimaryButton("Install$button", enabled = installable, main = true) { ua.onInstall(release!!) }
offer == Offer.AHEAD -> SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) }
offer == Offer.GONE -> PrimaryButton("Follow Nightly", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) }
offer == Offer.AHEAD && installBlock == null ->
SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) }
offer == Offer.GONE -> PrimaryButton("Follow Preview", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) }
}
}
if (s.sessionRunning && (offered || offer == Offer.AHEAD)) {
@@ -213,7 +223,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) {
Text(
if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more")
.clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
.clip(Shape12).glideBorder(hot, Shape12, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle)
.padding(horizontal = 6.dp, vertical = 4.dp),
@@ -221,10 +231,10 @@ private fun ReleaseNotes(title: String, notes: String, key: String) {
}
}
/** The headline for a build on offer: "DroidDeck 0.3.0", "New Nightly build", "PR #93 test build". */
/** The headline for a build on offer: "DroidDeck 0.3.0", "New Preview build", "PR #93 test build". */
private fun newBuild(f: Follow, r: Release) = when (f.channel) {
Channel.STABLE -> "DroidDeck ${r.version ?: r.tag}"
Channel.NIGHTLY -> "New Nightly build"
Channel.NIGHTLY -> "New Preview build"
Channel.TEST -> "PR #${r.pr} test build"
}
@@ -235,20 +245,27 @@ private fun changeTitle(t: String): String =
private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB"
/** The three channels as cards to pick from; Test builds opens its list of PRs under it. */
@Composable
private fun ChannelLabel() {
Text(
"UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
)
}
@Composable
private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
val colors = MaterialTheme.colorScheme
val catalog = u.catalog
val tests = catalog?.tests.orEmpty()
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
ChannelCard(
Icons.Outlined.Verified, "Stable", "Tested releases, for most people",
catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE,
) { ua.onFollow(Follow(Channel.STABLE)) }
ChannelCard(
Icons.Outlined.Bolt, "Nightly", "Newest fixes, the odd new bug",
catalog?.nightly?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY,
Icons.Outlined.Bolt, "Preview", "Newest main-branch fixes, before Stable",
catalog?.preview?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY,
) { ua.onFollow(Follow(Channel.NIGHTLY)) }
ChannelCard(
Icons.Outlined.Science, "Test builds", "Try a fix before it's released",
@@ -273,13 +290,12 @@ private fun ChannelCard(
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
// Focus shows at once, as on the Components page: no ripple, no fade.
val edge = if (hot || selected) pal.signal else pal.line
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().paneItem("channel:$title")
.clip(Shape14).background(colors.surface).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14)
.clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line)
.alpha(if (enabled) 1f else 0.55f)
.hoverable(src)
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick)
@@ -316,7 +332,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
.heightIn(min = 52.dp)
.clip(Shape12)
.background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12)
.glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line)
.hoverable(src)
.clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(onClick = onClick)
@@ -332,7 +348,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
private fun channelName(f: Follow) = when (f.channel) {
Channel.STABLE -> "Stable"
Channel.NIGHTLY -> "Nightly"
Channel.NIGHTLY -> "Preview"
Channel.TEST -> "the PR #${f.pr} test"
}
@@ -381,7 +381,7 @@ internal fun AdbTextField(
.fillMaxWidth()
.height(inputHeight)
.background(colors.surfaceVariant, shape)
.border(1.dp, if (focused) palette.signal else palette.line2, shape)
.glideBorder(focused, shape, palette.signal, palette.line2)
.padding(horizontal = 10.dp),
contentAlignment = Alignment.CenterStart,
) {
@@ -1,73 +1,121 @@
package com.droiddeck.launcher.update
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.core.Hashes
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
import java.security.MessageDigest
/**
* DroidDeck's own builds, on three channels: Stable (the org repo's latest release), Nightly (the
* newest `main-<sha>` release on DroidDeck-CI, one per merge to main) and Test (a `pr-<n>` release
* on DroidDeck-CI, a signed build of an open PR). All three are signed with the release key, so any
* of them installs over any other and keeps the app's data.
* DroidDeck's published builds. CI turns GitHub Releases into one static catalog in DroidDeck-CI;
* the app consumes that contract instead of inferring channels from tags and release-note prose.
*
* Which build is newer is decided without a diff of the repo (GitHub's compare answers in
* megabytes): main is linear, so the newest main build is the newest; Stable is ordered by its
* version; a test build is newer when its PR's head moved. The last check is remembered, so the
* page shows it without going online.
* [Channel.NIGHTLY] is intentionally kept as the internal enum/pref value for upgrades from older
* DroidDeck versions. The channel is presented to users as Preview.
*/
object AppUpdates {
private const val REPO = "Droid-Deck/DroidDeck"
private const val CI_REPO = "Droid-Deck/DroidDeck-CI"
private const val CATALOG_URL = "https://raw.githubusercontent.com/$CI_REPO/main/catalog.json"
private const val PREFS = "app_updates"
private const val KEY_CATALOG = "catalog"
private const val KEY_CHANNEL = "channel"
private const val CATALOG_SCHEMA = 1
enum class Channel { STABLE, NIGHTLY, TEST }
/** The channel the user follows; [pr] is the followed test build's PR, for [Channel.TEST]. */
data class Follow(val channel: Channel, val pr: Int = 0)
class Apk(val name: String, val url: String, val size: Long, val sha256: String)
/**
* One published build. [commit] is a full or short SHA of the source it was built from; [pr] is
* 0 for a main or stable build. [apk] is the file for the package this app runs as, or null when
* the build has none (a variant a release predates).
*/
class Release(
val tag: String, val title: String, val summary: String, val commit: String, val pr: Int,
val version: String?, val publishedAt: Long, val url: String, val apk: Apk?,
class Apk(
val name: String,
val url: String,
val size: Long,
val sha256: String,
val packageName: String,
val versionCode: Int,
val signerSha256: String,
)
class Catalog(val stable: Release?, val nightly: Release?, val tests: List<Release>, val checkedAt: Long)
class Release(
val tag: String,
val title: String,
val summary: String,
val commit: String,
val pr: Int,
val version: String?,
val versionCode: Int,
val publishedAt: Long,
val url: String,
val apk: Apk?,
)
/** What is running: the commit it was built from, its PR (0 for none), whether CI made it, and when it was committed. */
class Installed(val commit: String, val pr: Int, val version: String, val ci: Boolean, val committedAt: Long = 0L)
class Catalog(
val stable: Release?,
val preview: Release?,
val tests: List<Release>,
val checkedAt: Long,
)
class Installed(
val commit: String,
val pr: Int,
val version: String,
val versionCode: Int,
val updatable: Boolean,
val committedAt: Long = 0L,
)
enum class Offer {
/** This is the build the channel has. */
CURRENT,
/** The channel has a newer build of what is running. */
UPDATE,
/** The channel's build is a different line (a test build from Nightly, or back); installing moves over. */
SWITCH,
/** What is running is newer than the channel's build (a Nightly past the last Stable). */
AHEAD,
/** The followed test build is gone: its PR was merged or closed. */
GONE,
}
fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, BuildConfig.CI_BUILD, BuildConfig.COMMIT_TIME)
@Volatile private var releaseSigned = BuildConfig.CI_BUILD
fun init(context: Context) {
releaseSigned = runCatching {
signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }
}.getOrDefault(BuildConfig.CI_BUILD)
}
private fun signers(context: Context): List<String> {
val pm = context.packageManager
@Suppress("DEPRECATION")
val certs = if (Build.VERSION.SDK_INT >= 28) {
val info = pm.getPackageInfo(
context.packageName,
PackageManager.GET_SIGNING_CERTIFICATES,
).signingInfo ?: return emptyList()
if (info.hasMultipleSigners()) info.apkContentsSigners else info.signingCertificateHistory
} else {
pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures
} ?: return emptyList()
return certs.map { cert ->
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
.joinToString("") { "%02x".format(it) }
}
}
fun installed(): Installed = Installed(
BuildConfig.SOURCE_COMMIT,
BuildConfig.PR_NUMBER,
BuildConfig.VERSION_NAME,
BuildConfig.VERSION_CODE,
releaseSigned,
BuildConfig.COMMIT_TIME,
)
fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) {
Channel.STABLE -> catalog.stable
Channel.NIGHTLY -> catalog.nightly
Channel.NIGHTLY -> catalog.preview
Channel.TEST -> catalog.tests.firstOrNull { it.pr == follow.pr }
}
@@ -85,19 +133,27 @@ object AppUpdates {
}
}
/** The dot on the rail: the followed channel has a newer build of what is running. */
fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean =
catalog != null && installed().ci && offer(catalog, follow) == Offer.UPDATE
fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean {
if (catalog == null) return false
val me = installed()
val r = release(catalog, follow) ?: return false
return me.updatable && offer(catalog, follow, me) == Offer.UPDATE && installBlock(r, me) == null
}
fun isRunning(r: Release, me: Installed = installed()): Boolean {
if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false
return me.commit.startsWith(r.commit, ignoreCase = true) || r.commit.startsWith(me.commit, ignoreCase = true)
return me.commit.startsWith(r.commit, ignoreCase = true) ||
r.commit.startsWith(me.commit, ignoreCase = true)
}
/** Why Android cannot install this published build over [me], before downloading anything. */
fun installBlock(r: Release, me: Installed = installed()): String? = when {
r.apk == null -> "This build has no download for this copy of DroidDeck."
r.apk.versionCode < me.versionCode ->
"Android won't install it over this build because its versionCode ${r.apk.versionCode} is below the installed ${me.versionCode}."
else -> null
}
/**
* The channel followed. Until the user picks one it is worked out once from what is running and
* kept: a test build follows its PR, the Stable release follows Stable, and a main build Nightly.
*/
fun follow(context: Context, catalog: Catalog?): Follow {
stored(context)?.let { return it }
val me = installed()
@@ -105,7 +161,7 @@ object AppUpdates {
me.pr != 0 -> Follow(Channel.TEST, me.pr)
catalog == null -> return Follow(Channel.STABLE)
catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE)
me.ci -> Follow(Channel.NIGHTLY)
me.updatable -> Follow(Channel.NIGHTLY)
else -> Follow(Channel.STABLE)
}
setFollow(context, inferred)
@@ -113,7 +169,9 @@ object AppUpdates {
}
fun setFollow(context: Context, follow: Follow) {
val value = if (follow.channel == Channel.TEST) "test:${follow.pr}" else follow.channel.name.lowercase()
// Keep "nightly" on disk so an older DroidDeck still understands the preference after rollback.
val value = if (follow.channel == Channel.TEST) "test:${follow.pr}"
else follow.channel.name.lowercase()
prefs(context).edit().putString(KEY_CHANNEL, value).apply()
}
@@ -121,129 +179,115 @@ object AppUpdates {
val v = prefs(context).getString(KEY_CHANNEL, null) ?: return null
return when {
v == "stable" -> Follow(Channel.STABLE)
v == "nightly" -> Follow(Channel.NIGHTLY)
v.startsWith("test:") -> v.removePrefix("test:").toIntOrNull()?.let { Follow(Channel.TEST, it) }
v == "nightly" || v == "preview" -> Follow(Channel.NIGHTLY)
v.startsWith("test:") -> v.removePrefix("test:").toIntOrNull()?.let {
Follow(Channel.TEST, it)
}
else -> null
}
}
/** The last check, or null before the first. */
fun cached(context: Context): Catalog? {
val raw = prefs(context).getString(KEY_CATALOG, null) ?: return null
return runCatching { readCatalog(JSONObject(raw)) }.getOrNull()
return runCatching { readCachedCatalog(JSONObject(raw)) }.getOrNull()
}
/** Read every channel now and remember the result: three small requests, four for an annotated tag. */
/** One static request, rather than several unauthenticated GitHub API requests. */
fun refresh(context: Context): Catalog {
val suffix = variantSuffix(context.packageName)
val stable = runCatching { stable(suffix) }.getOrElse { if (it is NoRelease) null else throw it }
val ci = JSONArray(get("https://api.github.com/repos/$CI_REPO/releases?per_page=50"))
var nightly: Release? = null
val tests = ArrayList<Release>()
for (i in 0 until ci.length()) {
val r = ci.getJSONObject(i)
if (r.optBoolean("draft")) continue
val tag = r.getString("tag_name")
when {
tag.startsWith("main-") -> {
val candidate = ciRelease(r, suffix, tag.removePrefix("main-"), 0)
if (nightly == null || candidate.publishedAt > nightly.publishedAt) nightly = candidate
}
tag.startsWith("pr-") -> {
val pr = tag.removePrefix("pr-").toIntOrNull() ?: continue
val body = r.optString("body")
// Only builds of this repo's PRs: a test build from before the org move names another.
if (!body.contains("github.com/$REPO/pull/$pr")) continue
val commit = Regex("""Commit: `?([0-9a-f]{7,40})""").find(body)?.groupValues?.get(1) ?: continue
tests.add(ciRelease(r, suffix, commit, pr))
}
}
}
tests.sortByDescending { it.publishedAt }
val catalog = Catalog(stable, nightly, tests, System.currentTimeMillis())
val checkedAt = System.currentTimeMillis()
val catalog = readPublishedCatalog(
JSONObject(get("$CATALOG_URL?checked=$checkedAt")),
context.packageName,
checkedAt,
)
prefs(context).edit().putString(KEY_CATALOG, writeCatalog(catalog).toString()).apply()
return catalog
}
private class NoRelease : IOException("no stable release")
private fun stable(suffix: String): Release {
val r = try {
JSONObject(get("https://api.github.com/repos/$REPO/releases/latest"))
} catch (e: IOException) {
if (e.message?.contains("HTTP 404") == true) throw NoRelease() else throw e
internal fun readPublishedCatalog(
root: JSONObject,
packageName: String,
checkedAt: Long,
): Catalog {
val schema = root.optInt("schema")
val sourceRepo = root.optString("sourceRepo")
val ciRepo = root.optString("ciRepo")
if (schema != CATALOG_SCHEMA || sourceRepo != REPO || ciRepo != CI_REPO) {
throw IOException(
"The update catalog has an unsupported format (schema $schema, source $sourceRepo, CI $ciRepo)",
)
}
val tag = r.getString("tag_name")
// The tag's commit; an annotated tag points at a tag object first.
var obj = JSONObject(get("https://api.github.com/repos/$REPO/git/ref/tags/$tag")).getJSONObject("object")
if (obj.getString("type") == "tag") obj = JSONObject(get(obj.getString("url"))).getJSONObject("object")
val (title, summary) = describeStable(r.optString("name").ifBlank { tag }, r.optString("body"))
return Release(tag, title, summary, obj.getString("sha"), 0, tag.removePrefix("v"), published(r),
r.optString("html_url"), apk(r, suffix))
val tests = root.optJSONArray("tests") ?: JSONArray()
return Catalog(
root.optJSONObject("stable")?.let { readPublishedRelease(it, packageName) },
root.optJSONObject("preview")?.let { readPublishedRelease(it, packageName) },
(0 until tests.length())
.map { readPublishedRelease(tests.getJSONObject(it), packageName) }
.filter { it.pr > 0 }
.sortedByDescending { it.publishedAt },
checkedAt,
)
}
private fun ciRelease(r: JSONObject, suffix: String, commit: String, pr: Int): Release {
val (title, summary) = describeCi(r.optString("name"), r.optString("body"))
return Release(r.getString("tag_name"), title, summary, commit, pr, null, published(r), r.optString("html_url"), apk(r, suffix))
}
private fun published(r: JSONObject): Long =
runCatching { Instant.parse(r.optString("published_at")).toEpochMilli() }.getOrDefault(0L)
/** The apk for this package: `<name><suffix>.apk`, and for the standard app the one with no other variant's suffix. */
private fun apk(r: JSONObject, suffix: String): Apk? {
val others = variantSuffixes().values.filter { it.isNotEmpty() && it != suffix }
val assets = r.optJSONArray("assets") ?: return null
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.getString("name")
if (!name.endsWith("$suffix.apk") || others.any { name.endsWith("$it.apk") }) continue
// Only a file GitHub has a sha256 for: the download is checked against it.
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
return Apk(name, a.getString("browser_download_url"), a.optLong("size"), sha)
private fun readPublishedRelease(o: JSONObject, packageName: String): Release {
val commit = o.getString("commit")
if (!Regex("^[0-9a-fA-F]{7,40}$").matches(commit)) {
throw IOException("The update catalog contains an invalid commit")
}
return null
val versionCode = o.getInt("versionCode")
if (versionCode <= 0) throw IOException("The update catalog contains an invalid versionCode")
val apk = o.optJSONObject("apks")?.optJSONObject(packageName)?.let {
readPublishedApk(it, packageName)
}
if (apk != null && apk.versionCode != versionCode) {
throw IOException("The update catalog disagrees about the APK versionCode")
}
return Release(
o.getString("tag"),
o.optString("title"),
o.optString("summary"),
commit,
o.optInt("pr"),
if (o.isNull("version")) null else o.optString("version").ifEmpty { null },
versionCode,
o.optLong("publishedAt"),
o.optString("url"),
apk,
)
}
private fun variantSuffixes(): Map<String, String> = BuildConfig.VARIANT_SUFFIXES.split(';')
.mapNotNull { e -> e.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }.toMap()
private fun variantSuffix(packageName: String): String = variantSuffixes()[packageName] ?: ""
/**
* A CI release's notes open with what it is: "**Merged to main** - [PR #n](…): title (merged …)"
* or "**Not merged** - a test build of open [PR #n](…): title", then a paragraph from the PR.
*/
internal fun describeCi(name: String, body: String): Pair<String, String> {
val paragraphs = paragraphs(body)
val head = paragraphs.firstOrNull().orEmpty()
val title = head.substringAfter("): ", "").substringBeforeLast(" (merged").ifBlank { plain(head) }.ifBlank { name }
val summary = paragraphs.getOrNull(1)?.takeUnless { it.startsWith("Signed build") || it.startsWith("Source:") }.orEmpty()
return plain(title) to lines(summary)
private fun readPublishedApk(o: JSONObject, packageName: String): Apk {
val actualPackage = o.getString("packageName")
val versionCode = o.getInt("versionCode")
val signer = o.getString("signerSha256").lowercase()
val sha = o.getString("sha256").lowercase()
val url = o.getString("url")
if (actualPackage != packageName) throw IOException("The update catalog names the wrong Android package")
if (versionCode <= 0) throw IOException("The update catalog has an invalid APK versionCode")
if (!Regex("^[0-9a-f]{64}$").matches(sha)) {
throw IOException("The update catalog has an invalid checksum")
}
if (!signer.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true)) {
throw IOException("The update catalog is not signed for DroidDeck's release key")
}
val allowed = listOf(
"https://github.com/$REPO/releases/download/",
"https://github.com/$CI_REPO/releases/download/",
)
if (allowed.none { url.startsWith(it) }) {
throw IOException("The update catalog has an unexpected download URL")
}
return Apk(
o.getString("name"),
url,
o.optLong("size"),
sha,
actualPackage,
versionCode,
signer,
)
}
/** A stable release: its name, and the first paragraph of its notes that is prose rather than a heading or picture. */
private fun describeStable(name: String, body: String): Pair<String, String> =
name to lines(paragraphs(body).firstOrNull { !it.startsWith("#") && !it.startsWith("![") && !it.startsWith("<") }.orEmpty())
private fun paragraphs(body: String): List<String> =
body.replace("\r", "").split(Regex("\n\\s*\n")).map { it.trim() }.filter { it.isNotEmpty() }
/** A paragraph's lines as text, a list item's marker as a bullet: the page shows them collapsed or in full. */
private fun lines(md: String): String = md.lines().map { line ->
val item = Regex("""^\s*[-*]\s+""").find(line)
if (item != null) "• " + plain(line.substring(item.range.last + 1)) else plain(line)
}.filter { it.isNotEmpty() }.joinToString("\n")
/** Markdown down to text: links to their words, emphasis and code marks gone, on one line. */
private fun plain(md: String): String = md
.replace(Regex("""!\[[^]]*]\([^)]*\)"""), "")
.replace(Regex("""\[([^]]*)]\([^)]*\)"""), "$1")
.replace(Regex("""[*_`]{1,3}"""), "")
.replace(Regex("""\s+"""), " ")
.trim()
/** Dotted versions, number by number: "0.10.0" is past "0.9.2"; a missing part counts as 0. */
fun compareVersions(a: String, b: String): Int {
val x = a.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 }
val y = b.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 }
@@ -258,40 +302,79 @@ object AppUpdates {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
c.readTimeout = 20_000
c.setRequestProperty("Accept", "application/vnd.github+json")
c.setRequestProperty("Accept", "application/json")
c.setRequestProperty("User-Agent", "DroidDeck-app")
c.setRequestProperty("Cache-Control", "no-cache")
try {
val code = c.responseCode
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
if (code != 200) throw IOException("GitHub answered HTTP $code")
if (code != 200) throw IOException("The update catalog answered HTTP $code")
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
}
}
private fun writeRelease(r: Release): JSONObject = JSONObject()
.put("tag", r.tag).put("title", r.title).put("summary", r.summary).put("commit", r.commit).put("pr", r.pr)
.put("version", r.version).put("publishedAt", r.publishedAt).put("url", r.url)
.put("apk", r.apk?.let { JSONObject().put("name", it.name).put("url", it.url).put("size", it.size).put("sha256", it.sha256) })
private fun writeApk(a: Apk): JSONObject = JSONObject()
.put("name", a.name)
.put("url", a.url)
.put("size", a.size)
.put("sha256", a.sha256)
.put("packageName", a.packageName)
.put("versionCode", a.versionCode)
.put("signerSha256", a.signerSha256)
private fun readRelease(o: JSONObject): Release = Release(
o.getString("tag"), o.optString("title"), o.optString("summary"), o.getString("commit"), o.optInt("pr"),
o.optString("version").ifEmpty { null }, o.optLong("publishedAt"), o.optString("url"),
o.optJSONObject("apk")?.let { Apk(it.getString("name"), it.getString("url"), it.optLong("size"), it.getString("sha256")) },
private fun writeRelease(r: Release): JSONObject = JSONObject()
.put("tag", r.tag)
.put("title", r.title)
.put("summary", r.summary)
.put("commit", r.commit)
.put("pr", r.pr)
.put("version", r.version)
.put("versionCode", r.versionCode)
.put("publishedAt", r.publishedAt)
.put("url", r.url)
.put("apk", r.apk?.let(::writeApk))
private fun readCachedRelease(o: JSONObject): Release = Release(
o.getString("tag"),
o.optString("title"),
o.optString("summary"),
o.getString("commit"),
o.optInt("pr"),
if (o.isNull("version")) null else o.optString("version").ifEmpty { null },
o.getInt("versionCode"),
o.optLong("publishedAt"),
o.optString("url"),
o.optJSONObject("apk")?.let {
Apk(
it.getString("name"),
it.getString("url"),
it.optLong("size"),
it.getString("sha256"),
it.getString("packageName"),
it.getInt("versionCode"),
it.getString("signerSha256"),
)
},
)
private fun writeCatalog(c: Catalog): JSONObject = JSONObject()
.put("stable", c.stable?.let(::writeRelease)).put("nightly", c.nightly?.let(::writeRelease))
.put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } }).put("checkedAt", c.checkedAt)
.put("stable", c.stable?.let(::writeRelease))
.put("preview", c.preview?.let(::writeRelease))
.put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } })
.put("checkedAt", c.checkedAt)
private fun readCatalog(o: JSONObject): Catalog {
private fun readCachedCatalog(o: JSONObject): Catalog {
val tests = o.optJSONArray("tests") ?: JSONArray()
val preview = o.optJSONObject("preview") ?: o.optJSONObject("nightly")
return Catalog(
o.optJSONObject("stable")?.let(::readRelease), o.optJSONObject("nightly")?.let(::readRelease),
(0 until tests.length()).map { readRelease(tests.getJSONObject(it)) }, o.optLong("checkedAt"),
o.optJSONObject("stable")?.let(::readCachedRelease),
preview?.let(::readCachedRelease),
(0 until tests.length()).map { readCachedRelease(tests.getJSONObject(it)) },
o.optLong("checkedAt"),
)
}
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -4,9 +4,11 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.Hashes
import java.io.File
@@ -14,6 +16,7 @@ import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/**
* Installs one of DroidDeck's own builds over itself: download into the cache, check the file
@@ -60,9 +63,13 @@ object SelfInstaller {
}
}
}
if (apk.size > 0 && target.length() != apk.size) {
throw IOException("The download size didn't match the published APK and was discarded - try again")
}
if (!apk.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
throw IOException("The download didn't match its checksum and was discarded - try again")
}
validateDownloaded(context, target, apk)
return target
} catch (e: Exception) {
FileUtils.delete(target)
@@ -72,6 +79,48 @@ object SelfInstaller {
}
}
/** Inspect the actual downloaded APK before giving it to Android's installer. */
private fun validateDownloaded(context: Context, file: File, apk: AppUpdates.Apk) {
if (apk.packageName != context.packageName) {
throw IOException("The update is for Android package ${apk.packageName}, not ${context.packageName}")
}
if (!apk.signerSha256.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true)) {
throw IOException("The update metadata doesn't name DroidDeck's release signing key")
}
@Suppress("DEPRECATION")
val flags = if (Build.VERSION.SDK_INT >= 28) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
PackageManager.GET_SIGNATURES
}
val info = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
?: throw IOException("Android couldn't read the downloaded APK")
if (info.packageName != context.packageName) {
throw IOException("The downloaded APK is for ${info.packageName}, not ${context.packageName}")
}
@Suppress("DEPRECATION")
val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
if (code != apk.versionCode.toLong()) {
throw IOException("The downloaded APK says versionCode $code, not the published ${apk.versionCode}")
}
if (code < BuildConfig.VERSION_CODE.toLong()) {
throw IOException("Android won't install versionCode $code over the installed ${BuildConfig.VERSION_CODE}")
}
// Before Android 9 this lineage intentionally presents the public legacy test key. Android
// 9+ understands the v3 hand-over and must see DroidDeck's private release key as current.
if (Build.VERSION.SDK_INT >= 28) {
val signing = info.signingInfo ?: throw IOException("The downloaded APK has no signing information")
val digests = signing.apkContentsSigners.orEmpty().map { cert ->
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
.joinToString("") { "%02x".format(it) }
}
if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) {
throw IOException("The downloaded APK isn't signed with DroidDeck's release key")
}
}
}
/** Writes [apk] into an install session and commits it; the answer comes as an [ACTION_STATUS] broadcast. */
fun install(context: Context, apk: File) {
val installer = context.packageManager.packageInstaller
@@ -32,7 +32,9 @@ object CompositorHost {
outputWidth: Int,
outputHeight: Int,
refreshHz: Float,
fpsLimit: Int,
): Boolean {
pace(refreshHz, fpsLimit)
if (started) {
attached = surface
WaylandCompositor.nativeSetSurface(surface)
@@ -41,10 +43,6 @@ object CompositorHost {
}
WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
// The cadence the zero-copy layer votes for. Left at 0 the system infers it from the rate
// already being achieved, and on a phone whose vendor picks clocks from that a session
// that has been slowed reads as one that wants to be. (WinNative, WaylandSession.)
WaylandCompositor.nativeSetLayerFrameRate(refreshHz)
WaylandCompositor.nativeStartWithSurface(
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
)
@@ -54,6 +52,19 @@ object CompositorHost {
return true
}
/**
* The session's frame cap, on every attach: the compositor outlives sessions, and the next one
* may have a different cap. The release pacer hands a game one buffer back per capped frame
* (per refresh without a cap), and the zero-copy layer votes for the same cadence. Left at 0
* the vote is inferred from the rate already being achieved, and on a phone whose vendor picks
* clocks from that a session that has been slowed reads as one that wants to be. (WinNative,
* WaylandSession.)
*/
private fun pace(refreshHz: Float, fpsLimit: Int) {
WaylandCompositor.nativeSetFpsLimit(fpsLimit)
WaylandCompositor.nativeSetLayerFrameRate(if (fpsLimit > 0) fpsLimit.toFloat() else refreshHz)
}
/**
* The Surface changed size under the compositor (a foldable opening or closing). Android does
* not always recreate the Surface for that, and a swapchain built for the old size keeps
@@ -69,7 +80,7 @@ object CompositorHost {
// came back stretched with an engine on and right with it off. Disarmed, the window is
// rebound and the swapchain rebuilt; the caller re-arms once that has settled, and every
// engine-side object is made again for the new size.
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
WaylandCompositor.nativeLog("screen", "surface resized: rebinding the window, frame generation re-armed after")
WaylandCompositor.nativeSetSurface(null)
attached = surface
@@ -85,7 +96,7 @@ object CompositorHost {
*/
fun rearmFrameGen(rearm: () -> Unit) {
if (!started) return
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
WaylandCompositor.nativeLog("screen", "presenting window changed: frame generation re-armed")
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(rearm, 600)
}
@@ -207,6 +207,9 @@ public final class WaylandCompositor {
*/
public static native int nativeCursorSnapshot(int[] out);
/** The serial {@link #nativeCursorSnapshot} would report, without copying the image. */
public static native int nativeCursorSerial();
/** Enough for the header plus a 256x256 cursor, the largest the compositor snapshots. */
public static final int CURSOR_BUF_INTS = 6 + 256 * 256;
@@ -434,6 +437,14 @@ public final class WaylandCompositor {
* back to the game, like the X11 IdleNotify pacer, so the game itself slows to the cap. */
public static native void nativeSetFpsLimit(int fps);
/** KGSL power control off (true: the GPU held at its top clock) or back on. Device-wide and
* outlives the process; see {@code GpuClockPin}. A no-op without /dev/kgsl-3d0. */
public static native void nativeSetGpuTurbo(boolean on);
/** setpriority() on one of our own processes or threads, only ever raising it (no cgroup move,
* unlike android.os.Process.setThreadPriority). Returns the nice it is left at; 100 = unknown. */
public static native int nativeRaisePriority(int tid, int nice);
/** Inject the app's X-server input in scene (virtual desktop) pixels. type 2 = move to a,b;
* 3 = evdev button a (BTN_LEFT=0x110…) pressed (b=1) or released (b=0); 4 = a wheel steps,
* negative = up. */
@@ -450,10 +461,12 @@ public final class WaylandCompositor {
public static native void nativeSetFrameGenEngine(int kind);
/** Arm (multiplier 2..4: one real frame plus multiplier-1 interpolated ones per game frame) or
* disarm. Generated frames are presented ahead of the real frame on consecutive vblanks. */
public static native void nativeSetFrameGenArmed(boolean armed, int multiplier);
* disarm. A targetFps above 0 makes LSFG adaptive instead: it generates 0..3 frames per game
* frame, whatever reaches that rate. Generated frames are presented ahead of the real frame
* on consecutive vblanks. */
public static native void nativeSetFrameGenArmed(boolean armed, int multiplier, int targetFps);
/** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code LsfgNative.cacheFile}). */
/** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code Lossless.cacheFile}). */
public static native void nativeSetLsfgCachePath(String path);
/** Flow scale (0.25-1.0) and the panel's real refresh rate (the pacer never generates above it). */
+22
View File
@@ -19,6 +19,28 @@
<string name="resume_session">Resume</string>
<string name="stop_session">Stop session</string>
<string name="frame_gen_title">Frame generation</string>
<string name="frame_gen_hint">Select the frame generation mode</string>
<string name="frame_gen_off">Off</string>
<string name="frame_gen_winfg">Win-FG %1$d×</string>
<string name="frame_gen_lsfg">LSFG %1$d×</string>
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
<string name="lsfg_needs_steam">Needs Lossless Scaling on Steam</string>
<string name="lsfg_needs_install">Install Lossless Scaling in Steam or import Lossless.dll</string>
<string name="lsfg_import">Import Lossless.dll…</string>
<string name="lsfg_using_steam">Using the copy from Steam</string>
<string name="lsfg_using_import">Using an imported copy</string>
<string name="lsfg_pick_title">Choose Lossless.dll</string>
<string name="lsfg_imported">Lossless.dll imported</string>
<string name="lsfg_import_unchanged">This Lossless.dll is already in use</string>
<string name="lsfg_import_not_owned">Lossless Scaling is not owned on this Steam account</string>
<string name="lsfg_import_invalid">This file is not Lossless Scaling\'s Lossless.dll</string>
<string name="lsfg_import_failed">Could not prepare LSFG from this Lossless.dll</string>
<string name="lsfg_problem_missing">LSFG needs Lossless Scaling from Steam or an imported Lossless.dll</string>
<string name="lsfg_problem_failed">LSFG could not prepare its shaders from Lossless.dll</string>
<string name="hud_fg_unsupported">%1$s: unsupported</string>
<string name="hud_fg_failed">%1$s: engine failed</string>
<string name="hud_fg_needs_lossless">%1$s: needs Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · starting</string>
<string name="added_games_import_hint">Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games.</string>
<string name="second_library_import_hint">Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts.</string>
<string name="added_games_forget_hint">Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk.</string>
@@ -0,0 +1,133 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.net.Uri
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class DriverBundleTest {
private lateinit var context: Context
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
File(context.filesDir, "graphics_driver").deleteRecursively()
File(context.filesDir, LinuxVulkanDriverManager.DIR_NAME).deleteRecursively()
context.getSharedPreferences("driver_bundles", Context.MODE_PRIVATE).edit().clear().commit()
SessionPrefs.setAndroidDriver(context, "")
SessionPrefs.setLinuxDriver(context, "")
}
/** Enough of an AArch64 ELF for the importers: the header they read, then the libc they look for. */
private fun library(libc: String) = ByteArray(64).also {
byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte(), 2, 1).copyInto(it)
it[18] = 0xB7.toByte()
} + libc.toByteArray() + ByteArray(1)
private val manifest = JSONObject()
.put("package", "DD-Turnip").put("version", "0.1.0").put("tag", "DD-Turnip-v0.1.0")
.put("drivers", JSONObject()
.put("android", JSONObject().put("path", "android"))
.put("linux", JSONObject().put("path", "linux")))
private fun zip(entries: Map<String, ByteArray>): Uri {
val file = File.createTempFile("bundle", ".zip", context.cacheDir)
ZipOutputStream(file.outputStream()).use { z ->
for ((name, data) in entries) { z.putNextEntry(ZipEntry(name)); z.write(data); z.closeEntry() }
}
return Uri.fromFile(file)
}
private fun bundle(linuxLibc: String = "libc.so.6", extra: Map<String, ByteArray> = emptyMap()) = zip(mapOf(
"android/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("libraryName", "libvulkan_freedreno.so").toString().toByteArray(),
"android/libvulkan_freedreno.so" to library("libc.so"),
"linux/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("driverVersion", "DD-Turnip-v0.1.0").toString().toByteArray(),
"linux/libvulkan_freedreno.so" to library(linuxLibc),
"manifest.json" to manifest.toString().toByteArray(),
) + extra)
@Test fun manifestNeedsBothHalvesInSeparateSafeDirectories() {
val m = DriverBundle.parseManifest(manifest)!!
assertEquals("android", m.androidPath)
assertEquals("linux", m.linuxPath)
assertNull(DriverBundle.parseManifest(JSONObject().put("drivers", JSONObject().put("android", JSONObject().put("path", "a")))))
val same = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "android/") }
assertNull(DriverBundle.parseManifest(same))
val escape = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "../x") }
assertNull(DriverBundle.parseManifest(escape))
}
@Test fun singleDriverZipsAreNotBundles() {
val legacy = zip(mapOf("meta.json" to "{}".toByteArray(), "libvulkan_freedreno.so" to library("libc.so")))
assertFalse(DriverBundle.isBundle(context, legacy))
assertTrue(DriverBundle.isBundle(context, bundle()))
}
@Test fun installsBothHalvesAndPicksAndDeletesThemTogether() {
val b = DriverBundle.install(context, bundle())
assertEquals("DD-Turnip-v0.1.0", b.id)
assertEquals("DD-Turnip 0.1.0", b.label)
assertTrue(TurnipDriver(context).isInstalled(b.androidId))
assertTrue(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
assertEquals(b.id, DriverBundle.containing(context, b.linuxId, linux = true)?.id)
assertEquals(b.id, DriverBundle.containing(context, b.androidId, linux = false)?.id)
assertNull(DriverBundle.active(context))
DriverBundle.select(context, b)
assertEquals(b.androidId, SessionPrefs.androidDriver(context))
assertEquals(b.linuxId, SessionPrefs.linuxDriver(context))
assertNotNull(DriverBundle.active(context))
DriverBundle.remove(context, b)
assertFalse(TurnipDriver(context).isInstalled(b.androidId))
assertFalse(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
assertEquals("", SessionPrefs.androidDriver(context))
assertEquals("", SessionPrefs.linuxDriver(context))
assertTrue(DriverBundle.all(context).isEmpty())
}
@Test fun aRefusedHalfLeavesNothingInstalled() {
try {
DriverBundle.install(context, bundle(linuxLibc = "libc.so"))
fail("a bionic library in linux/ must be refused")
} catch (e: IllegalArgumentException) {
assertTrue(e.message!!.contains("glibc"))
}
assertTrue(TurnipDriver(context).enumerateImported().isEmpty())
assertTrue(LinuxVulkanDriverManager(context).enumerateInstalledDrivers().isEmpty())
}
@Test fun entriesOutsideTheBundleAreRefused() {
try {
DriverBundle.install(context, bundle(extra = mapOf("../escape.so" to ByteArray(4))))
fail("a zip-slip entry must be refused")
} catch (e: IllegalArgumentException) {
assertTrue(e.message!!.contains("outside"))
}
assertFalse(File(context.cacheDir.parentFile, "escape.so").exists())
}
@Test fun twoInstallsOfOneReleaseGetTheirOwnIds() {
val first = DriverBundle.install(context, bundle())
val second = DriverBundle.install(context, bundle())
assertEquals("DD-Turnip-v0.1.0-2", second.id)
assertEquals(2, DriverBundle.all(context).size)
assertFalse(first.androidId == second.androidId)
}
}
@@ -0,0 +1,109 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.gpu.GpuInfo.Family
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverPairsTest {
private fun gpu(model: Int, adreno: Boolean = true, oneUi: Boolean = false) =
GpuInfo("Adreno $model", model, GpuInfo.familyOf(adreno, model), "", oneUi)
private fun asset(name: String, tag: String, linux: Boolean, pair: String) =
TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair)
@Test
fun familiesFollowTheAdrenoModel() {
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 825))
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 810))
assertEquals(Family.A7XX_LOW, GpuInfo.familyOf(true, 722))
assertEquals(Family.A7XX, GpuInfo.familyOf(true, 740))
assertEquals(Family.A6XX, GpuInfo.familyOf(true, 610))
assertEquals(Family.ADRENO_UNKNOWN, GpuInfo.familyOf(true, 0))
assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, 0))
}
@Test
fun onlyTestedHardwareCountsAsSupported() {
assertEquals(GpuInfo.Support.TESTED, gpu(740).support)
assertEquals(GpuInfo.Support.TESTED, gpu(825).support)
assertEquals(GpuInfo.Support.UNTESTED, gpu(610).support)
assertEquals(GpuInfo.Support.UNTESTED, gpu(720).support)
assertEquals(GpuInfo.Support.UNSUPPORTED, gpu(0, adreno = false).support)
}
@Test
fun autoPicksWinNativeBalancedOn8xxAndBannersElsewhere() {
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), emptyList()))
assertEquals("840 with no DD-Turnip release listed", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(840), emptyList()))
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), emptyList()))
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), emptyList()))
assertEquals(DriverPairs.BANNER_ONEUI, DriverPairs.recommendedKey(gpu(740, oneUi = true), emptyList()))
assertEquals(DriverPairs.BANNER_710, DriverPairs.recommendedKey(gpu(720), emptyList()))
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), emptyList()))
assertNull(DriverPairs.recommendedKey(gpu(0, adreno = false), emptyList()))
}
@Test
fun pairsMatchHalvesAndNeverMixBannersReleases() {
val check = TurnipReleases.Check(
listOf(
asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER),
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER),
// The A8xx set's newest display build is r4, but its Linux half is only in r3.
asset("Turnip-r4-A8xx.zip", "r4", false, DriverPairs.BANNER_A8XX),
asset("Turnip-r3-A8xx-Linux.zip", "r3", true, DriverPairs.BANNER_A8XX),
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED),
),
emptyList(), emptyList(), 0L,
)
val pairs = DriverPairs.from(check).associateBy { it.key }
assertTrue(pairs.getValue(DriverPairs.BANNER).complete)
assertFalse("halves from two releases are not a pair", pairs.getValue(DriverPairs.BANNER_A8XX).complete)
val wn = pairs.getValue(DriverPairs.WN_BALANCED)
assertTrue("WinNative's two release lines pair up", wn.complete)
assertEquals("v1.19 + v0.1.2", wn.version)
assertTrue(wn.suits(gpu(830)))
assertFalse(pairs.getValue(DriverPairs.BANNER).suits(gpu(830)))
}
@Test
fun everyBundleReleaseIsACompletePairOnItsOwnNewestFirst() {
fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000,
false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true)
val newer = bundle("DD-Turnip-v0.2.0")
val older = bundle("DD-Turnip-v0.1.0")
val pairs = DriverPairs.from(TurnipReleases.Check(
listOf(asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), newer, older,
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER)),
emptyList(), emptyList(), 0L,
))
assertEquals(listOf(DriverPairs.ddTurnip("DD-Turnip-v0.2.0"), DriverPairs.ddTurnip("DD-Turnip-v0.1.0"), DriverPairs.BANNER), pairs.map { it.key })
val dd = pairs.first()
assertTrue(dd.complete)
assertEquals(listOf(newer), dd.assets)
assertEquals("v0.2.0", dd.version)
assertEquals("DroidDeck · DD-Turnip", dd.name)
assertTrue(dd.suits(gpu(840)))
assertTrue(dd.suits(gpu(740)))
}
@Test
fun adreno840GetsTheNewestOfficialDdTurnip() {
fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000,
false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true)
val pairs = DriverPairs.from(TurnipReleases.Check(
listOf(bundle("DD-Turnip-v0.9.0"), bundle("DD-Turnip-v1.0.0"), bundle("DD-Turnip-v0.10.1"),
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED)),
emptyList(), emptyList(), 0L,
))
assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(840), pairs))
assertEquals("other 8xx keep WinNative", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs))
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), pairs))
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs))
}
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more