mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-08 17:11:46 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4798ee84d5 | ||
|
|
95cb9159de | ||
|
|
6ea7b10d94 | ||
|
|
527fadd3b3 | ||
|
|
6affa87442 | ||
|
|
5d10c8e96d | ||
|
|
d25f6940ee | ||
|
|
d1707dc1b7 | ||
|
|
6588185e96 | ||
|
|
dcd955e175 | ||
|
|
77a332e615 | ||
|
|
c59884b42e | ||
|
|
d2298f4109 | ||
|
|
567ca34a48 | ||
|
|
2f6e43e3d1 | ||
|
|
773d37ff21 | ||
|
|
8a8bb2053c | ||
|
|
b14587baeb | ||
|
|
28a5423629 | ||
|
|
74977d1738 | ||
|
|
184409f33c | ||
|
|
42239e0097 | ||
|
|
c117f6f4c7 | ||
|
|
4a93663d14 | ||
|
|
1ca2d9bd49 | ||
|
|
c8baca10e6 | ||
|
|
9922823090 | ||
|
|
a760aea276 | ||
|
|
e079f9602f | ||
|
|
dfc0b95557 | ||
|
|
30270ca654 | ||
|
|
8c16a04d8d | ||
|
|
0fd50d76b2 |
No files matched your search
@@ -39,25 +39,11 @@ jobs:
|
||||
path: |
|
||||
gamescope.tzst
|
||||
gamescope.tzst.sha256
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: components-token
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-Components
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
GH_REPO: Droid-Deck/DroidDeck-Components
|
||||
TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" --notes "gamescope 3.16.29 with tools/gamescope/patches ($GITHUB_REPOSITORY $GITHUB_SHA, run $GITHUB_RUN_ID); staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
+111
-43
@@ -2,6 +2,17 @@ name: Build APK
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }}
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
play_bundle:
|
||||
type: boolean
|
||||
default: false
|
||||
source_sha:
|
||||
type: string
|
||||
default: ''
|
||||
play_version_code:
|
||||
type: string
|
||||
default: ''
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
@@ -32,7 +43,7 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.run_id }}
|
||||
group: build-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref_name) || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
|
||||
jobs:
|
||||
@@ -47,13 +58,27 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
|
||||
fetch-depth: 0
|
||||
sparse-checkout: |
|
||||
/*
|
||||
!/docs/releases/media/
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- name: Validate Play distribution inputs
|
||||
if: inputs.play_bundle == true
|
||||
env:
|
||||
PLAY_SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ $PLAY_SOURCE_SHA =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$(git rev-parse HEAD)" = "$PLAY_SOURCE_SHA"
|
||||
git merge-base --is-ancestor "$PLAY_SOURCE_SHA" origin/playstore
|
||||
test -f tools/playstore/components.lock.json || {
|
||||
echo "::error::Production component manifest is incomplete; the Play bundle cannot be built or signed."
|
||||
exit 1
|
||||
}
|
||||
python3 tools/playstore/payloads.py validate --play-only --manifest tools/playstore/components.lock.json
|
||||
- name: Pin and merge the APK source
|
||||
id: merge
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -86,7 +111,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
|
||||
fetch-depth: 0
|
||||
sparse-checkout: |
|
||||
/*
|
||||
@@ -136,23 +161,16 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
components-runner:
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/pick-runner.yml
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
|
||||
components:
|
||||
name: Prepare verified components
|
||||
needs: [source, components-runner]
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ${{ fromJSON(needs.components-runner.outputs.labels) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
|
||||
fetch-depth: 0
|
||||
sparse-checkout: |
|
||||
/*
|
||||
@@ -313,11 +331,6 @@ jobs:
|
||||
- name: Build the DiRT 3 / DiRT Showdown ws2_32 stand-in
|
||||
run: python3 tools/components/prepare.py dirt-ws2
|
||||
|
||||
- name: Cache graphics driver inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/build/quest-inputs
|
||||
key: quest-inputs-${{ hashFiles('tools/quest/build-driver.py') }}
|
||||
- name: Prepare graphics driver variants
|
||||
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
- name: Record and pack prepared components
|
||||
@@ -330,25 +343,18 @@ jobs:
|
||||
compression-level: 0
|
||||
retention-days: 14
|
||||
|
||||
build-runner:
|
||||
needs: [components]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/pick-runner.yml
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
|
||||
build:
|
||||
needs: [source, components, xbox, build-runner]
|
||||
needs: [source, components, xbox]
|
||||
name: Compile APK and Android tests
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ${{ fromJSON(needs.build-runner.outputs.labels) }}
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.play_bundle && 90 || 45 }}
|
||||
outputs:
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
|
||||
fetch-depth: 0
|
||||
sparse-checkout: |
|
||||
/*
|
||||
@@ -357,6 +363,16 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Reserve hosted runner space for Play components
|
||||
if: inputs.play_bundle == true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$GITHUB_ACTIONS" = true
|
||||
test "$RUNNER_ENVIRONMENT" = github-hosted
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/share/boost /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune --all --force
|
||||
df -h "$GITHUB_WORKSPACE" "$RUNNER_TEMP"
|
||||
|
||||
- name: Check human authorship
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -472,7 +488,7 @@ jobs:
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
@@ -488,8 +504,8 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BUILD_REF: ${{ github.ref_name }}
|
||||
BUILD_SHA: ${{ github.sha }}
|
||||
BUILD_REF: ${{ inputs.play_bundle && 'playstore' || github.ref_name }}
|
||||
BUILD_SHA: ${{ inputs.source_sha || github.sha }}
|
||||
run: |
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
tree_state=dirty
|
||||
@@ -563,11 +579,9 @@ jobs:
|
||||
- name: Cache checksum-pinned pack inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/droiddeck-pack-inputs
|
||||
~/.cache/droiddeck-build
|
||||
key: pack-inputs-v2-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env', 'tools/powervr/release.json') }}
|
||||
restore-keys: pack-inputs-v2-
|
||||
path: ~/.cache/droiddeck-pack-inputs
|
||||
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
|
||||
restore-keys: pack-inputs-v1-
|
||||
- name: Build the add-on packs
|
||||
run: |
|
||||
python3 tools/powervr/fetch.py
|
||||
@@ -706,6 +720,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
id: apk-upload
|
||||
if: inputs.play_bundle != true
|
||||
with:
|
||||
name: droiddeck-apk
|
||||
path: app/build/outputs/apk/release/*.apk
|
||||
@@ -713,7 +728,60 @@ jobs:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
- name: Stage verified Play components
|
||||
if: inputs.play_bundle == true
|
||||
env:
|
||||
DROIDDECK_ZSTD: /usr/bin/zstd
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/ci/install-ubuntu-packages.sh zstd
|
||||
"$DROIDDECK_ZSTD" --version
|
||||
python3 tools/playstore/payloads.py stage --play-only --manifest tools/playstore/components.lock.json \
|
||||
--cache "$RUNNER_TEMP/play-component-cache" --output build/playstore-payloads
|
||||
|
||||
- name: Build and check the Play bundle
|
||||
if: inputs.play_bundle == true
|
||||
env:
|
||||
PLAY_VERSION_CODE: ${{ inputs.play_version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/ci/install-ubuntu-packages.sh autoconf automake libtool autopoint gettext pkg-config cmake ninja-build
|
||||
python3 tools/playstore/build_audio.py build --output app/build/play-audio \
|
||||
--ndk "$ANDROID_HOME/ndk/$NDK_VERSION" --cache "$RUNNER_TEMP/play-audio-cache"
|
||||
./gradlew :app:bundleRelease :app:lintRelease :app:testReleaseUnitTest -PplayDistribution=true \
|
||||
-PndkVersion="$NDK_VERSION" -PplayVersionCode="$PLAY_VERSION_CODE" --tests com.droiddeck.launcher.core.ComponentPayloadStoreTest \
|
||||
--tests com.droiddeck.launcher.core.PlayDistributionTest \
|
||||
--tests com.droiddeck.launcher.core.PlayArchiveFramesTest \
|
||||
--tests com.droiddeck.launcher.PlayUpdatesTest \
|
||||
--tests com.droiddeck.launcher.ui.BuildWatermarkTest --console=plain
|
||||
python3 tools/playstore/payloads.py verify-bundle --play-only --manifest tools/playstore/components.lock.json \
|
||||
--bundle app/build/outputs/bundle/release/app-release.aab
|
||||
python3 tools/release/check_android_libraries.py app/build/outputs/bundle/release/app-release.aab --page-size 16384
|
||||
python3 tools/playstore/check_audio_bundle.py --bundle app/build/outputs/bundle/release/app-release.aab \
|
||||
--output app/build/play-audio --ndk "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
curl --fail --location --retry 3 --output "$RUNNER_TEMP/bundletool.jar" \
|
||||
https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar
|
||||
echo "a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 $RUNNER_TEMP/bundletool.jar" | sha256sum -c -
|
||||
java -jar "$RUNNER_TEMP/bundletool.jar" validate --bundle=app/build/outputs/bundle/release/app-release.aab
|
||||
java -jar "$RUNNER_TEMP/bundletool.jar" dump manifest --bundle=app/build/outputs/bundle/release/app-release.aab \
|
||||
--module=base > app/build/play-manifest.xml
|
||||
python3 -c 'import xml.etree.ElementTree as E; root=E.parse("app/build/play-manifest.xml").getroot(); assert not any(x.get("{http://schemas.android.com/apk/res/android}name") == "android.permission.REQUEST_INSTALL_PACKAGES" for x in root.findall("uses-permission"))'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: inputs.play_bundle == true
|
||||
with:
|
||||
name: droiddeck-play-bundle-review
|
||||
path: |
|
||||
app/build/outputs/bundle/release/*.aab
|
||||
build/playstore-payloads/provenance.json
|
||||
build/playstore-payloads/index.json
|
||||
app/build/play-manifest.xml
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: inputs.play_bundle != true
|
||||
with:
|
||||
name: droiddeck-packs
|
||||
path: app/build/packs/*.tar.zst
|
||||
@@ -747,7 +815,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
|
||||
fetch-depth: 0
|
||||
sparse-checkout: |
|
||||
/*
|
||||
@@ -801,7 +869,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
|
||||
fetch-depth: 0
|
||||
sparse-checkout: |
|
||||
/*
|
||||
@@ -873,7 +941,7 @@ jobs:
|
||||
|
||||
sign:
|
||||
needs: [build, checks]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
if: ${{ !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -885,7 +953,7 @@ jobs:
|
||||
|
||||
packs:
|
||||
needs: [build, checks]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
if: ${{ !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -894,7 +962,7 @@ jobs:
|
||||
|
||||
announce:
|
||||
needs: [build, sign, packs]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
if: ${{ !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
@@ -944,6 +1012,6 @@ jobs:
|
||||
|
||||
catalog:
|
||||
needs: announce
|
||||
if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }}
|
||||
if: ${{ always() && !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }}
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
secrets: inherit
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Pick runner
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
labels:
|
||||
value: ${{ jobs.pick.outputs.labels }}
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
name: Pick runner
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
|
||||
steps:
|
||||
- name: Pick the compile runner
|
||||
id: runner
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
labels='"ubuntu-24.04"'
|
||||
if [ -n "$GH_TOKEN" ]; then
|
||||
idle=$(gh api "orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners" --jq '[.runners[] | select(.status == "online" and .busy == false and any(.labels[]; .name == "droiddeck-ws"))] | length' 2>/dev/null || echo 0)
|
||||
if [ "${idle:-0}" -gt 0 ]; then
|
||||
labels='["self-hosted","droiddeck-ws"]'
|
||||
fi
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Pick the fork runner
|
||||
id: fork
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
labels='"ubuntu-24.04"'
|
||||
gist=https://api.github.com/gists/0c7ed7ad6d6493de84f5123689c64ae6
|
||||
body=$(curl -fsS --max-time 15 -H "Authorization: Bearer $GH_TOKEN" "$gist" 2>/dev/null || curl -fsS --max-time 15 "$gist" 2>/dev/null || echo '{}')
|
||||
IDLE_AT=$(jq -r 'select(.owner.login == "maxjivi05") | .files["idle-at"].content // "0"' <<< "$body" 2>/dev/null || echo 0)
|
||||
echo "Heartbeat: ${IDLE_AT:-none}"
|
||||
age=-1
|
||||
if [[ "${IDLE_AT:-0}" =~ ^[0-9]+$ ]] && [ "${IDLE_AT:-0}" -gt 0 ]; then
|
||||
age=$(( $(date +%s) - IDLE_AT ))
|
||||
fi
|
||||
if [ "$age" -ge 0 ] && [ "$age" -lt 180 ]; then
|
||||
labels='"droiddeck-fork"'
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Build PlayStore Bundle
|
||||
run-name: PlayStore ${{ inputs.version || 'branch build' }} from playstore
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
source_branch:
|
||||
type: string
|
||||
default: playstore
|
||||
push:
|
||||
branches: [playstore]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version for the file names, e.g. 0.1.7
|
||||
required: true
|
||||
source_branch:
|
||||
description: Branch to build
|
||||
type: choice
|
||||
options: [playstore]
|
||||
default: playstore
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: playstore-bundle
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Play distribution checks
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.sha || inputs.source_branch }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
python3 -m pip install PyYAML==6.0.3
|
||||
- name: Test Play distribution safeguards
|
||||
run: |
|
||||
python3 -m unittest \
|
||||
tools.tests.test_playstore_release_assets \
|
||||
tools.tests.test_playstore_split_archive \
|
||||
tools.tests.test_playstore_payloads \
|
||||
tools.tests.test_playstore_manifest \
|
||||
tools.tests.test_playstore_signing \
|
||||
tools.tests.test_playstore_workflow \
|
||||
tools.tests.test_playstore_archive_preparation \
|
||||
tools.tests.test_playstore_audio_bundle \
|
||||
tools.tests.test_playstore_emulators \
|
||||
tools.tests.test_android_page_alignment
|
||||
|
||||
source:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
version: ${{ steps.source.outputs.version }}
|
||||
version_code: ${{ steps.source.outputs.version_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.sha || inputs.source_branch }}
|
||||
persist-credentials: false
|
||||
- id: source
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
SOURCE_BRANCH: ${{ inputs.source_branch || 'playstore' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$SOURCE_BRANCH" = playstore
|
||||
test "$GITHUB_REF" = refs/heads/playstore
|
||||
sha=$(git rev-parse HEAD)
|
||||
name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle)
|
||||
[[ $name =~ ^[0-9A-Za-z._-]+$ ]]
|
||||
if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
|
||||
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::invalid version"; exit 1; }
|
||||
[[ $VERSION == "$name" || $VERSION == "$name"-* ]] || {
|
||||
echo "::error::version must match versionName in app/build.gradle; bump versionName and versionCode first"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
VERSION="$name"
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
source_code=$(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
|
||||
[[ $source_code =~ ^[1-9][0-9]*$ ]]
|
||||
[ "$source_code" -le 2100000000 ]
|
||||
echo "version_code=$source_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
bundle:
|
||||
needs: source
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
play_bundle: true
|
||||
source_sha: ${{ needs.source.outputs.sha }}
|
||||
play_version_code: ${{ needs.source.outputs.version_code }}
|
||||
secrets: inherit
|
||||
|
||||
sign:
|
||||
needs: [checks, source, bundle]
|
||||
runs-on: ubuntu-24.04
|
||||
environment: signing-playstore
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-play-bundle-review
|
||||
path: incoming
|
||||
- name: Sign the verified Play bundle
|
||||
env:
|
||||
RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }}
|
||||
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
VERSION: ${{ needs.source.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$RELEASE_KEYSTORE_B64"
|
||||
key="$RUNNER_TEMP/play-release.p12"
|
||||
(umask 077; printf '%s' "$RELEASE_KEYSTORE_B64" | base64 -d > "$key")
|
||||
mapfile -t bundles < <(find incoming -type f -name '*.aab')
|
||||
test "${#bundles[@]}" = 1
|
||||
mkdir -p signed
|
||||
python3 tools/playstore/sign_bundle.py "${bundles[0]}" "signed/DroidDeck-$VERSION-PlayStore.aab" \
|
||||
--keystore "$key"
|
||||
(cd signed && sha256sum *.aab > SHA256SUMS)
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: droiddeck-play-bundle-signed
|
||||
path: signed/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
|
||||
publish:
|
||||
needs: [source, sign]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-play-bundle-signed
|
||||
path: signed
|
||||
- name: Publish the PlayStore release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.source.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.source.outputs.sha }}
|
||||
VERSION_CODE: ${{ needs.source.outputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="playstore-$VERSION"
|
||||
if gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::error::PlayStore release $tag already exists"
|
||||
exit 1
|
||||
fi
|
||||
printf 'Signed Android App Bundle for Google Play upload.\n\nSource: `%s` on `playstore`.\nVersion code: `%s`.\n' "$SOURCE_SHA" "$VERSION_CODE" > "$RUNNER_TEMP/play-release.md"
|
||||
bundles=(signed/*.aab)
|
||||
[ "${#bundles[@]}" -eq 1 ] && [ -f "${bundles[0]}" ]
|
||||
python3 tools/playstore/release_assets.py --bundle "${bundles[0]}" --checksums signed/SHA256SUMS --output publish
|
||||
if compgen -G 'publish/*.part*' >/dev/null; then
|
||||
name=$(basename "${bundles[0]}")
|
||||
printf '\nGitHub release assets are split to stay below its per-file limit. Download every part and PART-SHA256SUMS, verify the parts, combine them in numeric order, and verify SHA256SUMS before uploading the resulting AAB to Google Play.\n\nThe complete signed AAB is also available in the droiddeck-play-bundle-signed artifact for this workflow run.\n\n```sh\nsha256sum -c PART-SHA256SUMS\nset -o noclobber\ncat "%s".part??? > "%s"\nsha256sum -c SHA256SUMS\n```\n' "$name" "$name" >> "$RUNNER_TEMP/play-release.md"
|
||||
fi
|
||||
gh release create "$tag" -R "$GITHUB_REPOSITORY" publish/* \
|
||||
--target "$SOURCE_SHA" --title "DroidDeck $VERSION · PlayStore" \
|
||||
--notes-file "$RUNNER_TEMP/play-release.md" --latest=false
|
||||
@@ -47,18 +47,10 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
retry() {
|
||||
for attempt in 1 2 3 4; do
|
||||
"$@" && return 0
|
||||
[ "$attempt" = 4 ] || sleep $((attempt * 20))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
existing=$(retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name')
|
||||
found=0
|
||||
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
|
||||
if grep -qxF "$tag" <<<"$existing"; then
|
||||
retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
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
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
name: PR Release Sweep
|
||||
run-name: sweep test builds of closed PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-release-sweep
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete test builds whose PR is no longer open
|
||||
env:
|
||||
CI_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
retry() {
|
||||
for attempt in 1 2 3 4; do
|
||||
"$@" && return 0
|
||||
[ "$attempt" = 4 ] || sleep $((attempt * 20))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
tags=$(GH_TOKEN="$CI_TOKEN" retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name' |
|
||||
grep -E '^pr-[0-9]+(\.0)?$' || true)
|
||||
failed=0
|
||||
for tag in $tags; do
|
||||
number=${tag#pr-}
|
||||
number=${number%.0}
|
||||
state=$(retry gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .state) || { failed=1; continue; }
|
||||
if [ "$state" != open ]; then
|
||||
if GH_TOKEN="$CI_TOKEN" retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag; then
|
||||
echo "Deleted $tag (PR #$number is $state)"
|
||||
else
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Release APKs
|
||||
run-name: release ${{ inputs.version }} from main ${{ github.sha }}
|
||||
run-name: ${{ github.ref == 'refs/heads/playstore' && format('PlayStore {0} from playstore {1}', inputs.version, github.sha) || format('release {0} from main {1}', inputs.version, github.sha) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -13,10 +13,23 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
group: ${{ github.ref == 'refs/heads/playstore' && 'release-playstore' || 'release' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
playstore:
|
||||
name: Build PlayStore Bundle
|
||||
if: github.ref == 'refs/heads/playstore'
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/playstore.yml
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
source_branch: playstore
|
||||
secrets: inherit
|
||||
|
||||
find:
|
||||
name: find the main build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
@@ -53,6 +66,7 @@ jobs:
|
||||
echo "Signing Build APK run $id (main ${GITHUB_SHA::7}) as $VERSION: $variants" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
sign:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: find
|
||||
uses: ./.github/workflows/sign.yml
|
||||
with:
|
||||
@@ -64,6 +78,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
packs:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: find
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -74,6 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
name: publish the release
|
||||
needs: [find, sign, packs]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Rules
|
||||
|
||||
- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants.
|
||||
|
||||
|
||||
## Google Play distribution
|
||||
|
||||
For `playstore` and all changes destined for Google Play, read and enforce [the mandatory distribution policy gate](tools/playstore/POLICY.md) before editing or integrating changes. Refuse known policy violations and hold uncertain changes for authoritative evidence. Review incoming main commits individually; never overwrite Play delivery, permission, updater, signing, versioning, catalog, or release protections. A successful build is not policy clearance.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# DroidDeck Privacy Policy
|
||||
|
||||
Effective date: 3 November 2026
|
||||
|
||||
DroidDeck is an open-source game launcher maintained by the Droid-Deck project. A separate DroidDeck online account is not required. DroidDeck does not include advertising analytics or automatically submit usage reports or crash reports to the project maintainers. The maintainers do not operate a central database of users' game libraries or sign-in credentials, and do not sell users' personal data.
|
||||
|
||||
## Information kept on your device
|
||||
|
||||
DroidDeck stores information needed to run its features locally. Depending on what you use, this includes settings, controller and display configurations, game-library metadata, artwork caches, installed components, game saves, download state, and diagnostic logs. Store integrations and the clients running inside DroidDeck may also retain account names, authentication tokens, cookies, and sign-in sessions. These are used to access the services you choose.
|
||||
|
||||
Game performance and racing telemetry can be processed on your device for HUD and secondary-screen displays. This is separate from sending analytics to the project maintainers.
|
||||
|
||||
## Permissions and optional features
|
||||
|
||||
DroidDeck accesses device information and resources to provide the features you enable:
|
||||
|
||||
- File access supports browsing and managing games, saves, libraries, and other files. Broad file access, when granted, allows access outside the app's private storage, including shared and removable storage.
|
||||
- The launcher reads launchable Android app names, icons, and package identifiers to display and launch those apps.
|
||||
- The accessibility service handles Home/Guide shortcuts and identifies apps occupying a secondary display. It reads app package names and window bounds locally. This service does not read text, capture screens, or transmit the information it reads.
|
||||
- Notification access lets the optional notification display read notification titles and content on the device.
|
||||
- Camera, microphone, motion, accessory, and input data support features such as tracking, glasses, capture, game audio, and controls. Screenshots or recordings you create are saved locally unless you choose to share or synchronize them.
|
||||
- Bluetooth, USB, Wi-Fi, and local-network access support accessories, network controls, synchronization, streaming, and connections you configure. Android can require location permission for Wi-Fi discovery.
|
||||
|
||||
You can manage permissions through Android settings. Turning off a permission or optional feature can limit the functionality that uses it.
|
||||
|
||||
## Connections to other services
|
||||
|
||||
DroidDeck connects to the stores, game services, cloud-streaming providers, artwork sources, and download services used by your selected features. Those services can receive information such as your IP address, requests, game identifiers, authentication information, or information you enter directly into their sign-in and purchase pages. Steam, games, and other software running through DroidDeck have their own network activity and privacy policies. Their providers determine how they handle that information.
|
||||
|
||||
The Google Play version downloads runtime modules and non-executable resource packs through Google Play. Google's Play Feature Delivery and Play Asset Delivery services process device metadata and the application version to deliver the correct files and preserve them across updates. Google documents encrypted processing and a fixed retention period. See [Play Feature Delivery data handling](https://developer.android.com/guide/playcore/feature-delivery#data-safety) and [Play Asset Delivery data handling](https://developer.android.com/guide/playcore/asset-delivery#terms-of-service).
|
||||
|
||||
The Google Play version contacts Google Play to check whether an app update is available. Selecting Update opens the app's Google Play listing. Google handles Play delivery and updates under its own [privacy policy](https://policies.google.com/privacy).
|
||||
|
||||
Optional Windows prerequisites can be downloaded from the sources identified in the app's pinned component inventory, including GitHub-hosted runtime packages. These providers receive the download request and IP address. DroidDeck verifies the package against the inventory before installing it into the guest runtime.
|
||||
|
||||
Optional Syncthing synchronization transfers selected files to the devices you configure and can use discovery and relay services. DroidDeck disables Syncthing usage reporting and crash reporting. Selected games or other guest software may use files and resources available within DroidDeck's runtime.
|
||||
|
||||
DroidDeck's privacy policy does not replace the policies of these separate providers. Review the relevant provider's policy when signing in, purchasing content, streaming, installing software, or enabling synchronization.
|
||||
|
||||
## Retention, removal, and reports
|
||||
|
||||
Local information remains until you clear it, sign out, remove the relevant files or feature data, or use Android's app-data controls. Removing DroidDeck or clearing its app storage removes Android-managed app data; libraries, saves, screenshots, and other files in shared storage or on other devices may remain. Signing out of a store integration does not delete the account held by that store. Manage or delete those accounts through the provider.
|
||||
|
||||
Diagnostic logs are kept locally. If you choose to send a report or export logs, the recipient receives what you submit. Public GitHub issues and discussions are public. Review reports and attachments before sharing them, and exclude passwords, authentication tokens, and other sensitive information.
|
||||
|
||||
## Contact and updates
|
||||
|
||||
For privacy questions, contact the Droid-Deck maintainers through the project's [GitHub issue form](https://github.com/Droid-Deck/DroidDeck/issues/new?title=Privacy%20question). Keep the initial public inquiry free of sensitive information; request a private contact method if necessary. GitHub handles information submitted through its service under its own [privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).
|
||||
|
||||
Updates to this policy will appear on this page with a revised effective date.
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
|
||||
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No source withheld.</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
|
||||
@@ -47,7 +47,7 @@ Help, Preview builds and device reports are on [Discord](https://discord.gg/JRGA
|
||||
|
||||
## Build
|
||||
|
||||
Needs Docker, Java 17, the Android SDK and NDK, Rust via rustup, GitHub CLI, Python 3 and `zstd`.
|
||||
Needs Docker, Java 21, the Android SDK and NDK, Rust via rustup, GitHub CLI, Python 3 and `zstd`.
|
||||
|
||||
```bash
|
||||
tools/build_local.sh
|
||||
@@ -79,8 +79,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
|
||||
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
|
||||
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
|
||||
- Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch.
|
||||
- Games and AppImage, Flatpak and Snap programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`.
|
||||
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
|
||||
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
|
||||
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
|
||||
- The arm64 client's sign in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest).
|
||||
@@ -95,7 +94,6 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
|
||||
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
|
||||
- World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used.
|
||||
- The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key.
|
||||
- Xbox games load [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) modules (LGPL-2.1-or-later), including `wintypes.dll`. Minecraft's GameInput setup was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT).
|
||||
- The Xbox Cloud Gaming player uses the WebRTC client of [@unknownskl](https://github.com/unknownskl)'s [Greenlight](https://github.com/unknownskl/greenlight) (MIT), and its offer tweaks and sharpening follow [@redphx](https://github.com/redphx)'s [Better xCloud](https://github.com/redphx/better-xcloud) (MIT). See `tools/xcloud-player/NOTICE.md`.
|
||||
- The itch.io butler pack ships butler's `7z.so`, itch.io's build of [7-Zip](https://www.7-zip.org) by Igor Pavlov (LGPL-2.1, unRAR restriction on RAR code), unmodified. Other archives use Apache Commons Compress and the runtime's bsdtar (libarchive).
|
||||
|
||||
+112
-26
@@ -1,7 +1,6 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
def localBuildOptions = new Properties()
|
||||
@@ -19,6 +18,7 @@ if (uvcDir) {
|
||||
if (!new File(uvcDir, required).isFile()) throw new GradleException("Incomplete UVC build: missing ${required}")
|
||||
}
|
||||
}
|
||||
|
||||
if (vitureSdkDir) {
|
||||
['android/arm64-v8a/libglasses.so', 'android/arm64-v8a/libcarina_vio.so',
|
||||
'android/arm64-v8a/libcloud_protocol.so', 'include/viture_glasses_provider.h',
|
||||
@@ -82,6 +82,9 @@ def buildBranch = providers.environmentVariable('GITHUB_HEAD_REF').getOrNull()?.
|
||||
?: gitValue(['branch', '--show-current']) ?: ''
|
||||
def escapeJava = { String value -> value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ' ').replace('\r', ' ') }
|
||||
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
|
||||
def playDistribution = providers.gradleProperty('playDistribution').getOrElse('false').toBoolean()
|
||||
def playManifest = playDistribution ? new groovy.json.JsonSlurper().parse(rootProject.file('tools/playstore/components.lock.json')) : null
|
||||
def playLegacyDesktop = playManifest?.entries?.find { it.id == 'desktop-legacy-kde' }
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' }
|
||||
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
|
||||
@@ -124,29 +127,30 @@ def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
|
||||
|
||||
android {
|
||||
namespace 'com.droiddeck.launcher'
|
||||
compileSdk 34
|
||||
compileSdk 37
|
||||
ndkVersion project.findProperty('ndkVersion') ?: '27.3.13750724'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.droiddeck.launcher"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
versionCode 18
|
||||
versionName "0.7.0"
|
||||
targetSdkVersion 37
|
||||
versionCode 20
|
||||
versionName "0.6.9.2"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
buildConfigField "String", "PR_TITLE", "\"${escapeJava(prTitle)}\""
|
||||
buildConfigField "String", "BUILD_BRANCH", "\"${escapeJava(buildBranch)}\""
|
||||
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
|
||||
buildConfigField "boolean", "PLAY_DISTRIBUTION", "${playDistribution}"
|
||||
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
buildConfigField "String", "SGDB_API_KEY", "\"${System.getenv("SGDB_API_KEY") ?: project.findProperty("sgdbApiKey") ?: ""}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
|
||||
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
|
||||
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
|
||||
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${playLegacyDesktop?.sha256 ?: desktopKde.DESKTOP_KDE_SHA256}\""
|
||||
buildConfigField "long", "DESKTOP_KDE_SIZE", "${playLegacyDesktop?.size ?: desktopKde.DESKTOP_KDE_SIZE}L"
|
||||
addonPacks.each { name, pack ->
|
||||
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
|
||||
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
|
||||
@@ -215,7 +219,7 @@ android {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
pickFirsts += ['**/libtermux.so']
|
||||
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
|
||||
excludes += ['**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,8 +237,18 @@ android {
|
||||
outputs.upToDateWhen { false }
|
||||
outputs.doNotCacheIf('CI executes the test suite on every run') { true }
|
||||
}
|
||||
jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED',
|
||||
'--add-opens=java.base/java.util=ALL-UNNAMED',
|
||||
'--add-opens=java.base/java.io=ALL-UNNAMED',
|
||||
'--add-opens=java.base/java.net=ALL-UNNAMED',
|
||||
'--add-opens=java.base/java.security=ALL-UNNAMED',
|
||||
'--add-opens=java.base/java.text=ALL-UNNAMED',
|
||||
'--add-opens=java.base/jdk.internal.access=ALL-UNNAMED',
|
||||
'--add-opens=java.desktop/java.awt.font=ALL-UNNAMED',
|
||||
'--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED'
|
||||
maxHeapSize = '2g'
|
||||
maxParallelForks = 2
|
||||
systemProperty 'robolectric.dependency.repo.url', 'https://repo.maven.apache.org/maven2'
|
||||
def logTarget = providers.systemProperty('robolectric.logging').orNull
|
||||
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
|
||||
}
|
||||
@@ -244,11 +258,37 @@ android {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
def verifyPlayAudio
|
||||
if (playDistribution) {
|
||||
android.buildTypes.debug.signingConfig = null
|
||||
android.buildTypes.release.signingConfig = null
|
||||
def requestedCode = providers.gradleProperty('playVersionCode').orNull
|
||||
if (requestedCode != null && requestedCode != android.defaultConfig.versionCode.toString()) {
|
||||
throw new GradleException('Play version code must match app/build.gradle')
|
||||
}
|
||||
def playAudio = layout.buildDirectory.dir('play-audio')
|
||||
def playNdk = androidComponents.sdkComponents.ndkDirectory.get().asFile
|
||||
verifyPlayAudio = tasks.register('verifyPlayAudio', Exec) {
|
||||
workingDir rootProject.projectDir
|
||||
commandLine pythonExecutable, 'tools/playstore/build_audio.py', 'verify',
|
||||
'--output', playAudio.get().asFile, '--ndk', playNdk
|
||||
}
|
||||
def stagePlayJni = tasks.register('stagePlayJni', Sync) {
|
||||
dependsOn(verifyPlayAudio)
|
||||
from(layout.projectDirectory.dir('src/main/jniLibs')) {
|
||||
exclude(['libltdl.so', 'libpulseaudio.so', 'libpulse.so', 'libpulsecommon-13.0.so',
|
||||
'libpulsecore-13.0.so', 'libsndfile.so'].collect { "arm64-v8a/${it}".toString() })
|
||||
}
|
||||
from(playAudio.map { it.dir('jni') })
|
||||
into(layout.buildDirectory.dir('playJni'))
|
||||
}
|
||||
android.sourceSets.main.jniLibs.setSrcDirs([layout.buildDirectory.dir('playJni')])
|
||||
tasks.named('preBuild').configure { dependsOn(stagePlayJni) }
|
||||
}
|
||||
|
||||
|
||||
// No instrumented tests; the unused androidTest variant breaks the VS Code Kotlin import.
|
||||
androidComponents {
|
||||
beforeVariants(selector().all()) { it.androidTest.enable = false }
|
||||
@@ -266,7 +306,7 @@ if (vitureSdkDir) {
|
||||
|
||||
def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
|
||||
from(rootProject.file('tools/linuxfs/overlay')) {
|
||||
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**'
|
||||
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**', 'usr/local/lib/droiddeck/*.py'
|
||||
}
|
||||
into(layout.projectDirectory.dir('src/main/assets/linuxfs'))
|
||||
}
|
||||
@@ -277,18 +317,34 @@ def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
|
||||
dependsOn(stageSessionScripts)
|
||||
from(layout.projectDirectory.dir('src/main/assets')) { exclude 'pulseaudio.tzst' }
|
||||
from(rootProject.file('tools/linuxfs/innoextract'))
|
||||
from(layout.buildDirectory.dir('prepared-assets'))
|
||||
from(layout.buildDirectory.dir('prepared-assets')) {
|
||||
if (playDistribution) exclude 'pulseaudio.tzst'
|
||||
}
|
||||
if (playDistribution) {
|
||||
dependsOn(verifyPlayAudio)
|
||||
from(layout.buildDirectory.dir('play-audio/assets'))
|
||||
}
|
||||
into(runtimeAssetsDir)
|
||||
}
|
||||
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
|
||||
if (vitureSdkDir) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('vitureAssets'))
|
||||
if (playDistribution) {
|
||||
def payloads = rootProject.file('build/playstore-payloads')
|
||||
def modules = new groovy.json.JsonSlurper().parse(new File(payloads, 'modules.json'))
|
||||
android.dynamicFeatures = modules.collect { ":${it}".toString() }.toSet()
|
||||
def packs = new groovy.json.JsonSlurper().parse(new File(payloads, 'assetpacks.json'))
|
||||
android.assetPacks = packs.collect { ":${it}".toString() }.toSet()
|
||||
android.sourceSets.main.assets.srcDir(new File(payloads, 'base/src/main/assets'))
|
||||
android.sourceSets.debug.manifest.srcFile('src/play/AndroidManifest.xml')
|
||||
android.sourceSets.release.manifest.srcFile('src/play/AndroidManifest.xml')
|
||||
}
|
||||
if (vitureSdkDir) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('vitureAssets').get().asFile)
|
||||
if (uvcDir) {
|
||||
def stageUvcNotices = tasks.register('stageUvcNotices', Copy) {
|
||||
from(new File(uvcDir, 'licenses'))
|
||||
into(layout.buildDirectory.dir('uvcAssets/licenses/uvc'))
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(stageUvcNotices) }
|
||||
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('uvcAssets'))
|
||||
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('uvcAssets').get().asFile)
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
|
||||
|
||||
@@ -296,15 +352,15 @@ def stageGogNotices = tasks.register('stageGogNotices', Copy) {
|
||||
from(layout.projectDirectory.dir('src/main/rust/stores/vendor/xdelta3')) { include 'LICENSE', 'README.droiddeck.md' }
|
||||
into(layout.buildDirectory.dir('gogNotices/licenses/xdelta3'))
|
||||
}
|
||||
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices'))
|
||||
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices').get().asFile)
|
||||
tasks.named('preBuild').configure { dependsOn(stageGogNotices) }
|
||||
|
||||
if (!ciBuild) {
|
||||
if (!ciBuild || playDistribution) {
|
||||
def stageLocalPacks = tasks.register('stageLocalPacks', Sync) {
|
||||
from(layout.buildDirectory.dir('packs')) { include '*.tar.zst' }
|
||||
into(layout.buildDirectory.dir('localPackAssets/packs'))
|
||||
}
|
||||
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('localPackAssets'))
|
||||
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('localPackAssets').get().asFile)
|
||||
tasks.named('preBuild').configure { dependsOn(stageLocalPacks) }
|
||||
}
|
||||
|
||||
@@ -339,7 +395,7 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
workingDir rustCrateDir
|
||||
def cargoBin = "${System.getProperty('user.home')}/.cargo/bin"
|
||||
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
|
||||
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
|
||||
environment 'ANDROID_NDK_HOME', androidComponents.sdkComponents.ndkDirectory.get().asFile.absolutePath
|
||||
environment 'CARGO_TERM_COLOR', 'never'
|
||||
if (!skipRust) environment 'RUSTC', providers.exec {
|
||||
commandLine 'rustup', 'which', '--toolchain', 'stable', 'rustc'
|
||||
@@ -348,7 +404,7 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
|
||||
'build', '--release', '--locked'
|
||||
}
|
||||
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir.get().asFile)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
def maliBridgeDir = rootProject.file('external/MaliBridge')
|
||||
@@ -361,20 +417,30 @@ def buildVkBridgeServer = tasks.register('buildVkBridgeServer', Exec) {
|
||||
inputs.file(new File(maliBridgeDir, 'scripts/build-server-android.sh'))
|
||||
outputs.dir(vkbridgeJniLibsDir)
|
||||
commandLine 'bash', new File(maliBridgeDir, 'scripts/build-server-android.sh').absolutePath,
|
||||
android.ndkDirectory.absolutePath, vkbridgeJniLibsDir.get().asFile.absolutePath
|
||||
androidComponents.sdkComponents.ndkDirectory.get().asFile.absolutePath, vkbridgeJniLibsDir.get().asFile.absolutePath
|
||||
}
|
||||
android.sourceSets.main.jniLibs.srcDir(vkbridgeJniLibsDir)
|
||||
android.sourceSets.main.jniLibs.srcDir(vkbridgeJniLibsDir.get().asFile)
|
||||
tasks.named('preBuild').configure { dependsOn(buildVkBridgeServer) }
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.android.play:app-update:2.1.0'
|
||||
implementation 'com.google.android.play:feature-delivery:2.1.0'
|
||||
implementation 'com.google.android.play:asset-delivery:2.3.0'
|
||||
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
|
||||
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
|
||||
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
implementation 'org.tukaani:xz:1.10'
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
implementation platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation('androidx.compose.material3:material3') {
|
||||
version { strictly '1.2.0' }
|
||||
}
|
||||
constraints {
|
||||
implementation('androidx.compose.material3:material3-android') {
|
||||
version { strictly '1.2.0' }
|
||||
}
|
||||
}
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
@@ -394,7 +460,27 @@ dependencies {
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
testImplementation 'org.robolectric:robolectric:4.17'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.7-4@jar'
|
||||
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
beforeVariants(selector().all()) { variant ->
|
||||
variant.enableUnitTest = true
|
||||
}
|
||||
}
|
||||
|
||||
configurations { hostZstd }
|
||||
dependencies { hostZstd 'com.github.luben:zstd-jni:1.5.7-4@jar' }
|
||||
androidComponents.onVariants(androidComponents.selector().all()) { variant ->
|
||||
variant.unitTest?.configureTestTask { test ->
|
||||
test.classpath = files(configurations.hostZstd) + test.classpath.filter { !it.name.contains("zstd-jni") }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,15 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<queries>
|
||||
<package android:name="com.xiaoji.egggame" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
</intent>
|
||||
<package android:name="cn.nubia.gamelauncher" />
|
||||
<intent>
|
||||
<action android:name="android.content.pm.action.CONFIRM_INSTALL" />
|
||||
@@ -10,10 +19,6 @@
|
||||
<intent>
|
||||
<action android:name="android.content.pm.action.CONFIRM_PERMISSIONS" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.SECONDARY_HOME" />
|
||||
@@ -22,6 +27,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
@@ -30,6 +36,10 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
@@ -41,7 +51,8 @@
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
@@ -63,6 +74,9 @@
|
||||
android:hardwareAccelerated="true"
|
||||
android:largeHeap="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:pageSizeCompat="enabled"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:appCategory="game"
|
||||
android:isGame="true">
|
||||
|
||||
@@ -195,6 +209,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".PinRequestActivity"
|
||||
android:exported="true"
|
||||
@@ -207,6 +222,13 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".input.ControllerEditorActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/SessionTheme"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
|
||||
|
||||
<activity
|
||||
android:name=".sync.SyncthingWebActivity"
|
||||
@@ -313,7 +335,12 @@
|
||||
<service
|
||||
android:name=".session.SessionService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false" />
|
||||
android:foregroundServiceType="specialUse|mediaPlayback|microphone"
|
||||
android:stopWithTask="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Runs the user's Linux game session (compositor, Steam client, audio) while the app is not on screen" />
|
||||
</service>
|
||||
<!-- The compositor's native state is one per process, so the bottom-screen desktop's compositor runs in its own. -->
|
||||
<service
|
||||
android:name=".wayland.BottomDesktopCompositorService"
|
||||
@@ -321,10 +348,16 @@
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".runtime.RuntimeInstallService"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Waits for the wireless debugging pairing code the user enters from the notification" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".stores.download.StoreDownloadService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -2953,19 +2953,15 @@ EXPORT DIR *opendir(const char *name) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
static bool open_has_mode(int flags) {
|
||||
return (flags & O_CREAT) != 0 || (flags & O_TMPFILE) == O_TMPFILE;
|
||||
}
|
||||
|
||||
EXPORT int open(const char *pathname, int flags, ...) {
|
||||
va_list va;
|
||||
mode_t mode = 0;
|
||||
mode_t mode;
|
||||
int fd;
|
||||
bool hasMode;
|
||||
|
||||
va_start(va, flags);
|
||||
|
||||
hasMode = open_has_mode(flags);
|
||||
hasMode = flags & O_CREAT;
|
||||
|
||||
if (hasMode) {
|
||||
mode = va_arg(va, mode_t);
|
||||
@@ -3037,13 +3033,13 @@ EXPORT int open(const char *pathname, int flags, ...) {
|
||||
|
||||
EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
va_list va;
|
||||
mode_t mode = 0;
|
||||
mode_t mode;
|
||||
int fd;
|
||||
bool hasMode;
|
||||
|
||||
va_start(va, flags);
|
||||
|
||||
hasMode = open_has_mode(flags);
|
||||
hasMode = flags & O_CREAT;
|
||||
|
||||
if (hasMode) {
|
||||
mode = va_arg(va, mode_t);
|
||||
@@ -4255,7 +4251,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
|
||||
|
||||
#ifdef __GLIBC__
|
||||
static mode_t open_mode(int flags, va_list va) {
|
||||
return open_has_mode(flags) ? va_arg(va, mode_t) : 0;
|
||||
return (flags & (O_CREAT | O_TMPFILE)) ? va_arg(va, mode_t) : 0;
|
||||
}
|
||||
|
||||
EXPORT int open64(const char *pathname, int flags, ...) {
|
||||
|
||||
@@ -7,8 +7,10 @@ import com.droiddeck.launcher.session.EnhancedSessionLogShare
|
||||
import com.droiddeck.launcher.session.GpuClockPin
|
||||
|
||||
class App : Application() {
|
||||
override fun attachBaseContext(base: android.content.Context) =
|
||||
override fun attachBaseContext(base: android.content.Context) {
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) com.google.android.play.core.splitcompat.SplitCompat.install(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
@@ -17,6 +19,7 @@ class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
com.droiddeck.launcher.core.ComponentDownloads.init(this)
|
||||
Thread({ runCatching { EnhancedSessionLogShare.cleanupAbandonedStages(this) } }, "diagnostic-stage-cleanup").start()
|
||||
com.droiddeck.launcher.ui.Themes.loadGradient(this)
|
||||
com.droiddeck.launcher.ui.BuildWatermark.install(this)
|
||||
@@ -68,7 +71,7 @@ class App : Application() {
|
||||
Thread({
|
||||
runCatching {
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
|
||||
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
|
||||
if (!com.droiddeck.launcher.runtime.LinuxRuntime.patchedProot(app)) com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
|
||||
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
|
||||
}
|
||||
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
|
||||
|
||||
@@ -5,11 +5,8 @@ import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ComponentUpdater
|
||||
import com.droiddeck.launcher.session.ComponentUpdates
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.ProtonDefault
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: Handler) {
|
||||
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
@@ -23,10 +20,6 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
get() = downloads
|
||||
private set(value) { downloads = value }
|
||||
val compDownloadRevision: Long get() = downloadRevision
|
||||
var compModes by mutableStateOf<Map<String, String>>(emptyMap())
|
||||
var compAuto by mutableStateOf<Map<String, List<ComponentUpdater.Status>>>(emptyMap())
|
||||
var compAutoHeld by mutableStateOf<Set<String>>(emptySet())
|
||||
var compAutoChecking by mutableStateOf(false)
|
||||
private var shownDefault: String? = null
|
||||
|
||||
fun refreshComponents(snapshotFirst: Boolean = false) {
|
||||
@@ -36,14 +29,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
|
||||
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
|
||||
runCatching { ComponentUpdater.settle(activity) }
|
||||
val modes = ComponentsManager.COMPONENTS.associateWith { SessionPrefs.componentMode(activity, it) }
|
||||
val auto = ComponentsManager.COMPONENTS.associateWith { runCatching { ComponentUpdater.status(activity, it) }.getOrDefault(emptyList()) }
|
||||
val held = if (runCatching { ComponentsManager.autoDxvkHeld(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
|
||||
ui.post {
|
||||
compModes = modes
|
||||
compAuto = auto
|
||||
compAutoHeld = held
|
||||
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
|
||||
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
if (chosen != null && chosen != shownDefault) {
|
||||
@@ -56,35 +42,6 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
}, "components").start()
|
||||
}
|
||||
|
||||
fun chooseComponentMode(comp: String, mode: String) {
|
||||
SessionPrefs.setComponentMode(activity, comp, mode)
|
||||
compModes = compModes + (comp to mode)
|
||||
if (mode != ComponentUpdates.AUTO) return
|
||||
checkComponentUpdates(force = true)
|
||||
}
|
||||
|
||||
private var checkAgain: Boolean? = null
|
||||
|
||||
fun checkComponentUpdates(force: Boolean) {
|
||||
if (compAutoChecking) {
|
||||
checkAgain = force || checkAgain == true
|
||||
return
|
||||
}
|
||||
compAutoChecking = true
|
||||
Thread({
|
||||
val notes = runCatching { ComponentUpdater.ensure(activity, force) }.onFailure { Log.w(TAG, "component updates", it) }.getOrDefault(emptyList())
|
||||
ui.post {
|
||||
compAutoChecking = false
|
||||
checkAgain?.let { again ->
|
||||
checkAgain = null
|
||||
checkComponentUpdates(again)
|
||||
}
|
||||
if (notes.isNotEmpty()) android.widget.Toast.makeText(activity, notes.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshComponents()
|
||||
}
|
||||
}, "component-updates").start()
|
||||
}
|
||||
|
||||
fun chooseProton(id: String) {
|
||||
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
|
||||
compProton = id
|
||||
|
||||
@@ -79,12 +79,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
|
||||
canRestart = !sessionRunning,
|
||||
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
|
||||
tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 },
|
||||
tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity),
|
||||
)
|
||||
|
||||
fun tpUbwcModeForDevice(): String? = tpUbwcMode.takeIf { gpu.model == 740 }
|
||||
|
||||
fun tpUbwcRestartRequired(): Boolean = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity)
|
||||
|
||||
fun chooseTpUbwcMode(mode: String) {
|
||||
if (gpu.model != 740) return
|
||||
SessionPrefs.setTpUbwcMode(activity, mode)
|
||||
@@ -129,14 +127,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
)
|
||||
}
|
||||
|
||||
var restartBlocked: () -> Boolean = { SessionState.running }
|
||||
|
||||
fun loadTpUbwcMode() {
|
||||
tpUbwcMode = SessionPrefs.tpUbwcMode(activity)
|
||||
}
|
||||
|
||||
fun restartApp() {
|
||||
if (restartBlocked()) return
|
||||
if (SessionState.running) return
|
||||
val launch = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return
|
||||
activity.startActivity(android.content.Intent.makeRestartActivityTask(launch.component)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "components"))
|
||||
@@ -603,19 +595,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
if (!linux && id == TurnipDriver.AUTO) {
|
||||
DriverBundle.selectDisplay(activity, id)
|
||||
pairModeOpen = false
|
||||
lastAutoCheck = 0L
|
||||
setMode(auto = true)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> DriverBundle.selectDisplay(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
pairModeOpen = false
|
||||
setMode(auto = false, mix = true)
|
||||
|
||||
@@ -22,8 +22,8 @@ import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -41,8 +41,6 @@ import com.droiddeck.launcher.ui.PackageRow
|
||||
import com.droiddeck.launcher.ui.exposeTestTags
|
||||
import com.droiddeck.launcher.session.OfflineMode
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.session.ComponentUpdater
|
||||
import com.droiddeck.launcher.session.ComponentUpdates
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.ui.ComponentsPage
|
||||
import com.droiddeck.launcher.session.GameSaves
|
||||
@@ -64,7 +62,9 @@ import com.droiddeck.launcher.ui.ModeSettings
|
||||
import com.droiddeck.launcher.ui.ModeSettingsActions
|
||||
import com.droiddeck.launcher.ui.ConfirmDialog
|
||||
import com.droiddeck.launcher.ui.ControllerActions
|
||||
import com.droiddeck.launcher.ui.ControllerMappingPage
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.ControllerEditorActivity
|
||||
import com.droiddeck.launcher.ui.FrontEndScreen
|
||||
import com.droiddeck.launcher.ui.FrontEndState
|
||||
import com.droiddeck.launcher.ui.FrontEndActions
|
||||
@@ -95,11 +95,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private val components = ComponentsMenu(this, ui)
|
||||
private val decky = DeckyMenu(this, ui)
|
||||
private val updates = UpdatesMenu(this, ui)
|
||||
private val protons = ProtonMenu(
|
||||
this, ui,
|
||||
idle = { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null },
|
||||
onInstalled = { if (!OfflineMode.enabled(this)) components.checkComponentUpdates(force = false) },
|
||||
)
|
||||
private val protons = ProtonMenu(this, ui) { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null }
|
||||
|
||||
private var installed by mutableStateOf<String?>(null)
|
||||
private var flood by mutableStateOf<PendingFlood?>(null)
|
||||
@@ -124,6 +120,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var frameGenLabel by mutableStateOf("")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showAccessibilityConsent by mutableStateOf(false)
|
||||
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var pendingSwitch by mutableStateOf<Intent?>(null)
|
||||
private var switchingTo: Intent? = null
|
||||
@@ -201,13 +198,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var downloadPrefix by mutableStateOf("")
|
||||
private var downloadProxy by mutableStateOf("")
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var lowStorageWarning by mutableStateOf(true)
|
||||
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private val gameLaunchRequest = java.util.concurrent.atomic.AtomicLong()
|
||||
private var preparingGames by mutableStateOf(emptySet<String>())
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
|
||||
private var storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
|
||||
@@ -354,6 +349,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SteamIntegration.Feature.BRIGHTNESS -> integrationSettings(SteamIntegration.brightnessSettings(this))
|
||||
SteamIntegration.Feature.HOME -> if (HomeApp.isDefault(this)) HomeApp.openSystemHomeSettings(this) else offerHomeScreen()
|
||||
SteamIntegration.Feature.SHORTCUTS -> {
|
||||
if (!SteamIntegration.accessibilityConsented(this)) {
|
||||
showAccessibilityConsent = true
|
||||
return
|
||||
}
|
||||
if (!integrationState.shortcuts) shortcutsTried = true
|
||||
SteamIntegration.setShortcutsListed(this, true)
|
||||
integrationSettings(SteamIntegration.shortcutSettings(this), android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
|
||||
@@ -591,6 +590,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var gameDebugLogsNeedsRestart by mutableStateOf(false)
|
||||
private var vacDiagnostics by mutableStateOf(false)
|
||||
private var agentCommands by mutableStateOf(false)
|
||||
private var bluetoothAllowed by mutableStateOf<Boolean?>(null)
|
||||
private var allFilesAllowed by mutableStateOf<Boolean?>(null)
|
||||
private var localNetworkAllowed by mutableStateOf<Boolean?>(null)
|
||||
private var graphicsLogs by mutableStateOf(false)
|
||||
private var steamGpuDrawing by mutableStateOf(false)
|
||||
private var bridgeFellBack by mutableStateOf(false)
|
||||
@@ -625,7 +627,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
|
||||
private var dualScreenSessions by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
|
||||
private var dualScreenControls by mutableStateOf(true)
|
||||
private var androidTextFieldKeyboard by mutableStateOf(false)
|
||||
private var launcherStarted = false
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private val secondScreenDisplayListener = object : DisplayManager.DisplayListener {
|
||||
@@ -654,6 +655,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var gameSpaceOffered = false
|
||||
|
||||
override fun startActivity(intent: Intent?) {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION && !com.droiddeck.launcher.core.PlayDelivery.state.value.ready &&
|
||||
intent?.component?.className == SessionActivity::class.java.name) {
|
||||
com.droiddeck.launcher.core.PlayDelivery.defer(intent)
|
||||
return
|
||||
}
|
||||
val compat = protons.protonBusyId != null || ProtonExtras.installInProgress
|
||||
if (intent?.component?.className == SessionActivity::class.java.name) {
|
||||
if (compatLaunch != null) return
|
||||
@@ -874,7 +880,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
Library.recordPlayed(this, game)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
|
||||
.returnTo(returnHome)
|
||||
@@ -890,15 +895,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
|
||||
}
|
||||
}
|
||||
.playing(game), steamSession = true)
|
||||
}
|
||||
|
||||
private fun Intent.playing(game: Library.SteamGame): Intent =
|
||||
apply { if (game.library == Library.ADDED) putExtra(SessionActivity.EXTRA_PLAYED_GAME, game.gameIdString) }
|
||||
|
||||
private fun recordPlayed(intent: Intent) {
|
||||
intent.getStringExtra(SessionActivity.EXTRA_PLAYED_GAME)?.let { Library.recordPlayed(this, it) }
|
||||
}, steamSession = true)
|
||||
}
|
||||
|
||||
private fun Intent.returnTo(returnHome: Boolean): Intent {
|
||||
@@ -925,17 +922,14 @@ class FrontEndActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
|
||||
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
|
||||
.playing(game)
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, next, returnHome) -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
|
||||
recordPlayed(next)
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
@@ -1097,8 +1091,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
drivers.restartBlocked = ::runtimeChangesBlocked
|
||||
drivers.loadTpUbwcMode()
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) {
|
||||
@Suppress("DEPRECATION")
|
||||
val pending = savedInstanceState?.getParcelable<Intent>("play_pending_session")
|
||||
if (pending?.component?.className == SessionActivity::class.java.name) com.droiddeck.launcher.core.PlayDelivery.defer(pending)
|
||||
}
|
||||
pendingSessionLogExport = savedInstanceState?.getString(KEY_PENDING_SESSION_LOG_EXPORT)?.let(::File)
|
||||
homeChoiceOpen = savedInstanceState?.getBoolean(STATE_HOME_CHOICE_OPEN) == true
|
||||
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
|
||||
@@ -1125,7 +1122,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
downloadProxy = DownloadSource.proxyHostPort(this)
|
||||
DownloadSource.probe()
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
lowStorageWarning = SessionPrefs.lowStorageWarning(this)
|
||||
LauncherIcon.repair(this, intent?.component)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
@@ -1136,11 +1132,28 @@ class FrontEndActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
val prootPack by com.droiddeck.launcher.core.AddonPack.PROOT.state.collectAsState()
|
||||
androidx.compose.runtime.LaunchedEffect(prootPack) {
|
||||
if (prootPack == com.droiddeck.launcher.core.AddonPack.State.Ready && installed == null) refresh()
|
||||
}
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
val delivery by com.droiddeck.launcher.core.PlayDelivery.state.collectAsState()
|
||||
androidx.compose.runtime.LaunchedEffect(delivery.ready) {
|
||||
if (delivery.ready) {
|
||||
com.droiddeck.launcher.core.PlayDelivery.takePendingSession()?.let { startActivity(it) }
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) {
|
||||
refresh()
|
||||
refreshDeliveredComponents(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (BuildConfig.PLAY_DISTRIBUTION && !delivery.ready) ConfirmDialog(
|
||||
title = getString(R.string.app_name),
|
||||
text = if (delivery.busy && delivery.percent >= 0)
|
||||
getString(R.string.setup_runtime_progress, getString(R.string.rtinst_downloading), delivery.percent)
|
||||
else getString(if (delivery.failed) R.string.user_apps_download_failed else R.string.rtinst_downloading),
|
||||
confirm = getString(if (delivery.busy) R.string.common_working else R.string.stores_dl_retry),
|
||||
onConfirm = { com.droiddeck.launcher.core.PlayDelivery.start(this) },
|
||||
onDismiss = { com.droiddeck.launcher.core.PlayDelivery.cancel(); finish() },
|
||||
progress = if (delivery.busy) delivery.percent else null,
|
||||
confirmEnabled = !delivery.busy, closeOnConfirm = false,
|
||||
)
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
|
||||
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
|
||||
@@ -1164,7 +1177,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
|
||||
vkBridge = vkBridge, venus = venus, venusHostVulkan = venusHostVulkan, vkBridgeSafe = vkBridgeSafe, bridgeProtonDxvk = bridgeProtonDxvk, adreno = adrenoGpu,
|
||||
shortcutPicker = shortcutPicker,
|
||||
preparingGames = preparingGames,
|
||||
shortcutLibraryScanning = shortcutLibraryScanning,
|
||||
gameSyncFolder = gameSyncFolder,
|
||||
busy = busy, stage = stage, percent = percent,
|
||||
@@ -1174,9 +1186,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
enhancedDiagnosticsArmed = enhancedDiagnosticsArmed,
|
||||
gameDebugLogsEnabled = gameDebugLogsEnabled, gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart,
|
||||
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
|
||||
bluetoothAllowed = bluetoothAllowed, allFilesAllowed = allFilesAllowed, localNetworkAllowed = localNetworkAllowed,
|
||||
graphicsLogs = graphicsLogs, steamGpuDrawing = steamGpuDrawing, bridgeFellBack = bridgeFellBack, bridgeFellBackMemory = bridgeFellBackMemory,
|
||||
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
|
||||
tpUbwcMode = drivers.tpUbwcModeForDevice(), tpUbwcRestartRequired = drivers.tpUbwcRestartRequired(), canRestartApp = !runtimeChangesBlocked(),
|
||||
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
@@ -1196,7 +1208,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
dualScreenMode = dualScreenMode,
|
||||
dualScreenSessions = dualScreenSessions,
|
||||
dualScreenControls = dualScreenControls,
|
||||
androidTextFieldKeyboard = androidTextFieldKeyboard,
|
||||
packages = packageRows,
|
||||
packageCatalogLoading = catalogLoading,
|
||||
packageBusyId = pkgId,
|
||||
@@ -1219,7 +1230,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
downloadPrefix = downloadPrefix,
|
||||
downloadProxy = downloadProxy,
|
||||
animationsEnabled = animationsEnabled,
|
||||
lowStorageWarning = lowStorageWarning,
|
||||
launcherIcon = launcherIcon,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
@@ -1246,26 +1256,14 @@ class FrontEndActivity : ComponentActivity() {
|
||||
},
|
||||
onSteamGame = { g ->
|
||||
if (shortcutPicker) chooseGameShortcut(g)
|
||||
else if (g.launchKey !in preparingGames) {
|
||||
preparingGames = preparingGames + g.launchKey
|
||||
prepareAndLaunch(g, g.launchKey)
|
||||
}
|
||||
},
|
||||
onStoreGame = { store, id ->
|
||||
val key = "${store.id}:$id"
|
||||
if (key !in preparingGames) {
|
||||
preparingGames = preparingGames + key
|
||||
val app = applicationContext
|
||||
Thread({
|
||||
val g = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
|
||||
runOnUiThread {
|
||||
if (g != null && !isFinishing && !isDestroyed) prepareAndLaunch(g, key)
|
||||
else {
|
||||
preparingGames = preparingGames - key
|
||||
if (g == null && !isFinishing && !isDestroyed) android.widget.Toast.makeText(this, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
else {
|
||||
val request = gameLaunchRequest.incrementAndGet()
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g) {
|
||||
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) return@beforeLaunch
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
|
||||
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
|
||||
}
|
||||
}, "store-play").start()
|
||||
}
|
||||
}
|
||||
},
|
||||
onEpicLauncher = { openEpicLauncher() },
|
||||
@@ -1421,10 +1419,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
},
|
||||
onBluetoothAllowed = { askBluetooth() },
|
||||
onLocalNetworkAllowed = { com.droiddeck.launcher.core.AppPermissions.requestLocalNetwork(this, 4) },
|
||||
onAllFilesAllowed = { com.droiddeck.launcher.files.AllFilesAccess.openSettings(this) },
|
||||
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
|
||||
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
|
||||
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
|
||||
onRestartApp = { drivers.restartApp() },
|
||||
onBackgroundAppLimit = { limit ->
|
||||
SessionPrefs.setBackgroundAppLimit(this, limit); backgroundAppLimit = limit
|
||||
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
|
||||
@@ -1528,14 +1527,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SessionPrefs.setDualScreenSessionControls(this, on)
|
||||
dualScreenControls = on
|
||||
},
|
||||
onAndroidTextFieldKeyboard = { on ->
|
||||
SessionPrefs.setAndroidTextFieldKeyboard(this, on)
|
||||
androidTextFieldKeyboard = on
|
||||
},
|
||||
onLowStorageWarning = { on ->
|
||||
SessionPrefs.setLowStorageWarning(this, on)
|
||||
lowStorageWarning = on
|
||||
},
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
@@ -1626,7 +1617,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
refreshPhantomStatus()
|
||||
showPhantomGate = true
|
||||
},
|
||||
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
|
||||
onStartWirelessAdbPairing = {
|
||||
if (com.droiddeck.launcher.core.AppPermissions.localNetwork(this)) WirelessAdbPairingService.start(this)
|
||||
else com.droiddeck.launcher.core.AppPermissions.requestLocalNetwork(this, 5)
|
||||
},
|
||||
onStartPairingGuide = { displayId, toggleOnly -> PairingGuideActivity.start(this, displayId, toggleOnly) },
|
||||
onOpenNotificationSettings = {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
@@ -1635,12 +1629,39 @@ class FrontEndActivity : ComponentActivity() {
|
||||
},
|
||||
updates = updates.actions(),
|
||||
controller = ControllerActions(
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
|
||||
onOpacity = { o -> ControllerPrefs.setOpacity(this, o); refreshController() },
|
||||
onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
|
||||
onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
|
||||
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
|
||||
onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() },
|
||||
onDeadZone = { v -> ControllerPrefs.setDeadZone(this, v); refreshController() },
|
||||
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
|
||||
),
|
||||
),
|
||||
page = page,
|
||||
)
|
||||
}
|
||||
if (showAccessibilityConsent) ConfirmDialog(
|
||||
title = androidx.compose.ui.res.stringResource(R.string.integration_shortcuts),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.integration_shortcuts_service_description),
|
||||
confirm = androidx.compose.ui.res.stringResource(R.string.glasses_allow),
|
||||
onConfirm = {
|
||||
SteamIntegration.acceptAccessibility(this@FrontEndActivity)
|
||||
integrationState = readIntegration()
|
||||
configureIntegration(SteamIntegration.Feature.SHORTCUTS)
|
||||
},
|
||||
onDismiss = { showAccessibilityConsent = false },
|
||||
)
|
||||
if (showRoms) RomsDialog(
|
||||
path = romsDir,
|
||||
onChoose = {
|
||||
@@ -1685,8 +1706,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
lowSpace?.let { free ->
|
||||
com.droiddeck.launcher.ui.LowSpaceDialog(
|
||||
free,
|
||||
warn = lowStorageWarning,
|
||||
onWarnChange = { on -> SessionPrefs.setLowStorageWarning(this, on); lowStorageWarning = on },
|
||||
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
|
||||
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
|
||||
onDismiss = { lowSpace = null },
|
||||
@@ -1732,15 +1751,24 @@ class FrontEndActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
val wanted = ArrayList<String>()
|
||||
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
for (permission in arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) wanted.add(permission)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !SessionPrefs.bluetoothAsked(this)
|
||||
&& checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||
wanted.add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
SessionPrefs.setBluetoothAsked(this)
|
||||
}
|
||||
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
|
||||
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
wanted.add(Manifest.permission.RECORD_AUDIO)
|
||||
SessionPrefs.setMicAsked(this)
|
||||
}
|
||||
wanted.addAll(com.droiddeck.launcher.core.AppPermissions.startup(this))
|
||||
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
|
||||
else askSharedStorage()
|
||||
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
|
||||
if (!SessionState.running) Thread({
|
||||
com.droiddeck.launcher.session.LogMigration.run(this)
|
||||
@@ -1755,6 +1783,23 @@ class FrontEndActivity : ComponentActivity() {
|
||||
}, "finish-abandoned").start()
|
||||
}
|
||||
|
||||
private fun askSharedStorage() {
|
||||
if (!com.droiddeck.launcher.files.AllFilesAccess.granted() && !SessionPrefs.allFilesAsked(this)) {
|
||||
Thread({
|
||||
if (com.droiddeck.launcher.files.AllFilesAccess.usesSharedStorage(this)) runOnUiThread {
|
||||
if (!SessionPrefs.allFilesAsked(this) && com.droiddeck.launcher.files.AllFilesAccess.ask(this)) SessionPrefs.setAllFilesAsked(this)
|
||||
}
|
||||
}, "all-files-access").start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
refreshPermissionRows()
|
||||
if (requestCode == 5 && com.droiddeck.launcher.core.AppPermissions.localNetwork(this)) WirelessAdbPairingService.start(this)
|
||||
askSharedStorage()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
@@ -1782,9 +1827,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
private fun playSteam() {
|
||||
val storage = com.droiddeck.launcher.storage.StorageState
|
||||
val free = storage.freeBytes(this)
|
||||
if (storage.shouldWarnLowSpace(SessionPrefs.lowStorageWarning(this), storage.lowSpaceDismissed, SessionState.running, free)) {
|
||||
lowSpace = free
|
||||
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
|
||||
lowSpace = storage.freeBytes(this)
|
||||
storage.refresh(this)
|
||||
} else startSteamSession()
|
||||
}
|
||||
@@ -1847,6 +1891,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.SuppressLint("RestrictedApi")
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (compatLaunch != null) {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BACK || event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
@@ -1872,6 +1917,27 @@ class FrontEndActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshPermissionRows() {
|
||||
localNetworkAllowed = if (Build.VERSION.SDK_INT >= 37) com.droiddeck.launcher.core.AppPermissions.localNetwork(this) else null
|
||||
bluetoothAllowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED else null
|
||||
allFilesAllowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) com.droiddeck.launcher.files.AllFilesAccess.granted() else null
|
||||
}
|
||||
|
||||
private fun askBluetooth() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val permission = Manifest.permission.BLUETOOTH_CONNECT
|
||||
val granted = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted || (SessionPrefs.bluetoothAsked(this) && !shouldShowRequestPermissionRationale(permission))) {
|
||||
runCatching {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.parse("package:$packageName")))
|
||||
}
|
||||
} else {
|
||||
SessionPrefs.setBluetoothAsked(this)
|
||||
requestPermissions(arrayOf(permission), 3)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: android.view.MotionEvent): Boolean {
|
||||
if (compatLaunch != null && !compatLaunch!!.loading.ended &&
|
||||
com.droiddeck.launcher.input.PadBridge.isControllerMotionEvent(event)) return true
|
||||
@@ -1886,6 +1952,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
com.droiddeck.launcher.core.PlayDelivery.start(this)
|
||||
if (!homeChoiceOpen) {
|
||||
HomeApp.releaseUnusedHomeEntry(this)
|
||||
refreshHomeAppState()
|
||||
@@ -1899,6 +1966,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
requestedOrientation = launcherOrientation
|
||||
SteamIntegration.syncShortcutsListing(this)
|
||||
refreshWifiDiscovery()
|
||||
refreshPermissionRows()
|
||||
com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
|
||||
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = { args -> openBattleNet(args) }
|
||||
@@ -1909,31 +1977,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
refreshPhantomStatus()
|
||||
syncLossless()
|
||||
updates.onResume()
|
||||
drivers.checkOnOpen()
|
||||
resumeInterruptedPackages()
|
||||
protons.checkUpdates()
|
||||
catalogFresh = false
|
||||
if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) {
|
||||
componentsQueueBusy = true
|
||||
Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
if (applied.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
if (!OfflineMode.enabled(this)) {
|
||||
val updated = runCatching { ComponentUpdater.ensure(this, force = false) }
|
||||
.onFailure { android.util.Log.w("ComponentUpdater", "automatic component updates", it) }.getOrDefault(emptyList())
|
||||
if (updated.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, updated.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
ui.post {
|
||||
componentsQueueBusy = false
|
||||
if (showComponents) components.refreshComponents()
|
||||
autoUpdateRuntime()
|
||||
}
|
||||
}, "components-queue").start()
|
||||
} else if (showComponents) components.refreshComponents()
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
refreshController()
|
||||
refreshHomeAppState()
|
||||
@@ -1943,6 +1987,28 @@ class FrontEndActivity : ComponentActivity() {
|
||||
DeckyManager.syncCefMarker(this)
|
||||
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
|
||||
refreshAddedGames()
|
||||
refreshDeliveredComponents()
|
||||
}
|
||||
|
||||
private fun refreshDeliveredComponents(force: Boolean = false) {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION && !com.droiddeck.launcher.core.PlayDelivery.state.value.ready) return
|
||||
drivers.checkOnOpen()
|
||||
resumeInterruptedPackages()
|
||||
protons.checkUpdates(force)
|
||||
if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) {
|
||||
componentsQueueBusy = true
|
||||
Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
if (applied.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
ui.post {
|
||||
componentsQueueBusy = false
|
||||
if (showComponents) components.refreshComponents()
|
||||
autoUpdateRuntime()
|
||||
}
|
||||
}, "components-queue").start()
|
||||
} else if (showComponents) components.refreshComponents()
|
||||
if (catalog == null) loadDesktopCatalog()
|
||||
if (!busy) Thread({ checkCatalog() }, "catalog").start()
|
||||
}
|
||||
@@ -1966,6 +2032,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == 7619 && BuildConfig.PLAY_DISTRIBUTION) {
|
||||
if (resultCode != RESULT_OK) com.droiddeck.launcher.core.PlayDelivery.cancel()
|
||||
return
|
||||
}
|
||||
if (HomeWidgets.onActivityResult(this, requestCode, resultCode)) return
|
||||
@Suppress("DEPRECATION")
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
@@ -1973,6 +2043,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) com.droiddeck.launcher.core.PlayDelivery.peekPendingSession()?.let {
|
||||
outState.putParcelable("play_pending_session", it)
|
||||
}
|
||||
outState.putBoolean(STATE_HOME_CHOICE_OPEN, homeChoiceOpen)
|
||||
outState.putBoolean(STATE_GAME_SPACE_OFFERED, gameSpaceOffered)
|
||||
pendingSessionLogExport?.let { outState.putString(KEY_PENDING_SESSION_LOG_EXPORT, it.absolutePath) }
|
||||
@@ -1989,19 +2062,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") state.getParcelable(key)
|
||||
|
||||
private fun prepareAndLaunch(g: Library.SteamGame, key: String) {
|
||||
val request = gameLaunchRequest.incrementAndGet()
|
||||
val done = { preparingGames = preparingGames - key }
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g, onBlocked = done) {
|
||||
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) { done(); return@beforeLaunch }
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
|
||||
done()
|
||||
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
com.droiddeck.launcher.core.PlayDelivery.detach(this)
|
||||
gameLaunchRequest.incrementAndGet()
|
||||
cancelCompatLaunch()
|
||||
updates.unregister()
|
||||
@@ -2064,7 +2126,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null
|
||||
dualScreenSessions = SessionPrefs.dualScreenSessionMode(this)
|
||||
dualScreenControls = SessionPrefs.dualScreenSessionControls(this)
|
||||
androidTextFieldKeyboard = SessionPrefs.androidTextFieldKeyboard(this)
|
||||
val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId)
|
||||
dualDisplayId = target?.displayId ?: -1
|
||||
syncDualPanel()
|
||||
@@ -2079,7 +2140,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private fun launchPending(): Boolean = gameLinkReading || (pendingGameLink != null && !gameLinkTried)
|
||||
|
||||
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
|
||||
val onOwnScreen = displayId == null || displayId == (display?.displayId ?: Display.DEFAULT_DISPLAY)
|
||||
val onOwnScreen = displayId == null || displayId == ((if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.displayId ?: Display.DEFAULT_DISPLAY)
|
||||
if (onOwnScreen) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
|
||||
try {
|
||||
HomeApp.launch(this, app, displayId)
|
||||
@@ -2125,6 +2186,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun loadDesktopCatalog() {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION && !com.droiddeck.launcher.core.PlayDelivery.state.value.ready) return
|
||||
if (catalogLoading || catalog != null) return
|
||||
catalogLoading = true
|
||||
Thread({
|
||||
@@ -2157,7 +2219,13 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun MappingHost() {
|
||||
com.droiddeck.launcher.ui.InputSettingsPage(onBack = { showMapping = false })
|
||||
val settings = controllerSettings ?: return
|
||||
ControllerMappingPage(
|
||||
mapping = settings.mapping,
|
||||
onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
|
||||
onReset = { ControllerPrefs.resetMapping(this); refreshController() },
|
||||
onBack = { showMapping = false },
|
||||
)
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
|
||||
@@ -2181,11 +2249,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private fun importComponent(uri: Uri) {
|
||||
val name = displayNameOf(uri) ?: "imported.wcp"
|
||||
components.componentAction(getString(R.string.comp_busy_importing)) {
|
||||
val tmp = File.createTempFile("component-import", ".wcp", cacheDir)
|
||||
try {
|
||||
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
|
||||
getString(R.string.comp_imported, ComponentUpdater.importPackage(this, tmp, name).version)
|
||||
} finally { tmp.delete() }
|
||||
val tmp = File(cacheDir, "component-import.wcp")
|
||||
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
|
||||
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2209,15 +2275,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
|
||||
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
|
||||
onDownload = { components.downloadComponent(it) },
|
||||
onRefresh = {
|
||||
components.refreshComponentCatalog()
|
||||
if (components.compModes.values.any { it == ComponentUpdates.AUTO }) components.checkComponentUpdates(force = true)
|
||||
},
|
||||
compModes = components.compModes,
|
||||
compAuto = components.compAuto,
|
||||
compAutoHeld = components.compAutoHeld,
|
||||
compAutoChecking = components.compAutoChecking,
|
||||
onCompMode = { comp, mode -> components.chooseComponentMode(comp, mode) },
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
|
||||
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
|
||||
games = steamGames,
|
||||
@@ -2241,6 +2299,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onMode = { m -> drivers.chooseMode(m) },
|
||||
onRestart = { drivers.restartApp() },
|
||||
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
@@ -2293,14 +2352,14 @@ class FrontEndActivity : ComponentActivity() {
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
handToBattleNet(full, next) -> {}
|
||||
handToBattleNet(full) { pendingSwitch = next } -> {}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
}, "battlenet-open").start()
|
||||
}
|
||||
|
||||
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
|
||||
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
|
||||
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
|
||||
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
@@ -2311,13 +2370,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
|
||||
ui.post {
|
||||
if (isDestroyed) return@post
|
||||
if (!handed) pendingSwitch = next
|
||||
else {
|
||||
recordPlayed(next)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
if (!handed) otherwise()
|
||||
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
}, "battlenet-request").start()
|
||||
return true
|
||||
@@ -2455,7 +2511,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
|
||||
},
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onInputSettings = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
@@ -3049,7 +3104,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private fun removeRuntime() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
|
||||
com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
|
||||
ready = false
|
||||
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
|
||||
followRuntimeOperation { removal.run(it) }
|
||||
@@ -3076,7 +3130,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refresh()
|
||||
if (ok == true && LinuxRuntime.isInstalled(this)) protons.checkUpdates()
|
||||
}
|
||||
}, "runtime-operation").start()
|
||||
}
|
||||
@@ -3084,12 +3137,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private fun autoUpdateRuntime() {
|
||||
val release = available?.takeIf { catalogFresh } ?: return
|
||||
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
|
||||
if (installed != null) com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
|
||||
if (release.version == installed) return
|
||||
if (installed == null && (OfflineMode.enabled(this) || !com.droiddeck.launcher.core.DeviceSupport.canDraw(this) ||
|
||||
!com.droiddeck.launcher.frontend.AutoComponents.online(this) || com.droiddeck.launcher.frontend.AutoComponents.unfavourable(this))) return
|
||||
if (release.version == installed || installed == null) return
|
||||
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
|
||||
Log.i(TAG, "runtime: installing ${release.version} over ${installed ?: "nothing"} by itself")
|
||||
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
|
||||
install(release)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,10 +200,11 @@ object HomeApp {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
fun idOf(task: android.app.ActivityManager.AppTask) =
|
||||
if (Build.VERSION.SDK_INT >= 29) task.taskInfo.taskId else task.taskInfo.persistentId
|
||||
task.taskInfo?.let { if (Build.VERSION.SDK_INT >= 29) it.taskId else it.persistentId } ?: -1
|
||||
val tasks = activity.getSystemService(android.app.ActivityManager::class.java).appTasks
|
||||
val task = tasks.firstOrNull { idOf(it) == activity.taskId } ?: return
|
||||
val excluded = task.taskInfo.baseIntent.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS != 0
|
||||
val baseIntent = task.taskInfo?.baseIntent ?: return@runCatching
|
||||
val excluded = baseIntent.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS != 0
|
||||
val step = recentsStep(hidden, id in owned, excluded)
|
||||
if (step == RecentsStep.NONE) return
|
||||
if (step != RecentsStep.FORGET) task.setExcludeFromRecents(step == RecentsStep.HIDE)
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
internal class ProtonMenu(
|
||||
private val activity: android.app.Activity,
|
||||
private val ui: Handler,
|
||||
private val onInstalled: () -> Unit = {},
|
||||
private val idle: () -> Boolean = { true },
|
||||
) {
|
||||
private object Operation {
|
||||
@@ -62,7 +61,6 @@ internal class ProtonMenu(
|
||||
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
|
||||
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
if (problem == null) onInstalled()
|
||||
if (automatic) ensureAutoUpdates()
|
||||
}
|
||||
}, "install-proton-$id").start()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -17,8 +18,12 @@ import com.droiddeck.launcher.ui.UpdatesActions
|
||||
import com.droiddeck.launcher.ui.UpdatesState
|
||||
import com.droiddeck.launcher.update.AppUpdates
|
||||
import com.droiddeck.launcher.update.SelfInstaller
|
||||
import com.google.android.play.core.appupdate.AppUpdateManager
|
||||
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
|
||||
import com.google.android.play.core.install.model.UpdateAvailability
|
||||
|
||||
internal class UpdatesMenu(private val activity: Activity, private val ui: Handler) {
|
||||
internal class UpdatesMenu(private val activity: Activity, private val ui: Handler,
|
||||
private val playManager: () -> AppUpdateManager = { AppUpdateManagerFactory.create(activity) }) {
|
||||
var catalog by mutableStateOf<AppUpdates.Catalog?>(null)
|
||||
var follow by mutableStateOf(AppUpdates.Follow(AppUpdates.Channel.STABLE))
|
||||
var checking by mutableStateOf(false)
|
||||
@@ -26,12 +31,15 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
var percent by mutableIntStateOf(-1)
|
||||
var askPermission by mutableStateOf(false)
|
||||
var playUpdateAvailable by mutableStateOf<Boolean?>(null)
|
||||
private var playCheckedAt by mutableStateOf(0L)
|
||||
private var pending: AppUpdates.Release? = null
|
||||
private var lastCheck = 0L
|
||||
private var request = 0
|
||||
@Volatile private var session = -1
|
||||
|
||||
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
|
||||
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission,
|
||||
playUpdateAvailable, playCheckedAt)
|
||||
|
||||
fun actions() = UpdatesActions(
|
||||
onCheck = { check(force = true) },
|
||||
@@ -39,15 +47,22 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
onInstall = ::install,
|
||||
onAllowInstalls = {
|
||||
askPermission = false
|
||||
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
|
||||
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
|
||||
if (!BuildConfig.PLAY_DISTRIBUTION) {
|
||||
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
|
||||
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
|
||||
}
|
||||
},
|
||||
onDismissPermission = { askPermission = false; pending = null },
|
||||
onOpenPlay = ::openPlay,
|
||||
)
|
||||
|
||||
val hasUpdate: Boolean get() = AppUpdates.hasUpdate(catalog, follow)
|
||||
val hasUpdate: Boolean get() = if (BuildConfig.PLAY_DISTRIBUTION) playUpdateAvailable == true else AppUpdates.hasUpdate(catalog, follow)
|
||||
|
||||
fun onResume() {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) {
|
||||
if (!SessionState.running) check(force = false)
|
||||
return
|
||||
}
|
||||
pending?.let { r ->
|
||||
if (SelfInstaller.canInstall(activity)) { pending = null; install(r) }
|
||||
}
|
||||
@@ -55,6 +70,10 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
|
||||
fun check(force: Boolean) {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) {
|
||||
checkPlay(force)
|
||||
return
|
||||
}
|
||||
if (checking || stage != null) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastCheck < 60_000) return
|
||||
@@ -75,6 +94,45 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
}, "app-updates").start()
|
||||
}
|
||||
|
||||
private fun checkPlay(force: Boolean) {
|
||||
if (checking) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastCheck < 60_000) return
|
||||
lastCheck = now
|
||||
checking = true
|
||||
error = null
|
||||
val id = ++request
|
||||
fun failed(failure: Throwable) {
|
||||
if (id != request || activity.isDestroyed) return
|
||||
checking = false
|
||||
if (force) error = activity.getString(R.string.upd_couldnt_check, failure.message ?: failure.javaClass.simpleName)
|
||||
}
|
||||
runCatching {
|
||||
playManager().appUpdateInfo.addOnSuccessListener { info ->
|
||||
if (id != request || activity.isDestroyed) return@addOnSuccessListener
|
||||
checking = false
|
||||
val availability = info.updateAvailability()
|
||||
playUpdateAvailable = when (availability) {
|
||||
UpdateAvailability.UPDATE_AVAILABLE, UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> true
|
||||
UpdateAvailability.UPDATE_NOT_AVAILABLE -> false
|
||||
else -> null
|
||||
}
|
||||
playCheckedAt = now
|
||||
}.addOnFailureListener { failed(it) }
|
||||
}.onFailure { failed(it) }
|
||||
}
|
||||
|
||||
private fun openPlay() {
|
||||
if (!BuildConfig.PLAY_DISTRIBUTION) return
|
||||
val listing = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${activity.packageName}"))
|
||||
.setPackage("com.android.vending")
|
||||
runCatching { activity.startActivity(listing) }.onFailure {
|
||||
runCatching {
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=${activity.packageName}")))
|
||||
}.onFailure { error = activity.getString(R.string.upd_couldnt_check, it.message ?: it.javaClass.simpleName) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun choose(f: AppUpdates.Follow) {
|
||||
AppUpdates.setFollow(activity, f)
|
||||
follow = f
|
||||
@@ -82,6 +140,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
|
||||
private fun install(r: AppUpdates.Release) {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) { openPlay(); return }
|
||||
if (stage != null) return
|
||||
val apk = r.apk ?: run { error = activity.getString(R.string.upd_no_download); return }
|
||||
AppUpdates.installBlock(r)?.let { error = it.message(activity); return }
|
||||
@@ -122,7 +181,9 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
fun start() {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) return
|
||||
catalog = AppUpdates.cached(activity)
|
||||
follow = AppUpdates.follow(activity, catalog)
|
||||
val filter = IntentFilter(SelfInstaller.ACTION_STATUS)
|
||||
@@ -131,6 +192,8 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
|
||||
fun unregister() {
|
||||
request++
|
||||
checking = false
|
||||
runCatching { activity.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,6 @@ object AgentGuest {
|
||||
private set
|
||||
var keyboardRequests by mutableIntStateOf(0)
|
||||
private set
|
||||
var gameTextField by mutableStateOf(false)
|
||||
private set
|
||||
var gameProgram by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var keyboardDismissals by mutableIntStateOf(0)
|
||||
private set
|
||||
@Volatile private var keyboardRoute = false
|
||||
@@ -47,7 +43,7 @@ object AgentGuest {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
|
||||
main.post { steamKeyboard = false; desktopTextField = false }
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@@ -63,7 +59,6 @@ object AgentGuest {
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
|
||||
"ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) }
|
||||
"wine-ime.json" -> readJson(File(dir, path))?.let { recordGameTextField(this, it) }
|
||||
"keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) }
|
||||
"keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) }
|
||||
}
|
||||
@@ -76,7 +71,7 @@ object AgentGuest {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
|
||||
main.post { steamKeyboard = false; desktopTextField = false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,16 +129,6 @@ object AgentGuest {
|
||||
SessionEvents.record("ime.changed", mapOf("textField" to focused, "seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
private fun recordGameTextField(source: FileObserver, snapshot: JSONObject) {
|
||||
val focused = snapshot.optBoolean("textField")
|
||||
val program = snapshot.optString("program").takeIf { it.isNotEmpty() }
|
||||
synchronized(lock) {
|
||||
if (source !== observer) return
|
||||
main.post { gameProgram = program; gameTextField = focused }
|
||||
}
|
||||
SessionEvents.record("game.ime.changed", mapOf("textField" to focused, "program" to program, "seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
private fun recordFocus(snapshot: JSONObject) {
|
||||
val fields = linkedMapOf<String, Any?>(
|
||||
"focusedApp" to snapshot.opt("focusedApp"),
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.droiddeck.launcher.core.FileUtils;
|
||||
import com.droiddeck.launcher.core.SessionPart;
|
||||
import com.droiddeck.launcher.core.HostProcess;
|
||||
import com.droiddeck.launcher.core.TarZst;
|
||||
import com.droiddeck.launcher.BuildConfig;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -113,7 +114,8 @@ public class PulseAudioComponent extends SessionPart {
|
||||
env.add("HOME=" + workingDir);
|
||||
env.add("TMPDIR=" + workingDir);
|
||||
|
||||
String command = workingDir.getAbsolutePath() + "/libpulseaudio.so"
|
||||
String command = HostProcess.LINKER + " " + workingDir.getAbsolutePath() + "/libpulseaudio.so"
|
||||
+ (BuildConfig.PLAY_DISTRIBUTION ? " --dl-search-path=" + modules.getAbsolutePath() : "")
|
||||
+ " --system=false --disable-shm=true --fail=false"
|
||||
+ " -n --file=default.pa --daemonize=false --use-pid-file=false --exit-idle-time=-1"
|
||||
+ " --log-level=info";
|
||||
@@ -194,6 +196,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
try {
|
||||
File modules = new File(workingDir, "modules/arm64");
|
||||
ArrayList<String> cmd = new ArrayList<>();
|
||||
cmd.add(HostProcess.LINKER);
|
||||
cmd.add(pactl.getAbsolutePath());
|
||||
java.util.Collections.addAll(cmd, args);
|
||||
ProcessBuilder builder = new ProcessBuilder(cmd);
|
||||
@@ -255,7 +258,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
try {
|
||||
File modules = new File(workingDir, "modules/arm64");
|
||||
ProcessBuilder builder = new ProcessBuilder(
|
||||
pactl.getAbsolutePath(), "suspend-" + kind, name, Boolean.toString(suspended));
|
||||
HostProcess.LINKER, pactl.getAbsolutePath(), "suspend-" + kind, name, Boolean.toString(suspended));
|
||||
builder.directory(workingDir);
|
||||
builder.redirectErrorStream(true);
|
||||
builder.redirectOutput(new File("/dev/null"));
|
||||
|
||||
@@ -95,8 +95,8 @@ internal class AddonPack(
|
||||
}
|
||||
val root = root(context).apply { mkdirs() }
|
||||
val archive = File(root, "$sha256.tar.zst")
|
||||
val downloaded = bundled(context, archive) || urls.any { url ->
|
||||
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
|
||||
val downloaded = bundled(context, archive) || !BuildConfig.PLAY_DISTRIBUTION && urls.any { url ->
|
||||
ComponentDownloads.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
|
||||
.also { if (!it) archive.delete() }
|
||||
}
|
||||
if (!downloaded) return false
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
object AppPermissions {
|
||||
fun granted(context: Context, permission: String): Boolean =
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun localNetwork(context: Context): Boolean = Build.VERSION.SDK_INT < 37 ||
|
||||
granted(context, Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
|
||||
fun startup(activity: Activity): List<String> = buildList {
|
||||
if (Build.VERSION.SDK_INT >= 37 && !localNetwork(activity) &&
|
||||
!activity.getSharedPreferences("runtime-permissions", Context.MODE_PRIVATE).getBoolean("local-network", false)) {
|
||||
add(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
activity.getSharedPreferences("runtime-permissions", Context.MODE_PRIVATE).edit().putBoolean("local-network", true).apply()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33 && !granted(activity, Manifest.permission.POST_NOTIFICATIONS) &&
|
||||
!activity.getSharedPreferences("stores", Context.MODE_PRIVATE).getBoolean("asked_notifications", false)) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
activity.getSharedPreferences("stores", Context.MODE_PRIVATE).edit().putBoolean("asked_notifications", true).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestLocalNetwork(activity: Activity, requestCode: Int) {
|
||||
if (Build.VERSION.SDK_INT < 37) return
|
||||
val prefs = activity.getSharedPreferences("runtime-permissions", Context.MODE_PRIVATE)
|
||||
if (localNetwork(activity) || prefs.getBoolean("local-network", false) &&
|
||||
!activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK)) {
|
||||
openSettings(activity)
|
||||
} else {
|
||||
prefs.edit().putBoolean("local-network", true).apply()
|
||||
activity.requestPermissions(arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK), requestCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun openSettings(context: Context) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.droiddeck.launcher.core;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import com.droiddeck.launcher.BuildConfig;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class ComponentDownloads {
|
||||
private static volatile ComponentPayloadStore store;
|
||||
|
||||
private ComponentDownloads() {}
|
||||
|
||||
public static void init(Context context) {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) {
|
||||
PlayDelivery.INSTANCE.init(context);
|
||||
store = new ComponentPayloadStore(path -> {
|
||||
try {
|
||||
return PlayDelivery.INSTANCE.open(path);
|
||||
} catch (IOException error) {
|
||||
throw new java.io.UncheckedIOException(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static ComponentPayloadStore bundled() throws IOException {
|
||||
ComponentPayloadStore current = store;
|
||||
if (current == null) throw new IOException("Bundled component delivery is unavailable");
|
||||
return current;
|
||||
}
|
||||
|
||||
public static String catalogName(String name) {
|
||||
return BuildConfig.PLAY_DISTRIBUTION ? "play-" + BuildConfig.SOURCE_COMMIT + "-" + name : name;
|
||||
}
|
||||
|
||||
public static String downloadString(String url) {
|
||||
return downloadString(url, null);
|
||||
}
|
||||
|
||||
public static String downloadString(String url, BooleanSupplier cancelled) {
|
||||
if (!BuildConfig.PLAY_DISTRIBUTION) return Downloader.downloadString(url, cancelled);
|
||||
try {
|
||||
return bundled().read(url, cancelled);
|
||||
} catch (Exception error) {
|
||||
Log.w("ComponentDownloads", "Bundled catalog unavailable: " + url, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String downloadDirect(String url) {
|
||||
return BuildConfig.PLAY_DISTRIBUTION ? downloadString(url) : Downloader.downloadDirect(url);
|
||||
}
|
||||
|
||||
public static long copyBundled(String url, OutputStream output, long limit, Consumer<Float> progress) throws IOException {
|
||||
if (!BuildConfig.PLAY_DISTRIBUTION) throw new IOException("Bundled delivery requires a Play distribution");
|
||||
try {
|
||||
return bundled().copy(url, output, limit, null, null, progress);
|
||||
} catch (java.io.UncheckedIOException error) {
|
||||
throw error.getCause();
|
||||
} catch (Exception error) {
|
||||
if (error instanceof IOException) throw (IOException) error;
|
||||
throw new IOException("Invalid bundled component index", error);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean downloadFile(String url, File destination, boolean resume, Consumer<Float> progress) {
|
||||
return downloadFile(url, destination, resume, null, null, progress);
|
||||
}
|
||||
|
||||
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256, Consumer<Float> progress) {
|
||||
return downloadFile(url, destination, resume, expectedSha256, null, progress);
|
||||
}
|
||||
|
||||
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
|
||||
BooleanSupplier cancelled, Consumer<Float> progress) {
|
||||
if (!BuildConfig.PLAY_DISTRIBUTION) {
|
||||
return Downloader.downloadFile(url, destination, resume, expectedSha256, cancelled, progress);
|
||||
}
|
||||
try {
|
||||
bundled().install(url, destination, expectedSha256, cancelled, progress);
|
||||
return true;
|
||||
} catch (Exception error) {
|
||||
Log.w("ComponentDownloads", "Bundled component unavailable: " + url, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.function.BooleanSupplier
|
||||
import java.util.function.Consumer
|
||||
|
||||
internal class ComponentPayloadStore @JvmOverloads constructor(
|
||||
private val download: (String, File, String, BooleanSupplier?, Consumer<Float>?) -> Boolean = { url, file, digest, cancelled, progress ->
|
||||
Downloader.downloadCandidates(url, listOf(url), file, false, digest, cancelled, progress)
|
||||
},
|
||||
private val assets: (String) -> InputStream,
|
||||
) {
|
||||
private val entries by lazy {
|
||||
val index = readObject("play-components/index.json", 2 * 1024 * 1024)
|
||||
if (index.getInt("schema") != 1) throw IOException("Unsupported component index")
|
||||
index.getJSONObject("entries")
|
||||
}
|
||||
|
||||
private fun readObject(path: String, limit: Int = INDEX_LIMIT): JSONObject {
|
||||
val bytes = assets(path).use { input ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(65536)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
if (output.size() + count > limit) throw IOException("Component metadata exceeds its size limit")
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
return JSONObject(String(bytes, Charsets.UTF_8))
|
||||
}
|
||||
|
||||
fun copy(url: String, output: OutputStream, limit: Long, expectedSha256: String? = null,
|
||||
cancelled: BooleanSupplier? = null, progress: Consumer<Float>? = null): Long {
|
||||
checkCancelled(cancelled)
|
||||
val entry = entries.optJSONObject(url) ?: throw IOException("Component is absent from this build: $url")
|
||||
val size = entry.getLong("size")
|
||||
val digest = entry.getString("sha256")
|
||||
if (size <= 0 || size > limit || !SHA256.matches(digest)) throw IOException("Invalid component metadata")
|
||||
if (expectedSha256 != null && !digest.equals(expectedSha256, ignoreCase = true)) {
|
||||
throw IOException("Component version does not match this build")
|
||||
}
|
||||
val recipe = "play-components/recipes/$digest.json"
|
||||
if (entry.getString("recipe") != recipe) throw IOException("Invalid component recipe path")
|
||||
val chunks = readObject(recipe).getJSONArray("chunks")
|
||||
if (chunks.length() == 0) throw IOException("Component contains no payload")
|
||||
val fullDigest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(65536)
|
||||
var total = 0L
|
||||
var lastPercent = -1
|
||||
for (index in 0 until chunks.length()) {
|
||||
checkCancelled(cancelled)
|
||||
val chunk = chunks.getJSONObject(index)
|
||||
val path = chunk.getString("asset")
|
||||
val chunkSize = chunk.getLong("size")
|
||||
val chunkSha256 = chunk.getString("sha256")
|
||||
if (!ASSET.matches(path) || chunkSize <= 0 || chunkSize > size - total || !SHA256.matches(chunkSha256)) {
|
||||
throw IOException("Invalid component chunk metadata")
|
||||
}
|
||||
val chunkDigest = MessageDigest.getInstance("SHA-256")
|
||||
var copied = 0L
|
||||
assets(path).use { input ->
|
||||
while (true) {
|
||||
checkCancelled(cancelled)
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
if (count.toLong() > chunkSize - copied) throw IOException("Component chunk exceeds pinned size")
|
||||
chunkDigest.update(buffer, 0, count)
|
||||
fullDigest.update(buffer, 0, count)
|
||||
output.write(buffer, 0, count)
|
||||
copied += count
|
||||
total += count
|
||||
val percent = (total * 100 / size).toInt().coerceAtMost(99)
|
||||
if (percent != lastPercent) {
|
||||
lastPercent = percent
|
||||
progress?.accept(percent / 100f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (copied != chunkSize || hex(chunkDigest.digest()) != chunkSha256) {
|
||||
throw IOException("Component chunk failed verification")
|
||||
}
|
||||
}
|
||||
checkCancelled(cancelled)
|
||||
if (total != size || hex(fullDigest.digest()) != digest) throw IOException("Component failed verification")
|
||||
progress?.accept(1f)
|
||||
return total
|
||||
}
|
||||
|
||||
fun read(url: String, cancelled: BooleanSupplier? = null): String {
|
||||
val output = ByteArrayOutputStream()
|
||||
copy(url, output, INDEX_LIMIT.toLong(), cancelled = cancelled)
|
||||
return output.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
|
||||
fun install(url: String, destination: File, expectedSha256: String? = null,
|
||||
cancelled: BooleanSupplier? = null, progress: Consumer<Float>? = null) {
|
||||
val parent = destination.absoluteFile.parentFile ?: throw IOException("Component destination has no parent")
|
||||
Files.createDirectories(parent.toPath())
|
||||
val temporary = Files.createTempFile(parent.toPath(), destination.name + ".", ".component-part")
|
||||
try {
|
||||
val entry = entries.optJSONObject(url) ?: throw IOException("Component is absent from this build: $url")
|
||||
if (entry.optString("delivery") == "windows-guest") {
|
||||
val digest = entry.getString("sha256")
|
||||
val size = entry.getLong("size")
|
||||
val source = java.net.URI(entry.getString("download_url"))
|
||||
if (!entry.getString("id").startsWith("windows-") || !SHA256.matches(digest) || size <= 0 ||
|
||||
source.scheme != "https" || source.host.isNullOrEmpty() || source.userInfo != null || source.fragment != null ||
|
||||
expectedSha256 != null && !digest.equals(expectedSha256, ignoreCase = true)) {
|
||||
throw IOException("Invalid Windows prerequisite identity")
|
||||
}
|
||||
checkCancelled(cancelled)
|
||||
if (!download(source.toString(), temporary.toFile(), digest, cancelled, progress) || Files.size(temporary) != size) {
|
||||
throw IOException("Windows prerequisite download failed verification")
|
||||
}
|
||||
val actual = Files.newInputStream(temporary).use { input ->
|
||||
val hash = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(65536)
|
||||
while (true) {
|
||||
checkCancelled(cancelled)
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
hash.update(buffer, 0, count)
|
||||
}
|
||||
hex(hash.digest())
|
||||
}
|
||||
if (actual != digest) throw IOException("Windows prerequisite failed verification")
|
||||
} else {
|
||||
Files.newOutputStream(temporary).use { output ->
|
||||
copy(url, output, Long.MAX_VALUE, expectedSha256, cancelled, progress)
|
||||
}
|
||||
}
|
||||
checkCancelled(cancelled)
|
||||
Files.move(temporary, destination.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary)
|
||||
Files.deleteIfExists(temporary.resolveSibling(temporary.fileName.toString() + ".resume.properties"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkCancelled(cancelled: BooleanSupplier?) {
|
||||
if (Thread.currentThread().isInterrupted || cancelled?.asBoolean == true) throw IOException("Component copy cancelled")
|
||||
}
|
||||
|
||||
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it.toInt() and 255) }
|
||||
|
||||
companion object {
|
||||
private const val INDEX_LIMIT = 16 * 1024 * 1024
|
||||
private val SHA256 = Regex("[0-9a-f]{64}")
|
||||
private val ASSET = Regex("play-components/[0-9a-f]{64}/[0-9]{4,}")
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ object DeviceReport {
|
||||
h("App")
|
||||
runCatching {
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
k("Version", "${info.versionName} (${info.longVersionCode})")
|
||||
k("Version", "${info.versionName} (${androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info)})")
|
||||
k("App build", "${com.droiddeck.launcher.BuildConfig.BUILD_LABEL} (source ${com.droiddeck.launcher.BuildConfig.SOURCE_COMMIT})")
|
||||
}
|
||||
k("Package", context.packageName)
|
||||
|
||||
@@ -58,7 +58,7 @@ public final class Downloader {
|
||||
|
||||
private static void closeAsync(InputStream active) {
|
||||
CANCEL_EXECUTOR.execute(() -> {
|
||||
try { active.close(); } catch (IOException | IllegalStateException ignored) { }
|
||||
try { active.close(); } catch (IOException ignored) { }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,11 +139,13 @@ public final class Downloader {
|
||||
java.util.function.Consumer<Float> progress) {
|
||||
Identity identity = loadIdentity(destination, resume, expectedSha256);
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
if (Thread.currentThread().isInterrupted()) return false;
|
||||
String source = sources.get(i);
|
||||
if (isCancelled(cancelled)) return false;
|
||||
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
|
||||
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress);
|
||||
if (outcome == Outcome.CANCELLED) return false;
|
||||
if (Thread.currentThread().isInterrupted()) return false;
|
||||
if (outcome == Outcome.DONE) {
|
||||
if (expectedSha256 != null) {
|
||||
String actualSha256 = Hashes.sha256(destination);
|
||||
@@ -296,6 +298,7 @@ public final class Downloader {
|
||||
byte[] buffer = new byte[1 << 16];
|
||||
long lastReport = 0;
|
||||
for (int read = in.read(buffer); read > 0; read = in.read(buffer)) {
|
||||
if (Thread.currentThread().isInterrupted()) return Outcome.RETRY;
|
||||
out.write(buffer, 0, read);
|
||||
written += read;
|
||||
if (isCancelled(cancelled)) return Outcome.CANCELLED;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
object ForegroundServices {
|
||||
fun start(service: Service, id: Int, notification: Notification, types: Int = 0): Boolean = try {
|
||||
if (Build.VERSION.SDK_INT >= 29 && types != 0) service.startForeground(id, notification, types)
|
||||
else service.startForeground(id, notification)
|
||||
true
|
||||
} catch (error: RuntimeException) {
|
||||
if (Build.VERSION.SDK_INT < 31 || error !is android.app.ForegroundServiceStartNotAllowedException) throw error
|
||||
Log.w("ForegroundServices", "Foreground work cannot start in the current lifecycle", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@ object Hashes {
|
||||
@JvmStatic
|
||||
fun sha512(file: File): String = hex(file, "SHA-512")
|
||||
|
||||
@JvmStatic
|
||||
fun md5(file: File): String = hex(file, "MD5")
|
||||
|
||||
@JvmStatic
|
||||
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import java.io.File
|
||||
import java.util.function.Consumer
|
||||
|
||||
object HostProcess {
|
||||
const val LINKER = "/system/bin/linker64"
|
||||
|
||||
private const val TAG = "HostProcess"
|
||||
|
||||
@JvmStatic
|
||||
@@ -53,14 +55,17 @@ object HostProcess {
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pidOf(process: Process): Int = try {
|
||||
val field = process.javaClass.getDeclaredField("pid")
|
||||
field.isAccessible = true
|
||||
val pid = field.getInt(process)
|
||||
field.isAccessible = false
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
-1
|
||||
fun pidOf(process: Process): Int {
|
||||
Regex("""\bpid=(\d+)\b""").find(process.toString())?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||
return try {
|
||||
val field = process.javaClass.getDeclaredField("pid")
|
||||
field.isAccessible = true
|
||||
val pid = field.getInt(process)
|
||||
field.isAccessible = false
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import com.google.android.gms.tasks.Task
|
||||
import java.util.concurrent.TimeUnit
|
||||
import com.google.android.play.core.assetpacks.AssetPackManagerFactory
|
||||
import com.google.android.play.core.assetpacks.model.AssetPackStatus
|
||||
import com.google.android.play.core.splitcompat.SplitCompat
|
||||
import com.google.android.play.core.splitinstall.SplitInstallManagerFactory
|
||||
import com.google.android.play.core.splitinstall.SplitInstallRequest
|
||||
import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
internal object PlayDelivery {
|
||||
data class State(val ready: Boolean = !BuildConfig.PLAY_DISTRIBUTION, val busy: Boolean = false,
|
||||
val failed: Boolean = false, val percent: Int = -1)
|
||||
|
||||
private val mutableState = MutableStateFlow(State())
|
||||
val state = mutableState.asStateFlow()
|
||||
private lateinit var app: Context
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val running = AtomicBoolean(false)
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private var activity = WeakReference<Activity>(null)
|
||||
@Volatile private var session = 0
|
||||
@Volatile private var confirmation = false
|
||||
@Volatile private var attempt = 0
|
||||
private var pendingSession: android.content.Intent? = null
|
||||
private var server: android.net.LocalServerSocket? = null
|
||||
private val clients = java.util.concurrent.Semaphore(2)
|
||||
private val splits by lazy { SplitInstallManagerFactory.create(app) }
|
||||
private val assets by lazy { AssetPackManagerFactory.getInstance(app) }
|
||||
private val definition by lazy {
|
||||
app.assets.open("play-components/delivery.json").bufferedReader().use { JSONObject(it.readText()) }
|
||||
}
|
||||
private fun names(key: String): List<String> = definition.getJSONArray(key).let { list ->
|
||||
(0 until list.length()).map { list.getString(it) }
|
||||
}
|
||||
|
||||
fun init(context: Context) {
|
||||
if (!BuildConfig.PLAY_DISTRIBUTION) return
|
||||
app = context.applicationContext
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun defer(intent: android.content.Intent) { pendingSession = android.content.Intent(intent) }
|
||||
|
||||
@Synchronized
|
||||
fun peekPendingSession(): android.content.Intent? = pendingSession?.let { android.content.Intent(it) }
|
||||
|
||||
@Synchronized
|
||||
fun takePendingSession(): android.content.Intent? = pendingSession.also { pendingSession = null }
|
||||
|
||||
fun start(owner: Activity) {
|
||||
if (!BuildConfig.PLAY_DISTRIBUTION) return
|
||||
activity = WeakReference(owner)
|
||||
if (mutableState.value.ready) return
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
cancelled.set(false)
|
||||
attempt++
|
||||
confirmation = false
|
||||
mutableState.value = State(busy = true)
|
||||
Thread({
|
||||
var completed = State(failed = true)
|
||||
try {
|
||||
installModules()
|
||||
confirmation = false
|
||||
installAssets()
|
||||
checkCancelled()
|
||||
startServer()
|
||||
completed = State(ready = true, percent = 100)
|
||||
} catch (error: Exception) {
|
||||
android.util.Log.w("PlayDelivery", "Play component delivery failed", error)
|
||||
} finally {
|
||||
if (cancelled.get()) cancel()
|
||||
running.set(false)
|
||||
mutableState.value = completed
|
||||
}
|
||||
}, "play-components").start()
|
||||
}
|
||||
|
||||
fun detach(owner: Activity) {
|
||||
if (activity.get() === owner) activity.clear()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancelled.set(true)
|
||||
synchronized(this) { pendingSession = null }
|
||||
if (session > 0) splits.cancelInstall(session)
|
||||
if (::app.isInitialized) runCatching { assets.cancel(names("packs")) }
|
||||
}
|
||||
|
||||
private fun <T> await(task: Task<T>): T = Tasks.await(task, 30, TimeUnit.SECONDS)
|
||||
|
||||
private fun checkCancelled() {
|
||||
if (cancelled.get() || Thread.currentThread().isInterrupted) throw IOException("Play delivery cancelled")
|
||||
}
|
||||
|
||||
private fun installModules() {
|
||||
val missing = names("modules").filterNot { it in splits.installedModules }
|
||||
if (missing.isEmpty()) return
|
||||
val previous = await(splits.sessionStates).firstOrNull {
|
||||
it.moduleNames().containsAll(missing) && it.status() in setOf(
|
||||
SplitInstallSessionStatus.PENDING, SplitInstallSessionStatus.DOWNLOADING,
|
||||
SplitInstallSessionStatus.DOWNLOADED, SplitInstallSessionStatus.INSTALLING,
|
||||
SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION)
|
||||
}
|
||||
session = previous?.sessionId() ?: run {
|
||||
val request = attempt
|
||||
val task = splits.startInstall(SplitInstallRequest.newBuilder().apply { missing.forEach(::addModule) }.build())
|
||||
task.addOnSuccessListener { id -> if (request == attempt && cancelled.get()) splits.cancelInstall(id) }
|
||||
await(task)
|
||||
}
|
||||
while (true) {
|
||||
checkCancelled()
|
||||
val current = await(splits.getSessionState(session))
|
||||
val request = attempt
|
||||
when (current.status()) {
|
||||
SplitInstallSessionStatus.INSTALLED -> {
|
||||
if (!SplitCompat.install(app) || !splits.installedModules.containsAll(missing))
|
||||
throw IOException("Play modules could not be activated")
|
||||
session = 0
|
||||
return
|
||||
}
|
||||
SplitInstallSessionStatus.FAILED, SplitInstallSessionStatus.CANCELED ->
|
||||
throw IOException("Play module delivery failed: ${current.errorCode()}")
|
||||
SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> handler.post {
|
||||
val owner = activity.get()
|
||||
if (request == attempt && session == current.sessionId() && running.get() && !cancelled.get() &&
|
||||
!confirmation && owner != null && !owner.isFinishing && !owner.isDestroyed) {
|
||||
runCatching { splits.startConfirmationDialogForResult(current, owner, 7619) }
|
||||
.onSuccess { confirmation = it }.onFailure { cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
val total = current.totalBytesToDownload()
|
||||
mutableState.value = State(busy = true, percent = if (total > 0)
|
||||
(current.bytesDownloaded() * 80 / total).toInt().coerceIn(0, 80) else -1)
|
||||
Thread.sleep(350)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installAssets() {
|
||||
val packs = names("packs")
|
||||
val missing = packs.filter { assets.getPackLocation(it) == null }
|
||||
if (missing.isEmpty()) return
|
||||
val requestAttempt = attempt
|
||||
val fetch = assets.fetch(missing)
|
||||
fetch.addOnSuccessListener { if (requestAttempt == attempt && cancelled.get()) assets.cancel(missing) }
|
||||
await(fetch)
|
||||
while (true) {
|
||||
checkCancelled()
|
||||
val states = await(assets.getPackStates(missing)).packStates().values
|
||||
if (states.any { it.status() == AssetPackStatus.FAILED || it.status() == AssetPackStatus.CANCELED })
|
||||
throw IOException("Play data delivery failed")
|
||||
if (missing.all { assets.getPackLocation(it) != null }) return
|
||||
val request = attempt
|
||||
if (states.any { it.status() == AssetPackStatus.WAITING_FOR_WIFI || it.status() == AssetPackStatus.REQUIRES_USER_CONFIRMATION })
|
||||
handler.post {
|
||||
val owner = activity.get()
|
||||
if (request == attempt && running.get() && !cancelled.get() &&
|
||||
!confirmation && owner != null && !owner.isFinishing && !owner.isDestroyed) {
|
||||
confirmation = true
|
||||
assets.showConfirmationDialog(owner).addOnSuccessListener { result ->
|
||||
if (request == attempt && result != Activity.RESULT_OK) cancel()
|
||||
}.addOnFailureListener { if (request == attempt) cancel() }
|
||||
}
|
||||
}
|
||||
val total = states.sumOf { it.totalBytesToDownload() }
|
||||
val downloaded = states.sumOf { it.bytesDownloaded() }
|
||||
mutableState.value = State(busy = true, percent = if (total > 0)
|
||||
(80 + downloaded * 19 / total).toInt().coerceIn(80, 99) else -1)
|
||||
Thread.sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startServer() {
|
||||
if (server != null) return
|
||||
val listener = android.net.LocalServerSocket(app.packageName + ".play-components")
|
||||
server = listener
|
||||
Thread({
|
||||
while (true) {
|
||||
val client = try { listener.accept() } catch (_: IOException) { break }
|
||||
if (!clients.tryAcquire()) { client.close(); continue }
|
||||
Thread({
|
||||
try {
|
||||
client.use {
|
||||
client.soTimeout = 30_000
|
||||
if (client.peerCredentials.uid != android.os.Process.myUid()) return@use
|
||||
val input = java.io.DataInputStream(client.inputStream)
|
||||
val length = input.readInt()
|
||||
if (length !in 1..256) return@use
|
||||
val path = ByteArray(length).also(input::readFully).toString(Charsets.UTF_8)
|
||||
if (!Regex("play-components/[0-9a-f]{64}/[0-9]{4,}").matches(path)) return@use
|
||||
val output = client.outputStream
|
||||
open(path).use { source ->
|
||||
output.write(1)
|
||||
source.copyTo(output, 65536)
|
||||
}
|
||||
}
|
||||
} catch (error: IOException) {
|
||||
android.util.Log.w("PlayDelivery", "Component stream closed", error)
|
||||
} finally {
|
||||
clients.release()
|
||||
}
|
||||
}, "play-component-reader").apply { isDaemon = true; start() }
|
||||
}
|
||||
}, "play-component-server").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun open(path: String): InputStream {
|
||||
try {
|
||||
return app.assets.open(path)
|
||||
} catch (_: java.io.FileNotFoundException) {
|
||||
val info = app.packageManager.getApplicationInfo(app.packageName, 0)
|
||||
for (split in info.splitSourceDirs.orEmpty()) {
|
||||
val archive = ZipFile(split)
|
||||
val entry = archive.getEntry("assets/$path")
|
||||
if (entry == null) {
|
||||
archive.close()
|
||||
continue
|
||||
}
|
||||
return object : FilterInputStream(archive.getInputStream(entry)) {
|
||||
override fun close() { try { super.close() } finally { archive.close() } }
|
||||
}
|
||||
}
|
||||
for (name in names("packs")) {
|
||||
val directory = assets.getPackLocation(name)?.assetsPath()?.let(::File) ?: continue
|
||||
val file = File(directory, path)
|
||||
if (file.canonicalPath.startsWith(directory.canonicalPath + File.separator) && file.isFile)
|
||||
return file.inputStream()
|
||||
}
|
||||
if (!running.get()) mutableState.value = State(failed = true)
|
||||
throw IOException("Play component is not installed: $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import com.droiddeck.launcher.input.HomeButtonService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
object SteamIntegration {
|
||||
private const val ACCESSIBILITY_CONSENT_VERSION = 1
|
||||
const val ACTION_SETUP = "com.droiddeck.launcher.STEAM_INTEGRATION"
|
||||
const val EXTRA_FEATURE = "integration_feature"
|
||||
enum class Feature { WIFI, BRIGHTNESS, HOME, SHORTCUTS }
|
||||
@@ -36,7 +37,7 @@ object SteamIntegration {
|
||||
locationOn = WifiDiscovery.locationEnabled(context),
|
||||
brightness = Settings.System.canWrite(context),
|
||||
home = HomeApp.isDefault(context),
|
||||
shortcuts = context.getSystemService(AccessibilityManager::class.java)
|
||||
shortcuts = accessibilityConsented(context) && context.getSystemService(AccessibilityManager::class.java)
|
||||
.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).any {
|
||||
val service = it.resolveInfo.serviceInfo
|
||||
ComponentName(service.packageName, service.name) == ComponentName(context, HomeButtonService::class.java)
|
||||
@@ -56,6 +57,14 @@ object SteamIntegration {
|
||||
|
||||
private fun shortcutsComponent(context: Context) = ComponentName(context, HomeButtonService::class.java)
|
||||
|
||||
fun accessibilityConsented(context: Context): Boolean =
|
||||
SessionPrefs.sessionPrefs(context).getInt("accessibility_consent", 0) == ACCESSIBILITY_CONSENT_VERSION
|
||||
|
||||
fun acceptAccessibility(context: Context) {
|
||||
SessionPrefs.sessionPrefs(context).edit().putInt("accessibility_consent", ACCESSIBILITY_CONSENT_VERSION).apply()
|
||||
HomeButtonService.consentChanged()
|
||||
}
|
||||
|
||||
fun shortcutsChosen(context: Context): Boolean = shortcutsSelection(context) == true
|
||||
|
||||
private fun shortcutsSelection(context: Context): Boolean? {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
object SystemInsets {
|
||||
fun fit(view: View) {
|
||||
if (Build.VERSION.SDK_INT < 35) return
|
||||
val left = view.paddingLeft
|
||||
val top = view.paddingTop
|
||||
val right = view.paddingRight
|
||||
val bottom = view.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { target, insets ->
|
||||
val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or
|
||||
WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime())
|
||||
target.setPadding(left + safe.left, top + safe.top, right + safe.right, bottom + safe.bottom)
|
||||
insets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(view)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import com.droiddeck.launcher.session.SessionPrefs
|
||||
object WifiDiscovery {
|
||||
val permissions = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
|
||||
fun permissionGranted(context: Context): Boolean = permissions.any {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
fun permissionGranted(context: Context): Boolean =
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) else permissions).any {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun locationEnabled(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
(context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager)?.isLocationEnabled == true
|
||||
|
||||
@@ -60,7 +60,10 @@ class WirelessAdbPairingService : Service() {
|
||||
pairingPort = null
|
||||
working = false
|
||||
publish(Stage.Waiting)
|
||||
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
|
||||
if (!ForegroundServices.start(this, NOTIFICATION_ID, notification(Stage.Waiting))) {
|
||||
finish(Stage.Failed(getString(R.string.adbpair_title_failed)))
|
||||
return
|
||||
}
|
||||
startDiscovery()
|
||||
main.removeCallbacks(timeout)
|
||||
main.postDelayed(timeout, TIMEOUT_MS)
|
||||
@@ -256,6 +259,10 @@ class WirelessAdbPairingService : Service() {
|
||||
private fun publish(stage: Stage) { current.value = stage }
|
||||
|
||||
fun start(context: Context) {
|
||||
if (!AppPermissions.localNetwork(context)) {
|
||||
publish(Stage.Failed(context.getString(R.string.setup_local_network)))
|
||||
return
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
|
||||
publish(Stage.Waiting)
|
||||
val intent = Intent(context, WirelessAdbPairingService::class.java)
|
||||
|
||||
@@ -1,21 +1,52 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AddedExes
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
|
||||
object AllFilesAccess {
|
||||
fun missing(sdk: Int = Build.VERSION.SDK_INT, granted: () -> Boolean = { Environment.isExternalStorageManager() }): Boolean =
|
||||
fun granted(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
|
||||
|
||||
fun usesSharedStorage(context: Context): Boolean {
|
||||
val own = (listOf(context.filesDir.parentFile) + context.getExternalFilesDirs(null).map { it?.parentFile })
|
||||
.filterNotNull().map { it.absolutePath + "/" }
|
||||
fun shared(path: String) = own.none { (path + "/").startsWith(it) }
|
||||
return AddedExes.list(context).any { shared(it.folder) } ||
|
||||
StoreInstallRoot.roots(context).any { shared(it.absolutePath) }
|
||||
}
|
||||
|
||||
fun ask(activity: Activity): Boolean {
|
||||
if (granted() || activity.isFinishing || activity.isDestroyed) return false
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.setup_all_files)
|
||||
.setMessage(R.string.fm_drive_access_text)
|
||||
.setPositiveButton(R.string.upd_open_settings) { _, _ -> openSettings(activity) }
|
||||
.setNegativeButton(R.string.common_cancel, null)
|
||||
.show()
|
||||
return true
|
||||
}
|
||||
|
||||
fun missing(sdk: Int = Build.VERSION.SDK_INT, granted: () -> Boolean = { AllFilesAccess.granted() }): Boolean =
|
||||
sdk >= Build.VERSION_CODES.R && !granted()
|
||||
|
||||
fun openSettings(activity: Activity) = open(activity)
|
||||
|
||||
fun open(context: Context) {
|
||||
val app = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:" + context.packageName))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(app) }.recoverCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
|
||||
val forApp = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:" + context.packageName)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
context.startActivity(forApp)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.files
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -22,8 +23,11 @@ class FileManagerActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
com.droiddeck.launcher.files.AllFilesAccess.ask(this)
|
||||
} else if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
|
||||
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
|
||||
setContent {
|
||||
|
||||
@@ -1210,7 +1210,7 @@ fun FileManagerScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else
|
||||
} else {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
if (canGoUp) item(key = "..") {
|
||||
ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.UP) })
|
||||
@@ -1290,6 +1290,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pullState.verticalOffset > 0.5f || pullState.isRefreshing) {
|
||||
PullToRefreshContainer(
|
||||
state = pullState,
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -26,8 +27,11 @@ class FilePickerActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
com.droiddeck.launcher.files.AllFilesAccess.ask(this)
|
||||
} else if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
|
||||
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
|
||||
val extensions = intent.getStringArrayExtra(EXTRA_EXTENSIONS)?.toList() ?: emptyList()
|
||||
|
||||
@@ -90,8 +90,7 @@ object StorageRoots {
|
||||
.filter { present(it.state) }
|
||||
.forEachIndexed { index, volume ->
|
||||
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
|
||||
val directory = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) volume.directory else null
|
||||
val dirName = directory?.name?.takeIf { it.isNotBlank() }
|
||||
val dirName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) volume.directory?.name?.takeIf { it.isNotBlank() } else null
|
||||
val descSlug = volume.getDescription(context)?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
|
||||
@@ -99,7 +98,9 @@ object StorageRoots {
|
||||
entry.removable = !volume.isPrimary && volume.isRemovable
|
||||
if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug)
|
||||
|
||||
directory?.let { entry.candidates += it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
volume.directory?.let { entry.candidates += it }
|
||||
}
|
||||
val name = uuid ?: dirName
|
||||
if (name != null) {
|
||||
entry.candidates += File("/storage/$name")
|
||||
|
||||
@@ -523,9 +523,12 @@ object AddedGames {
|
||||
}
|
||||
}
|
||||
|
||||
fun listingFile(context: Context): File = File(context.filesDir, "session/added-games.json")
|
||||
|
||||
@Synchronized
|
||||
fun writeListing(context: Context, games: List<Game>,
|
||||
extra: List<JSONObject> = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
val file = listingFile(context).apply { parentFile?.mkdirs() }
|
||||
val json = StringBuilder("[")
|
||||
games.forEachIndexed { i, g ->
|
||||
if (i > 0) json.append(',')
|
||||
@@ -545,7 +548,13 @@ object AddedGames {
|
||||
if (games.isNotEmpty() || i > 0) json.append(',')
|
||||
json.append(entry.toString())
|
||||
}
|
||||
file.writeText(json.append(']').toString())
|
||||
val staged = File(file.path + ".tmp")
|
||||
staged.writeText(json.append(']').toString())
|
||||
java.nio.file.Files.move(
|
||||
staged.toPath(), file.toPath(),
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
return file
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.json.JSONArray
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object AddedGamesPublisher {
|
||||
private const val TAG = "AddedGamesPublisher"
|
||||
@@ -13,6 +15,7 @@ object AddedGamesPublisher {
|
||||
internal var now = false
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "added-games-publish") }
|
||||
private val retries = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "added-games-publish-retry") }
|
||||
|
||||
fun later(context: Context, live: () -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
@@ -23,6 +26,43 @@ object AddedGamesPublisher {
|
||||
if (now) work.run() else worker.execute(work)
|
||||
}
|
||||
|
||||
fun refreshDuringSession(context: Context, active: () -> Boolean) {
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
try {
|
||||
if (!active()) return@execute
|
||||
val before = AddedGames.listingFile(app).takeIf { it.isFile }?.readText().orEmpty()
|
||||
val known = runCatching {
|
||||
val listing = JSONArray(before)
|
||||
List(listing.length()) { listing.getJSONObject(it).optString("exe") }.toSet()
|
||||
}.getOrDefault(emptySet())
|
||||
val games = AddedGames.scan(app)
|
||||
if (!active()) return@execute
|
||||
AddedGames.writeListing(app, games)
|
||||
val arch = com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(app)
|
||||
LibraryCache.save(app, Library.launchableGames(app, added = games, arch = arch), arch == "x86_64")
|
||||
val fresh = games.filter { it.guestExe !in known }
|
||||
if (fresh.isEmpty()) return@execute
|
||||
Log.i(TAG, "session refresh: ${fresh.size} game(s) not in the listing the session started with")
|
||||
retries.scheduleWithFixedDelay({
|
||||
try {
|
||||
if (!active()) throw java.util.concurrent.CancellationException()
|
||||
if (com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, fresh) { true } ||
|
||||
!com.droiddeck.launcher.session.SessionState.running) {
|
||||
throw java.util.concurrent.CancellationException()
|
||||
}
|
||||
} catch (e: java.util.concurrent.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "session shortcut retry: ${e.message}")
|
||||
}
|
||||
}, 5, 5, TimeUnit.SECONDS)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "session refresh: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun republish(context: Context) {
|
||||
val games = AddedGames.scan(context)
|
||||
AddedGames.writeListing(context, games)
|
||||
|
||||
@@ -475,15 +475,15 @@ object AutoComponents {
|
||||
return true
|
||||
}
|
||||
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame, onBlocked: () -> Unit = {}, onReady: () -> Unit) {
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val ready = runCatching { prepareLaunch(app, game) }.getOrElse {
|
||||
Log.w(TAG, "${game.name}: launch repair discovery failed: ${it.javaClass.simpleName}: ${it.message}")
|
||||
false
|
||||
}
|
||||
Handler(Looper.getMainLooper()).post(if (ready) onReady else onBlocked)
|
||||
queueGame(app, game, asked = false)
|
||||
if (ready) Handler(Looper.getMainLooper()).post(onReady)
|
||||
}, "auto-components-launch").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,11 @@ object Library {
|
||||
val source: String = SOURCE_STEAM,
|
||||
val storeId: String? = null,
|
||||
val logo: File? = null,
|
||||
val installedAt: Long = 0L,
|
||||
) {
|
||||
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
|
||||
val profileKey: String get() = when {
|
||||
source == com.droiddeck.launcher.stores.Store.BATTLENET.id -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
|
||||
library == ADDED -> java.lang.Integer.toUnsignedString(appId)
|
||||
@@ -32,7 +30,6 @@ object Library {
|
||||
}
|
||||
}
|
||||
const val ADDED = "added"
|
||||
private const val PLAYED = "added_games_played"
|
||||
const val EPIC_LAUNCHER = "epic-launcher"
|
||||
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
|
||||
const val BATTLENET_LAUNCHER = "battlenet-launcher"
|
||||
@@ -223,18 +220,6 @@ object Library {
|
||||
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, game: SteamGame) {
|
||||
if (game.library == ADDED) recordPlayed(context, game.gameIdString)
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, gameId: String) {
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).edit()
|
||||
.putLong(gameId, System.currentTimeMillis() / 1000).apply()
|
||||
}
|
||||
|
||||
internal fun recordedPlay(context: Context, gameId: Long): Long =
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).getLong(java.lang.Long.toUnsignedString(gameId), 0L)
|
||||
|
||||
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptyMap()
|
||||
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
|
||||
@@ -263,13 +248,12 @@ object Library {
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
|
||||
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
|
||||
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
|
||||
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
logo = art.logo,
|
||||
installedAt = g.folder.lastModified(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ object LibraryCache {
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
logo = o.file("logo"),
|
||||
installedAt = o.optLong("installedAt"),
|
||||
)
|
||||
} to x64
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
|
||||
@@ -45,7 +44,7 @@ object LibraryCache {
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
|
||||
.put("source", g.source).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -14,8 +14,8 @@ object SteamLaunchOptions {
|
||||
private const val TAG = "SteamLaunchOptions"
|
||||
private val STEAM = listOf("UserLocalConfigStore", "Software", "Valve", "Steam")
|
||||
|
||||
fun current(context: Context, appId: Int): String? =
|
||||
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId)
|
||||
fun current(context: Context, appId: Int): String =
|
||||
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId).orEmpty()
|
||||
|
||||
enum class Saved { YES, NO, DESKTOP }
|
||||
|
||||
@@ -35,8 +35,7 @@ object SteamLaunchOptions {
|
||||
internal fun steam(steamHome: File, appId: Int): String? {
|
||||
val config = AddedGames.accountConfig(steamHome) ?: return null
|
||||
val text = runCatching { File(config, "localconfig.vdf").readText() }.getOrNull() ?: return null
|
||||
if (section(text, STEAM) == null) return null
|
||||
return launchOptions(text, appId).orEmpty()
|
||||
return launchOptions(text, appId)
|
||||
}
|
||||
|
||||
internal fun write(steamHome: File, appId: Int, options: String): Boolean {
|
||||
|
||||
@@ -162,6 +162,7 @@ object Glasses {
|
||||
private val simulating = java.util.concurrent.atomic.AtomicReference<Thread?>(null)
|
||||
private val frames = ArrayDeque<String>()
|
||||
|
||||
@android.annotation.SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
fun init(context: Context) {
|
||||
if (::handler.isInitialized) return
|
||||
app = context.applicationContext
|
||||
|
||||
@@ -33,7 +33,7 @@ internal class AndroidXrealConnector(context: Context) : XrealConnector {
|
||||
|
||||
companion object {
|
||||
internal fun hasDirectRoute(properties: LinkProperties, address: InetAddress): Boolean =
|
||||
properties.routes.any { !it.hasGateway() && it.destination.prefixLength > 0 && it.matches(address) }
|
||||
properties.routes.any { (it.gateway?.isAnyLocalAddress != false) && it.destination.prefixLength > 0 && it.matches(address) }
|
||||
}
|
||||
|
||||
override fun connect(): XrealChannels {
|
||||
|
||||
@@ -27,11 +27,12 @@ object AutoPicks {
|
||||
}
|
||||
|
||||
val BUILT_IN = listOf(
|
||||
Rule(setOf(Family.A8XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)),
|
||||
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_ONEUI)),
|
||||
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
|
||||
Rule(setOf(Family.A7XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.A6XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)),
|
||||
)
|
||||
|
||||
@@ -159,12 +159,6 @@ object DriverBundle {
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
fun selectDisplay(context: Context, id: String) {
|
||||
val left = active(context)
|
||||
SessionPrefs.setAndroidDriver(context, id)
|
||||
if (left != null && left.androidId != id) SessionPrefs.setLinuxDriver(context, "")
|
||||
}
|
||||
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
|
||||
@@ -191,7 +191,6 @@ public final class SystemVulkanDriver {
|
||||
|
||||
/** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */
|
||||
public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException {
|
||||
if (!AddonPack.POWERVR.installed(context)) return;
|
||||
pruneUnused(runtimeDirectory(context), guestsStopped);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.ComponentDownloads
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
@@ -17,9 +18,9 @@ import java.net.URL
|
||||
object TurnipReleases {
|
||||
private const val TAG = "TurnipReleases"
|
||||
private const val PREFS = "turnip_releases"
|
||||
private const val KEY_RELEASE = "latest"
|
||||
private val KEY_RELEASE = ComponentDownloads.catalogName("latest")
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
private const val KEY_PICKS = "autoPicks"
|
||||
private val KEY_PICKS = ComponentDownloads.catalogName("autoPicks")
|
||||
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
|
||||
val sha256: String = "", val pair: String = "", val bundle: Boolean = false,
|
||||
@@ -97,7 +98,6 @@ object TurnipReleases {
|
||||
// Venus): its driver pairs fall past the first 15, so it is read in one full page.
|
||||
val perPage = if (src.repo == NON_ADRENO_REPO) 100 else 15
|
||||
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=$perPage"))
|
||||
val found = JSONArray()
|
||||
val seen = HashSet<String>()
|
||||
var newest: String? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
@@ -115,13 +115,12 @@ object TurnipReleases {
|
||||
val label = kind.label
|
||||
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
|
||||
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
|
||||
found.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
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("pair", kind.pair)
|
||||
.put("bundle", kind.bundle).put("id", a.optLong("id")).put("updated_at", a.optString("updated_at")))
|
||||
}
|
||||
}
|
||||
for (i in 0 until found.length()) assets.put(found.get(i))
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
} catch (e: Exception) {
|
||||
failed.add(src.label)
|
||||
@@ -129,8 +128,7 @@ object TurnipReleases {
|
||||
}
|
||||
}
|
||||
if (failed.size == sources.size) throw lastError ?: IOException("no source answered")
|
||||
carryOver(failed, prefs(context).getString(KEY_RELEASE, null)?.let { runCatching { JSONObject(it) }.getOrNull() }, assets, latest)
|
||||
runCatching { fetch(context, AutoPicks.URL, AutoPicks.URL) }
|
||||
runCatching { get(context, AutoPicks.URL, AutoPicks.URL) }
|
||||
.onFailure { Log.i(TAG, "auto picks: ${it.message}") }
|
||||
.getOrNull()?.takeIf { AutoPicks.parse(it) != null }
|
||||
?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() }
|
||||
@@ -140,22 +138,12 @@ object TurnipReleases {
|
||||
return parse(stored)
|
||||
}
|
||||
|
||||
internal var fetch: (Context, String, String) -> String = { context, source, url -> get(context, source, url) }
|
||||
|
||||
internal fun carryOver(failed: List<String>, previous: JSONObject?, assets: JSONArray, latest: JSONArray) {
|
||||
if (previous == null || failed.isEmpty()) return
|
||||
for ((key, into) in listOf("assets" to assets, "latest" to latest)) {
|
||||
val old = previous.optJSONArray(key) ?: continue
|
||||
for (i in 0 until old.length()) old.optJSONObject(i)?.takeIf { it.optString("source") in failed }?.let(into::put)
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(context: Context, url: String): String {
|
||||
val sources = DownloadSource.candidates(url)
|
||||
var last: IOException? = null
|
||||
for ((index, source) in sources.withIndex()) {
|
||||
try {
|
||||
val body = fetch(context, source, url)
|
||||
val body = get(context, source, url)
|
||||
DownloadSource.succeeded(source)
|
||||
return body
|
||||
} catch (e: IOException) {
|
||||
@@ -168,6 +156,8 @@ object TurnipReleases {
|
||||
}
|
||||
|
||||
private fun get(context: Context, source: String, url: String): String {
|
||||
if (BuildConfig.PLAY_DISTRIBUTION) return ComponentDownloads.downloadString(url)
|
||||
?: throw IOException("Driver catalog is absent from this build")
|
||||
val c = URL(source).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 20_000
|
||||
@@ -261,7 +251,7 @@ object TurnipReleases {
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val ok = Downloader.downloadFile(asset.url, target, true, asset.sha256) { f -> progress(if (f < 0) -1 else (f * 100).toInt()) }
|
||||
val ok = ComponentDownloads.downloadFile(asset.url, target, true, asset.sha256) { f -> progress(if (f < 0) -1 else (f * 100).toInt()) }
|
||||
if (!ok) throw IOException(context.getString(R.string.turnip_download_failed))
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
FileUtils.delete(target)
|
||||
|
||||
@@ -37,8 +37,11 @@ internal object TrackingPack {
|
||||
if (nativeLoaded) return true
|
||||
if (!pack.installed(context)) return false
|
||||
return runCatching {
|
||||
System.load(file(context, OPENCV).absolutePath)
|
||||
System.load(file(context, MEDIAPIPE).absolutePath)
|
||||
for (name in listOf(OPENCV, MEDIAPIPE)) {
|
||||
val library = file(context, name)
|
||||
check(library.isFile && library.setReadOnly()) { "Tracking library cannot be made read-only: $name" }
|
||||
System.load(library.absolutePath)
|
||||
}
|
||||
nativeLoaded = true
|
||||
true
|
||||
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
|
||||
|
||||
@@ -141,8 +141,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var dDividerTop = -1f
|
||||
private var dFrame: Bitmap? = null
|
||||
private var dFrameKey = ""
|
||||
private var sFrame: Bitmap? = null
|
||||
private var sFrameKey = ""
|
||||
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private val colGpu = 0xFF5EE08A.toInt()
|
||||
@@ -276,17 +274,9 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
seenFpsUpdates = updates
|
||||
periodDone.trySend(Unit)
|
||||
}
|
||||
if (graphRect == null) return
|
||||
if (stripRect != null) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (now - lastGraphDrawMs < STRIP_GRAPH_REDRAW_MS) return
|
||||
lastGraphDrawMs = now
|
||||
}
|
||||
invalidate()
|
||||
if (graphRect != null) invalidate()
|
||||
}
|
||||
|
||||
private var lastGraphDrawMs = 0L
|
||||
|
||||
private fun cycleSize() {
|
||||
size = size.next()
|
||||
onSizeCycledListener?.accept(size.token)
|
||||
@@ -1350,15 +1340,10 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
canvas.drawText(g.text, g.x, g.baseline, paint)
|
||||
if (g.glow) paint.clearShadowLayer()
|
||||
}
|
||||
headerMark?.let {
|
||||
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedMark(canvas, it)
|
||||
else DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent)
|
||||
}
|
||||
headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
|
||||
headerWordmark?.let { WordmarkArt.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
|
||||
|
||||
graphRect?.let {
|
||||
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedGraph(canvas, it) else drawGraph(canvas, it)
|
||||
}
|
||||
graphRect?.let { drawGraph(canvas, it) }
|
||||
|
||||
lockController.drawBadge(canvas)
|
||||
}
|
||||
@@ -1383,66 +1368,18 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
canvas.drawPath(graphPath, graphPaint)
|
||||
}
|
||||
|
||||
private var cachedGraphBitmap: Bitmap? = null
|
||||
private var cachedGraphCanvas: Canvas? = null
|
||||
private val cachedGraphRect = RectF()
|
||||
private var cachedMarkBitmap: Bitmap? = null
|
||||
private var cachedMarkKey = ""
|
||||
|
||||
private fun drawCachedGraph(canvas: Canvas, rect: RectF) {
|
||||
val w = rect.width().roundToInt().coerceAtLeast(1)
|
||||
val h = rect.height().roundToInt().coerceAtLeast(1)
|
||||
var bitmap = cachedGraphBitmap
|
||||
if (bitmap == null || bitmap.width != w || bitmap.height != h) {
|
||||
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
cachedGraphBitmap = bitmap
|
||||
cachedGraphCanvas = Canvas(bitmap)
|
||||
cachedGraphRect.set(0f, 0f, w.toFloat(), h.toFloat())
|
||||
}
|
||||
bitmap.eraseColor(Color.TRANSPARENT)
|
||||
drawGraph(cachedGraphCanvas!!, cachedGraphRect)
|
||||
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
|
||||
}
|
||||
|
||||
private fun drawCachedMark(canvas: Canvas, rect: RectF) {
|
||||
val w = rect.width().roundToInt().coerceAtLeast(1)
|
||||
val h = rect.height().roundToInt().coerceAtLeast(1)
|
||||
val key = "$w,$h,${blend(colValue)},$accent"
|
||||
var bitmap = cachedMarkBitmap
|
||||
if (bitmap == null || key != cachedMarkKey) {
|
||||
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
DroidDeckMark.draw(Canvas(bitmap), 0f, 0f, h.toFloat(), blend(colValue), accent)
|
||||
cachedMarkBitmap = bitmap
|
||||
cachedMarkKey = key
|
||||
}
|
||||
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
|
||||
}
|
||||
|
||||
private fun drawStrip(canvas: Canvas, rect: RectF) {
|
||||
val p = parent as? View
|
||||
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
|
||||
stripFrame(rect, atBottom)?.let { canvas.drawBitmap(it, 0f, 0f, null) }
|
||||
}
|
||||
|
||||
private fun stripFrame(rect: RectF, atBottom: Boolean): Bitmap? {
|
||||
val w = rect.width().roundToInt(); val h = rect.height().roundToInt()
|
||||
if (w <= 0 || h <= 0) return null
|
||||
val stroke = outlineWidth()
|
||||
val key = "$w,$h,$stroke,$atBottom,$surfaceRgb,$bgOpacity,$outlineIntensity,${outlineColor()}"
|
||||
if (key == sFrameKey) sFrame?.let { return it }
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val c = Canvas(bmp)
|
||||
headerPaint.shader = null
|
||||
headerPaint.color = panelColor()
|
||||
c.drawRect(rect, headerPaint)
|
||||
if (stroke > 0f) {
|
||||
val line = max(stroke / 2f, density)
|
||||
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
|
||||
if (atBottom) c.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
|
||||
else c.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
|
||||
}
|
||||
sFrame = bmp; sFrameKey = key
|
||||
return bmp
|
||||
canvas.drawRect(rect, headerPaint)
|
||||
val stroke = outlineWidth()
|
||||
if (stroke <= 0f) return
|
||||
val line = max(stroke / 2f, density)
|
||||
val p = parent as? View
|
||||
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
|
||||
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
|
||||
if (atBottom) canvas.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
|
||||
else canvas.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
|
||||
}
|
||||
|
||||
private fun outlineWidth(): Float = if (outlineIntensity > 0f) outlineIntensity * sp(3.5f) else 0f
|
||||
@@ -1562,7 +1499,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
const val TAB_PAD_X_SP = 7f
|
||||
const val TAB_PAD_Y_SP = 5.5f
|
||||
const val REFRESH_MS = 500L
|
||||
const val STRIP_GRAPH_REDRAW_MS = 66L
|
||||
private const val HUE_IDLE_MS = 500L
|
||||
const val MIN_SCALE = 0.5f
|
||||
const val MAX_SCALE = 2f
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
@@ -34,8 +33,6 @@ object BottomScreenGuests {
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun current(arrivedAt: Long, ownShownAt: Long?): Boolean = ownShownAt == null || arrivedAt >= ownShownAt
|
||||
|
||||
internal fun ignorable(type: Int): Boolean = type == AccessibilityWindowInfo.TYPE_INPUT_METHOD ||
|
||||
type == AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY ||
|
||||
type == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER ||
|
||||
@@ -45,8 +42,9 @@ object BottomScreenGuests {
|
||||
sdk >= 33 && DualScreen.yieldsBottom(model)
|
||||
}
|
||||
|
||||
@androidx.annotation.RequiresApi(33)
|
||||
class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private class Arrival(val pkg: String, val at: Long)
|
||||
private class Arrival(val pkg: String)
|
||||
|
||||
private val thread = HandlerThread("bottom-screen").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
@@ -55,8 +53,6 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private var homes: Set<String> = emptySet()
|
||||
private var keyboards: Set<String> = emptySet()
|
||||
private var holders: Map<Int, String?> = emptyMap()
|
||||
private var ownWindows: Map<Int, Set<Int>> = emptyMap()
|
||||
private val ownShownAt = mutableMapOf<Int, Long>()
|
||||
private val check = Runnable { evaluate() }
|
||||
|
||||
fun start() {
|
||||
@@ -77,7 +73,7 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
|
||||
val pkg = event.packageName?.toString() ?: return
|
||||
if (event.displayId == Display.DEFAULT_DISPLAY || pkg == own || pkg == SYSTEM_UI) return
|
||||
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
|
||||
arrivals[event.displayId] = Arrival(pkg)
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
|
||||
else -> return
|
||||
@@ -102,25 +98,19 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
val all = runCatching { service.windowsOnAllDisplays }.getOrNull() ?: return
|
||||
val displays = service.getSystemService(DisplayManager::class.java) ?: return
|
||||
val taken = mutableMapOf<Int, String?>()
|
||||
val shown = mutableMapOf<Int, Set<Int>>()
|
||||
val now = SystemClock.uptimeMillis()
|
||||
for (i in 0 until all.size()) {
|
||||
val id = all.keyAt(i)
|
||||
if (id == Display.DEFAULT_DISPLAY) continue
|
||||
val display = displays.getDisplay(id)?.takeIf(DualScreen::builtIn) ?: continue
|
||||
val windows = all.valueAt(i)
|
||||
val topPkg = windows.asSequence()
|
||||
.filter { !BottomScreenGuests.ignorable(it.type) && cover(it, display) >= BottomScreenGuests.MIN_COVER }
|
||||
.sortedByDescending { it.layer }
|
||||
.map { it to packageOf(it) }
|
||||
val ours = windows.filter { it.second == own }.map { it.first.id }.toSet()
|
||||
if (!ownWindows[id].orEmpty().containsAll(ours)) ownShownAt[id] = now
|
||||
shown[id] = ours
|
||||
val topPkg = windows.firstNotNullOfOrNull { it.second?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]?.takeIf { BottomScreenGuests.current(it.at, ownShownAt[id]) }
|
||||
seen[id]?.takeIf { topPkg != null || arrival == null }?.let { arrivals.remove(id, it) }
|
||||
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]
|
||||
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
|
||||
BottomScreenGuests.holder(topPkg, arrival?.pkg?.takeUnless { it in keyboards }, homes)?.let { taken[id] = it }
|
||||
}
|
||||
ownWindows = shown
|
||||
if (current !== this) return
|
||||
if (taken != holders) Log.i(TAG, "bottom screen holders: $taken")
|
||||
holders = taken
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import com.droiddeck.launcher.ui.focusGlide
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.onSignal
|
||||
import com.droiddeck.launcher.ui.LocalPalette
|
||||
|
||||
class ControllerEditorActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
private lateinit var controls: OnScreenControls
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
immersive()
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(0xFF0B0F14.toInt())
|
||||
controls = OnScreenControls(this, null, editing = true)
|
||||
root.addView(controls, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
val bar = ComposeView(this).apply {
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
var snap by remember { mutableStateOf(ControllerPrefs.read(context).snap) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(20.dp))
|
||||
.padding(start = 5.dp, end = 5.dp, top = 8.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
EditorButton(stringResource(R.string.ctrl_editor_snap), false, selected = snap) {
|
||||
snap = !snap
|
||||
ControllerPrefs.setSnap(context, snap)
|
||||
controls.setSnap(snap)
|
||||
}
|
||||
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
|
||||
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
|
||||
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
|
||||
controls.saveLayout()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(bar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
|
||||
setContentView(root)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorButton(label: String, primary: Boolean, selected: Boolean? = null, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Text(
|
||||
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = when {
|
||||
primary -> pal.onSignal
|
||||
selected == true -> pal.signal
|
||||
else -> MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
when {
|
||||
primary -> pal.signal
|
||||
selected == true -> pal.signal.copy(alpha = 0.18f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
)
|
||||
.focusGlide(RoundedCornerShape(50))
|
||||
.then(
|
||||
if (selected == null) Modifier.clickable(onClick = onClick)
|
||||
else Modifier.toggleable(value = selected, role = Role.Switch, onValueChange = { onClick() }),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) immersive()
|
||||
}
|
||||
|
||||
private fun immersive() {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ object ControllerPrefs {
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
joyConVr = joyConVr(context),
|
||||
snap = snap(context),
|
||||
snap = p.getBoolean("snap", true),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
deadZone = deadZone(context),
|
||||
@@ -87,6 +87,11 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putString("map.$id", target).apply()
|
||||
}
|
||||
|
||||
fun resetMapping(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
|
||||
fun setTint(context: Context, tint: Int) {
|
||||
prefs(context).edit().putInt("tint", tint).apply()
|
||||
}
|
||||
@@ -124,6 +129,10 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("steamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setQamButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("qamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setKeyboardButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("keyboardButton", on).apply()
|
||||
}
|
||||
@@ -138,8 +147,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("joyConVr", on).apply()
|
||||
}
|
||||
|
||||
fun snap(context: Context): Boolean = prefs(context).getBoolean("snap", true)
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
@@ -162,7 +169,62 @@ object ControllerPrefs {
|
||||
prefs(context).edit().remove("layout.${width}x$height").apply()
|
||||
}
|
||||
|
||||
fun screenKeysShown(context: Context): Boolean = prefs(context).getBoolean("screenKeysShown", true)
|
||||
|
||||
fun setScreenKeysShown(context: Context, shown: Boolean) {
|
||||
prefs(context).edit().putBoolean("screenKeysShown", shown).apply()
|
||||
}
|
||||
|
||||
fun screenKeys(context: Context): List<ScreenKey> = decodeScreenKeys(prefs(context).getString("screenKeys", null).orEmpty())
|
||||
|
||||
fun setScreenKeys(context: Context, keys: List<ScreenKey>) {
|
||||
prefs(context).edit().putString("screenKeys", encodeScreenKeys(keys)).apply()
|
||||
}
|
||||
|
||||
fun encodeScreenKeys(keys: List<ScreenKey>): String = keys.joinToString(";") {
|
||||
"${it.codes.joinToString("+")}:${it.x},${it.y},${it.size}:${escape(it.glyph)}:${escape(it.text)}:${escape(it.image)}:${it.opacity}:${it.shape}:${if (it.toggle) 1 else 0}:${if (it.labels) 1 else 0}"
|
||||
}
|
||||
|
||||
fun decodeScreenKeys(raw: String): List<ScreenKey> = raw.split(';').mapNotNull { entry ->
|
||||
val parts = entry.split(':')
|
||||
if (parts.size < 2) return@mapNotNull null
|
||||
val keys = parts[0].split('+').mapNotNull { it.toIntOrNull() }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
val nums = parts[1].split(',').mapNotNull { it.toFloatOrNull() }.takeIf { it.size == 3 } ?: return@mapNotNull null
|
||||
ScreenKey(
|
||||
keys, nums[0], nums[1], nums[2].toInt(),
|
||||
parts.getOrNull(2)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(3)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(4)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(5)?.toFloatOrNull()?.coerceIn(0.2f, 1f) ?: 1f,
|
||||
parts.getOrNull(6)?.toIntOrNull()?.coerceIn(SCREEN_KEY_PILL, SCREEN_KEY_STICK) ?: SCREEN_KEY_PILL,
|
||||
parts.getOrNull(7) == "1",
|
||||
parts.getOrNull(8) != "0",
|
||||
)
|
||||
}
|
||||
|
||||
fun screenLayouts(context: Context): Map<String, String> =
|
||||
prefs(context).all.mapNotNull { (key, value) ->
|
||||
if (!key.startsWith("screenLayout.") || value !is String) null else key.removePrefix("screenLayout.") to value
|
||||
}.toMap()
|
||||
|
||||
fun saveScreenLayout(context: Context, name: String, raw: String) {
|
||||
prefs(context).edit().putString("screenLayout.$name", raw).apply()
|
||||
}
|
||||
|
||||
fun deleteScreenLayout(context: Context, name: String) {
|
||||
prefs(context).edit().remove("screenLayout.$name").apply()
|
||||
}
|
||||
|
||||
private fun escape(value: String) = value.replace("%", "%25").replace(";", "%3B").replace(":", "%3A").replace(",", "%2C")
|
||||
|
||||
private fun unescape(value: String) = value.replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("rumble", true).apply()
|
||||
}
|
||||
|
||||
fun resetAllLayouts(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
}
|
||||
@@ -5,165 +5,53 @@ object DeckControls {
|
||||
const val R4 = 2
|
||||
const val L5 = 4
|
||||
const val R5 = 8
|
||||
private val controls = arrayOf(InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5,
|
||||
InputControl.LEFT_PAD, InputControl.RIGHT_PAD, InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK)
|
||||
private val sources = ArrayList<Source>()
|
||||
private val physical = Source(InputRole.PHYSICAL)
|
||||
private var router: InputRouter? = null
|
||||
private const val LEFT_TOUCH = 16
|
||||
private const val RIGHT_TOUCH = 32
|
||||
private const val LEFT_CLICK = 64
|
||||
private const val RIGHT_CLICK = 128
|
||||
|
||||
private const val SLOT = 0
|
||||
|
||||
private var controls = 0
|
||||
private val pads = ShortArray(4)
|
||||
private val pressure = ShortArray(2)
|
||||
private val touchOwners = arrayOfNulls<Source>(2)
|
||||
|
||||
@Synchronized
|
||||
fun setRouter(next: InputRouter?) {
|
||||
if (router === next) return
|
||||
for (source in sources) source.closeRoutes()
|
||||
router = next
|
||||
for (source in sources) source.configure()
|
||||
fun setGrip(bit: Int, down: Boolean) {
|
||||
set(bit, down)
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearRouter(owner: InputRouter?) {
|
||||
if (owner != null && router === owner) setRouter(null)
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
|
||||
val at = if (right) 2 else 0
|
||||
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
|
||||
pads[at] = if (touching) axis(x) else 0
|
||||
pads[at + 1] = if (touching) axis(y) else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@JvmOverloads
|
||||
fun source(role: InputRole, routed: Boolean = false) = Source(role, routed)
|
||||
|
||||
@Synchronized
|
||||
fun setGrip(bit: Int, down: Boolean) = physical.setGrip(bit, down)
|
||||
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = physical.setPad(right, touching, x, y)
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) = physical.setClick(right, down)
|
||||
fun setClick(right: Boolean, down: Boolean) {
|
||||
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
|
||||
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
for (source in sources) source.clear()
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun suspend() {
|
||||
for (source in sources) source.suspend()
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refresh() {
|
||||
for (source in sources) source.route()
|
||||
publish()
|
||||
}
|
||||
|
||||
class Source internal constructor(private val role: InputRole, private val routed: Boolean = false) {
|
||||
private val x = FloatArray(8)
|
||||
private val y = FloatArray(8)
|
||||
private val touched = BooleanArray(8)
|
||||
private var routes: Array<InputRouter.Channel>? = null
|
||||
internal var mask = 0
|
||||
internal val axes = ShortArray(4)
|
||||
|
||||
internal fun configure() {
|
||||
routes = router?.takeUnless { routed }?.let { r -> Array(8) { i ->
|
||||
r.channel(role, controls[i]).also { it.awaitNeutral(x[i], y[i], touched[i]) }
|
||||
} }
|
||||
mask = 0
|
||||
axes.fill(0)
|
||||
}
|
||||
|
||||
private fun attach() {
|
||||
if (this !in sources) { sources.add(this); configure() }
|
||||
}
|
||||
|
||||
fun setGrip(bit: Int, down: Boolean) = synchronized(DeckControls) {
|
||||
val at = when (bit) { L4 -> 0; R4 -> 1; L5 -> 2; R5 -> 3; else -> return@synchronized }
|
||||
update(at, if (down) 1f else 0f)
|
||||
}
|
||||
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = synchronized(DeckControls) {
|
||||
update(if (right) 5 else 4, if (touching) x else 0f, if (touching) y else 0f, touching)
|
||||
}
|
||||
|
||||
fun setClick(right: Boolean, down: Boolean) = synchronized(DeckControls) {
|
||||
update(if (right) 7 else 6, if (down) 1f else 0f)
|
||||
}
|
||||
|
||||
private fun update(at: Int, value: Float, second: Float = 0f, contact: Boolean = false) {
|
||||
val timing = InputTiming.begin()
|
||||
attach()
|
||||
x[at] = value; y[at] = second; touched[at] = contact
|
||||
route()
|
||||
publish()
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
internal fun native(control: InputControl?, value: Float, second: Float, contact: Boolean) = synchronized(DeckControls) {
|
||||
require(routed)
|
||||
attach()
|
||||
x.fill(0f); y.fill(0f); touched.fill(false)
|
||||
val at = controls.indexOf(control)
|
||||
if (at >= 0) { x[at] = value; y[at] = second; touched[at] = contact }
|
||||
route(); publish()
|
||||
}
|
||||
|
||||
internal fun route() {
|
||||
mask = 0
|
||||
axes.fill(0)
|
||||
for (i in controls.indices) {
|
||||
val target = if (routes == null) controls[i] else routes!![i].update(x[i], y[i], touched[i]) ?: continue
|
||||
val at = controls.indexOf(target)
|
||||
if (at < 0) continue
|
||||
if (at == 4 || at == 5) {
|
||||
if (touched[i]) {
|
||||
mask = mask or (1 shl at)
|
||||
axes[(at - 4) * 2] = axis(x[i]); axes[(at - 4) * 2 + 1] = axis(y[i])
|
||||
}
|
||||
} else if (x[i] > 0.5f) mask = mask or (1 shl at)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun clear() {
|
||||
x.fill(0f); y.fill(0f); touched.fill(false); mask = 0; axes.fill(0)
|
||||
routes?.forEach { it.release() }
|
||||
}
|
||||
|
||||
internal fun suspend() {
|
||||
mask = 0; axes.fill(0)
|
||||
routes?.forEach { it.suspend() }
|
||||
}
|
||||
|
||||
fun release() = synchronized(DeckControls) { clear(); publish() }
|
||||
|
||||
internal fun closeRoutes() { routes?.forEach { it.close() }; routes = null }
|
||||
|
||||
fun close() = synchronized(DeckControls) {
|
||||
clear(); closeRoutes(); sources.remove(this); publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun axis(value: Float) = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() {
|
||||
var mask = 0
|
||||
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
|
||||
controls = 0
|
||||
pads.fill(0)
|
||||
for (source in sources) {
|
||||
mask = mask or source.mask
|
||||
}
|
||||
for (side in 0..1) {
|
||||
val contact = 16 shl side
|
||||
val owner = touchOwners[side]?.takeIf { it.mask and contact != 0 }
|
||||
?: sources.firstOrNull { it.mask and contact != 0 }
|
||||
touchOwners[side] = owner
|
||||
if (owner == null) continue
|
||||
pads[side * 2] = owner.axes[side * 2]
|
||||
pads[side * 2 + 1] = owner.axes[side * 2 + 1]
|
||||
}
|
||||
pressure[0] = if (mask and 64 != 0) Short.MAX_VALUE else 0
|
||||
pressure[1] = if (mask and 128 != 0) Short.MAX_VALUE else 0
|
||||
FakeInputWriter.writeDeckControls(0, mask, pads, pressure)
|
||||
pressure.fill(0)
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun set(bit: Int, on: Boolean) {
|
||||
controls = if (on) controls or bit else controls and bit.inv()
|
||||
}
|
||||
|
||||
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
|
||||
}
|
||||
@@ -65,7 +65,8 @@ class DesktopKeyboardButton(context: Context, private val onTap: () -> Unit) : V
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
|
||||
place(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetBottom ?: 0)
|
||||
if (Build.VERSION.SDK_INT >= 28) place(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetBottom ?: 0)
|
||||
else place(0, 0)
|
||||
return super.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,14 +70,6 @@ class DirectTouchHold(
|
||||
handler.removeCallbacks(hold)
|
||||
}
|
||||
|
||||
fun cancelTouch() {
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(lift)
|
||||
liftPending = false
|
||||
if (forwarded) touch.cancel()
|
||||
forwarded = false
|
||||
}
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
if (liftPending) {
|
||||
|
||||
@@ -16,7 +16,6 @@ object DualScreen {
|
||||
const val MODE_OFF = "off"
|
||||
val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF)
|
||||
private const val TYPE_INTERNAL = 1
|
||||
private const val TYPE_EXTERNAL = 2
|
||||
private val knownModels = setOf("retroid pocket duo", "ayn thor")
|
||||
private val touchedDisplays = mutableSetOf<Int>()
|
||||
private val takenListeners = java.util.concurrent.CopyOnWriteArraySet<() -> Unit>()
|
||||
@@ -86,22 +85,9 @@ object DualScreen {
|
||||
when (mode) {
|
||||
MODE_OFF -> null
|
||||
MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId)
|
||||
else -> if (dockedToDisplay(displayManager, primaryDisplayId)) null else builtInDisplay(displayManager, primaryDisplayId)
|
||||
else -> builtInDisplay(displayManager, primaryDisplayId)
|
||||
}?.takeUnless { taken(it.displayId) }
|
||||
|
||||
fun leavesBottomOnDock(wasDocked: Boolean, docked: Boolean, mode: String, automaticOnBuiltIn: Boolean): Boolean =
|
||||
docked && !wasDocked && mode == MODE_AUTO && automaticOnBuiltIn
|
||||
|
||||
fun dockedToDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Boolean {
|
||||
val glasses = com.droiddeck.launcher.glasses.Glasses.state.value
|
||||
val external = displayManager.getDisplays().filter {
|
||||
it.isValid && it.displayId != primaryDisplayId && it.state != Display.STATE_OFF && displayType(it) == TYPE_EXTERNAL
|
||||
}
|
||||
val glassesId = glasses.displayId.takeIf { it != Display.INVALID_DISPLAY }
|
||||
?: com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(external, glasses.present)?.displayId
|
||||
return external.any { it.displayId != glassesId && com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(listOf(it), false) == null }
|
||||
}
|
||||
|
||||
fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? =
|
||||
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
|
||||
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.input
|
||||
import android.view.MotionEvent
|
||||
|
||||
internal class GestureTouchStreamGate {
|
||||
enum class Source { SURFACE, ON_SCREEN_CONTROLS }
|
||||
enum class Source { SURFACE, SCREEN_KEYS, ON_SCREEN_CONTROLS }
|
||||
|
||||
private var owner: Source? = null
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
class HeldInput(private val emit: (Int, Boolean) -> Unit) {
|
||||
private val owners = IntArray(768)
|
||||
|
||||
@Synchronized
|
||||
fun set(source: Int, code: Int, down: Boolean) {
|
||||
if (code !in owners.indices) return
|
||||
val before = owners[code]
|
||||
val after = if (down) before or source else before and source.inv()
|
||||
owners[code] = after
|
||||
if ((before == 0) != (after == 0)) emit(code, after != 0)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release(source: Int) {
|
||||
for (code in owners.indices) if (owners[code] and source != 0) set(source, code, false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
for (code in owners.indices) if (owners[code] != 0) {
|
||||
owners[code] = 0
|
||||
emit(code, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import android.os.Looper
|
||||
import android.view.KeyEvent
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import com.droiddeck.launcher.core.SteamIntegration
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
object HomeButtonShortcuts {
|
||||
private var owner: Any? = null
|
||||
@@ -103,26 +105,38 @@ object SystemKeyCapture {
|
||||
|
||||
class HomeButtonService : AccessibilityService(), InputManager.InputDeviceListener {
|
||||
private var bottomScreen: BottomScreenWatch? = null
|
||||
private var consented = false
|
||||
|
||||
override fun onServiceConnected() {
|
||||
current = WeakReference(this)
|
||||
getSystemService(InputManager::class.java).registerInputDeviceListener(this, Handler(Looper.getMainLooper()))
|
||||
if (bottomScreen == null && BottomScreenGuests.supported()) bottomScreen = BottomScreenWatch(this).also { it.start() }
|
||||
updateConsent()
|
||||
}
|
||||
|
||||
private fun updateConsent() {
|
||||
consented = SteamIntegration.accessibilityConsented(this)
|
||||
if (consented && android.os.Build.VERSION.SDK_INT >= 33 && bottomScreen == null && BottomScreenGuests.supported()) {
|
||||
bottomScreen = BottomScreenWatch(this).also { it.start() }
|
||||
}
|
||||
if (!consented) stopBottomScreen()
|
||||
}
|
||||
|
||||
private fun stopBottomScreen() {
|
||||
bottomScreen?.stop()
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33) bottomScreen?.stop()
|
||||
bottomScreen = null
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent) =
|
||||
SystemKeyCapture.onKeyEvent(event) || HomeButtonShortcuts.onKeyEvent(this, event)
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) { event?.let { bottomScreen?.onEvent(it) } }
|
||||
consented && (SystemKeyCapture.onKeyEvent(event) || HomeButtonShortcuts.onKeyEvent(this, event))
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (android.os.Build.VERSION.SDK_INT >= 33) event?.let { bottomScreen?.onEvent(it) } }
|
||||
override fun onInterrupt() { HomeButtonShortcuts.release() }
|
||||
override fun onInputDeviceAdded(deviceId: Int) {}
|
||||
override fun onInputDeviceChanged(deviceId: Int) {}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) { HomeButtonShortcuts.onDeviceRemoved(deviceId) }
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
if (current?.get() === this) current = null
|
||||
consented = false
|
||||
HomeButtonShortcuts.release()
|
||||
stopBottomScreen()
|
||||
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
|
||||
@@ -130,9 +144,16 @@ class HomeButtonService : AccessibilityService(), InputManager.InputDeviceListen
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (current?.get() === this) current = null
|
||||
HomeButtonShortcuts.release()
|
||||
stopBottomScreen()
|
||||
getSystemService(InputManager::class.java).unregisterInputDeviceListener(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var current: WeakReference<HomeButtonService>? = null
|
||||
|
||||
fun consentChanged() { current?.get()?.updateConsent() }
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
internal object InputButtonImages {
|
||||
const val DIR = "screenkeys"
|
||||
private const val MAX_SIZE_DP = 140
|
||||
|
||||
fun file(context: Context, name: String): File? =
|
||||
name.takeIf { it.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && it != "." && it != ".." }?.let { File(File(context.filesDir, DIR), it) }
|
||||
|
||||
fun import(context: Context, uri: Uri): String? {
|
||||
val dir = File(context.filesDir, DIR).apply { mkdirs() }
|
||||
val name = "key_" + UUID.randomUUID() + ".png"
|
||||
val longest = (MAX_SIZE_DP * context.resources.displayMetrics.density).toInt()
|
||||
return runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
val source = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (source <= 0) return@runCatching null
|
||||
var sample = 1
|
||||
while (source / (sample * 2) >= longest) sample *= 2
|
||||
val bitmap = context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} ?: return@runCatching null
|
||||
val scale = longest.toFloat() / maxOf(bitmap.width, bitmap.height)
|
||||
val scaled = if (scale < 1f) Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt().coerceAtLeast(1),
|
||||
(bitmap.height * scale).toInt().coerceAtLeast(1), true) else bitmap
|
||||
try {
|
||||
val saved = File(dir, name).outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
if (saved) name else null
|
||||
} finally {
|
||||
if (scaled !== bitmap) bitmap.recycle()
|
||||
scaled.recycle()
|
||||
}
|
||||
}.getOrNull().also { if (it == null) File(dir, name).delete() }
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object InputLayoutFile {
|
||||
private val NAMES: Map<Int, String> = mapOf(
|
||||
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
|
||||
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
|
||||
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
|
||||
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
|
||||
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
|
||||
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
|
||||
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
|
||||
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
|
||||
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
|
||||
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
|
||||
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
|
||||
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
|
||||
21 to "KEY_Y", 44 to "KEY_Z",
|
||||
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
|
||||
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
|
||||
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
|
||||
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
|
||||
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
|
||||
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
|
||||
)
|
||||
private val CODES: Map<String, Int> = NAMES.entries.associate { (code, name) -> name to code }
|
||||
|
||||
fun fileName(layout: SavedInputLayout) = layout.name.replace(Regex("[^A-Za-z0-9 _.-]"), "_").trim().ifEmpty { "droiddeck-layout" } + ".icp"
|
||||
|
||||
fun export(layout: SavedInputLayout, bindings: SavedInputBindings?, width: Int, height: Int): String? {
|
||||
val snap = width.coerceAtLeast(1) / 100f
|
||||
val elements = JSONArray()
|
||||
for (e in layout.elements) {
|
||||
val action = bindings?.action(e) ?: InputAction.Disabled
|
||||
val directional = e.kind == InputElementKind.DPAD || e.kind == InputElementKind.STICK
|
||||
val codes = when {
|
||||
directional && action is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left)
|
||||
!directional && action is InputAction.Keys -> action.codes
|
||||
else -> continue
|
||||
}
|
||||
val binds = JSONArray()
|
||||
for (code in codes) {
|
||||
val name = NAMES[code]
|
||||
if (name != null) binds.put(name) else if (directional) binds.put("")
|
||||
}
|
||||
if ((0 until binds.length()).all { binds.getString(it).isEmpty() }) continue
|
||||
val shape = when {
|
||||
directional -> "ROUND_RECT"
|
||||
e.buttonShape == InputButtonShape.CIRCLE -> "CIRCLE"
|
||||
e.buttonShape == InputButtonShape.RECTANGLE -> "RECT"
|
||||
else -> "ROUND_RECT"
|
||||
}
|
||||
val sizePx = e.height * height
|
||||
val scale = when {
|
||||
directional -> sizePx * 2.3f / (14f * snap)
|
||||
shape == "CIRCLE" -> sizePx / (6f * snap)
|
||||
else -> sizePx / (4f * snap)
|
||||
}
|
||||
elements.put(JSONObject()
|
||||
.put("type", when (e.kind) { InputElementKind.DPAD -> "D_PAD"; InputElementKind.STICK -> "STICK"; else -> "BUTTON" })
|
||||
.put("shape", shape).put("customColor", 0).put("bindings", binds).put("scale", scale.toDouble())
|
||||
.put("x", (e.x + e.width / 2).toDouble()).put("y", (e.y + e.height / 2).toDouble())
|
||||
.put("toggleSwitch", e.toggle).put("swipeable", false)
|
||||
.put("text", if (e.icon != 0 || e.image.isNotEmpty()) "" else e.name).put("iconId", 0)
|
||||
.apply { if (e.opacity < 1f) put("opacity", e.opacity.toDouble()) })
|
||||
}
|
||||
if (elements.length() == 0) return null
|
||||
return JSONObject().put("id", 0).put("name", layout.name).put("cursorSpeed", 1.5).put("elements", elements).toString()
|
||||
}
|
||||
|
||||
fun parse(raw: String, width: Int, density: Float): List<LegacyScreenKey>? {
|
||||
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val elements = root.optJSONArray("elements") ?: return null
|
||||
val snap = width.coerceAtLeast(1) / 100f
|
||||
val keys = mutableListOf<LegacyScreenKey>()
|
||||
for (i in 0 until elements.length()) {
|
||||
val element = elements.optJSONObject(i) ?: continue
|
||||
val type = element.optString("type", "BUTTON")
|
||||
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
|
||||
val binds = element.optJSONArray("bindings") ?: continue
|
||||
val raw = (0 until binds.length()).map { CODES[binds.optString(it)] ?: 0 }
|
||||
val directional = type != "BUTTON"
|
||||
val codes = when {
|
||||
!directional -> raw.filter { it != 0 }.distinct().take(8)
|
||||
type == "STICK" && raw.size <= 4 -> listOf(raw.getOrElse(0) { 0 }, 0, raw.getOrElse(1) { 0 }, 0, raw.getOrElse(2) { 0 }, 0, raw.getOrElse(3) { 0 }, 0)
|
||||
else -> raw.take(if (type == "STICK") 8 else 4)
|
||||
}
|
||||
if (codes.none { it != 0 }) continue
|
||||
val shape = when {
|
||||
type == "D_PAD" -> 5
|
||||
type == "STICK" -> 6
|
||||
else -> when (element.optString("shape")) { "CIRCLE" -> 2; "RECT" -> 3; "SQUARE" -> 1; else -> 0 }
|
||||
}
|
||||
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val sizePx = when {
|
||||
directional -> 14f * snap * scale / 2.3f
|
||||
shape == 2 -> 6f * snap * scale
|
||||
else -> 4f * snap * scale
|
||||
}
|
||||
fun coordinate(name: String) = element.optDouble(name, .5).toFloat().takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: .5f
|
||||
keys += LegacyScreenKey(codes, coordinate("x"), coordinate("y"), (sizePx / density).roundToInt().coerceIn(24, 140).toFloat(),
|
||||
text = element.optString("text").trim().take(24),
|
||||
opacity = element.optDouble("opacity", 1.0).toFloat().takeIf { it.isFinite() }?.coerceIn(.2f, 1f) ?: 1f,
|
||||
shape = shape, toggle = element.optBoolean("toggleSwitch"))
|
||||
if (keys.size == 64) break
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
fun import(context: Context, raw: String, fallbackName: String, screen: InputLayoutScreen, style: InputSurfaceStyle,
|
||||
width: Int, height: Int, density: Float, id: String = newInputId()): Pair<SavedInputLayout, SavedInputBindings>? {
|
||||
val keys = parse(raw, width, density)?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val converted = runCatching { LegacyScreenKeyMigration.convert(context, keys, width, height, density) }.getOrNull() ?: return null
|
||||
val name = runCatching { JSONObject(raw).optString("name") }.getOrNull().orEmpty().trim().take(80).ifEmpty { fallbackName.trim().take(80) }
|
||||
val layout = SavedInputLayout(id, name, converted.map { it.first }, style = style, screen = screen,
|
||||
aspect = (width.coerceAtLeast(1).toFloat() / height.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
return layout to SavedInputBindings(newInputId(), name, false, id, elements = converted.associate { it.first.id to it.second })
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
object InputLibraries {
|
||||
private const val VERSION = 14
|
||||
private const val KEY = "library"
|
||||
private const val RECOVERY = "library-recovery."
|
||||
const val OVERLAY = "builtin.overlay"
|
||||
const val DECK = "builtin.deck"
|
||||
const val TRACKPAD = "builtin.trackpad"
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): InputLibrary {
|
||||
val p = prefs(context)
|
||||
val raw = p.getString(KEY, null)
|
||||
val decoded = raw?.let(::decode)
|
||||
if (raw != null && decoded == null) return scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
|
||||
var library = decoded ?: scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
|
||||
val version = raw?.let { JSONObject(it).getInt("version") } ?: 0
|
||||
if (version < 5) library = scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, library)))
|
||||
if (version < 11) library = neutralStockLayouts(library)
|
||||
if (version < 10) library = restyleGamepads(context, library)
|
||||
if (version < 12) library = barStockLayouts(library)
|
||||
if (version < 13) library = LegacyScreenKeyMigration.pin(library)
|
||||
if (version < 14) library = deckGamepadSurfaces(context, library)
|
||||
if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) {
|
||||
val legacy = LegacyScreenKeyMigration.snapshot(context)
|
||||
if (legacy != null) {
|
||||
val migrated = runCatching { LegacyScreenKeyMigration.migrate(context, library, legacy) }.getOrElse {
|
||||
android.util.Log.e("InputLibraries", "Could not migrate screen keys; keeping the original settings", it)
|
||||
return library
|
||||
}
|
||||
val merged = mergeScreenKeys(migrated, legacy)
|
||||
val upgraded = merged ?: migrated
|
||||
val encoded = encode(upgraded)
|
||||
require(encoded.length <= 2_000_000)
|
||||
val edit = p.edit().putString(KEY, encoded).putString(LegacyScreenKeyMigration.BACKUP, legacy)
|
||||
if (merged != null) edit.putBoolean(LegacyScreenKeyMigration.MERGED, true)
|
||||
if (!edit.commit()) return library
|
||||
LegacyScreenKeyMigration.clean(context)
|
||||
return upgraded
|
||||
}
|
||||
}
|
||||
val backup = p.getString(LegacyScreenKeyMigration.BACKUP, null)
|
||||
if (backup != null && !p.contains(LegacyScreenKeyMigration.MERGED)) {
|
||||
val merged = mergeScreenKeys(library, backup)
|
||||
if (merged != null) {
|
||||
val encoded = encode(merged)
|
||||
if (encoded.length <= 2_000_000 && p.edit().putString(KEY, encoded).putBoolean(LegacyScreenKeyMigration.MERGED, true).commit()) library = merged
|
||||
}
|
||||
}
|
||||
if (raw == null || version < VERSION) write(context, library)
|
||||
if (p.contains(LegacyScreenKeyMigration.BACKUP)) LegacyScreenKeyMigration.clean(context)
|
||||
return library
|
||||
}
|
||||
|
||||
private fun mergeScreenKeys(library: InputLibrary, legacy: String): InputLibrary? = runCatching { LegacyScreenKeyMigration.merge(library, legacy) }
|
||||
.onFailure { android.util.Log.e("InputLibraries", "Could not show the imported screen keys with the gamepad", it) }.getOrNull()
|
||||
|
||||
fun hasPendingScreenKeyMigration(context: Context) = !prefs(context).contains(LegacyScreenKeyMigration.BACKUP) && LegacyScreenKeyMigration.snapshot(context) != null
|
||||
|
||||
fun hasLibraryRecovery(context: Context) = prefs(context).all.keys.any { it.startsWith(RECOVERY) }
|
||||
|
||||
@Synchronized
|
||||
fun update(context: Context, change: (InputLibrary) -> InputLibrary): InputLibrary {
|
||||
val p = prefs(context)
|
||||
val stored = p.getString(KEY, null)
|
||||
val invalid = stored != null && decode(stored) == null
|
||||
require(!invalid || runCatching { JSONObject(stored!!).getInt("version") }.getOrNull()?.let { it <= VERSION } != false) {
|
||||
"The saved input library cannot be edited by this version."
|
||||
}
|
||||
val updated = change(read(context)).validate()
|
||||
if (invalid) {
|
||||
val encoded = encode(updated)
|
||||
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
|
||||
check(p.edit().putString(RECOVERY + UUID.nameUUIDFromBytes(stored!!.toByteArray(Charsets.UTF_8)), stored)
|
||||
.putString(KEY, encoded).commit()) { "Could not save the input library and its recovery copy." }
|
||||
} else write(context, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
private fun write(context: Context, library: InputLibrary) {
|
||||
val encoded = encode(library.validate())
|
||||
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
|
||||
prefs(context).edit().putString(KEY, encoded).apply()
|
||||
}
|
||||
|
||||
private fun migrate(context: Context): InputLibrary {
|
||||
val profiles = mutableListOf<SavedInputProfile>()
|
||||
val stock = InputStockLayouts.layouts()
|
||||
val layouts = (listOf(SavedInputLayout(OVERLAY, "Built-in gamepad", template = InputLayoutTemplate.OVERLAY)) + stock).toMutableList()
|
||||
val bindings = InputStockLayouts.presets(stock).toMutableList()
|
||||
for (id in listOf(InputProfile.GAME, InputProfile.DESKTOP)) {
|
||||
val old = InputProfiles.readLegacy(context, id)
|
||||
val name = if (id == InputProfile.GAME) "Game" else "Desktop"
|
||||
val legacySurface = InputSurfaceLayout.hasLegacy(context, id) ||
|
||||
old.bindings[InputRole.SECONDARY] != InputProfile.defaults(id).bindings[InputRole.SECONDARY]
|
||||
val layout = if (legacySurface) SavedInputLayout("$id.layout", "$name touch", InputSurfaceLayout.readLegacy(context, id)).also { layouts += it }
|
||||
else stock.first { it.id == if (id == InputProfile.DESKTOP) TRACKPAD else DECK }
|
||||
val parts = InputRole.entries.associateWith { role ->
|
||||
if (role == InputRole.SECONDARY && !legacySurface) {
|
||||
val preset = bindings.first { it.layoutId == layout.id }
|
||||
return@associateWith InputPart(preset.id, layout.id)
|
||||
}
|
||||
val preset = SavedInputBindings("$id.${role.name.lowercase()}", "$name / ${role.name.lowercase()}",
|
||||
role == InputRole.PHYSICAL, if (role == InputRole.SECONDARY) layout.id else null,
|
||||
old.bindings[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) old.keys else emptyList())
|
||||
bindings += preset
|
||||
InputPart(preset.id, when (role) { InputRole.PHYSICAL -> null; InputRole.PRIMARY -> OVERLAY; InputRole.SECONDARY -> layout.id })
|
||||
}
|
||||
profiles += SavedInputProfile(id, name, id, parts)
|
||||
}
|
||||
val selected = prefs(context).getString("selected", InputProfiles.AUTO)
|
||||
?.takeIf { it == InputProfiles.AUTO || profiles.any { p -> p.id == it } } ?: InputProfiles.AUTO
|
||||
return InputLibrary(profiles, layouts, bindings, selected).validate()
|
||||
}
|
||||
|
||||
fun setAdaptiveSticks(context: Context, layoutId: String, on: Boolean): Boolean = runCatching {
|
||||
update(context) { library ->
|
||||
library.copy(layouts = library.layouts.map { layout ->
|
||||
if (layout.id != layoutId || !layout.editable) layout
|
||||
else layout.copy(elements = layout.elements.map { if (it.kind == InputElementKind.STICK) it.copy(floating = on, hideWhenIdle = on) else it })
|
||||
})
|
||||
}
|
||||
}.onSuccess { ControllerPrefs.setAdaptiveSticks(context, on) }.isSuccess
|
||||
|
||||
fun setQuickButton(context: Context, layoutId: String, button: InputCommand, shown: Boolean): Boolean = runCatching {
|
||||
update(context) { library -> library.layouts.firstOrNull { it.id == layoutId && it.editable }?.let { library.withLayout(it.withQuickButton(button, shown)) } ?: library }
|
||||
}.isSuccess
|
||||
|
||||
fun defaultPositions(context: Context, library: InputLibrary, layout: SavedInputLayout): List<InputSurfaceElement>? {
|
||||
val origin = layout.origin ?: layout.id
|
||||
InputStockLayouts.layouts().firstOrNull { it.id == origin }?.let { return it.elements }
|
||||
if (origin == OVERLAY || origin.startsWith("migrated.layout.")) {
|
||||
val (surfaceContext, metrics) = primaryDisplay(context)
|
||||
val size = if (origin == OVERLAY) listOf(metrics.widthPixels, metrics.heightPixels)
|
||||
else origin.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
|
||||
return size.takeIf { it.size == 2 && it.all { side -> side > 0 } }?.let { LegacyGamepadLayout(surfaceContext, it[0], it[1]).elements() }
|
||||
}
|
||||
return library.layouts.firstOrNull { it.id == origin && it.id != layout.id }?.elements
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun primaryDisplay(context: Context): Pair<Context, DisplayMetrics> {
|
||||
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
val surfaceContext = display?.let(context::createDisplayContext) ?: context
|
||||
return surfaceContext to DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
|
||||
}
|
||||
|
||||
private fun restyleGamepads(context: Context, library: InputLibrary): InputLibrary {
|
||||
if (library.layouts.none { it.id == OVERLAY || it.id.startsWith("migrated.layout.") }) return library
|
||||
val (surfaceContext, metrics) = primaryDisplay(context)
|
||||
return library.copy(layouts = library.layouts.map { layout ->
|
||||
val size = when {
|
||||
layout.id == OVERLAY -> listOf(metrics.widthPixels, metrics.heightPixels)
|
||||
layout.id.startsWith("migrated.layout.") -> layout.id.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
|
||||
else -> emptyList()
|
||||
}
|
||||
if (size.size != 2 || size.any { it <= 0 }) layout
|
||||
else layout.copy(elements = LegacyGamepadLayout(surfaceContext, size[0], size[1]).restyle(layout.elements))
|
||||
})
|
||||
}
|
||||
|
||||
private fun upgradeOverlay(context: Context, library: InputLibrary): InputLibrary {
|
||||
if (library.layouts.none { it.template == InputLayoutTemplate.OVERLAY }) return library
|
||||
val (surfaceContext, metrics) = primaryDisplay(context)
|
||||
val settings = ControllerPrefs.read(context)
|
||||
val elements = LegacyGamepadLayout(surfaceContext, metrics.widthPixels.coerceAtLeast(1), metrics.heightPixels.coerceAtLeast(1)).elements()
|
||||
val style = InputSurfaceStyle(settings.tint, settings.opacity)
|
||||
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.OVERLAY }.associate { old ->
|
||||
old.id to old.copy(name = "Gamepad", elements = elements, template = InputLayoutTemplate.CUSTOM, style = style,
|
||||
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
}
|
||||
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
|
||||
val migrated = mutableMapOf<Pair<String, String>, String>()
|
||||
for (profile in library.profiles) {
|
||||
val parts = profile.parts.mapValues { (role, part) ->
|
||||
val layout = replacements[part.layout] ?: return@mapValues part
|
||||
val key = part.bindings to layout.id
|
||||
val id = migrated.getOrPut(key) {
|
||||
val old = library.preset(part.bindings)
|
||||
val newId = migrationBindingId(result, old.id, layout.id)
|
||||
val keyboard = elements.filter { it.id == "builtin:keyboard" }.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) }
|
||||
val clicks = if (settings.stickClick) old.clicks else old.clicks + elements.filter { it.hasClick }.associate { it.id to InputAction.Disabled }
|
||||
result = result.withPreset(old.copy(id = newId, layoutId = layout.id, elements = old.elements + keyboard, clicks = clicks))
|
||||
newId
|
||||
}
|
||||
part.copy(bindings = id)
|
||||
}
|
||||
result = result.withProfile(profile.copy(parts = parts))
|
||||
}
|
||||
val oldBindings = migrated.keys.map { it.first }.toSet()
|
||||
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
|
||||
// Older editor layouts were resolution-specific. Keep each as a selectable saved layout.
|
||||
for (key in ControllerPrefs.prefs(context).all.keys.filter { it.startsWith("layout.") }.sorted()) {
|
||||
val size = key.removePrefix("layout.").split('x').mapNotNull { it.toIntOrNull() }
|
||||
if (size.size != 2 || size.any { it <= 0 } || result.layouts.size >= 128) continue
|
||||
if (size[0] == metrics.widthPixels && size[1] == metrics.heightPixels) continue
|
||||
val id = "migrated.$key"
|
||||
if (result.layouts.none { it.id == id }) result = result.withLayout(SavedInputLayout(id, "Gamepad ${size[0]} × ${size[1]}",
|
||||
LegacyGamepadLayout(surfaceContext, size[0], size[1]).elements(), style = style, aspect = (size[0].toFloat() / size[1]).coerceIn(.05f, 20f)))
|
||||
}
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
private fun neutralStockLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
|
||||
if ((layout.id == DECK || layout.id == TRACKPAD) && layout.style.tint == ControllerPrefs.STEAM_BLUE) layout.copy(style = layout.style.copy(tint = null))
|
||||
else layout
|
||||
})
|
||||
|
||||
private fun upgradeStockLayouts(library: InputLibrary): InputLibrary {
|
||||
val stock = InputStockLayouts.layouts()
|
||||
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.DECK || it.template == InputLayoutTemplate.TRACKPAD }
|
||||
.associate { old -> old.id to stock.first { it.id == if (old.template == InputLayoutTemplate.DECK) DECK else TRACKPAD }.copy(id = old.id, name = old.name) }
|
||||
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
|
||||
val migrated = mutableMapOf<Pair<String, String>, String>()
|
||||
for (profile in library.profiles) {
|
||||
val parts = profile.parts.mapValues { (_, part) ->
|
||||
val layout = replacements[part.layout] ?: return@mapValues part
|
||||
val id = migrated.getOrPut(part.bindings to layout.id) {
|
||||
val old = library.preset(part.bindings)
|
||||
val id = migrationBindingId(result, old.id, layout.id)
|
||||
val original = library.layouts.first { it.id == layout.id }
|
||||
val stockLayout = layout.copy(id = if (original.template == InputLayoutTemplate.TRACKPAD) TRACKPAD else DECK)
|
||||
result = result.withPreset(old.copy(id = id, layoutId = layout.id,
|
||||
elements = InputStockLayouts.actions(stockLayout) + old.elements, clicks = InputStockLayouts.clicks(stockLayout) + old.clicks))
|
||||
id
|
||||
}
|
||||
part.copy(bindings = id)
|
||||
}
|
||||
result = result.withProfile(profile.copy(parts = parts))
|
||||
}
|
||||
val oldBindings = migrated.keys.map { it.first }.toSet()
|
||||
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
|
||||
return addStockLayouts(result)
|
||||
}
|
||||
|
||||
private fun addStockLayouts(library: InputLibrary): InputLibrary {
|
||||
val stock = InputStockLayouts.layouts()
|
||||
var result = library
|
||||
for (layout in stock) if (result.layouts.none { it.id == layout.id }) result = result.withLayout(layout)
|
||||
for (preset in InputStockLayouts.presets(stock)) if (result.bindings.none { it.id == preset.id }) result = result.withPreset(preset)
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
private fun barStockLayouts(library: InputLibrary): InputLibrary {
|
||||
var result = library
|
||||
for (stock in InputStockLayouts.layouts().filter { it.id == DECK || it.id == TRACKPAD }) {
|
||||
val saved = result.layouts.firstOrNull { it.id == stock.id } ?: continue
|
||||
val (commands, rest) = saved.elements.partition { it.id.startsWith("command:") }
|
||||
val untouched = commands.isNotEmpty() && commands.all { it.control == InputControl.A && it.kind == InputElementKind.BUTTON } &&
|
||||
rest.size == stock.elements.size && rest.all { e -> stock.elements.any { s -> s.id == e.id && s.copy(y = e.y, height = e.height) == e &&
|
||||
kotlin.math.abs(s.y + s.height - e.y - e.height) < 1e-3f } }
|
||||
if (untouched) result = result.withLayout(saved.copy(elements = stock.elements))
|
||||
}
|
||||
return addStockLayouts(result)
|
||||
}
|
||||
|
||||
private fun deckGamepadSurfaces(context: Context, library: InputLibrary): InputLibrary {
|
||||
val leftPad = SessionPrefs.gamepadSurface(context, right = false) == SessionPrefs.SURFACE_TRACKPAD
|
||||
val rightStick = SessionPrefs.gamepadSurface(context, right = true) == SessionPrefs.SURFACE_STICK
|
||||
if (!leftPad && !rightStick) return library
|
||||
val saved = library.layouts.firstOrNull { it.id == DeckPanel.GAMEPAD.layout } ?: return library
|
||||
if (saved.elements != InputStockLayouts.gamepad(leftPad = false, rightStick = false)) return library
|
||||
return library.withLayout(saved.copy(elements = InputStockLayouts.gamepad(leftPad, rightStick))).validate()
|
||||
}
|
||||
|
||||
private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String {
|
||||
val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8)
|
||||
val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed)
|
||||
var id = base
|
||||
var suffix = 0
|
||||
while (library.bindings.any { it.id == id }) id = "$base.${++suffix}"
|
||||
return id
|
||||
}
|
||||
|
||||
private fun scopeLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
|
||||
val roles = library.profiles.flatMap { p -> p.parts.filterValues { it.layout == layout.id }.keys } +
|
||||
library.selections.values.flatMap { s -> s.parts.filterValues { library.preset(it).layoutId == layout.id }.keys }
|
||||
val screen = when {
|
||||
InputRole.PRIMARY in roles && InputRole.SECONDARY in roles -> InputLayoutScreen.BOTH
|
||||
InputRole.PRIMARY in roles -> InputLayoutScreen.MAIN
|
||||
InputRole.SECONDARY in roles -> InputLayoutScreen.SECONDARY
|
||||
layout.id == OVERLAY || layout.id.startsWith("migrated.layout.") -> InputLayoutScreen.MAIN
|
||||
else -> InputLayoutScreen.SECONDARY
|
||||
}
|
||||
layout.copy(screen = screen)
|
||||
})
|
||||
|
||||
internal fun encode(library: InputLibrary): String {
|
||||
val layouts = JSONArray()
|
||||
for (layout in library.layouts) {
|
||||
val elements = JSONArray()
|
||||
for (e in layout.elements) elements.put(JSONObject().put("id", e.id).put("kind", e.kind.name)
|
||||
.put("control", e.control.id).put("name", e.name).put("x", e.x.toDouble()).put("y", e.y.toDouble())
|
||||
.put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image)
|
||||
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel).put("alwaysShown", e.alwaysShown))
|
||||
layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements)
|
||||
.put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble())
|
||||
.put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL))
|
||||
}
|
||||
val bindings = JSONArray()
|
||||
for (b in library.bindings) {
|
||||
val controls = JSONObject(); b.controls.forEach { (c, a) -> controls.put(c.id, InputProfiles.encode(a)) }
|
||||
val elements = JSONObject(); b.elements.forEach { (id, a) -> elements.put(id, InputProfiles.encode(a)) }
|
||||
val clicks = JSONObject(); b.clicks.forEach { (id, a) -> clicks.put(id, InputProfiles.encode(a)) }
|
||||
val keys = JSONArray()
|
||||
b.keys.forEach { binding -> keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
|
||||
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", InputProfiles.encode(binding.action))) }
|
||||
bindings.put(JSONObject().put("id", b.id).put("name", b.name).put("physical", b.physical)
|
||||
.put("layout", b.layoutId).put("controls", controls).put("elements", elements).put("keys", keys).put("clicks", clicks))
|
||||
}
|
||||
val profiles = JSONArray()
|
||||
for (p in library.profiles) {
|
||||
val parts = JSONObject()
|
||||
p.parts.forEach { (role, part) -> parts.put(role.name, JSONObject().put("bindings", part.bindings).put("layout", part.layout)) }
|
||||
profiles.put(JSONObject().put("id", p.id).put("name", p.name).put("base", p.base).put("parts", parts))
|
||||
}
|
||||
val selections = JSONObject()
|
||||
for ((mode, selection) in library.selections) {
|
||||
val parts = JSONObject()
|
||||
selection.parts.forEach { (role, id) -> parts.put(role.name, id) }
|
||||
selections.put(mode, JSONObject().put("profile", selection.profile).put("parts", parts).put("profileSelected", selection.profileSelected))
|
||||
}
|
||||
return JSONObject().put("selections", selections).put("version", VERSION).put("selected", library.selected).put("steam", library.steam).put("desktop", library.desktop)
|
||||
.put("profiles", profiles).put("layouts", layouts).put("bindings", bindings).toString()
|
||||
}
|
||||
|
||||
internal fun decode(raw: String): InputLibrary? = runCatching {
|
||||
require(raw.length <= 2_000_000)
|
||||
val doc = JSONObject(raw); require(doc.getInt("version") in 2..VERSION)
|
||||
val layouts = doc.getJSONArray("layouts").objects(256).map { layout ->
|
||||
val elements = layout.getJSONArray("elements").objects(64).map { e ->
|
||||
InputSurfaceElement(requireNotNull(InputControl.fromId(e.getString("control"))), e.getDouble("x").toFloat(),
|
||||
e.getDouble("y").toFloat(), e.getDouble("width").toFloat(), e.getDouble("height").toFloat(),
|
||||
e.getString("id"), InputElementKind.valueOf(e.getString("kind")), e.getString("name"),
|
||||
e.optBoolean("floating", true), e.optBoolean("hideWhenIdle", false), e.optBoolean("mouseGestures", false), e.optBoolean("toggle", false), e.optInt("icon", 0), e.optString("image", ""),
|
||||
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true), e.optBoolean("alwaysShown", false))
|
||||
}
|
||||
SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")),
|
||||
InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100),
|
||||
layout.optJSONArray("hidden")?.let { hidden -> (0 until hidden.length()).map { InputCommand.valueOf(hidden.getString(it)) }.toSet() }.orEmpty()),
|
||||
InputLayoutScreen.valueOf(layout.optString("screen", InputLayoutScreen.BOTH.name)), layout.optDouble("aspect", 0.0).toFloat(), layout.optionalString("origin"),
|
||||
layout.optDouble("scale", 1.0).toFloat())
|
||||
}
|
||||
val bindings = doc.getJSONArray("bindings").objects(512).map { b ->
|
||||
val controls = b.getJSONObject("controls").actions(28).mapKeys { requireNotNull(InputControl.fromId(it.key)) }
|
||||
val keys = b.getJSONArray("keys").objects(64).map { k -> PhysicalInputBinding(
|
||||
PhysicalInputKey(k.getString("device"), k.getString("name"), k.getInt("keyCode"), k.getInt("scanCode")),
|
||||
requireNotNull(InputProfiles.decode(k.getJSONObject("action")))) }
|
||||
SavedInputBindings(b.getString("id"), b.getString("name"), b.getBoolean("physical"), b.optionalString("layout"),
|
||||
controls, b.getJSONObject("elements").actions(64), keys, b.optJSONObject("clicks")?.actions(64).orEmpty())
|
||||
}
|
||||
val profiles = doc.getJSONArray("profiles").objects(64).map { p ->
|
||||
val parts = p.getJSONObject("parts")
|
||||
SavedInputProfile(p.getString("id"), p.getString("name"), p.getString("base"), InputRole.entries.associateWith { role ->
|
||||
val part = parts.getJSONObject(role.name); InputPart(part.getString("bindings"), part.optionalString("layout"))
|
||||
})
|
||||
}
|
||||
val selections = doc.optJSONObject("selections")?.let { saved ->
|
||||
require(saved.length() <= 2)
|
||||
saved.keys().asSequence().associateWith { mode ->
|
||||
val value = saved.getJSONObject(mode)
|
||||
val parts = value.getJSONObject("parts")
|
||||
require(parts.length() <= InputRole.entries.size)
|
||||
val profile = value.getString("profile")
|
||||
InputSelection(profile, parts.keys().asSequence().associate { role -> InputRole.valueOf(role) to parts.getString(role) },
|
||||
value.optBoolean("profileSelected", profile != InputProfiles.AUTO || parts.length() == 0))
|
||||
}
|
||||
}.orEmpty()
|
||||
InputLibrary(profiles, layouts, bindings, doc.getString("selected"), doc.getString("steam"), doc.getString("desktop"), selections).validate()
|
||||
}.getOrNull()
|
||||
|
||||
private fun JSONArray.objects(limit: Int): List<JSONObject> {
|
||||
require(length() <= limit)
|
||||
return (0 until length()).map { getJSONObject(it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.actions(limit: Int): Map<String, InputAction> {
|
||||
require(length() <= limit)
|
||||
return keys().asSequence().associateWith { requireNotNull(InputProfiles.decode(getJSONObject(it))) }
|
||||
}
|
||||
|
||||
private fun JSONObject.optionalString(key: String): String? = if (isNull(key)) null else getString(key)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
enum class InputLayoutTemplate { CUSTOM, OVERLAY, DECK, TRACKPAD }
|
||||
|
||||
enum class InputLayoutScreen { MAIN, SECONDARY, BOTH;
|
||||
fun supports(role: InputRole) = role != InputRole.PHYSICAL && (this == BOTH ||
|
||||
if (role == InputRole.PRIMARY) this == MAIN else this == SECONDARY)
|
||||
}
|
||||
|
||||
data class InputSurfaceStyle(val tint: Int? = ControllerPrefs.STEAM_BLUE, val opacity: Int = 100, val hidden: Set<InputCommand> = emptySet()) {
|
||||
fun hides(element: InputSurfaceElement) = element.quickButton?.let { it in hidden } == true
|
||||
fun shown(elements: List<InputSurfaceElement>) = if (hidden.isEmpty()) elements else elements.filterNot(::hides)
|
||||
}
|
||||
|
||||
data class SavedInputLayout(
|
||||
val id: String, val name: String, val elements: List<InputSurfaceElement> = emptyList(),
|
||||
val template: InputLayoutTemplate = InputLayoutTemplate.CUSTOM,
|
||||
val style: InputSurfaceStyle = InputSurfaceStyle(),
|
||||
val screen: InputLayoutScreen = InputLayoutScreen.BOTH,
|
||||
val aspect: Float = 0f,
|
||||
val origin: String? = null,
|
||||
val scale: Float = 1f,
|
||||
) {
|
||||
val editable get() = template == InputLayoutTemplate.CUSTOM
|
||||
val quickButtons get() = QUICK_BUTTONS.filter { button -> elements.any { it.quickButton == button } }
|
||||
fun withQuickButton(button: InputCommand, shown: Boolean) =
|
||||
copy(style = style.copy(hidden = if (shown) style.hidden - button else style.hidden + button))
|
||||
fun derived(id: String, name: String) = copy(id = id, name = name, origin = origin ?: this.id)
|
||||
val adaptiveSticks get() = elements.filter { it.kind == InputElementKind.STICK }.takeIf { editable && it.isNotEmpty() }?.all { it.floating && it.hideWhenIdle }
|
||||
fun supports(role: InputRole) = screen.supports(role) && when (template) {
|
||||
InputLayoutTemplate.OVERLAY -> role == InputRole.PRIMARY
|
||||
InputLayoutTemplate.DECK, InputLayoutTemplate.TRACKPAD -> role == InputRole.SECONDARY
|
||||
InputLayoutTemplate.CUSTOM -> true
|
||||
}
|
||||
}
|
||||
|
||||
data class SavedInputBindings(
|
||||
val id: String, val name: String, val physical: Boolean,
|
||||
val layoutId: String? = null,
|
||||
val controls: Map<InputControl, InputAction> = emptyMap(),
|
||||
val elements: Map<String, InputAction> = emptyMap(),
|
||||
val keys: List<PhysicalInputBinding> = emptyList(),
|
||||
val clicks: Map<String, InputAction> = emptyMap(),
|
||||
) {
|
||||
fun compatible(layout: SavedInputLayout?) = if (physical) layout == null
|
||||
else layout != null && if (layout.editable) layoutId == layout.id else layoutId == null
|
||||
|
||||
fun action(element: InputSurfaceElement) = elements[element.id]
|
||||
?: if (element.id == "legacy:${element.control.id}") controls[element.control] ?: InputAction.Native()
|
||||
else InputAction.Disabled
|
||||
|
||||
fun clickAction(element: InputSurfaceElement) = clicks[element.id]
|
||||
?: defaultElementClick(element, action(element)) { controls[it] ?: InputAction.Native() }
|
||||
}
|
||||
|
||||
data class InputSelection(
|
||||
val profile: String = InputProfiles.AUTO, val parts: Map<InputRole, String> = emptyMap(), val profileSelected: Boolean = false,
|
||||
)
|
||||
|
||||
data class InputPart(val bindings: String, val layout: String? = null)
|
||||
|
||||
data class SavedInputProfile(val id: String, val name: String, val base: String, val parts: Map<InputRole, InputPart>) {
|
||||
fun binding(role: InputRole) = parts.getValue(role).bindings
|
||||
}
|
||||
|
||||
data class InputLibrary(
|
||||
val profiles: List<SavedInputProfile>, val layouts: List<SavedInputLayout>, val bindings: List<SavedInputBindings>,
|
||||
val selected: String = InputProfiles.AUTO,
|
||||
val steam: String = InputProfile.GAME, val desktop: String = InputProfile.DESKTOP,
|
||||
val selections: Map<String, InputSelection> = emptyMap(),
|
||||
) {
|
||||
fun profile(id: String) = profiles.first { it.id == id }
|
||||
fun preset(id: String) = bindings.first { it.id == id }
|
||||
fun layout(profile: String, role: InputRole) = profiles.firstOrNull { it.id == profile }?.parts?.get(role)?.layout
|
||||
?.let { id -> layouts.firstOrNull { it.id == id } }
|
||||
private fun mode(isDesktop: Boolean) = if (isDesktop) InputProfile.DESKTOP else InputProfile.GAME
|
||||
fun selection(isDesktop: Boolean) = selections[mode(isDesktop)] ?: InputSelection(selected)
|
||||
fun activeId(isDesktop: Boolean): String {
|
||||
val id = selection(isDesktop).profile
|
||||
return if (id == InputProfiles.AUTO) { if (isDesktop) desktop else steam } else id
|
||||
}
|
||||
fun selectProfile(isDesktop: Boolean, id: String) = copy(selections = selections + (mode(isDesktop) to InputSelection(id, profileSelected = true)))
|
||||
fun selectPart(isDesktop: Boolean, role: InputRole, bindingsId: String): InputLibrary {
|
||||
val previous = selection(isDesktop)
|
||||
return copy(selections = selections + (mode(isDesktop) to previous.copy(parts = previous.parts + (role to bindingsId))))
|
||||
}
|
||||
fun activePart(isDesktop: Boolean, role: InputRole): InputPart {
|
||||
val binding = selection(isDesktop).parts[role]?.let(::preset)
|
||||
return if (binding != null) InputPart(binding.id, binding.layoutId) else profile(activeId(isDesktop)).parts.getValue(role)
|
||||
}
|
||||
fun activeLayout(isDesktop: Boolean, role: InputRole) = activePart(isDesktop, role).layout?.let { id -> layouts.first { it.id == id } }
|
||||
fun panelPart(isDesktop: Boolean, panel: String?): InputPart =
|
||||
panel?.let { id -> bindings.firstOrNull { it.id == id } }?.let { InputPart(it.id, it.layoutId) } ?: activePart(isDesktop, InputRole.SECONDARY)
|
||||
fun panelLayout(isDesktop: Boolean, panel: String?) = panelPart(isDesktop, panel).layout?.let { id -> layouts.firstOrNull { it.id == id } }
|
||||
fun compileActive(isDesktop: Boolean, panel: String? = null): InputProfile = compileParts(activeId(isDesktop), InputRole.entries.associateWith {
|
||||
preset(if (it == InputRole.SECONDARY) panelPart(isDesktop, panel).bindings else activePart(isDesktop, it).bindings)
|
||||
})
|
||||
|
||||
fun saveSelection(isDesktop: Boolean, name: String, id: String = newInputId()): InputLibrary {
|
||||
val current = profile(activeId(isDesktop))
|
||||
var result = withProfile(SavedInputProfile(id, inputName(name), current.base, InputRole.entries.associateWith { activePart(isDesktop, it) }))
|
||||
for (role in InputRole.entries) result = result.usePart(id, role, id, shared = false)
|
||||
return result.selectProfile(isDesktop, id)
|
||||
}
|
||||
|
||||
|
||||
fun compile(id: String): InputProfile {
|
||||
val profile = profile(id)
|
||||
val parts = InputRole.entries.associateWith { preset(profile.binding(it)) }
|
||||
return compileParts(id, parts)
|
||||
}
|
||||
|
||||
private fun compileParts(id: String, parts: Map<InputRole, SavedInputBindings>): InputProfile {
|
||||
return InputProfile(id, parts.mapValues { it.value.controls }.filterValues { it.isNotEmpty() },
|
||||
parts.getValue(InputRole.PHYSICAL).keys,
|
||||
parts.mapValues { it.value.elements }.filterValues { it.isNotEmpty() },
|
||||
parts.mapValues { it.value.clicks }.filterValues { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
fun usersOfBindings(id: String) = profiles.filter { p -> p.parts.values.any { it.bindings == id } }
|
||||
fun selectedBindings(id: String) = selections.values.any { id in it.parts.values }
|
||||
fun usersOfLayout(id: String) = profiles.filter { p -> p.parts.values.any { it.layout == id } }
|
||||
fun withProfile(value: SavedInputProfile) = copy(profiles = profiles.filterNot { it.id == value.id } + value)
|
||||
fun withPreset(value: SavedInputBindings) = copy(bindings = bindings.filterNot { it.id == value.id } + value)
|
||||
fun withLayout(value: SavedInputLayout) = copy(layouts = layouts.filterNot { it.id == value.id } + value,
|
||||
bindings = bindings.map { preset -> if (preset.layoutId != value.id) preset else preset.copy(
|
||||
elements = preset.elements.filter { (id, action) -> value.elements.any { it.id == id && action.validFor(it) } },
|
||||
clicks = preset.clicks.filter { (id, action) -> value.elements.any { it.id == id && it.hasClick && action.validForClick() } }) })
|
||||
|
||||
fun duplicateProfile(id: String, name: String, newId: String = newInputId(), sharePhysical: Boolean = false): InputLibrary {
|
||||
require(profiles.size < 64)
|
||||
var result = withProfile(profile(id).copy(id = newId, name = inputName(name)))
|
||||
for (role in InputRole.entries) result = result.usePart(newId, role, id, shared = sharePhysical && role == InputRole.PHYSICAL)
|
||||
return result
|
||||
}
|
||||
|
||||
fun usePart(profileId: String, role: InputRole, sourceId: String, shared: Boolean): InputLibrary {
|
||||
val target = profile(profileId)
|
||||
val source = profile(sourceId).parts.getValue(role)
|
||||
if (shared) return withProfile(target.copy(parts = target.parts + (role to source)))
|
||||
val sourceLayout = layout(sourceId, role)
|
||||
val name = (target.name + " / " + role.name.lowercase()).take(80)
|
||||
val copiedLayout = sourceLayout?.takeIf { it.editable }?.derived(newInputId(), name)
|
||||
val copiedBindings = preset(source.bindings).copy(id = newInputId(), name = name,
|
||||
layoutId = copiedLayout?.id ?: preset(source.bindings).layoutId)
|
||||
return (if (copiedLayout == null) this else withLayout(copiedLayout)).withPreset(copiedBindings)
|
||||
.withProfile(target.copy(parts = target.parts + (role to InputPart(copiedBindings.id, copiedLayout?.id ?: source.layout))))
|
||||
}
|
||||
|
||||
fun useLayout(profileId: String, role: InputRole, layoutId: String, shared: Boolean): InputLibrary {
|
||||
val source = layouts.first { it.id == layoutId }
|
||||
require(source.supports(role))
|
||||
val target = profile(profileId)
|
||||
val layout = if (shared || !source.editable) source else source.derived(newInputId(), (target.name + " / " + source.name).take(80))
|
||||
val previous = preset(target.binding(role))
|
||||
val actions = if (previous.compatible(source)) previous else bindings.firstOrNull { it.compatible(source) } ?: SavedInputBindings(newInputId(), target.name, false,
|
||||
controls = if (source.editable) emptyMap() else InputProfile.defaults(target.base).bindings[role].orEmpty(),
|
||||
elements = if (source.editable) source.elements.associate { it.id to InputAction.Disabled } else emptyMap())
|
||||
val copied = actions.copy(id = newInputId(), name = (target.name + " / " + role.name.lowercase()).take(80),
|
||||
layoutId = layout.id.takeIf { layout.editable })
|
||||
return withLayout(layout).withPreset(copied)
|
||||
.withProfile(target.copy(parts = target.parts + (role to InputPart(copied.id, layout.id))))
|
||||
}
|
||||
|
||||
fun copyBindings(id: String, name: String, profileId: String? = null, role: InputRole? = null, newId: String = newInputId()): InputLibrary {
|
||||
require(bindings.size < 512)
|
||||
var result = withPreset(preset(id).copy(id = newId, name = inputName(name)))
|
||||
if (profileId != null && role != null) result = result.chooseBindings(profileId, role, newId)
|
||||
return result
|
||||
}
|
||||
|
||||
fun chooseBindings(profileId: String, role: InputRole, id: String): InputLibrary {
|
||||
val profile = profile(profileId)
|
||||
require(preset(id).compatible(layout(profileId, role)))
|
||||
return withProfile(profile.copy(parts = profile.parts + (role to profile.parts.getValue(role).copy(bindings = id))))
|
||||
}
|
||||
|
||||
fun chooseLayout(profileId: String, role: InputRole, id: String): InputLibrary {
|
||||
val layout = layouts.first { it.id == id }
|
||||
require(layout.supports(role))
|
||||
val profile = profile(profileId)
|
||||
val previous = preset(profile.binding(role))
|
||||
val compatible = bindings.firstOrNull { it.id == previous.id && it.compatible(layout) }
|
||||
?: bindings.firstOrNull { it.compatible(layout) }
|
||||
?: SavedInputBindings(newInputId(), layout.name, false, if (layout.editable) layout.id else null)
|
||||
return withPreset(compatible).withProfile(profile.copy(parts = profile.parts + (role to InputPart(compatible.id, id))))
|
||||
}
|
||||
|
||||
fun copyLayout(id: String, name: String, newId: String = newInputId()): InputLibrary {
|
||||
val source = layouts.first { it.id == id }
|
||||
require(source.editable)
|
||||
return withLayout(source.derived(newId, inputName(name)))
|
||||
}
|
||||
|
||||
fun removeProfile(id: String): InputLibrary {
|
||||
require(id != InputProfile.GAME && id != InputProfile.DESKTOP)
|
||||
return copy(profiles = profiles.filterNot { it.id == id }, selected = selected.takeUnless { it == id } ?: InputProfiles.AUTO,
|
||||
steam = steam.takeUnless { it == id } ?: InputProfile.GAME, desktop = desktop.takeUnless { it == id } ?: InputProfile.DESKTOP,
|
||||
selections = selections.mapValues { (_, selection) -> if (selection.profile == id) selection.copy(profile = InputProfiles.AUTO) else selection })
|
||||
}
|
||||
|
||||
fun removeLayout(id: String): InputLibrary {
|
||||
require(usersOfLayout(id).isEmpty() && bindings.none { it.layoutId == id && selectedBindings(it.id) } && layouts.first { it.id == id }.editable)
|
||||
return copy(layouts = layouts.filterNot { it.id == id }, bindings = bindings.filterNot { it.layoutId == id })
|
||||
}
|
||||
|
||||
fun removeBindings(id: String): InputLibrary {
|
||||
require(usersOfBindings(id).isEmpty() && !selectedBindings(id))
|
||||
return copy(bindings = bindings.filterNot { it.id == id })
|
||||
}
|
||||
|
||||
fun resetProfile(id: String): InputLibrary {
|
||||
val p = profile(id)
|
||||
var result = this
|
||||
val defaults = InputProfile.defaults(p.base)
|
||||
val parts = p.parts.mapValues { (role, part) ->
|
||||
val previous = preset(part.bindings)
|
||||
val isPrivate = usersOfBindings(part.bindings).all { it.id == id }
|
||||
val preset = SavedInputBindings(if (isPrivate) previous.id else newInputId(),
|
||||
if (isPrivate) previous.name else (p.name + " / " + role.name.lowercase()).take(80), role == InputRole.PHYSICAL,
|
||||
part.layout?.takeIf { layout(id, role)?.editable == true }, defaults.bindings[role].orEmpty(),
|
||||
elements = (layout(id, role)?.let { InputStockLayouts.actions(it) } ?: emptyMap()) + layout(id, role)?.elements.orEmpty().filter { it.id == "builtin:keyboard" }
|
||||
.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) })
|
||||
result = result.withPreset(preset)
|
||||
part.copy(bindings = preset.id)
|
||||
}
|
||||
return result.withProfile(p.copy(parts = parts))
|
||||
}
|
||||
|
||||
fun validate(): InputLibrary {
|
||||
require(profiles.size in 2..64 && layouts.size in 3..256 && bindings.size in 1..512)
|
||||
fun unique(ids: List<String>) { require(ids.all { it.isNotBlank() && it.length <= 128 } && ids.distinct().size == ids.size) }
|
||||
unique(profiles.map { it.id }); unique(layouts.map { it.id }); unique(bindings.map { it.id })
|
||||
require(profiles.any { it.id == InputProfile.GAME } && profiles.any { it.id == InputProfile.DESKTOP })
|
||||
require(selected == InputProfiles.AUTO || profiles.any { it.id == selected })
|
||||
require(profiles.any { it.id == steam } && profiles.any { it.id == desktop })
|
||||
for (layout in layouts) {
|
||||
require(layout.style.opacity in 1..100 && layout.style.hidden.all { it in QUICK_BUTTONS })
|
||||
require(layout.aspect == 0f || layout.aspect.isFinite() && layout.aspect in .05f..20f)
|
||||
require(layout.scale.isFinite() && layout.scale in .5f..2f)
|
||||
inputName(layout.name); require(layout.elements.size <= 64); unique(layout.elements.map { it.id })
|
||||
for (e in layout.elements) {
|
||||
inputName(e.name)
|
||||
require(e.icon == 0 || InputButtonIcons.has(e.icon))
|
||||
require(e.image.isEmpty() || e.image.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && e.image != "." && e.image != "..")
|
||||
require(e.opacity.isFinite() && e.opacity in 0.01f..1f)
|
||||
require(!e.toggle || e.kind == InputElementKind.BUTTON || e.kind == InputElementKind.TRIGGER)
|
||||
require(listOf(e.x, e.y, e.width, e.height).all { it.isFinite() })
|
||||
require(e == e.bounded())
|
||||
require(if (e.id.startsWith("legacy:")) e.id == "legacy:${e.control.id}" && e.kind == InputElementKind.from(e.control)
|
||||
else e.control == e.kind.control)
|
||||
}
|
||||
}
|
||||
for (preset in bindings) {
|
||||
inputName(preset.name)
|
||||
require(preset.controls.all { (c, a) -> a.validFor(c) })
|
||||
require(preset.elements.size <= 64 && preset.keys.size <= 64 && preset.clicks.size <= 64)
|
||||
require(preset.keys.map { Triple(it.key.device, it.key.keyCode, it.key.scanCode) }.distinct().size == preset.keys.size)
|
||||
require(preset.physical || preset.keys.isEmpty())
|
||||
val layout = preset.layoutId?.let { id -> layouts.first { it.id == id } }
|
||||
require(!preset.physical || layout == null && preset.elements.isEmpty() && preset.clicks.isEmpty())
|
||||
require(preset.elements.all { (id, action) -> layout?.elements?.firstOrNull { it.id == id }?.let { action.validFor(it) } == true })
|
||||
require(preset.clicks.all { (id, action) -> layout?.elements?.any { it.id == id && it.hasClick } == true && action.validForClick() })
|
||||
require(preset.keys.all { it.key.device.isNotBlank() && it.key.keyCode >= 0 && it.key.scanCode >= 0 &&
|
||||
it.action.validFor(InputControl.A) && (it.action !is InputAction.Native || it.action.target == null) })
|
||||
}
|
||||
require(selections.keys.all { it == InputProfile.GAME || it == InputProfile.DESKTOP })
|
||||
for (selection in selections.values) {
|
||||
require(selection.profile == InputProfiles.AUTO || profiles.any { it.id == selection.profile })
|
||||
for ((role, id) in selection.parts) {
|
||||
val binding = preset(id)
|
||||
require(binding.physical == (role == InputRole.PHYSICAL))
|
||||
val layout = binding.layoutId?.let { lid -> layouts.first { it.id == lid } }
|
||||
require(binding.compatible(layout) && (layout == null || layout.supports(role)))
|
||||
}
|
||||
}
|
||||
for (p in profiles) {
|
||||
inputName(p.name); require(p.base == InputProfile.GAME || p.base == InputProfile.DESKTOP)
|
||||
require(p.parts.keys == InputRole.entries.toSet())
|
||||
for ((role, part) in p.parts) {
|
||||
val layout = part.layout?.let { id -> layouts.first { it.id == id } }
|
||||
require(if (role == InputRole.PHYSICAL) layout == null else layout?.supports(role) == true)
|
||||
require(preset(part.bindings).compatible(layout))
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
internal fun inputName(value: String): String = value.trim().also { require(it.isNotEmpty() && it.length <= 80) }
|
||||
fun newInputId(): String = UUID.randomUUID().toString()
|
||||
|
||||
internal fun InputAction.validFor(element: InputSurfaceElement): Boolean = when {
|
||||
this !is InputAction.Native -> validFor(element.control)
|
||||
element.id.startsWith("legacy:") -> validFor(element.control)
|
||||
element.kind == InputElementKind.DPAD -> target == null
|
||||
target == null -> false
|
||||
element.kind == InputElementKind.TRACKPAD -> target.isTrackpad
|
||||
element.kind == InputElementKind.STICK -> target == InputControl.LS || target == InputControl.RS
|
||||
element.kind == InputElementKind.TRIGGER -> target == InputControl.LT || target == InputControl.RT
|
||||
else -> target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Choreographer
|
||||
|
||||
class InputMotion(private val router: () -> InputRouter?) : Choreographer.FrameCallback {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var scheduled = false
|
||||
private var lastFrame = 0L
|
||||
private val start = Runnable {
|
||||
if (!scheduled) {
|
||||
scheduled = true
|
||||
lastFrame = System.nanoTime()
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun request() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) start.run() else handler.post(start)
|
||||
}
|
||||
|
||||
override fun doFrame(frameTimeNanos: Long) {
|
||||
val dt = (frameTimeNanos - lastFrame).coerceAtLeast(0) / 1_000_000_000f
|
||||
lastFrame = frameTimeNanos
|
||||
if (router()?.tick(dt) == true) Choreographer.getInstance().postFrameCallback(this)
|
||||
else scheduled = false
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
handler.removeCallbacks(start)
|
||||
Choreographer.getInstance().removeFrameCallback(this)
|
||||
scheduled = false
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
enum class InputRole { PHYSICAL, PRIMARY, SECONDARY }
|
||||
|
||||
enum class InputControl(val id: String, val label: String, val dimensions: Int = 1) {
|
||||
A("a", "A"), B("b", "B"), X("x", "X"), Y("y", "Y"),
|
||||
LB("lb", "L1"), RB("rb", "R1"), SELECT("select", "View / Select"), START("start", "Menu / Start"),
|
||||
L3("l3", "L3"), R3("r3", "R3"), GUIDE("guide", "Steam / Guide"), QAM("qam", "Quick access"),
|
||||
UP("up", "D-pad up"), RIGHT("right", "D-pad right"), DOWN("down", "D-pad down"), LEFT("left", "D-pad left"),
|
||||
LT("lt", "L2"), RT("rt", "R2"), LS("ls", "Left stick", 2), RS("rs", "Right stick", 2),
|
||||
L4("l4", "L4"), R4("r4", "R4"), L5("l5", "L5"), R5("r5", "R5"),
|
||||
LEFT_PAD("left-pad", "Left trackpad", 2), RIGHT_PAD("right-pad", "Right trackpad", 2),
|
||||
LEFT_CLICK("left-click", "Left trackpad click"), RIGHT_CLICK("right-click", "Right trackpad click");
|
||||
|
||||
val isPad: Boolean get() = ordinal <= RS.ordinal
|
||||
val isTrackpad: Boolean get() = this == LEFT_PAD || this == RIGHT_PAD
|
||||
|
||||
companion object {
|
||||
val padControls = entries.filter { it.isPad }.toTypedArray()
|
||||
fun fromId(id: String) = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
enum class InputCommand { MENU, KEYBOARD, STEAM, QUICK_ACCESS }
|
||||
|
||||
sealed interface InputAction {
|
||||
data class Native(val target: InputControl? = null) : InputAction
|
||||
data object Disabled : InputAction
|
||||
data class Keys(val codes: List<Int>) : InputAction
|
||||
data class MouseButton(val code: Int) : InputAction
|
||||
data class Command(val command: InputCommand) : InputAction
|
||||
data class Directions(val up: Int, val right: Int, val down: Int, val left: Int, val diagonals: List<Int>? = null) : InputAction
|
||||
data class Pointer(val speed: Float = 900f) : InputAction
|
||||
data class Scroll(val speed: Float = 10f, val horizontal: Boolean = false) : InputAction
|
||||
}
|
||||
|
||||
data class InputProfile(
|
||||
val id: String,
|
||||
val bindings: Map<InputRole, Map<InputControl, InputAction>> = emptyMap(),
|
||||
val keys: List<PhysicalInputBinding> = emptyList(),
|
||||
val elements: Map<InputRole, Map<String, InputAction>> = emptyMap(),
|
||||
val clicks: Map<InputRole, Map<String, InputAction>> = emptyMap(),
|
||||
) {
|
||||
fun action(role: InputRole, control: InputControl): InputAction =
|
||||
bindings[role]?.get(control) ?: InputAction.Native()
|
||||
|
||||
fun withBinding(role: InputRole, control: InputControl, action: InputAction) =
|
||||
copy(bindings = bindings + (role to (bindings[role].orEmpty() + (control to action))))
|
||||
|
||||
fun elementAction(role: InputRole, id: String, control: InputControl): InputAction =
|
||||
elements[role]?.get(id) ?: if (id == "legacy:${control.id}") action(role, control) else InputAction.Disabled
|
||||
|
||||
fun elementClickAction(role: InputRole, element: InputSurfaceElement): InputAction = clicks[role]?.get(element.id)
|
||||
?: defaultElementClick(element, elementAction(role, element.id, element.control)) { action(role, it) }
|
||||
|
||||
companion object {
|
||||
const val GAME = "game"
|
||||
const val DESKTOP = "desktop"
|
||||
|
||||
fun defaults(id: String): InputProfile {
|
||||
if (id != DESKTOP) return InputProfile(GAME)
|
||||
val desktop = mapOf(
|
||||
InputControl.LS to InputAction.Pointer(), InputControl.RS to InputAction.Scroll(),
|
||||
InputControl.A to InputAction.MouseButton(272), InputControl.B to InputAction.MouseButton(273),
|
||||
InputControl.X to InputAction.Keys(listOf(28)), InputControl.Y to InputAction.Keys(listOf(1)),
|
||||
InputControl.UP to InputAction.Keys(listOf(103)), InputControl.RIGHT to InputAction.Keys(listOf(106)),
|
||||
InputControl.DOWN to InputAction.Keys(listOf(108)), InputControl.LEFT to InputAction.Keys(listOf(105)),
|
||||
InputControl.LT to InputAction.MouseButton(273), InputControl.RT to InputAction.MouseButton(272),
|
||||
InputControl.LB to InputAction.Keys(listOf(29)), InputControl.RB to InputAction.Keys(listOf(56)),
|
||||
InputControl.SELECT to InputAction.Command(InputCommand.KEYBOARD),
|
||||
InputControl.START to InputAction.Command(InputCommand.MENU),
|
||||
InputControl.LEFT_PAD to InputAction.Scroll(), InputControl.RIGHT_PAD to InputAction.Pointer(),
|
||||
InputControl.LEFT_CLICK to InputAction.MouseButton(273), InputControl.RIGHT_CLICK to InputAction.MouseButton(272),
|
||||
)
|
||||
return InputProfile(DESKTOP, InputRole.entries.associateWith { desktop })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun defaultElementClick(element: InputSurfaceElement, action: InputAction, legacy: (InputControl) -> InputAction): InputAction {
|
||||
fun click(control: InputControl) = when (control) {
|
||||
InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3
|
||||
InputControl.LEFT_PAD -> InputControl.LEFT_CLICK; InputControl.RIGHT_PAD -> InputControl.RIGHT_CLICK
|
||||
else -> null
|
||||
}
|
||||
if (!element.hasClick) return InputAction.Disabled
|
||||
if (element.id == "legacy:${element.control.id}") {
|
||||
val target = click(element.control) ?: return InputAction.Disabled
|
||||
return legacy(target).let { if (it is InputAction.Native && it.target == null) InputAction.Native(target) else it }
|
||||
}
|
||||
return (action as? InputAction.Native)?.let { click(it.target ?: element.control) }?.let { InputAction.Native(it) } ?: InputAction.Disabled
|
||||
}
|
||||
|
||||
internal fun InputAction.validForClick(): Boolean = if (this is InputAction.Native)
|
||||
target != null && target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
|
||||
else validFor(InputControl.A)
|
||||
|
||||
internal fun InputAction.validFor(control: InputControl): Boolean = when (this) {
|
||||
is InputAction.Native -> target == null || (control.isPad == target.isPad && control.dimensions == target.dimensions &&
|
||||
(!control.isTrackpad && !target.isTrackpad || control.isTrackpad && target.isTrackpad))
|
||||
is InputAction.Keys -> codes.isNotEmpty() && codes.size <= 8 && codes.distinct().size == codes.size && codes.all { it in 1..255 }
|
||||
is InputAction.MouseButton -> code in 272..276
|
||||
is InputAction.Directions -> control.dimensions == 2 && listOf(up, right, down, left).all { it in 0..255 } && (diagonals == null || diagonals.size == 4 && diagonals.all { it in 0..255 })
|
||||
is InputAction.Pointer -> control.dimensions == 2 && speed.isFinite() && speed in 100f..3000f
|
||||
is InputAction.Scroll -> control.dimensions == 2 && speed.isFinite() && speed in 1f..60f
|
||||
else -> true
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object InputProfiles {
|
||||
const val AUTO = "auto"
|
||||
private const val VERSION = 1
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
|
||||
|
||||
fun selection(context: Context): String = InputLibraries.read(context).selected
|
||||
|
||||
fun select(context: Context, id: String) {
|
||||
InputLibraries.update(context) { library ->
|
||||
require(id == AUTO || library.profiles.any { it.id == id })
|
||||
library.copy(selected = id, selections = emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
fun active(context: Context, desktop: Boolean): InputProfile {
|
||||
val library = InputLibraries.read(context)
|
||||
return library.compileActive(desktop)
|
||||
}
|
||||
|
||||
fun read(context: Context, id: String): InputProfile {
|
||||
return InputLibraries.read(context).compile(id)
|
||||
}
|
||||
|
||||
internal fun readLegacy(context: Context, id: String): InputProfile {
|
||||
migrate(context)
|
||||
var result = InputProfile.defaults(id)
|
||||
val stored = prefs(context).getString("profile.$id", null) ?: return result
|
||||
val document = runCatching { JSONObject(stored) }.getOrNull() ?: return result
|
||||
if (document.optInt("version") != VERSION) return result
|
||||
val bindings = document.optJSONArray("bindings") ?: return result
|
||||
for (i in 0 until minOf(bindings.length(), 256)) {
|
||||
val entry = bindings.optJSONObject(i) ?: continue
|
||||
val role = runCatching { InputRole.valueOf(entry.getString("role")) }.getOrNull() ?: continue
|
||||
val control = InputControl.fromId(entry.optString("control")) ?: continue
|
||||
val action = decode(entry.optJSONObject("action") ?: continue) ?: continue
|
||||
if (action.validFor(control)) result = result.withBinding(role, control, action)
|
||||
}
|
||||
val keys = document.optJSONArray("keys")
|
||||
if (keys != null) result = result.copy(keys = (0 until minOf(keys.length(), 64)).mapNotNull { i ->
|
||||
runCatching {
|
||||
val item = keys.getJSONObject(i)
|
||||
val action = decode(item.getJSONObject("action")) ?: return@mapNotNull null
|
||||
if (!action.validFor(InputControl.A) || action is InputAction.Native && action.target != null) return@mapNotNull null
|
||||
val key = PhysicalInputKey(item.getString("device"), item.getString("name"), item.getInt("keyCode"), item.getInt("scanCode"))
|
||||
if (key.device.isBlank() || key.keyCode < 0 || key.scanCode < 0) return@mapNotNull null
|
||||
PhysicalInputBinding(key, action)
|
||||
}.getOrNull()
|
||||
}.distinctBy { Triple(it.key.device, it.key.keyCode, it.key.scanCode) })
|
||||
return result
|
||||
}
|
||||
|
||||
fun save(context: Context, profile: InputProfile) {
|
||||
InputLibraries.update(context) { library ->
|
||||
val definition = library.profile(profile.id)
|
||||
var updated = library
|
||||
for (role in InputRole.entries) {
|
||||
val preset = updated.preset(definition.binding(role))
|
||||
updated = updated.withPreset(preset.copy(controls = profile.bindings[role].orEmpty(),
|
||||
elements = profile.elements[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) profile.keys else emptyList(),
|
||||
clicks = profile.clicks[role].orEmpty()))
|
||||
}
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLegacy(context: Context, profile: InputProfile) {
|
||||
val bindings = JSONArray()
|
||||
for ((role, controls) in profile.bindings) for ((control, action) in controls) {
|
||||
require(action.validFor(control))
|
||||
bindings.put(JSONObject().put("role", role.name).put("control", control.id).put("action", encode(action)))
|
||||
}
|
||||
val keys = JSONArray()
|
||||
for (binding in profile.keys) keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
|
||||
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", encode(binding.action)))
|
||||
prefs(context).edit().putString("profile.${profile.id}", JSONObject()
|
||||
.put("version", VERSION).put("id", profile.id).put("bindings", bindings).put("keys", keys).toString()).apply()
|
||||
}
|
||||
|
||||
fun reset(context: Context, id: String) = InputLibraries.update(context) { it.resetProfile(id) }.let { }
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("migrated", true).apply()
|
||||
InputSurfaceLayout.resetAll(context)
|
||||
}
|
||||
|
||||
private fun migrate(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.getBoolean("migrated", false)) return
|
||||
var game = InputProfile.defaults(InputProfile.GAME)
|
||||
val old = ControllerPrefs.read(context)
|
||||
for ((id, target) in old.mapping) {
|
||||
val source = InputControl.fromId(id) ?: continue
|
||||
val action = if (target == ControllerPrefs.OFF) InputAction.Disabled
|
||||
else InputAction.Native(InputControl.fromId(target) ?: continue)
|
||||
if (target != id) game = game.withBinding(InputRole.PRIMARY, source, action)
|
||||
}
|
||||
if (old.selectSteam) game = game.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE))
|
||||
if (!p.contains("profile.${InputProfile.GAME}")) saveLegacy(context, game)
|
||||
if (old.selectSteam && !p.contains("profile.${InputProfile.DESKTOP}")) {
|
||||
saveLegacy(context, InputProfile.defaults(InputProfile.DESKTOP)
|
||||
.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE)))
|
||||
}
|
||||
p.edit().putBoolean("migrated", true).apply()
|
||||
}
|
||||
|
||||
internal fun encode(action: InputAction): JSONObject = JSONObject().apply {
|
||||
when (action) {
|
||||
is InputAction.Native -> { put("type", "native"); action.target?.let { put("target", it.id) } }
|
||||
InputAction.Disabled -> put("type", "disabled")
|
||||
is InputAction.Keys -> { put("type", "keys"); put("codes", JSONArray(action.codes)) }
|
||||
is InputAction.MouseButton -> { put("type", "button"); put("code", action.code) }
|
||||
is InputAction.Command -> { put("type", "command"); put("command", action.command.name) }
|
||||
is InputAction.Directions -> { put("type", "directions"); put("codes", JSONArray(listOf(action.up, action.right, action.down, action.left))); action.diagonals?.let { put("diagonals", JSONArray(it)) } }
|
||||
is InputAction.Pointer -> { put("type", "pointer"); put("speed", action.speed.toDouble()) }
|
||||
is InputAction.Scroll -> { put("type", "scroll"); put("speed", action.speed.toDouble()); put("horizontal", action.horizontal) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun decode(json: JSONObject): InputAction? = runCatching {
|
||||
when (json.getString("type")) {
|
||||
"native" -> InputAction.Native(if (json.has("target")) InputControl.fromId(json.getString("target")) ?: return null else null)
|
||||
"disabled" -> InputAction.Disabled
|
||||
"keys" -> InputAction.Keys(json.getJSONArray("codes").let { a -> (0 until a.length()).map { a.getInt(it) } })
|
||||
"button" -> InputAction.MouseButton(json.getInt("code"))
|
||||
"command" -> InputAction.Command(InputCommand.valueOf(json.getString("command")))
|
||||
"directions" -> json.getJSONArray("codes").let { InputAction.Directions(it.getInt(0), it.getInt(1), it.getInt(2), it.getInt(3), json.optJSONArray("diagonals")?.let { d -> (0 until d.length()).map { d.getInt(it) } }) }
|
||||
"pointer" -> InputAction.Pointer(json.getDouble("speed").toFloat())
|
||||
"scroll" -> InputAction.Scroll(json.getDouble("speed").toFloat(), json.optBoolean("horizontal", false))
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
interface InputOutput {
|
||||
fun key(code: Int, down: Boolean)
|
||||
fun button(code: Int, down: Boolean)
|
||||
fun pointer(dx: Float, dy: Float)
|
||||
fun scroll(steps: Int)
|
||||
fun scrollHorizontal(steps: Int) {}
|
||||
fun command(command: InputCommand)
|
||||
fun command(command: InputCommand, role: InputRole) = command(command)
|
||||
fun requestMotion()
|
||||
}
|
||||
|
||||
class InputRouter(private val output: InputOutput) {
|
||||
private val keys = IntArray(256)
|
||||
private val buttons = IntArray(5)
|
||||
private val channels = ArrayList<Channel>()
|
||||
private val pads = ArrayList<PadSource>()
|
||||
private var profile = InputProfile.defaults(InputProfile.GAME)
|
||||
private var moving = false
|
||||
private var wheel = 0f
|
||||
private var horizontalWheel = 0f
|
||||
private var enabled = true
|
||||
private val snapshots = ArrayList<() -> Unit>()
|
||||
|
||||
@Synchronized
|
||||
fun beforeProfileChange(snapshot: () -> Unit) { snapshots.add(snapshot) }
|
||||
|
||||
@Synchronized
|
||||
fun removeSnapshot(snapshot: () -> Unit) { snapshots.remove(snapshot) }
|
||||
|
||||
@Synchronized
|
||||
fun setEnabled(on: Boolean): Boolean {
|
||||
if (enabled == on) return false
|
||||
if (!on) releaseAll()
|
||||
enabled = on
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setProfile(next: InputProfile) {
|
||||
if (profile == next) return
|
||||
for (snapshot in snapshots) snapshot()
|
||||
for (pad in pads) pad.snapshot()
|
||||
profile = next
|
||||
for (channel in channels) channel.configure(channel.actionFrom(next))
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
wheel = 0f
|
||||
horizontalWheel = 0f
|
||||
moving = false
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
@Synchronized
|
||||
fun pad(role: InputRole, retained: PadState? = null): PadSource = PadSource(role, retained).also { pads.add(it) }
|
||||
|
||||
@Synchronized
|
||||
fun channel(role: InputRole, control: InputControl, fixedAction: InputAction? = null, element: InputSurfaceElement? = null, elementClick: Boolean = false): Channel = Channel(role, control, fixedAction, element, elementClick).also {
|
||||
channels.add(it)
|
||||
it.configure(it.actionFrom(profile))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
for (snapshot in snapshots) snapshot()
|
||||
for (pad in pads) pad.snapshot()
|
||||
for (channel in channels) channel.suspend()
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
wheel = 0f
|
||||
horizontalWheel = 0f
|
||||
moving = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun tick(seconds: Float): Boolean {
|
||||
if (!enabled) return false
|
||||
var x = 0f
|
||||
var y = 0f
|
||||
var scroll = 0f
|
||||
var horizontalScroll = 0f
|
||||
for (channel in channels) {
|
||||
if (channel.blocked || channel.control.isTrackpad) continue
|
||||
val cx = if (abs(channel.x) > 0.12f) channel.x else 0f
|
||||
val cy = if (abs(channel.y) > 0.12f) channel.y else 0f
|
||||
when (val action = channel.action) {
|
||||
is InputAction.Pointer -> { x += cx * action.speed; y += cy * action.speed }
|
||||
is InputAction.Scroll -> if (action.horizontal) horizontalScroll += cx * action.speed else scroll += cy * action.speed
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val dt = seconds.coerceIn(0f, 0.05f)
|
||||
if (x != 0f || y != 0f) output.pointer(x * dt, y * dt)
|
||||
wheel += scroll * dt
|
||||
horizontalWheel += horizontalScroll * dt
|
||||
flushWheel()
|
||||
moving = x != 0f || y != 0f || scroll != 0f || horizontalScroll != 0f
|
||||
return moving
|
||||
}
|
||||
|
||||
private fun flushWheel() {
|
||||
val steps = wheel.toInt()
|
||||
if (steps != 0) { wheel -= steps; output.scroll(steps) }
|
||||
val horizontalSteps = horizontalWheel.toInt()
|
||||
if (horizontalSteps != 0) { horizontalWheel -= horizontalSteps; output.scrollHorizontal(horizontalSteps) }
|
||||
}
|
||||
|
||||
private fun key(code: Int, down: Boolean) {
|
||||
val was = keys[code]
|
||||
keys[code] = (was + if (down) 1 else -1).coerceAtLeast(0)
|
||||
if ((was == 0) != (keys[code] == 0)) output.key(code, down)
|
||||
}
|
||||
|
||||
private fun button(code: Int, down: Boolean) {
|
||||
val at = code - 272
|
||||
val was = buttons[at]
|
||||
buttons[at] = (was + if (down) 1 else -1).coerceAtLeast(0)
|
||||
if ((was == 0) != (buttons[at] == 0)) output.button(code, down)
|
||||
}
|
||||
|
||||
inner class Channel internal constructor(val role: InputRole, val control: InputControl, private val fixedAction: InputAction?, private val element: InputSurfaceElement?, private val elementClick: Boolean) {
|
||||
var action: InputAction = InputAction.Native()
|
||||
private set
|
||||
internal var x = 0f
|
||||
internal var y = 0f
|
||||
internal var contact = false
|
||||
internal var blocked = false
|
||||
private var held = false
|
||||
private val directions = BooleanArray(4)
|
||||
private var radialKeys = emptyList<Int>()
|
||||
|
||||
internal fun actionFrom(profile: InputProfile) = element?.let { if (elementClick) profile.elementClickAction(role, it) else profile.elementAction(role, it.id, control) }
|
||||
?: profile.action(role, control)
|
||||
|
||||
internal fun configure(next: InputAction) {
|
||||
suspend()
|
||||
action = (fixedAction ?: next).takeIf { if (elementClick) it.validForClick() else if (element != null) it.validFor(element) else it.validFor(control) }
|
||||
?: if (element != null) InputAction.Disabled else InputAction.Native()
|
||||
}
|
||||
|
||||
internal fun observe(value: Float, second: Float = 0f, touching: Boolean = false) {
|
||||
x = value; y = second; contact = touching
|
||||
}
|
||||
|
||||
internal fun awaitNeutral(value: Float, second: Float, touching: Boolean) = synchronized(this@InputRouter) {
|
||||
observe(value, second, touching)
|
||||
suspend()
|
||||
}
|
||||
|
||||
internal fun nativeIdentity(): Boolean = enabled && !blocked && (action as? InputAction.Native)?.target == null && action is InputAction.Native
|
||||
|
||||
internal fun suspend() {
|
||||
releaseOutputs()
|
||||
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
|
||||
}
|
||||
|
||||
fun release() = synchronized(this@InputRouter) {
|
||||
releaseOutputs()
|
||||
x = 0f; y = 0f; contact = false; blocked = false
|
||||
}
|
||||
|
||||
fun close() = synchronized(this@InputRouter) {
|
||||
release()
|
||||
channels.remove(this)
|
||||
}
|
||||
|
||||
fun acceptsPointerGesture() = synchronized(this@InputRouter) { enabled && !blocked && contact && action is InputAction.Pointer }
|
||||
|
||||
fun pointerDelta(dx: Float, dy: Float) = synchronized(this@InputRouter) {
|
||||
val pointer = action as? InputAction.Pointer
|
||||
if (enabled && !blocked && contact && pointer != null && dx.isFinite() && dy.isFinite()) output.pointer(dx * pointer.speed, dy * pointer.speed)
|
||||
}
|
||||
|
||||
fun wheelSteps(steps: Int) = synchronized(this@InputRouter) {
|
||||
val scroll = action as? InputAction.Scroll
|
||||
if (enabled && !blocked && scroll != null && steps != 0) {
|
||||
if (scroll.horizontal) output.scrollHorizontal(steps) else output.scroll(steps)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(value: Float, second: Float = 0f, touching: Boolean = false): InputControl? = synchronized(this@InputRouter) {
|
||||
val oldX = x
|
||||
val oldY = y
|
||||
val oldContact = contact
|
||||
x = value.coerceIn(-1f, 1f)
|
||||
y = second.coerceIn(-1f, 1f)
|
||||
contact = touching
|
||||
if (!enabled) {
|
||||
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
|
||||
return@synchronized null
|
||||
}
|
||||
if (blocked) {
|
||||
if (!contact && abs(x) <= 0.12f && abs(y) <= 0.12f) blocked = false
|
||||
return@synchronized null
|
||||
}
|
||||
when (val a = action) {
|
||||
is InputAction.Native -> return@synchronized a.target ?: control
|
||||
is InputAction.Disabled -> Unit
|
||||
is InputAction.Pointer -> {
|
||||
if (control.isTrackpad) {
|
||||
if (contact && oldContact) output.pointer((x - oldX) * a.speed, -(y - oldY) * a.speed)
|
||||
} else requestMotion()
|
||||
}
|
||||
is InputAction.Scroll -> {
|
||||
if (control.isTrackpad) {
|
||||
if (contact && oldContact) {
|
||||
if (a.horizontal) horizontalWheel -= (x - oldX) * a.speed else wheel += (y - oldY) * a.speed
|
||||
flushWheel()
|
||||
}
|
||||
} else requestMotion()
|
||||
}
|
||||
is InputAction.Directions -> {
|
||||
val vertical = if (control.isTrackpad) -y else y
|
||||
if (a.diagonals == null) {
|
||||
direction(0, -vertical, a.up); direction(1, x, a.right)
|
||||
direction(2, vertical, a.down); direction(3, -x, a.left)
|
||||
} else {
|
||||
val cardinal = listOf(a.up, a.right, a.down, a.left)
|
||||
val sector = ((kotlin.math.round(Math.toDegrees(kotlin.math.atan2(vertical, x).toDouble()) / 45).toInt() + 2) % 8 + 8) % 8
|
||||
val next = if (kotlin.math.hypot(x, vertical) < .24f) emptyList() else if (sector % 2 == 0) listOf(cardinal[sector / 2])
|
||||
else a.diagonals[sector / 2].let { if (it != 0) listOf(it) else listOf(cardinal[sector / 2], cardinal[(sector / 2 + 1) % 4]) }
|
||||
val codes = next.filter { it != 0 }.distinct()
|
||||
radialKeys.filterNot { it in codes }.forEach { key(it, false) }
|
||||
codes.filterNot { it in radialKeys }.forEach { key(it, true) }
|
||||
radialKeys = codes
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val down = if (control.isTrackpad) contact else maxOf(abs(x), abs(y)) > if (held) 0.35f else 0.55f
|
||||
if (held != down) {
|
||||
held = down
|
||||
when (a) {
|
||||
is InputAction.Keys -> if (down) a.codes.forEach { key(it, true) } else a.codes.asReversed().forEach { key(it, false) }
|
||||
is InputAction.MouseButton -> button(a.code, down)
|
||||
is InputAction.Command -> if (!down) output.command(a.command, role)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun requestMotion() {
|
||||
if (!moving && (abs(x) > 0.12f || abs(y) > 0.12f)) { moving = true; output.requestMotion() }
|
||||
}
|
||||
|
||||
private fun direction(at: Int, value: Float, code: Int) {
|
||||
val down = value > if (directions[at]) 0.35f else 0.55f
|
||||
if (directions[at] != down) { directions[at] = down; if (code != 0) key(code, down) }
|
||||
}
|
||||
|
||||
private fun releaseOutputs() {
|
||||
when (val a = action) {
|
||||
is InputAction.Keys -> if (held) a.codes.asReversed().forEach { key(it, false) }
|
||||
is InputAction.MouseButton -> if (held) button(a.code, false)
|
||||
is InputAction.Directions -> {
|
||||
val codes = intArrayOf(a.up, a.right, a.down, a.left)
|
||||
for (i in directions.indices) if (directions[i] && codes[i] != 0) key(codes[i], false)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
radialKeys.forEach { key(it, false) }; radialKeys = emptyList()
|
||||
held = false
|
||||
directions.fill(false)
|
||||
}
|
||||
}
|
||||
|
||||
inner class PadSource internal constructor(role: InputRole, retained: PadState?) {
|
||||
private val routes = Array(InputControl.padControls.size) { channel(role, InputControl.padControls[it]) }
|
||||
private val result = PadState()
|
||||
private var last: PadState? = null
|
||||
private var identity = true
|
||||
private var nativeCandidate = true
|
||||
|
||||
init {
|
||||
last = retained
|
||||
if (retained != null) for (route in routes) {
|
||||
route.awaitNeutral(retained.value(route.control), retained.secondValue(route.control), false)
|
||||
}
|
||||
refreshIdentity()
|
||||
}
|
||||
|
||||
internal fun refreshIdentity() {
|
||||
nativeCandidate = routes.all { (it.action as? InputAction.Native)?.target == null && it.action is InputAction.Native }
|
||||
identity = enabled && nativeCandidate && routes.none { it.blocked }
|
||||
}
|
||||
|
||||
internal fun snapshot() {
|
||||
val raw = last ?: return
|
||||
for (route in routes) {
|
||||
route.x = raw.value(route.control)
|
||||
route.y = raw.secondValue(route.control)
|
||||
}
|
||||
}
|
||||
|
||||
fun route(raw: PadState): PadState = synchronized(this@InputRouter) {
|
||||
last = raw
|
||||
if (identity) return@synchronized raw
|
||||
result.clear()
|
||||
for (route in routes) {
|
||||
val x = raw.value(route.control)
|
||||
val y = raw.secondValue(route.control)
|
||||
val target = route.update(x, y) ?: continue
|
||||
result.add(target, x, y)
|
||||
}
|
||||
if (nativeCandidate && enabled && routes.none { it.blocked }) identity = true
|
||||
result
|
||||
}
|
||||
|
||||
fun release() = synchronized(this@InputRouter) {
|
||||
for (route in routes) route.release()
|
||||
last = null
|
||||
result.clear()
|
||||
refreshIdentity()
|
||||
}
|
||||
|
||||
fun close() = synchronized(this@InputRouter) {
|
||||
release()
|
||||
for (route in routes) route.close()
|
||||
pads.remove(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val padButtons = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB,
|
||||
PadState.SELECT, PadState.START, PadState.L3, PadState.R3, PadState.GUIDE, PadState.QAM)
|
||||
|
||||
internal fun PadState.value(control: InputControl): Float = when (control) {
|
||||
InputControl.UP -> if (up) 1f else 0f
|
||||
InputControl.RIGHT -> if (right) 1f else 0f
|
||||
InputControl.DOWN -> if (down) 1f else 0f
|
||||
InputControl.LEFT -> if (left) 1f else 0f
|
||||
InputControl.LT -> leftTrigger
|
||||
InputControl.RT -> rightTrigger
|
||||
InputControl.LS -> leftX
|
||||
InputControl.RS -> rightX
|
||||
else -> if (control.ordinal < padButtons.size && isDown(padButtons[control.ordinal])) 1f else 0f
|
||||
}
|
||||
|
||||
internal fun PadState.secondValue(control: InputControl): Float = when (control) {
|
||||
InputControl.LS -> leftY
|
||||
InputControl.RS -> rightY
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
internal fun PadState.add(control: InputControl, x: Float, y: Float = 0f) {
|
||||
when (control) {
|
||||
InputControl.UP -> up = up || x > 0.5f
|
||||
InputControl.RIGHT -> right = right || x > 0.5f
|
||||
InputControl.DOWN -> down = down || x > 0.5f
|
||||
InputControl.LEFT -> left = left || x > 0.5f
|
||||
InputControl.LT -> leftTrigger = maxOf(leftTrigger, x)
|
||||
InputControl.RT -> rightTrigger = maxOf(rightTrigger, x)
|
||||
InputControl.LS -> if (x * x + y * y > leftX * leftX + leftY * leftY) { leftX = x; leftY = y }
|
||||
InputControl.RS -> if (x * x + y * y > rightX * rightX + rightY * rightY) { rightX = x; rightY = y }
|
||||
else -> if (control.ordinal < padButtons.size && x > 0.5f) press(padButtons[control.ordinal], true)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
enum class DeckPanel(val id: String, val layout: String, val bindings: String, @StringRes val label: Int) {
|
||||
GAMEPAD("gamepad", "builtin.deck.gamepad", "preset.deck.gamepad", R.string.deck_tab_gamepad),
|
||||
GRIPS("grips", "builtin.deck.grips", "preset.deck.grips", R.string.deck_tab_grips),
|
||||
LEFT("left", "builtin.deck.left", "preset.deck.left", R.string.deck_tab_left),
|
||||
RIGHT("right", "builtin.deck.right", "preset.deck.right", R.string.deck_tab_right),
|
||||
BOTH("both", InputLibraries.DECK, InputStockLayouts.DECK, R.string.deck_tab_both);
|
||||
|
||||
companion object {
|
||||
fun from(id: String?) = entries.firstOrNull { it.id == id } ?: BOTH
|
||||
}
|
||||
}
|
||||
|
||||
internal object InputStockLayouts {
|
||||
private val NEUTRAL = InputSurfaceStyle(tint = null)
|
||||
const val DECK = "preset.deck"
|
||||
const val TRACKPAD = "preset.trackpad"
|
||||
private fun grips() = listOf(InputControl.L4, InputControl.L5, InputControl.R5, InputControl.R4).mapIndexed { i, c ->
|
||||
InputSurfaceElement(c, .02f + i * .245f, .78f, .225f, .20f)
|
||||
}
|
||||
private fun pads() = listOf(InputSurfaceElement(InputControl.LEFT_PAD, .02f, .02f, .47f, .74f),
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, .51f, .02f, .47f, .74f))
|
||||
private fun gripGrid() = listOf(InputControl.L4 to (.02f to .02f), InputControl.L5 to (.02f to .51f),
|
||||
InputControl.R4 to (.51f to .02f), InputControl.R5 to (.51f to .51f)).map { (c, at) ->
|
||||
InputSurfaceElement(c, at.first, at.second, .47f, .47f)
|
||||
}
|
||||
private fun pad(right: Boolean) = listOf(
|
||||
InputSurfaceElement(if (right) InputControl.RIGHT_PAD else InputControl.LEFT_PAD, .02f, .02f, .96f, .74f),
|
||||
InputSurfaceElement(if (right) InputControl.RIGHT_CLICK else InputControl.LEFT_CLICK, .02f, .78f, .96f, .20f),
|
||||
)
|
||||
private fun gamepad(): List<InputSurfaceElement> {
|
||||
fun round(c: InputControl, x: Float, y: Float, size: Float) = InputSurfaceElement(c, x, y, size, size, buttonShape = InputButtonShape.CIRCLE)
|
||||
fun pill(c: InputControl, x: Float) = InputSurfaceElement(c, x, .02f, .2f, .12f, buttonShape = InputButtonShape.PILL)
|
||||
return listOf(pill(InputControl.LT, .02f), pill(InputControl.LB, .24f), pill(InputControl.RB, .56f), pill(InputControl.RT, .78f),
|
||||
InputSurfaceElement(InputControl.LS, .02f, .17f, .4f, .4f),
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, .58f, .59f, .4f, .39f),
|
||||
round(InputControl.UP, .14f, .6f, .12f), round(InputControl.LEFT, .03f, .73f, .12f),
|
||||
round(InputControl.RIGHT, .25f, .73f, .12f), round(InputControl.DOWN, .14f, .86f, .12f),
|
||||
round(InputControl.Y, .72f, .17f, .12f), round(InputControl.X, .61f, .3f, .12f),
|
||||
round(InputControl.B, .83f, .3f, .12f), round(InputControl.A, .72f, .43f, .12f),
|
||||
InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL),
|
||||
InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL))
|
||||
}
|
||||
fun gamepad(leftPad: Boolean, rightStick: Boolean) = gamepad().map {
|
||||
when {
|
||||
leftPad && it.control == InputControl.LS -> InputSurfaceElement(InputControl.LEFT_PAD, it.x, it.y, it.width, it.height)
|
||||
rightStick && it.control == InputControl.RIGHT_PAD -> InputSurfaceElement(InputControl.RS, it.x, it.y, it.width, it.height)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
|
||||
fun layouts() = listOf(
|
||||
SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf(
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, .02f, .02f, .96f, .96f, mouseGestures = true)), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.GAMEPAD.layout, "Deck gamepad", gamepad(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.GRIPS.layout, "Deck grips", gripGrid(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.LEFT.layout, "Deck left trackpad", pad(right = false), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.RIGHT.layout, "Deck right trackpad", pad(right = true), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
)
|
||||
|
||||
fun actions(layout: SavedInputLayout): Map<String, InputAction> = (if (layout.id == InputLibraries.TRACKPAD) mapOf(
|
||||
"legacy:right-pad" to InputAction.Pointer(), "legacy:a" to InputAction.MouseButton(272), "legacy:b" to InputAction.MouseButton(273),
|
||||
) else emptyMap()).filterKeys { id -> layout.elements.any { it.id == id } }
|
||||
|
||||
fun clicks(layout: SavedInputLayout): Map<String, InputAction> = if (layout.id == InputLibraries.TRACKPAD)
|
||||
mapOf("legacy:right-pad" to InputAction.MouseButton(272)) else emptyMap()
|
||||
|
||||
fun presets(layouts: List<SavedInputLayout>) = layouts.map { layout ->
|
||||
val desktop = layout.id == InputLibraries.TRACKPAD
|
||||
val id = if (desktop) TRACKPAD else DeckPanel.entries.first { it.layout == layout.id }.bindings
|
||||
SavedInputBindings(id, layout.name, false, layout.id,
|
||||
controls = InputProfile.defaults(if (desktop) InputProfile.DESKTOP else InputProfile.GAME).bindings[InputRole.SECONDARY].orEmpty(),
|
||||
elements = actions(layout), clicks = clicks(layout))
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class InputElementKind(val control: InputControl) {
|
||||
BUTTON(InputControl.A), STICK(InputControl.LS), TRACKPAD(InputControl.LEFT_PAD),
|
||||
DPAD(InputControl.LS), TRIGGER(InputControl.LT);
|
||||
|
||||
companion object {
|
||||
fun from(control: InputControl) = when {
|
||||
control.isTrackpad -> TRACKPAD
|
||||
control.dimensions == 2 -> STICK
|
||||
control == InputControl.LT || control == InputControl.RT -> TRIGGER
|
||||
else -> BUTTON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class InputButtonShape { ROUNDED, PILL, CIRCLE, RECTANGLE }
|
||||
|
||||
data class InputSurfaceElement(
|
||||
val control: InputControl, val x: Float, val y: Float, val width: Float, val height: Float,
|
||||
val id: String = "legacy:${control.id}",
|
||||
val kind: InputElementKind = InputElementKind.from(control),
|
||||
val name: String = control.label,
|
||||
val floating: Boolean = true,
|
||||
val hideWhenIdle: Boolean = false,
|
||||
val mouseGestures: Boolean = false,
|
||||
val toggle: Boolean = false,
|
||||
val icon: Int = 0,
|
||||
val image: String = "",
|
||||
val opacity: Float = 1f,
|
||||
val buttonShape: InputButtonShape = InputButtonShape.ROUNDED,
|
||||
val showLabel: Boolean = true,
|
||||
val alwaysShown: Boolean = false,
|
||||
) {
|
||||
val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD
|
||||
|
||||
fun bounded() = copy(x = x.coerceIn(0f, 0.97f), y = y.coerceIn(0f, 0.97f),
|
||||
width = width.coerceIn(0.03f, (1f - x.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)), height = height.coerceIn(0.03f, (1f - y.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)))
|
||||
}
|
||||
|
||||
internal val QUICK_BUTTONS = listOf(InputCommand.STEAM, InputCommand.QUICK_ACCESS, InputCommand.KEYBOARD)
|
||||
|
||||
val InputSurfaceElement.quickButton: InputCommand? get() = when {
|
||||
id == "legacy:${InputControl.GUIDE.id}" -> InputCommand.STEAM
|
||||
id == "legacy:${InputControl.QAM.id}" -> InputCommand.QUICK_ACCESS
|
||||
id == "builtin:keyboard" -> InputCommand.KEYBOARD
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun List<InputSurfaceElement>.scaled(factor: Float): List<InputSurfaceElement> = map { e ->
|
||||
fun anchor(centre: Float) = when { centre < 1f / 3f -> 0f; centre > 2f / 3f -> 1f; else -> .5f }
|
||||
val w = (e.width * factor).coerceIn(.03f, 1f)
|
||||
val h = (e.height * factor).coerceIn(.03f, 1f)
|
||||
val cx = e.x + e.width / 2
|
||||
val cy = e.y + e.height / 2
|
||||
val ax = anchor(cx)
|
||||
val ay = anchor(cy)
|
||||
e.copy(x = (ax + (cx - ax) * factor - w / 2).coerceIn(0f, 1f - w), y = (ay + (cy - ay) * factor - h / 2).coerceIn(0f, 1f - h),
|
||||
width = w, height = h).bounded()
|
||||
}
|
||||
|
||||
internal fun List<InputSurfaceElement>.withPositionsOf(defaults: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val byId = defaults.associateBy { it.id }
|
||||
return map { e -> byId[e.id]?.let { d -> e.copy(x = d.x, y = d.y, width = d.width, height = d.height).bounded() } ?: e }
|
||||
}
|
||||
|
||||
object InputSurfaceLayout {
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE)
|
||||
|
||||
fun resetAll(context: Context) { prefs(context).edit().clear().apply() }
|
||||
|
||||
fun defaults() = listOf(
|
||||
InputSurfaceElement(InputControl.LEFT_PAD, 0.02f, 0.02f, 0.46f, 0.65f),
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, 0.52f, 0.02f, 0.46f, 0.65f),
|
||||
InputSurfaceElement(InputControl.A, 0.02f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.B, 0.26f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.SELECT, 0.52f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.START, 0.76f, 0.72f, 0.22f, 0.25f),
|
||||
)
|
||||
|
||||
fun read(context: Context, profile: String, role: InputRole = InputRole.SECONDARY): List<InputSurfaceElement> =
|
||||
InputLibraries.read(context).layout(profile, role)?.elements.orEmpty()
|
||||
|
||||
internal fun hasLegacy(context: Context, profile: String) = prefs(context).contains(profile)
|
||||
|
||||
internal fun readLegacy(context: Context, profile: String): List<InputSurfaceElement> {
|
||||
val text = prefs(context).getString(profile, null) ?: return defaults()
|
||||
return runCatching {
|
||||
val doc = JSONObject(text)
|
||||
require(doc.getInt("version") == 1)
|
||||
val elements = doc.getJSONArray("elements")
|
||||
(0 until minOf(elements.length(), 28)).mapNotNull { i ->
|
||||
val item = elements.optJSONObject(i) ?: return@mapNotNull null
|
||||
val control = InputControl.fromId(item.optString("control")) ?: return@mapNotNull null
|
||||
val values = listOf("x", "y", "w", "h").map { item.optDouble(it, Double.NaN).toFloat() }
|
||||
if (values.any { !it.isFinite() }) return@mapNotNull null
|
||||
InputSurfaceElement(control, values[0], values[1], values[2], values[3]).bounded()
|
||||
}.distinctBy { it.control }
|
||||
}.getOrElse { defaults() }
|
||||
}
|
||||
|
||||
fun save(context: Context, profile: String, layout: List<InputSurfaceElement>) {
|
||||
InputLibraries.update(context) { library ->
|
||||
val saved = library.layout(profile, InputRole.SECONDARY) ?: return@update library
|
||||
library.withLayout(saved.copy(elements = layout.map { it.bounded() }))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun saveLegacy(context: Context, profile: String, layout: List<InputSurfaceElement>) {
|
||||
val array = JSONArray()
|
||||
for (element in layout.distinctBy { it.control }.take(28)) {
|
||||
val e = element.bounded()
|
||||
array.put(JSONObject().put("control", e.control.id).put("x", e.x.toDouble()).put("y", e.y.toDouble())
|
||||
.put("w", e.width.toDouble()).put("h", e.height.toDouble()))
|
||||
}
|
||||
prefs(context).edit().putString(profile, JSONObject().put("version", 1).put("elements", array).toString()).apply()
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
internal data class SurfaceSnapResult(val element: InputSurfaceElement, val x: Float? = null, val y: Float? = null)
|
||||
|
||||
internal fun snapSurfaceElement(element: InputSurfaceElement, others: List<InputSurfaceElement>, thresholdX: Float, thresholdY: Float,
|
||||
corner: Int = 0): SurfaceSnapResult {
|
||||
fun axis(start: Float, size: Float, targets: List<Float>, threshold: Float, low: Boolean, high: Boolean): Triple<Float, Float, Float?> {
|
||||
val anchors = when { low -> listOf(0f); high -> listOf(size); else -> listOf(0f, size / 2f, size) }
|
||||
val candidate = anchors.flatMap { anchor -> targets.map { target -> Triple(target - start - anchor, target, anchor) } }
|
||||
.filter { abs(it.first) <= threshold }.minByOrNull { abs(it.first) } ?: return Triple(start, size, null)
|
||||
val offset = candidate.first
|
||||
val nextStart = if (high) start else start + offset
|
||||
val nextSize = when { low -> size - offset; high -> size + offset; else -> size }
|
||||
if (nextStart < 0f || nextSize < .03f || nextStart + nextSize > 1.00001f) return Triple(start, size, null)
|
||||
return Triple(nextStart.coerceAtLeast(0f), nextSize, candidate.second)
|
||||
}
|
||||
val xTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.x, it.x + it.width / 2f, it.x + it.width) }
|
||||
val yTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.y, it.y + it.height / 2f, it.y + it.height) }
|
||||
val x = axis(element.x, element.width, xTargets, thresholdX, corner and 1 != 0, corner and 2 != 0)
|
||||
val y = axis(element.y, element.height, yTargets, thresholdY, corner and 4 != 0, corner and 8 != 0)
|
||||
return SurfaceSnapResult(element.copy(x = x.first, y = y.first, width = x.second, height = y.second).bounded(), x.third, y.third)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole, val element: InputSurfaceElement) {
|
||||
private val router = pad.inputRouter
|
||||
private val route = router?.channel(role, element.control, element = element)
|
||||
private val deck = DeckControls.source(role, routed = true)
|
||||
private val clickDeck = DeckControls.source(role, routed = true)
|
||||
private val clickOwner = Any()
|
||||
private val clickRoute = if (element.hasClick) router?.channel(role, InputControl.A, element = element, elementClick = true) else null
|
||||
private val gestureWheel = if (element.mouseGestures) router?.channel(role, InputControl.RIGHT_PAD, fixedAction = InputAction.Scroll()) else null
|
||||
private val gestureRight = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(273)) else null
|
||||
private val gestureMiddle = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(274)) else null
|
||||
val mouseGestures get() = element.mouseGestures && route?.action is InputAction.Pointer
|
||||
val directions get() = route?.action as? InputAction.Directions
|
||||
|
||||
fun gestureContact(touching: Boolean) { if (!closed) route?.update(0f, 0f, touching) }
|
||||
fun pointerDelta(dx: Float, dy: Float) {
|
||||
if (closed) return
|
||||
val timing = InputTiming.begin()
|
||||
route?.pointerDelta(dx, dy)
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
fun gestureWheel(steps: Int) { if (!closed && route?.acceptsPointerGesture() == true) gestureWheel?.wheelSteps(steps) }
|
||||
fun gestureButton(code: Int, down: Boolean) {
|
||||
if (closed || down && route?.acceptsPointerGesture() != true) return
|
||||
when (code) {
|
||||
272 -> click(down)
|
||||
273 -> gestureRight?.update(if (down) 1f else 0f)
|
||||
274 -> gestureMiddle?.update(if (down) 1f else 0f)
|
||||
}
|
||||
}
|
||||
|
||||
private var nativePad = false
|
||||
private var nativeDeck = false
|
||||
private var clickPad = false
|
||||
private var clickDeckActive = false
|
||||
private var closed = false
|
||||
private var fingerDown = false
|
||||
private var latchBlocked = false
|
||||
var latched = false
|
||||
private set
|
||||
var onStateChanged: () -> Unit = {}
|
||||
private fun resetLatch() { latched = false; latchBlocked = fingerDown; onStateChanged() }
|
||||
private val snapshot: () -> Unit = { releaseNative(); resetLatch(); if (element.toggle) route?.release() }
|
||||
|
||||
init { router?.beforeProfileChange(snapshot) }
|
||||
|
||||
fun update(value: Float, y: Float = 0f, touching: Boolean = false) {
|
||||
if (closed) return
|
||||
val timing = InputTiming.begin()
|
||||
val x = if (element.toggle && route?.action !is InputAction.Command && route?.action !is InputAction.Disabled) {
|
||||
val down = value > .5f
|
||||
if (!down) latchBlocked = false
|
||||
if (down && !fingerDown && !latchBlocked) latched = !latched
|
||||
fingerDown = down
|
||||
if (latched) 1f else 0f
|
||||
} else value
|
||||
val target = if (router == null) element.control else route?.update(x, y, touching)
|
||||
val toPad = target?.isPad == true
|
||||
val toDeck = target != null && !target.isPad
|
||||
if (toPad || nativePad) writePad(this, target) { state ->
|
||||
state.clear()
|
||||
if (toPad) {
|
||||
if (element.kind == InputElementKind.DPAD) {
|
||||
state.up = y < -.5f; state.down = y > .5f; state.left = x < -.5f; state.right = x > .5f
|
||||
} else state.add(target, x, y)
|
||||
}
|
||||
}
|
||||
if (toDeck || nativeDeck) deck.native(if (toDeck) target else null, x, y, touching)
|
||||
nativePad = toPad; nativeDeck = toDeck
|
||||
if (element.toggle) onStateChanged()
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (closed) return
|
||||
route?.takeIf { it.action is InputAction.Command }?.release()
|
||||
update(0f)
|
||||
}
|
||||
|
||||
fun click(down: Boolean) {
|
||||
if (closed) return
|
||||
val timing = InputTiming.begin()
|
||||
val target = if (router == null) when (element.control) { InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3; else -> null } else clickRoute?.update(if (down) 1f else 0f)
|
||||
val toPad = target?.isPad == true
|
||||
val toDeck = target != null && !target.isPad
|
||||
if (toPad || clickPad) writePad(clickOwner, target) { state ->
|
||||
state.clear()
|
||||
if (toPad && down) state.add(target, 1f)
|
||||
}
|
||||
if (toDeck || clickDeckActive) clickDeck.native(if (toDeck) target else null, if (down) 1f else 0f, 0f, false)
|
||||
clickPad = toPad; clickDeckActive = toDeck
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
private fun writePad(owner: Any, target: InputControl?, mutation: (PadState) -> Unit) {
|
||||
if (router != null) pad.applyRoutedTouch(owner, role, mutation)
|
||||
else pad.applyTouch(owner, role, when (target) { InputControl.LS -> PadBridge.LEFT_STICK; InputControl.RS -> PadBridge.RIGHT_STICK; else -> 0 }, mutation)
|
||||
}
|
||||
|
||||
private fun releaseNative() {
|
||||
if (nativePad) pad.releaseTouch(this)
|
||||
if (nativeDeck) deck.release()
|
||||
if (clickPad) pad.releaseTouch(clickOwner)
|
||||
if (clickDeckActive) clickDeck.release()
|
||||
clickPad = false; clickDeckActive = false
|
||||
nativePad = false; nativeDeck = false
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
router?.removeSnapshot(snapshot)
|
||||
releaseNative(); resetLatch()
|
||||
route?.close(); clickRoute?.close(); gestureWheel?.close(); gestureRight?.close(); gestureMiddle?.close(); deck.close(); clickDeck.close()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (closed) return
|
||||
releaseNative(); resetLatch()
|
||||
route?.release(); clickRoute?.release(); gestureWheel?.release(); gestureRight?.release(); gestureMiddle?.release()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
object InputTiming {
|
||||
@JvmField @Volatile var enabled = false
|
||||
private val samples = LongArray(4096)
|
||||
private var count = 0L
|
||||
private var next = 0
|
||||
|
||||
@JvmStatic fun begin(): Long = if (enabled) System.nanoTime() else 0L
|
||||
|
||||
@JvmStatic fun end(start: Long) {
|
||||
if (start == 0L) return
|
||||
val elapsed = System.nanoTime() - start
|
||||
synchronized(this) {
|
||||
samples[next] = elapsed
|
||||
next = (next + 1) % samples.size
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun reset() { count = 0; next = 0; samples.fill(0) }
|
||||
|
||||
@Synchronized fun report(): String {
|
||||
val size = minOf(count, samples.size.toLong()).toInt()
|
||||
if (size == 0) return "No input samples"
|
||||
val values = samples.copyOf(size).sortedArray()
|
||||
fun percentile(p: Double) = values[((size - 1) * p).toInt()] / 1000.0
|
||||
return String.format(Locale.ROOT, "Dispatch to publication (µs): p50 %.1f, p95 %.1f, p99 %.1f; %d samples, last %d retained",
|
||||
percentile(.5), percentile(.95), percentile(.99), count, size)
|
||||
}
|
||||
}
|
||||
@@ -89,31 +89,21 @@ class KeyboardHost(
|
||||
clearFocus()
|
||||
}
|
||||
|
||||
fun type(text: CharSequence?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
val events = map.getEvents(text.toString().toCharArray())
|
||||
if (events != null) {
|
||||
for (event in events) sendKeyEvent(event)
|
||||
} else {
|
||||
for (ch in text) {
|
||||
val t = System.currentTimeMillis()
|
||||
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
|
||||
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
|
||||
sendKeyEvent(CharKeyEvent(down, ch))
|
||||
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tap(keyCode: Int) {
|
||||
val t = System.currentTimeMillis()
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
|
||||
}
|
||||
|
||||
private inner class KeyConnection : BaseInputConnection(this, false) {
|
||||
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
type(text)
|
||||
if (text.isNullOrEmpty()) return true
|
||||
val events = map.getEvents(text.toString().toCharArray())
|
||||
if (events != null) {
|
||||
for (event in events) sendKeyEvent(event)
|
||||
} else {
|
||||
for (ch in text) {
|
||||
val t = System.currentTimeMillis()
|
||||
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
|
||||
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
|
||||
sendKeyEvent(CharKeyEvent(down, ch))
|
||||
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -133,6 +123,12 @@ class KeyboardHost(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun tap(keyCode: Int) {
|
||||
val t = System.currentTimeMillis()
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
|
||||
}
|
||||
|
||||
override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence = ""
|
||||
override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""
|
||||
override fun getSelectedText(flags: Int): CharSequence? = null
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import kotlin.math.*
|
||||
|
||||
internal class LegacyGamepadLayout(private val context: Context, private val width: Int, private val height: Int, private val safe: Rect = Rect(),
|
||||
private val scale: Float = 1f) {
|
||||
private val resources get() = context.resources
|
||||
private val settings = ControllerPrefs.read(context)
|
||||
private var fit = 1f
|
||||
private class Control(val id: String, val group: String, val stick: Int, val radiusDp: Float, val offsetXDp: Float, val offsetYDp: Float) {
|
||||
var radius = 0f
|
||||
var cx = 0f
|
||||
var cy = 0f
|
||||
val halfW get() = if (id in shoulderIds) radius * SHOULDER_WIDTH else radius
|
||||
val halfH get() = if (id in shoulderIds) radius * SHOULDER_HEIGHT else radius
|
||||
}
|
||||
private val controls = listOf(
|
||||
Control("up", "dpad", -1, 30f, 0f, -50f),
|
||||
Control("right", "dpad", -1, 30f, 50f, 0f),
|
||||
Control("down", "dpad", -1, 30f, 0f, 50f),
|
||||
Control("left", "dpad", -1, 30f, -50f, 0f),
|
||||
Control("ls", "ls", 0, 48f, 0f, 0f),
|
||||
Control("rs", "rs", 1, 48f, 0f, 0f),
|
||||
Control("a", "face", -1, 30f, 0f, 50f),
|
||||
Control("b", "face", -1, 30f, 50f, 0f),
|
||||
Control("x", "face", -1, 30f, -50f, 0f),
|
||||
Control("y", "face", -1, 30f, 0f, -50f),
|
||||
Control("lb", "lb", -1, 24f, 0f, 0f),
|
||||
Control("rb", "rb", -1, 24f, 0f, 0f),
|
||||
Control("lt", "lt", -1, 24f, 0f, 0f),
|
||||
Control("rt", "rt", -1, 24f, 0f, 0f),
|
||||
Control("select", "select", -1, 20f, 0f, 0f),
|
||||
Control("start", "start", -1, 20f, 0f, 0f),
|
||||
Control("guide", "guide", -1, 22f, 0f, 0f),
|
||||
Control("qam", "qam", -1, 22f, 0f, 0f),
|
||||
)
|
||||
|
||||
private val groups = controls.map { it.group }.distinct()
|
||||
|
||||
private fun dp(value: Float) = value * resources.displayMetrics.density
|
||||
|
||||
private fun scaled(value: Float) = dp(value) * settings.size / 100f * scale * fit
|
||||
|
||||
private fun fitScale(): Float {
|
||||
val metrics = resources.displayMetrics
|
||||
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
|
||||
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
|
||||
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
|
||||
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
|
||||
}
|
||||
|
||||
private fun pxPerMm(dpi: Float): Float {
|
||||
val nominal = resources.displayMetrics.densityDpi.toFloat()
|
||||
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
|
||||
}
|
||||
|
||||
private fun placeAuto(w: Float, h: Float) {
|
||||
val metrics = resources.displayMetrics
|
||||
val mmX = pxPerMm(metrics.xdpi)
|
||||
val mmY = pxPerMm(metrics.ydpi)
|
||||
val usableW = w - safe.left - safe.right
|
||||
val usableH = h - safe.top - safe.bottom
|
||||
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
|
||||
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
|
||||
val gap = dp(12f)
|
||||
val floor = BOTTOM_MARGIN_MM * mmY
|
||||
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
|
||||
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
|
||||
put("ls", safe.left + inboard, restY)
|
||||
put("face", w - safe.right - inboard, restY)
|
||||
besideBelow("dpad", "ls", 1f, gap, floor)
|
||||
besideBelow("rs", "face", -1f, gap, floor)
|
||||
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
|
||||
val shoulderY = if (phone) safe.top + floor + halfH("lb")
|
||||
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
|
||||
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
|
||||
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
|
||||
val bottom = h - safe.bottom - floor - extent("guide")
|
||||
val spread = extent("guide") + gap / 2f
|
||||
put("guide", w / 2f - spread, bottom)
|
||||
put("qam", w / 2f + spread, bottom)
|
||||
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
|
||||
put("select", inner, restY)
|
||||
put("start", w - inner, restY)
|
||||
if (phone) {
|
||||
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
|
||||
put("select", top, shoulderY)
|
||||
put("start", w - top, shoulderY)
|
||||
}
|
||||
if (crowded("select", gap) || crowded("start", gap)) {
|
||||
above("select", "dpad", gap)
|
||||
val (sx, sy) = centre("select")
|
||||
put("start", w - sx, sy)
|
||||
if (crowded("start", gap)) above("start", "rs", gap)
|
||||
}
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val row = centre("select").second
|
||||
put("guide", w / 2f - spread, row)
|
||||
put("qam", w / 2f + spread, row)
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val top = safe.top + dp(8f) + extent("guide")
|
||||
put("guide", w / 2f - spread, top)
|
||||
put("qam", w / 2f + spread, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drop(group: String, anchor: String, gap: Float): Float =
|
||||
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
|
||||
|
||||
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
val reach = extent(anchor) + extent(group) + gap
|
||||
val floor = height - safe.bottom - margin - extent(group)
|
||||
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
|
||||
val dx = sqrt(max(0f, reach * reach - dy * dy))
|
||||
put(group, ax + inward * dx, ay + dy)
|
||||
}
|
||||
|
||||
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
|
||||
val (ax, _) = centre(anchor)
|
||||
val spread = extent(bumper) + gap / 2f
|
||||
put(bumper, ax + inward * spread, y)
|
||||
put(trigger, ax - inward * spread, y)
|
||||
}
|
||||
|
||||
private fun above(group: String, anchor: String, gap: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
put(group, ax, ay - extent(anchor) - gap - extent(group))
|
||||
}
|
||||
|
||||
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
|
||||
val (x, y) = centre(group)
|
||||
return groups.any { other ->
|
||||
if (other == group || other == except) return@any false
|
||||
val (ox, oy) = centre(other)
|
||||
val dx = x - ox
|
||||
val dy = y - oy
|
||||
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
|
||||
}
|
||||
}
|
||||
|
||||
private fun members(group: String) = controls.filter { it.group == group }
|
||||
|
||||
private fun centre(group: String): Pair<Float, Float> {
|
||||
val first = members(group).first()
|
||||
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
|
||||
}
|
||||
|
||||
private fun extent(group: String): Float = members(group).maxOf { control ->
|
||||
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
|
||||
}
|
||||
|
||||
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
|
||||
|
||||
private fun put(group: String, x: Float, y: Float) {
|
||||
for (control in members(group)) {
|
||||
control.cx = x + scaled(control.offsetXDp)
|
||||
control.cy = y + scaled(control.offsetYDp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clamp(group: String) {
|
||||
val list = members(group)
|
||||
val margin = dp(4f)
|
||||
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
|
||||
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
|
||||
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
|
||||
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
|
||||
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
|
||||
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
|
||||
if (dx == 0f && dy == 0f) return
|
||||
for (control in list) { control.cx += dx; control.cy += dy }
|
||||
}
|
||||
|
||||
|
||||
fun elements(): List<InputSurfaceElement> {
|
||||
fit = fitScale()
|
||||
controls.forEach { it.radius = scaled(it.radiusDp) }
|
||||
placeAuto(width.toFloat(), height.toFloat())
|
||||
for ((group, position) in ControllerPrefs.layout(context, width, height)) {
|
||||
if (group in groups && position.first.isFinite() && position.second.isFinite()) put(group, position.first * width, position.second * height)
|
||||
}
|
||||
groups.forEach(::clamp)
|
||||
val result = controls.filter {
|
||||
settings.mapping[it.id] != ControllerPrefs.OFF && (it.id != "guide" || settings.steamButton) && (it.id != "qam" || settings.qamButton)
|
||||
}.map { c ->
|
||||
InputSurfaceElement(requireNotNull(InputControl.fromId(c.id)), (c.cx - c.halfW) / width, (c.cy - c.halfH) / height,
|
||||
2 * c.halfW / width, 2 * c.halfH / height, floating = settings.adaptiveSticks, hideWhenIdle = c.stick >= 0 && settings.adaptiveSticks,
|
||||
buttonShape = if (c.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE).let(::widenStick).bounded()
|
||||
}.toMutableList()
|
||||
if (settings.keyboardButton) result += keyboard()
|
||||
return result
|
||||
}
|
||||
|
||||
fun regenerate(stored: List<InputSurfaceElement>): List<InputSurfaceElement> = withGeometry(stored, elements())
|
||||
|
||||
fun rescale(stored: List<InputSurfaceElement>, from: LegacyGamepadLayout): List<InputSurfaceElement> {
|
||||
val before = from.elements().associateBy { it.id }
|
||||
val after = elements().associateBy { it.id }
|
||||
return stored.map { e ->
|
||||
val b = before[e.id] ?: return@map e
|
||||
val a = after[e.id] ?: return@map e
|
||||
val w = (a.width * e.width / b.width).coerceIn(.03f, 1f)
|
||||
val h = (a.height * e.height / b.height).coerceIn(.03f, 1f)
|
||||
val cx = a.x + a.width / 2 + (e.x + e.width / 2 - b.x - b.width / 2)
|
||||
val cy = a.y + a.height / 2 + (e.y + e.height / 2 - b.y - b.height / 2)
|
||||
e.copy(x = (cx - w / 2).coerceIn(0f, 1f - w), y = (cy - h / 2).coerceIn(0f, 1f - h), width = w, height = h).bounded()
|
||||
}
|
||||
}
|
||||
|
||||
fun buttonsOnly(stored: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val inset = dp(BUTTONS_ONLY_INSET_DP)
|
||||
val bottom = height - safe.bottom - inset
|
||||
val radius = dp(BUTTONS_ONLY_RADIUS_DP)
|
||||
fun pick(control: InputControl, shown: Boolean) = stored.firstOrNull { it.id == "legacy:${control.id}" }
|
||||
?: stored.firstOrNull { it.control == control && it.kind == InputElementKind.BUTTON }
|
||||
?: InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE).takeIf { shown }
|
||||
return listOfNotNull(
|
||||
pick(InputControl.GUIDE, settings.steamButton)?.let { circle(it, safe.left + inset, bottom, radius) },
|
||||
pick(InputControl.QAM, settings.qamButton)?.let { circle(it, width - safe.right - inset, bottom, radius) },
|
||||
stored.firstOrNull { it.id == KEYBOARD_ID }?.let { circle(it, width / 2f, bottom, dp(KEYBOARD_RADIUS_DP)) },
|
||||
)
|
||||
}
|
||||
|
||||
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean, left: InputControl = InputControl.LS,
|
||||
right: InputControl = InputControl.RS): List<InputSurfaceElement> {
|
||||
val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait)
|
||||
val placed = mutableListOf<InputSurfaceElement>()
|
||||
fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) {
|
||||
val b = boxes[slot] ?: return
|
||||
placed += InputSurfaceElement(control, b.left / width, b.top / height, b.width / width, b.height / height,
|
||||
floating = false, buttonShape = shape).bounded()
|
||||
}
|
||||
fun cross(controls: List<InputControl>, slot: GamepadGeometry.Slot) {
|
||||
val b = boxes[slot] ?: return
|
||||
val side = min(b.width, b.height)
|
||||
val spread = side * CROSS_SPREAD
|
||||
val radius = side * CROSS_RADIUS
|
||||
controls.zip(listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)).forEach { (control, direction) ->
|
||||
placed += circle(InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE),
|
||||
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
|
||||
}
|
||||
}
|
||||
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
|
||||
box(right, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.RB, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.RT, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL)
|
||||
box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL)
|
||||
if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) }
|
||||
val surfaces = listOf(left, right).filter { it.isTrackpad }
|
||||
return withGeometry(stored, placed) + placed.filter { p -> (GRIPS.any { it.first == p.control } || p.control in surfaces) && stored.none { it.id == p.id } }
|
||||
}
|
||||
|
||||
private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) =
|
||||
e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height).bounded()
|
||||
|
||||
private fun withGeometry(stored: List<InputSurfaceElement>, placed: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val byId = placed.associateBy { it.id }
|
||||
return stored.mapNotNull { e -> byId[e.id]?.let { e.copy(x = it.x, y = it.y, width = it.width, height = it.height) } }
|
||||
}
|
||||
|
||||
private fun keyboard(): InputSurfaceElement {
|
||||
val radius = dp(KEYBOARD_RADIUS_DP)
|
||||
val cx = safe.left + dp(KEYBOARD_X_DP)
|
||||
val cy = height - safe.bottom - dp(KEYBOARD_Y_DP)
|
||||
return InputSurfaceElement(InputControl.A, (cx - radius) / width, (cy - radius) / height, 2 * radius / width, 2 * radius / height,
|
||||
KEYBOARD_ID, name = "Keyboard", icon = InputButtonIcons.KEYBOARD, buttonShape = InputButtonShape.CIRCLE).bounded()
|
||||
}
|
||||
|
||||
fun restyle(elements: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val dpad = elements.filter { it.id == "legacy:${it.control.id}" && it.control in DPAD && it.buttonShape == InputButtonShape.ROUNDED }
|
||||
val centreX = dpad.map { (it.x + it.width / 2f) * width }.average().toFloat()
|
||||
val centreY = dpad.map { (it.y + it.height / 2f) * height }.average().toFloat()
|
||||
val resize = untouchedDpad(dpad, centreX, centreY)
|
||||
return elements.map { e ->
|
||||
when {
|
||||
e.id == KEYBOARD_ID && e.icon == 0 && e.name == "Keyboard" && e.buttonShape == InputButtonShape.ROUNDED &&
|
||||
e.x == .02f && e.y == .9f && e.width == .12f && e.height == .08f -> keyboard()
|
||||
e.id == "legacy:${e.control.id}" && e.buttonShape == InputButtonShape.ROUNDED -> {
|
||||
val round = e.copy(buttonShape = if (e.control.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE)
|
||||
(if (resize && e.control in DPAD) resizeDpad(round, centreX, centreY) else widenStick(round)).bounded()
|
||||
}
|
||||
else -> e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun untouchedDpad(dpad: List<InputSurfaceElement>, centreX: Float, centreY: Float): Boolean {
|
||||
if (dpad.size != DPAD.size) return false
|
||||
val radius = dpad[0].width * width / 2f
|
||||
val tolerance = 1.5f
|
||||
val offset = radius * LEGACY_DPAD_OFFSET_DP / LEGACY_DPAD_RADIUS_DP
|
||||
return dpad.all { e ->
|
||||
val old = controls.first { it.id == e.control.id }
|
||||
val dx = (e.x + e.width / 2f) * width - centreX
|
||||
val dy = (e.y + e.height / 2f) * height - centreY
|
||||
abs(e.width * width / 2f - radius) <= tolerance && abs(e.height * height / 2f - radius) <= tolerance &&
|
||||
abs(dx - offset * sign(old.offsetXDp)) <= tolerance && abs(dy - offset * sign(old.offsetYDp)) <= tolerance
|
||||
}
|
||||
}
|
||||
|
||||
private fun resizeDpad(e: InputSurfaceElement, centreX: Float, centreY: Float): InputSurfaceElement {
|
||||
val old = controls.first { it.id == e.control.id }
|
||||
val radius = e.width * width / 2f * old.radiusDp / LEGACY_DPAD_RADIUS_DP
|
||||
val cx = centreX + ((e.x + e.width / 2f) * width - centreX) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
|
||||
val cy = centreY + ((e.y + e.height / 2f) * height - centreY) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
|
||||
return e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height)
|
||||
}
|
||||
|
||||
private fun widenStick(e: InputSurfaceElement): InputSurfaceElement {
|
||||
val extra = e.width * (STICK_REACH - 1f)
|
||||
return when (e.control) {
|
||||
InputControl.LS -> (e.x - extra).coerceAtLeast(0f).let { x -> e.copy(x = x, width = e.x + e.width - x) }
|
||||
InputControl.RS -> e.copy(width = (e.width + extra).coerceAtMost(1f - e.x))
|
||||
else -> e
|
||||
}
|
||||
}
|
||||
private companion object {
|
||||
const val MIN_FACE_MM = 9.5f
|
||||
const val FULL_SIZE_HEIGHT_MM = 85f
|
||||
const val BOTTOM_MARGIN_MM = 5f
|
||||
const val DROP_DEGREES = 35.0
|
||||
const val PHONE_MAX_HEIGHT_MM = 90f
|
||||
const val SHOULDER_WIDTH = 1.45f
|
||||
const val SHOULDER_HEIGHT = 0.8f
|
||||
val shoulderIds = setOf("lb", "rb", "lt", "rt")
|
||||
const val STICK_REACH = 1.25f
|
||||
const val LEGACY_DPAD_RADIUS_DP = 26f
|
||||
const val LEGACY_DPAD_OFFSET_DP = 52f
|
||||
val DPAD = setOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
|
||||
const val KEYBOARD_ID = "builtin:keyboard"
|
||||
const val KEYBOARD_RADIUS_DP = 18f
|
||||
const val KEYBOARD_X_DP = 90f
|
||||
const val KEYBOARD_Y_DP = 34f
|
||||
const val BUTTONS_ONLY_RADIUS_DP = 30f
|
||||
const val BUTTONS_ONLY_INSET_DP = 44f
|
||||
const val CROSS_SPREAD = .30f
|
||||
const val CROSS_RADIUS = .16f
|
||||
val DPAD_ORDER = listOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
|
||||
val FACE_ORDER = listOf(InputControl.Y, InputControl.B, InputControl.A, InputControl.X)
|
||||
val GRIPS = listOf(InputControl.L4 to GamepadGeometry.Slot.L4, InputControl.L5 to GamepadGeometry.Slot.L5,
|
||||
InputControl.R5 to GamepadGeometry.Slot.R5, InputControl.R4 to GamepadGeometry.Slot.R4)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object LegacyScreenKeyMigration {
|
||||
const val BACKUP = "legacy-screen-keys-backup"
|
||||
const val MERGED = "legacy-screen-keys-merged"
|
||||
private const val SHOWN = "migrated.screenkeys.0"
|
||||
|
||||
fun snapshot(context: Context): String? {
|
||||
val values = ControllerPrefs.prefs(context).all.filterKeys { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }
|
||||
return values.takeIf { it.isNotEmpty() }?.let { JSONObject(it).toString() }
|
||||
}
|
||||
|
||||
fun clean(context: Context) {
|
||||
val prefs = ControllerPrefs.prefs(context)
|
||||
prefs.edit().apply { prefs.all.keys.filter { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }.forEach(::remove) }.apply()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun migrate(context: Context, library: InputLibrary, raw: String): InputLibrary {
|
||||
val old = JSONObject(raw)
|
||||
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
val metrics = DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
|
||||
val settings = ControllerPrefs.read(context)
|
||||
val style = InputSurfaceStyle(settings.tint, settings.opacity)
|
||||
val sets = buildList {
|
||||
old.optString("screenKeys").takeIf { it.isNotBlank() }?.let { add("Screen keys (imported)" to it) }
|
||||
old.keys().asSequence().filter { it.startsWith("screenLayout.") }.sorted().forEach { key ->
|
||||
add((key.removePrefix("screenLayout.").ifBlank { "Screen keys" } + " (imported)").take(80) to old.getString(key))
|
||||
}
|
||||
}
|
||||
var result = library
|
||||
for ((index, set) in sets.withIndex()) {
|
||||
val id = "migrated.screenkeys.$index"
|
||||
if (result.profiles.any { it.id == id }) continue
|
||||
val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density)
|
||||
if (converted.isEmpty()) continue
|
||||
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first.copy(alwaysShown = true) }, style = style, screen = InputLayoutScreen.MAIN,
|
||||
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second })
|
||||
val base = library.profile(library.steam)
|
||||
val profile = SavedInputProfile(id, set.first, base.base, base.parts + (InputRole.PRIMARY to InputPart(bindings.id, layout.id)))
|
||||
result = result.withLayout(layout).withPreset(bindings).withProfile(profile)
|
||||
}
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
fun merge(library: InputLibrary, raw: String): InputLibrary {
|
||||
val old = JSONObject(raw)
|
||||
if (!old.optBoolean("screenKeysShown", true) || old.optString("screenKeys").isBlank()) return library
|
||||
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" } ?: return library
|
||||
val actions = library.bindings.firstOrNull { it.id == "$SHOWN.bindings" }?.elements.orEmpty()
|
||||
var result = library
|
||||
for (part in listOf(false, true).map { library.activePart(it, InputRole.PRIMARY) }.distinct()) {
|
||||
val layout = result.layouts.firstOrNull { it.id == part.layout } ?: continue
|
||||
val preset = result.preset(part.bindings)
|
||||
if (!layout.editable || preset.layoutId != layout.id || layout.elements.any { e -> keys.elements.any { it.id == e.id } } ||
|
||||
layout.elements.size + keys.elements.size > 64) continue
|
||||
val added = keys.elements.mapNotNull { e -> actions[e.id]?.let { e.id to it } }.toMap()
|
||||
result = result.withLayout(layout.copy(elements = layout.elements + keys.elements))
|
||||
.withPreset(preset.copy(elements = added + preset.elements))
|
||||
}
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
fun pin(library: InputLibrary): InputLibrary {
|
||||
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" }?.elements.orEmpty()
|
||||
return library.copy(layouts = library.layouts.map { layout ->
|
||||
val imported = layout.id.startsWith("migrated.screenkeys.")
|
||||
layout.copy(elements = layout.elements.map { e ->
|
||||
val key = e.id.startsWith("imported:") && (imported || keys.any { it.id == e.id && it.kind == e.kind && it.name == e.name })
|
||||
if (key) e.copy(alwaysShown = true) else e
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
|
||||
convert(context, parse(raw), width, height, density)
|
||||
|
||||
internal fun parse(raw: String): List<LegacyScreenKey> = raw.split(';').filter { it.isNotBlank() }.map { entry ->
|
||||
val parts = entry.split(':')
|
||||
require(parts.size >= 2) { "Invalid legacy screen key" }
|
||||
val codes = parts[0].split('+').map { it.toInt() }
|
||||
val position = parts[1].split(',').map { it.toFloat() }
|
||||
require(position.size == 3)
|
||||
fun text(at: Int) = parts.getOrNull(at).orEmpty().replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
|
||||
LegacyScreenKey(codes, position[0], position[1], position[2], text(2), text(3), text(4),
|
||||
parts.getOrNull(5)?.toFloatOrNull() ?: 1f, parts.getOrNull(6)?.toIntOrNull() ?: 0, parts.getOrNull(7) == "1", parts.getOrNull(8) != "0")
|
||||
}
|
||||
|
||||
internal fun convert(context: Context, keys: List<LegacyScreenKey>, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
|
||||
keys.mapIndexed { index, key ->
|
||||
val codes = key.codes
|
||||
require(codes.all { it in 0..255 })
|
||||
require(listOf(key.x, key.y, key.size).all { it.isFinite() })
|
||||
val shape = key.shape
|
||||
val kind = when (shape) { 5 -> InputElementKind.DPAD; 6 -> InputElementKind.STICK; 4 -> InputElementKind.TRIGGER; else -> InputElementKind.BUTTON }
|
||||
val label = key.text.ifEmpty { key.glyph }.ifEmpty { codes.filter { it != 0 }.joinToString("+") { pcKeyLabel(context, it) } }.ifBlank { "Button" }.take(80)
|
||||
val image = key.image
|
||||
val icon = codes.singleOrNull()?.takeIf { InputButtonIcons.has(it) && key.text.isEmpty() && key.glyph.isEmpty() } ?: 0
|
||||
val heightPx = key.size.coerceIn(24f, 140f) * density
|
||||
val ratio = when { shape == 2 -> 1f; shape == 4 -> 1.7f; shape == 5 || shape == 6 || image.isNotEmpty() || icon != 0 -> 1.15f; else -> (.55f * label.length + 1.05f).coerceIn(1.7f, 4.6f) }
|
||||
val w = (heightPx * ratio / width.coerceAtLeast(1)).coerceIn(.03f, 1f)
|
||||
val h = (heightPx / height.coerceAtLeast(1)).coerceIn(.03f, 1f)
|
||||
val element = InputSurfaceElement(kind.control, (key.x - w / 2).coerceIn(0f, 1f - w), (key.y - h / 2).coerceIn(0f, 1f - h), w, h,
|
||||
id = "imported:$index", kind = kind, name = label, floating = false,
|
||||
toggle = key.toggle && kind != InputElementKind.DPAD && kind != InputElementKind.STICK,
|
||||
icon = icon, image = image, opacity = key.opacity.coerceIn(.2f, 1f),
|
||||
buttonShape = when (shape) { 0 -> InputButtonShape.PILL; 2 -> InputButtonShape.CIRCLE; 3 -> InputButtonShape.RECTANGLE; else -> InputButtonShape.ROUNDED },
|
||||
showLabel = key.labels).bounded()
|
||||
val action = when (kind) {
|
||||
InputElementKind.DPAD -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(1) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(3) { 0 })
|
||||
InputElementKind.STICK -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(4) { 0 }, codes.getOrElse(6) { 0 }, listOf(1, 3, 5, 7).map { codes.getOrElse(it) { 0 } })
|
||||
else -> codes.filter { it != 0 }.distinct().let { if (it.isEmpty()) InputAction.Disabled else InputAction.Keys(it) }
|
||||
}
|
||||
require(action.validFor(element))
|
||||
element to action
|
||||
}
|
||||
}
|
||||
|
||||
internal data class LegacyScreenKey(
|
||||
val codes: List<Int>, val x: Float, val y: Float, val size: Float,
|
||||
val glyph: String = "", val text: String = "", val image: String = "",
|
||||
val opacity: Float = 1f, val shape: Int = 0, val toggle: Boolean = false, val labels: Boolean = true,
|
||||
)
|
||||
@@ -3,181 +3,809 @@ package com.droiddeck.launcher.input
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.RectF
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import com.droiddeck.launcher.ui.InputSurfaceView
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayout.Dock? = null) : FrameLayout(context) {
|
||||
private val surface = InputSurfaceView(context, pad, inputRole = InputRole.PRIMARY, haptics = dock != null)
|
||||
private var layout: SavedInputLayout? = null
|
||||
private val safe = Rect()
|
||||
private var viewWidth = 0
|
||||
private var viewHeight = 0
|
||||
private val placements = object : LinkedHashMap<Placement, List<InputSurfaceElement>>(4, .75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Placement, List<InputSurfaceElement>>) = size > PLACEMENT_CACHE
|
||||
}
|
||||
private val quickToggle = QuickToggle(context)
|
||||
var isButtonsOnly = false
|
||||
private set
|
||||
var isQuickHidden = false
|
||||
private set
|
||||
var isPinnedOnly = false
|
||||
private set
|
||||
val hasPinned get() = layout?.elements?.any { it.alwaysShown } == true
|
||||
var onTouchMiss: ((MotionEvent) -> Unit)?
|
||||
get() = surface.onTouchMiss
|
||||
set(value) { surface.onTouchMiss = value }
|
||||
@SuppressLint("ViewConstructor")
|
||||
class OnScreenControls(
|
||||
context: Context,
|
||||
private val pad: PadBridge?,
|
||||
private val editing: Boolean = false,
|
||||
private val onKeyboard: (() -> Unit)? = null,
|
||||
) : View(context) {
|
||||
|
||||
init {
|
||||
addView(surface, LayoutParams(-1, -1))
|
||||
val size = (QUICK_HIT_DP * 2 * resources.displayMetrics.density).toInt()
|
||||
addView(quickToggle, LayoutParams(size, size, Gravity.START or Gravity.BOTTOM))
|
||||
}
|
||||
private class Control(
|
||||
val id: String,
|
||||
val group: String,
|
||||
val stick: Int,
|
||||
val radiusDp: Float,
|
||||
val offsetXDp: Float,
|
||||
val offsetYDp: Float,
|
||||
) {
|
||||
var target = id
|
||||
var radius = 0f
|
||||
var cx = 0f
|
||||
var cy = 0f
|
||||
var pressedBy = -1
|
||||
var kx = 0f
|
||||
var ky = 0f
|
||||
var ax = 0f
|
||||
var ay = 0f
|
||||
var dirty = false
|
||||
var clicked = false
|
||||
var lastUp = 0L
|
||||
val wide = id in shoulderIds
|
||||
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
|
||||
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
|
||||
|
||||
fun updateLayout(value: SavedInputLayout?) {
|
||||
if (layout == value) return
|
||||
releaseAll()
|
||||
layout = value
|
||||
render()
|
||||
}
|
||||
|
||||
fun setButtonsOnly(enabled: Boolean) {
|
||||
if (isButtonsOnly == enabled) return
|
||||
releaseAll()
|
||||
isButtonsOnly = enabled
|
||||
isQuickHidden = false
|
||||
render()
|
||||
}
|
||||
|
||||
fun setQuickHidden(hidden: Boolean) {
|
||||
if (isButtonsOnly || isQuickHidden == hidden) return
|
||||
releaseAll()
|
||||
isQuickHidden = hidden
|
||||
render()
|
||||
}
|
||||
|
||||
fun setPinnedOnly(enabled: Boolean) {
|
||||
if (isPinnedOnly == enabled) return
|
||||
releaseAll()
|
||||
isPinnedOnly = enabled
|
||||
render()
|
||||
}
|
||||
|
||||
private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean)
|
||||
|
||||
private fun placed(): List<InputSurfaceElement> {
|
||||
val key = Placement(layout, viewWidth, viewHeight, Rect(safe), isButtonsOnly)
|
||||
return placements.getOrPut(key) {
|
||||
OnScreenLayout.elements(context, layout, viewWidth, viewHeight, safe, isButtonsOnly, dock)
|
||||
}
|
||||
}
|
||||
|
||||
private fun render() {
|
||||
val elements = placed()
|
||||
surface.updateCustomLayout(when {
|
||||
isPinnedOnly -> elements.filter { it.alwaysShown }
|
||||
isQuickHidden -> elements.filter { it.id == "builtin:keyboard" || it.alwaysShown }
|
||||
else -> elements
|
||||
})
|
||||
val style = layout?.style ?: InputSurfaceStyle()
|
||||
surface.updateStyle(style)
|
||||
quickToggle.tint = style.tint
|
||||
quickToggle.alpha = style.opacity / 100f
|
||||
quickToggle.visibility = if (isButtonsOnly || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
|
||||
quickToggle.invalidate()
|
||||
}
|
||||
|
||||
fun takesTouchAt(x: Float, y: Float) = surface.takesTouchAt(x, y) ||
|
||||
quickToggle.visibility == VISIBLE && x >= quickToggle.left && x < quickToggle.right && y >= quickToggle.top && y < quickToggle.bottom
|
||||
fun releaseAll() = surface.releaseInputs()
|
||||
fun reload() { releaseAll(); placements.clear(); render() }
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
if (w != viewWidth || h != viewHeight) {
|
||||
viewWidth = w
|
||||
viewHeight = h
|
||||
render()
|
||||
}
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() { releaseAll(); super.onDetachedFromWindow() }
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
|
||||
val offset = ((QUICK_CENTER_DP - QUICK_HIT_DP) * resources.displayMetrics.density).toInt()
|
||||
val params = quickToggle.layoutParams as LayoutParams
|
||||
val left = (cutout?.safeInsetLeft ?: 0) + offset
|
||||
val bottom = (cutout?.safeInsetBottom ?: 0) + offset
|
||||
if (params.leftMargin != left || params.bottomMargin != bottom) {
|
||||
params.leftMargin = left
|
||||
params.bottomMargin = bottom
|
||||
quickToggle.layoutParams = params
|
||||
}
|
||||
val fresh = Rect(cutout?.safeInsetLeft ?: 0, if (dock == null) cutout?.safeInsetTop ?: 0 else 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
|
||||
if (fresh != safe) {
|
||||
safe.set(fresh)
|
||||
render()
|
||||
}
|
||||
return super.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
private inner class QuickToggle(context: Context) : View(context) {
|
||||
var tint: Int? = ControllerPrefs.STEAM_BLUE
|
||||
private val density = resources.displayMetrics.density
|
||||
private var armed = false
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 1.5f * density }
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
textSize = 22 * density
|
||||
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
|
||||
val hit = r * 1.25f + padding
|
||||
return dx * dx + dy * dy <= hit * hit
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val x = width / 2f
|
||||
val y = height / 2f
|
||||
val radius = QUICK_RADIUS_DP * density
|
||||
val color = tint
|
||||
fill.color = if (color != null) InputSurfaceView.shade(color, 190, 0.12f) else (InputSurfaceView.SURFACE and 0x00ffffff) or (190 shl 24)
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
stroke.color = if (color != null) InputSurfaceView.light(color, 150, 0.25f) else InputSurfaceView.LINE
|
||||
canvas.drawCircle(x, y, radius, stroke)
|
||||
text.color = if (color != null) InputSurfaceView.light(color, 210, 0.75f) else InputSurfaceView.TEXT
|
||||
canvas.drawText(if (isQuickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> armed = true
|
||||
MotionEvent.ACTION_CANCEL -> armed = false
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (armed && event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat()) {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
setQuickHidden(!isQuickHidden)
|
||||
}
|
||||
armed = false
|
||||
}
|
||||
}
|
||||
fun drag(x: Float, y: Float): Boolean {
|
||||
var dx = x - ax
|
||||
var dy = y - ay
|
||||
val d = sqrt(dx * dx + dy * dy)
|
||||
val atLimit = d > radius
|
||||
if (atLimit) { dx = dx / d * radius; dy = dy / d * radius }
|
||||
if (!atLimit && abs(dx - kx) < MIN_STICK_MOVE_PX && abs(dy - ky) < MIN_STICK_MOVE_PX) return false
|
||||
kx = dx; ky = dy; dirty = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val stickRedraw = ThrottledRedraw(this, STICK_REDRAW_MS)
|
||||
|
||||
private val controls = listOf(
|
||||
Control("up", "dpad", -1, 26f, 0f, -52f),
|
||||
Control("right", "dpad", -1, 26f, 52f, 0f),
|
||||
Control("down", "dpad", -1, 26f, 0f, 52f),
|
||||
Control("left", "dpad", -1, 26f, -52f, 0f),
|
||||
Control("ls", "ls", 0, 48f, 0f, 0f),
|
||||
Control("rs", "rs", 1, 48f, 0f, 0f),
|
||||
Control("a", "face", -1, 30f, 0f, 50f),
|
||||
Control("b", "face", -1, 30f, 50f, 0f),
|
||||
Control("x", "face", -1, 30f, -50f, 0f),
|
||||
Control("y", "face", -1, 30f, 0f, -50f),
|
||||
Control("lb", "lb", -1, 24f, 0f, 0f),
|
||||
Control("rb", "rb", -1, 24f, 0f, 0f),
|
||||
Control("lt", "lt", -1, 24f, 0f, 0f),
|
||||
Control("rt", "rt", -1, 24f, 0f, 0f),
|
||||
Control("select", "select", -1, 20f, 0f, 0f),
|
||||
Control("start", "start", -1, 20f, 0f, 0f),
|
||||
Control("guide", "guide", -1, 22f, 0f, 0f),
|
||||
Control("qam", "qam", -1, 22f, 0f, 0f),
|
||||
)
|
||||
|
||||
private val groups = controls.map { it.group }.distinct()
|
||||
|
||||
private var buttonsOnly = false
|
||||
val isButtonsOnly: Boolean get() = buttonsOnly
|
||||
private var quickHidden = false
|
||||
private var quickPressedBy = -1
|
||||
private var keyboardPressedBy = -1
|
||||
private var forwardedPointerBits = 0
|
||||
var onTouchMiss: ((MotionEvent) -> Unit)? = null
|
||||
private var settings = ControllerPrefs.read(context)
|
||||
private var snap = settings.snap
|
||||
private var safe = Rect()
|
||||
private var selected: String? = null
|
||||
private var snapX: Float? = null
|
||||
private var snapY: Float? = null
|
||||
private var grabX = 0f
|
||||
private var grabY = 0f
|
||||
private var ignoreSaved = false
|
||||
private var fit = 1f
|
||||
|
||||
private var idleFill = 0
|
||||
private var heldFill = 0
|
||||
private var idleStroke = 0
|
||||
private var heldStroke = 0
|
||||
private var idleText = 0
|
||||
private var stickFill = 0
|
||||
private var knobFill = 0
|
||||
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val arrow = Path()
|
||||
private val box = RectF()
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
}
|
||||
|
||||
init {
|
||||
applySettings()
|
||||
}
|
||||
|
||||
private fun dp(value: Float) = value * resources.displayMetrics.density
|
||||
|
||||
private fun scaled(value: Float) = dp(value) * settings.size / 100f * fit
|
||||
|
||||
private fun fitScale(): Float {
|
||||
val metrics = resources.displayMetrics
|
||||
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
|
||||
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
|
||||
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
|
||||
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
releaseAll()
|
||||
settings = ControllerPrefs.read(context)
|
||||
setSnap(settings.snap)
|
||||
applySettings()
|
||||
relayout()
|
||||
}
|
||||
|
||||
private fun clearSnapGuides() {
|
||||
if (snapX == null && snapY == null) return
|
||||
snapX = null
|
||||
snapY = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun applySettings() {
|
||||
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
|
||||
val tint = settings.tint
|
||||
val alpha = settings.opacity / 100f
|
||||
fun shade(a: Int, f: Float) = Color.argb((a * alpha).toInt(), (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt())
|
||||
fun light(a: Int, f: Float) = Color.argb(
|
||||
(a * alpha).toInt(),
|
||||
(Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
|
||||
(Color.green(tint) + (255 - Color.green(tint)) * f).toInt(),
|
||||
(Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(),
|
||||
)
|
||||
idleFill = shade(80, 0.12f)
|
||||
heldFill = shade(160, 1f)
|
||||
idleStroke = light(150, 0.25f)
|
||||
heldStroke = light(230, 0.7f)
|
||||
idleText = light(210, 0.75f)
|
||||
stickFill = shade(60, 0.12f)
|
||||
knobFill = shade(120, 0.35f)
|
||||
stroke.strokeWidth = dp(1.5f)
|
||||
}
|
||||
|
||||
fun setButtonsOnly(enabled: Boolean) {
|
||||
if (buttonsOnly == enabled) return
|
||||
releaseAll()
|
||||
buttonsOnly = enabled
|
||||
quickHidden = false
|
||||
relayout()
|
||||
}
|
||||
|
||||
fun setQuickHidden(hidden: Boolean) {
|
||||
if (buttonsOnly || editing || quickHidden == hidden) return
|
||||
if (hidden) releaseAll()
|
||||
quickHidden = hidden
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setSnap(on: Boolean) {
|
||||
snap = on
|
||||
if (!on) clearSnapGuides()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
relayout()
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
|
||||
val fresh = if (Build.VERSION.SDK_INT >= 28) Rect(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0) else Rect()
|
||||
if (fresh != safe) { safe = fresh; relayout() }
|
||||
return super.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
stickRedraw.cancel()
|
||||
releaseAll()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
private fun relayout() {
|
||||
if (width <= 0 || height <= 0) return
|
||||
fit = if (buttonsOnly && !editing) 1f else fitScale()
|
||||
for (control in controls) control.radius = if (buttonsOnly && !editing && (control.id == "guide" || control.id == "qam")) dp(30f) else scaled(control.radiusDp)
|
||||
if (buttonsOnly && !editing) {
|
||||
val inset = dp(44f)
|
||||
put("guide", safe.left + inset, height - safe.bottom - inset)
|
||||
put("qam", width - safe.right - inset, height - safe.bottom - inset)
|
||||
} else {
|
||||
placeAuto(width.toFloat(), height.toFloat())
|
||||
if (!ignoreSaved) for ((group, pos) in ControllerPrefs.layout(context, width, height)) {
|
||||
if (group in groups) put(group, pos.first * width, pos.second * height)
|
||||
}
|
||||
}
|
||||
groups.forEach { clamp(it) }
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun pxPerMm(dpi: Float): Float {
|
||||
val nominal = resources.displayMetrics.densityDpi.toFloat()
|
||||
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
|
||||
}
|
||||
|
||||
private fun placeAuto(w: Float, h: Float) {
|
||||
val metrics = resources.displayMetrics
|
||||
val mmX = pxPerMm(metrics.xdpi)
|
||||
val mmY = pxPerMm(metrics.ydpi)
|
||||
val usableW = w - safe.left - safe.right
|
||||
val usableH = h - safe.top - safe.bottom
|
||||
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
|
||||
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
|
||||
val gap = dp(12f)
|
||||
val floor = BOTTOM_MARGIN_MM * mmY
|
||||
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
|
||||
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
|
||||
put("ls", safe.left + inboard, restY)
|
||||
put("face", w - safe.right - inboard, restY)
|
||||
besideBelow("dpad", "ls", 1f, gap, floor)
|
||||
besideBelow("rs", "face", -1f, gap, floor)
|
||||
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
|
||||
val shoulderY = if (phone) safe.top + floor + halfH("lb")
|
||||
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
|
||||
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
|
||||
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
|
||||
val bottom = h - safe.bottom - floor - extent("guide")
|
||||
val spread = extent("guide") + gap / 2f
|
||||
put("guide", w / 2f - spread, bottom)
|
||||
put("qam", w / 2f + spread, bottom)
|
||||
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
|
||||
put("select", inner, restY)
|
||||
put("start", w - inner, restY)
|
||||
if (phone) {
|
||||
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
|
||||
put("select", top, shoulderY)
|
||||
put("start", w - top, shoulderY)
|
||||
}
|
||||
if (crowded("select", gap) || crowded("start", gap)) {
|
||||
above("select", "dpad", gap)
|
||||
val (sx, sy) = centre("select")
|
||||
put("start", w - sx, sy)
|
||||
if (crowded("start", gap)) above("start", "rs", gap)
|
||||
}
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val row = centre("select").second
|
||||
put("guide", w / 2f - spread, row)
|
||||
put("qam", w / 2f + spread, row)
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val top = safe.top + dp(8f) + extent("guide")
|
||||
put("guide", w / 2f - spread, top)
|
||||
put("qam", w / 2f + spread, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drop(group: String, anchor: String, gap: Float): Float =
|
||||
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
|
||||
|
||||
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
val reach = extent(anchor) + extent(group) + gap
|
||||
val floor = height - safe.bottom - margin - extent(group)
|
||||
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
|
||||
val dx = sqrt(max(0f, reach * reach - dy * dy))
|
||||
put(group, ax + inward * dx, ay + dy)
|
||||
}
|
||||
|
||||
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
|
||||
val (ax, _) = centre(anchor)
|
||||
val spread = extent(bumper) + gap / 2f
|
||||
put(bumper, ax + inward * spread, y)
|
||||
put(trigger, ax - inward * spread, y)
|
||||
}
|
||||
|
||||
private fun above(group: String, anchor: String, gap: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
put(group, ax, ay - extent(anchor) - gap - extent(group))
|
||||
}
|
||||
|
||||
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
|
||||
val (x, y) = centre(group)
|
||||
return groups.any { other ->
|
||||
if (other == group || other == except) return@any false
|
||||
val (ox, oy) = centre(other)
|
||||
val dx = x - ox
|
||||
val dy = y - oy
|
||||
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
|
||||
}
|
||||
}
|
||||
|
||||
private fun members(group: String) = controls.filter { it.group == group }
|
||||
|
||||
private fun centre(group: String): Pair<Float, Float> {
|
||||
val first = members(group).first()
|
||||
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
|
||||
}
|
||||
|
||||
private fun extent(group: String): Float = members(group).maxOf { control ->
|
||||
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
|
||||
}
|
||||
|
||||
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
|
||||
|
||||
private fun put(group: String, x: Float, y: Float) {
|
||||
for (control in members(group)) {
|
||||
control.cx = x + scaled(control.offsetXDp)
|
||||
control.cy = y + scaled(control.offsetYDp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clamp(group: String) {
|
||||
val list = members(group)
|
||||
val margin = dp(4f)
|
||||
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
|
||||
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
|
||||
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
|
||||
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
|
||||
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
|
||||
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
|
||||
if (dx == 0f && dy == 0f) return
|
||||
for (control in list) { control.cx += dx; control.cy += dy }
|
||||
}
|
||||
|
||||
fun saveLayout() {
|
||||
if (width <= 0 || height <= 0) return
|
||||
if (ignoreSaved) { ControllerPrefs.resetLayout(context, width, height); return }
|
||||
ControllerPrefs.setLayout(context, width, height, groups.associateWith { group ->
|
||||
val (x, y) = centre(group)
|
||||
x / width to y / height
|
||||
})
|
||||
}
|
||||
|
||||
fun resetLayout() {
|
||||
ControllerPrefs.resetLayout(context, width, height)
|
||||
ignoreSaved = true
|
||||
selected = null
|
||||
relayout()
|
||||
}
|
||||
|
||||
private fun label(control: Control): String = when (control.target) {
|
||||
"ls" -> "L"
|
||||
"rs" -> "R"
|
||||
"select" -> "⧉"
|
||||
"start" -> "☰"
|
||||
"guide" -> if (buttonsOnly && !editing) "Steam" else "◉"
|
||||
"qam" -> "⋯"
|
||||
else -> control.target.uppercase()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (!quickHidden) for (control in controls) {
|
||||
if (!isVisible(control)) continue
|
||||
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
|
||||
val held = control.pressedBy != -1
|
||||
val radius = control.radius
|
||||
if (control.stick >= 0) {
|
||||
val bx = if (held) control.ax else control.cx
|
||||
val by = if (held) control.ay else control.cy
|
||||
fill.color = if (held) heldFill else stickFill
|
||||
canvas.drawCircle(bx, by, radius, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawCircle(bx, by, radius, stroke)
|
||||
val knob = radius * 0.46f
|
||||
fill.color = if (held || control.clicked) heldFill else knobFill
|
||||
canvas.drawCircle(bx + control.kx, by + control.ky, knob, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawCircle(bx + control.kx, by + control.ky, knob, stroke)
|
||||
text.color = if (held) Color.WHITE else idleText
|
||||
text.textSize = knob * 0.8f
|
||||
canvas.drawText(label(control), bx + control.kx, by + control.ky + text.textSize * 0.35f, text)
|
||||
} else if (control.wide) {
|
||||
box.set(control.cx - control.halfW, control.cy - control.halfH, control.cx + control.halfW, control.cy + control.halfH)
|
||||
val corner = control.halfH * 0.55f
|
||||
fill.color = if (held) heldFill else idleFill
|
||||
canvas.drawRoundRect(box, corner, corner, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawRoundRect(box, corner, corner, stroke)
|
||||
text.color = if (held) Color.WHITE else idleText
|
||||
text.textSize = control.halfH * 0.8f
|
||||
canvas.drawText(label(control), control.cx, control.cy + text.textSize * 0.35f, text)
|
||||
} else {
|
||||
fill.color = if (held) heldFill else idleFill
|
||||
canvas.drawCircle(control.cx, control.cy, radius, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawCircle(control.cx, control.cy, radius, stroke)
|
||||
text.color = if (held) Color.WHITE else idleText
|
||||
val direction = directions.indexOf(control.target)
|
||||
if (direction >= 0) {
|
||||
drawArrow(canvas, control.cx, control.cy, radius * 0.34f, direction)
|
||||
continue
|
||||
}
|
||||
val name = label(control)
|
||||
text.textSize = if (name.length > 2) dp(11f) else radius * if (name.length == 2) 0.62f else 0.85f
|
||||
canvas.drawText(name, control.cx, control.cy + text.textSize * 0.35f, text)
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
stroke.color = heldStroke
|
||||
snapX?.let { canvas.drawLine(it, safe.top.toFloat(), it, (height - safe.bottom).toFloat(), stroke) }
|
||||
snapY?.let { canvas.drawLine(safe.left.toFloat(), it, (width - safe.right).toFloat(), it, stroke) }
|
||||
selected?.let { group ->
|
||||
val (x, y) = centre(group)
|
||||
val single = members(group).singleOrNull()
|
||||
if (single?.wide == true) {
|
||||
val pad = dp(6f)
|
||||
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
|
||||
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
|
||||
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
|
||||
}
|
||||
} else if (!buttonsOnly) {
|
||||
val (x, y) = quickCenter()
|
||||
val radius = dp(18f)
|
||||
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
stroke.color = idleStroke
|
||||
canvas.drawCircle(x, y, radius, stroke)
|
||||
text.color = idleText
|
||||
text.textSize = dp(22f)
|
||||
canvas.drawText(if (quickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
if (!editing && settings.keyboardButton && onKeyboard != null) {
|
||||
val (x, y) = keyboardCenter()
|
||||
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
|
||||
canvas.drawCircle(x, y, dp(18f), fill)
|
||||
stroke.color = if (keyboardPressedBy == -1) idleStroke else heldStroke
|
||||
canvas.drawCircle(x, y, dp(18f), stroke)
|
||||
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
|
||||
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
|
||||
for (row in 0..1) for (col in 0..2) {
|
||||
val kx = x + dp(-6f + col * 5f)
|
||||
val ky = y + dp(-3f + row * 4f)
|
||||
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
|
||||
}
|
||||
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
|
||||
}
|
||||
}
|
||||
|
||||
private fun quickCenter() = (safe.left + dp(34f)) to (height - safe.bottom - dp(34f))
|
||||
|
||||
private fun keyboardCenter() = if (buttonsOnly)
|
||||
width / 2f to (height - safe.bottom - dp(44f))
|
||||
else (safe.left + dp(90f)) to (height - safe.bottom - dp(34f))
|
||||
|
||||
private fun keyboardContains(x: Float, y: Float): Boolean {
|
||||
if (!settings.keyboardButton || onKeyboard == null) return false
|
||||
val (cx, cy) = keyboardCenter()
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
return dx * dx + dy * dy <= dp(26f) * dp(26f)
|
||||
}
|
||||
|
||||
private fun quickContains(x: Float, y: Float): Boolean {
|
||||
val (cx, cy) = quickCenter()
|
||||
val radius = dp(26f)
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
return dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
|
||||
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int) {
|
||||
val (fx, fy) = when (direction) {
|
||||
0 -> 0f to -1f
|
||||
1 -> 1f to 0f
|
||||
2 -> 0f to 1f
|
||||
else -> -1f to 0f
|
||||
}
|
||||
arrow.reset()
|
||||
arrow.moveTo(x + fx * size, y + fy * size)
|
||||
arrow.lineTo(x - fx * size * 0.7f - fy * size, y - fy * size * 0.7f + fx * size)
|
||||
arrow.lineTo(x - fx * size * 0.7f + fy * size, y - fy * size * 0.7f - fx * size)
|
||||
arrow.close()
|
||||
fill.color = text.color
|
||||
canvas.drawPath(arrow, fill)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (editing) return onEditTouch(event)
|
||||
if (event.getToolType(event.actionIndex) == MotionEvent.TOOL_TYPE_MOUSE) return false
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
val index = event.actionIndex
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) forwardedPointerBits = 0
|
||||
if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN && forwardMiss(event, x, y)) return true
|
||||
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && keyboardContains(x, y)) {
|
||||
keyboardPressedBy = event.getPointerId(index)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
if (keyboardPressedBy != -1) return true
|
||||
if (!buttonsOnly && event.actionMasked == MotionEvent.ACTION_DOWN && quickContains(x, y)) {
|
||||
quickPressedBy = event.getPointerId(index)
|
||||
return true
|
||||
}
|
||||
if (quickPressedBy != -1) return true
|
||||
if (quickHidden) return false
|
||||
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
|
||||
if (control.pressedBy != -1) return true
|
||||
control.pressedBy = event.getPointerId(index)
|
||||
if (control.stick >= 0) {
|
||||
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
val adaptive = settings.adaptiveSticks
|
||||
control.ax = if (adaptive) event.getX(index) else control.cx
|
||||
control.ay = if (adaptive) event.getY(index) else control.cy
|
||||
control.drag(event.getX(index), event.getY(index))
|
||||
control.dirty = true
|
||||
}
|
||||
if (SessionPrefs.lowLatency(context)) requestUnbufferedDispatch(event)
|
||||
apply()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
|
||||
if (keyboardPressedBy != -1) return true
|
||||
if (quickPressedBy != -1) return true
|
||||
var changed = false
|
||||
var stickMoved = false
|
||||
for (index in 0 until event.pointerCount) {
|
||||
val pointer = event.getPointerId(index)
|
||||
if (forwardedPointerBits and (1 shl pointer) != 0) continue
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
val stick = controls.firstOrNull { it.stick >= 0 && it.pressedBy == pointer }
|
||||
if (stick != null) { stickMoved = stick.drag(x, y) || stickMoved; continue }
|
||||
val over = controlAt(x, y)?.takeIf { it.stick < 0 }
|
||||
for (control in controls) {
|
||||
if (control.stick < 0 && control.pressedBy == pointer && control !== over) {
|
||||
control.pressedBy = -1
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (over != null && over.pressedBy == -1) {
|
||||
over.pressedBy = pointer
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) apply() else if (stickMoved) apply(stickOnly = true)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
val pointer = event.getPointerId(event.actionIndex)
|
||||
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
forwardedPointerBits = 0
|
||||
releaseAll()
|
||||
return true
|
||||
}
|
||||
if (forwardedPointerBits and (1 shl pointer) != 0) {
|
||||
forwardedPointerBits = forwardedPointerBits and (1 shl pointer).inv()
|
||||
return true
|
||||
}
|
||||
if (keyboardPressedBy == pointer) {
|
||||
val tapped = event.actionMasked == MotionEvent.ACTION_UP && keyboardContains(event.x, event.y)
|
||||
keyboardPressedBy = -1
|
||||
invalidate()
|
||||
if (tapped) { releaseAll(); onKeyboard?.invoke() }
|
||||
return true
|
||||
}
|
||||
if (quickPressedBy == pointer) {
|
||||
val toggled = event.actionMasked == MotionEvent.ACTION_UP && quickContains(event.x, event.y)
|
||||
quickPressedBy = -1
|
||||
if (toggled) setQuickHidden(!quickHidden)
|
||||
return true
|
||||
}
|
||||
if (quickHidden) return false
|
||||
var changed = false
|
||||
for (control in controls) {
|
||||
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
if (control.pressedBy != -1) changed = true
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
|
||||
control.pressedBy = -1
|
||||
control.clicked = false
|
||||
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
|
||||
control.kx = 0f; control.ky = 0f
|
||||
}
|
||||
}
|
||||
if (changed) apply()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun forwardMiss(event: MotionEvent, x: Float, y: Float): Boolean {
|
||||
if (onTouchMiss == null || keyboardPressedBy != -1 || quickPressedBy != -1) return false
|
||||
if (!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null) return false
|
||||
forwardedPointerBits = forwardedPointerBits or (1 shl event.getPointerId(event.actionIndex))
|
||||
forwardPointers(event, forwardedPointerBits)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun forwardPointers(event: MotionEvent, pointerIdBits: Int) {
|
||||
val split = event.pointerSubset(pointerIdBits) ?: return
|
||||
try {
|
||||
onTouchMiss?.invoke(split)
|
||||
} finally {
|
||||
split.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEditTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
clearSnapGuides()
|
||||
val control = controlAt(event.x, event.y)
|
||||
selected = control?.group
|
||||
val group = selected ?: run { invalidate(); return false }
|
||||
val (x, y) = centre(group)
|
||||
grabX = x - event.x
|
||||
grabY = y - event.y
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val group = selected ?: return true
|
||||
ignoreSaved = false
|
||||
val rawX = event.x + grabX
|
||||
val rawY = event.y + grabY
|
||||
val others = groups.filter { it != group && members(it).any(::isVisible) }.map { centre(it) }
|
||||
val nextX = if (snap) closestSnap(rawX, others.map { it.first } + width / 2f) else null
|
||||
val nextY = if (snap) closestSnap(rawY, others.map { it.second }) else null
|
||||
put(group, nextX ?: rawX, nextY ?: rawY)
|
||||
clamp(group)
|
||||
val (x, y) = centre(group)
|
||||
val visibleX = nextX?.takeIf { abs(x - it) <= SNAP_TOLERANCE_PX }
|
||||
val visibleY = nextY?.takeIf { abs(y - it) <= SNAP_TOLERANCE_PX }
|
||||
if ((visibleX != null && visibleX != snapX) || (visibleY != null && visibleY != snapY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
snapX = visibleX
|
||||
snapY = visibleY
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> clearSnapGuides()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun closestSnap(position: Float, targets: List<Float>): Float? =
|
||||
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
|
||||
|
||||
fun takesTouchAt(x: Float, y: Float): Boolean =
|
||||
editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) ||
|
||||
(!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null)
|
||||
|
||||
private fun controlAt(x: Float, y: Float): Control? =
|
||||
controls.firstOrNull {
|
||||
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
}
|
||||
|
||||
private fun adaptiveStickAt(x: Float, y: Float): Control? {
|
||||
if (!settings.adaptiveSticks || editing || buttonsOnly || quickHidden) return null
|
||||
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
|
||||
if (controls.any { control ->
|
||||
control.stick < 0 && isVisible(control) && control.contains(x, y, control.radius, 5f)
|
||||
}) return null
|
||||
return controls.filter { control ->
|
||||
val dx = if (control.stick == 0) max(0f, x - control.cx) else x - control.cx
|
||||
val dy = y - control.cy
|
||||
val reach = control.radius * 1.4f * sqrt(1.5f)
|
||||
control.stick >= 0 && isVisible(control) && control.pressedBy == -1 &&
|
||||
dx * dx + dy * dy <= reach * reach
|
||||
}.minByOrNull { control ->
|
||||
val dx = x - control.cx
|
||||
val dy = y - control.cy
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
|
||||
private fun isVisible(control: Control): Boolean = when {
|
||||
!editing && control.id == "guide" && !settings.steamButton -> false
|
||||
!editing && control.id == "qam" && !settings.qamButton -> false
|
||||
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
|
||||
else -> control.target != ControllerPrefs.OFF
|
||||
}
|
||||
|
||||
private fun apply(stickOnly: Boolean = false) {
|
||||
val bridge = pad
|
||||
if (bridge == null || editing) { invalidate(); return }
|
||||
val qamHeld = controls.any { it.id == "qam" && it.pressedBy != -1 }
|
||||
if (buttonsOnly) {
|
||||
bridge.setSystemButtons(controls.any { it.id == "guide" && it.pressedBy != -1 }, qamHeld)
|
||||
} else {
|
||||
bridge.setSystemButtons(false, qamHeld)
|
||||
val stickWrites = controls.filter { it.stick >= 0 && it.dirty }.fold(0) { mask, control ->
|
||||
mask or if (control.stick == 0) PadBridge.LEFT_STICK else PadBridge.RIGHT_STICK
|
||||
}
|
||||
bridge.applyTouch(stickWrites) { state ->
|
||||
val used = HashSet<String>()
|
||||
val held = HashSet<String>()
|
||||
for (control in controls) {
|
||||
when {
|
||||
control.stick >= 0 -> {
|
||||
val click = if (control.stick == 0) "l3" else "r3"
|
||||
if (settings.stickClick) used.add(click)
|
||||
if (control.clicked) held.add(click)
|
||||
if (!control.dirty) continue
|
||||
if (control.stick == 0) { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius }
|
||||
else { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius }
|
||||
control.dirty = false
|
||||
}
|
||||
control.id == "qam" || control.target == ControllerPrefs.OFF -> {}
|
||||
else -> {
|
||||
used.add(control.target)
|
||||
if (control.pressedBy != -1) held.add(control.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (target in used) write(state, target, target in held)
|
||||
}
|
||||
}
|
||||
if (stickOnly) stickRedraw.request() else invalidate()
|
||||
}
|
||||
|
||||
private fun write(state: PadState, target: String, down: Boolean) {
|
||||
when (target) {
|
||||
"a" -> state.press(PadState.A, down)
|
||||
"b" -> state.press(PadState.B, down)
|
||||
"x" -> state.press(PadState.X, down)
|
||||
"y" -> state.press(PadState.Y, down)
|
||||
"lb" -> state.press(PadState.LB, down)
|
||||
"rb" -> state.press(PadState.RB, down)
|
||||
"select" -> state.press(PadState.SELECT, down)
|
||||
"start" -> state.press(PadState.START, down)
|
||||
"l3" -> state.press(PadState.L3, down)
|
||||
"r3" -> state.press(PadState.R3, down)
|
||||
"guide" -> state.press(PadState.GUIDE, down)
|
||||
"lt" -> state.leftTrigger = if (down) 1f else 0f
|
||||
"rt" -> state.rightTrigger = if (down) 1f else 0f
|
||||
"up" -> state.up = down
|
||||
"right" -> state.right = down
|
||||
"down" -> state.down = down
|
||||
"left" -> state.left = down
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
quickPressedBy = -1
|
||||
keyboardPressedBy = -1
|
||||
invalidate()
|
||||
if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return
|
||||
controls.forEach {
|
||||
it.pressedBy = -1
|
||||
it.clicked = false
|
||||
it.lastUp = 0L
|
||||
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
|
||||
it.kx = 0f; it.ky = 0f
|
||||
}
|
||||
apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val QUICK_CENTER_DP = 34f
|
||||
const val QUICK_HIT_DP = 26f
|
||||
const val QUICK_RADIUS_DP = 18f
|
||||
const val PLACEMENT_CACHE = 4
|
||||
const val DOUBLE_TAP_MS = 300L
|
||||
const val STICK_REDRAW_MS = 33L
|
||||
const val MIN_STICK_MOVE_PX = 1f
|
||||
const val MIN_FACE_MM = 9.5f
|
||||
const val FULL_SIZE_HEIGHT_MM = 85f
|
||||
const val BOTTOM_MARGIN_MM = 5f
|
||||
const val DROP_DEGREES = 35.0
|
||||
const val PHONE_MAX_HEIGHT_MM = 90f
|
||||
const val SHOULDER_WIDTH = 1.45f
|
||||
const val SHOULDER_HEIGHT = 0.8f
|
||||
const val SNAP_DP = 9f
|
||||
const val SNAP_TOLERANCE_PX = 1f
|
||||
val shoulderIds = setOf("lb", "rb", "lt", "rt")
|
||||
val directions = listOf("up", "right", "down", "left")
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object OnScreenLayout {
|
||||
data class Dock(val portrait: Boolean, val grips: Boolean, val left: InputControl = InputControl.LS, val right: InputControl = InputControl.RS) {
|
||||
companion object {
|
||||
fun deck(portrait: Boolean, gamepad: SavedInputLayout?): Dock {
|
||||
fun side(right: Boolean) = gamepad?.elements?.firstOrNull {
|
||||
(it.kind == InputElementKind.STICK || it.kind == InputElementKind.TRACKPAD) && (it.x + it.width / 2f >= .5f) == right
|
||||
}?.kind
|
||||
val left = if (side(false) == InputElementKind.TRACKPAD) InputControl.LEFT_PAD else InputControl.LS
|
||||
val right = if (side(true) == InputElementKind.STICK) InputControl.RS else InputControl.RIGHT_PAD
|
||||
return Dock(portrait, true, left, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TOLERANCE = .01f
|
||||
private const val KEYBOARD_ID = "builtin:keyboard"
|
||||
|
||||
fun elements(context: Context, layout: SavedInputLayout?, width: Int, height: Int, safe: Rect,
|
||||
buttonsOnly: Boolean = false, dock: Dock? = null): List<InputSurfaceElement> {
|
||||
val stored = layout?.elements ?: return emptyList()
|
||||
if (width <= 0 || height <= 0) return stored
|
||||
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
|
||||
val (reference, generated) = reference(context, layout, width, height)
|
||||
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + fit(stored.filter { it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
|
||||
val others = fit(stored.filterNot { generated && isGamepad(it) || dock != null && it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val placed = when {
|
||||
gamepad.isEmpty() -> emptyList()
|
||||
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips, dock.left, dock.right)
|
||||
else -> generator.regenerate(gamepad)
|
||||
}
|
||||
val byId = (placed + others).associateBy { it.id }
|
||||
return stored.mapNotNull { byId[it.id] } + placed.filter { p -> stored.none { it.id == p.id } }
|
||||
}
|
||||
|
||||
fun fit(elements: List<InputSurfaceElement>, referenceWidth: Float, referenceHeight: Float, width: Int, height: Int, safe: Rect): List<InputSurfaceElement> {
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
val scale = min(w / referenceWidth, h / referenceHeight)
|
||||
return elements.map { e ->
|
||||
val ew = e.width * referenceWidth * scale
|
||||
val eh = e.height * referenceHeight * scale
|
||||
val left = anchor(e.x, e.width, referenceWidth, w, scale).coerceIn(safe.left.toFloat(), max(safe.left.toFloat(), w - safe.right - ew))
|
||||
val top = anchor(e.y, e.height, referenceHeight, h, scale).coerceIn(safe.top.toFloat(), max(safe.top.toFloat(), h - safe.bottom - eh))
|
||||
e.copy(x = left / w, y = top / h, width = ew / w, height = eh / h).bounded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun anchor(start: Float, size: Float, reference: Float, actual: Float, scale: Float): Float {
|
||||
val centre = start + size / 2f
|
||||
val scaled = size * reference * scale
|
||||
return when {
|
||||
centre < 1f / 3f -> start * reference * scale
|
||||
centre > 2f / 3f -> actual - (1f - start - size) * reference * scale - scaled
|
||||
else -> centre * actual - scaled / 2f
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGamepad(e: InputSurfaceElement) = e.id.startsWith("legacy:") || e.id == KEYBOARD_ID
|
||||
|
||||
fun generatedSize(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Int, Int>? =
|
||||
reference(context, layout, width, height).takeIf { it.second }?.first?.let { it.first.toInt() to it.second.toInt() }
|
||||
|
||||
private fun reference(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Pair<Float, Float>, Boolean> {
|
||||
val (display, metrics) = InputLibraries.primaryDisplay(context)
|
||||
val long = max(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
|
||||
val short = min(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
|
||||
val gamepad = layout.elements.filter(::isGamepad)
|
||||
if (gamepad.any { it.id.startsWith("legacy:") }) {
|
||||
val named = layout.id.removePrefix("migrated.layout.").takeIf { it != layout.id }?.split('x')?.mapNotNull { it.toIntOrNull() }
|
||||
?.takeIf { it.size == 2 && it.all { size -> size > 0 } }?.let { it[0] to it[1] }
|
||||
for ((w, h) in listOfNotNull(named, width to height, long to short, short to long).distinct()) {
|
||||
if (matches(gamepad, LegacyGamepadLayout(display, w, h, scale = layout.scale).elements())) return (w.toFloat() to h.toFloat()) to true
|
||||
}
|
||||
}
|
||||
val aspect = layout.aspect.takeIf { it > 0f } ?: (long.toFloat() / short)
|
||||
return (aspect to 1f) to false
|
||||
}
|
||||
|
||||
private fun matches(stored: List<InputSurfaceElement>, generated: List<InputSurfaceElement>): Boolean {
|
||||
val byId = generated.associateBy { it.id }
|
||||
return stored.all { e ->
|
||||
val g = byId[e.id] ?: return@all false
|
||||
abs(e.x - g.x) <= TOLERANCE && abs(e.y - g.y) <= TOLERANCE && abs(e.width - g.width) <= TOLERANCE && abs(e.height - g.height) <= TOLERANCE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,25 +59,11 @@ public final class PadBridge {
|
||||
private final int[] retainedSticks = new int[SLOTS];
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final PadState homeGuideReport = new PadState();
|
||||
private final PadState combinedState = new PadState();
|
||||
private InputRouter inputRouter;
|
||||
private final InputRouter.PadSource[] physicalRoutes = new InputRouter.PadSource[SLOTS];
|
||||
private final java.util.IdentityHashMap<Object, TouchSource> touchSources = new java.util.IdentityHashMap<>();
|
||||
private final Object defaultTouchSource = new Object();
|
||||
|
||||
private static final class TouchSource {
|
||||
final PadState raw = new PadState();
|
||||
final PadState passthrough = new PadState();
|
||||
final InputRole role;
|
||||
boolean routed;
|
||||
InputRouter.PadSource route;
|
||||
TouchSource(InputRole role) { this.role = role; }
|
||||
}
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private volatile boolean selectSteam;
|
||||
private volatile boolean builtinPad = true;
|
||||
private TouchRoute touchRoute;
|
||||
private boolean touchMouseActive;
|
||||
private final PadState routedTouch = new PadState();
|
||||
private boolean systemGuidePressed;
|
||||
private boolean systemQamPressed;
|
||||
@@ -86,7 +72,6 @@ public final class PadBridge {
|
||||
private int homeGuideSlot = -1;
|
||||
private final PadState lastBackQamReport = new PadState();
|
||||
private final PadState lastWrittenReport = new PadState();
|
||||
private final PadState liveReport = new PadState();
|
||||
private boolean backQamAxesBlocked;
|
||||
private final java.util.Set<Runnable> backQamWrites = new java.util.HashSet<>();
|
||||
private long backQamDelayMs, backQamWriteAt;
|
||||
@@ -94,11 +79,6 @@ public final class PadBridge {
|
||||
private int chordButton = -1;
|
||||
private boolean chordButtonPressed;
|
||||
private boolean qamTapPressed;
|
||||
private boolean guideTapPressed;
|
||||
private int guideTapGeneration;
|
||||
private boolean vrStickTapPressed;
|
||||
private int vrStickTapGeneration;
|
||||
private boolean routedQamPressed;
|
||||
private int qamChordGeneration;
|
||||
private volatile Runnable onPlayerInput;
|
||||
private Runnable onChordInput;
|
||||
@@ -139,36 +119,8 @@ public final class PadBridge {
|
||||
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
|
||||
}
|
||||
|
||||
public synchronized void setInputRouter(InputRouter router) {
|
||||
if (inputRouter == router) return;
|
||||
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
|
||||
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
|
||||
inputRouter = router;
|
||||
for (int slot = 0; slot < SLOTS; slot++) physicalRoutes[slot] = router == null ? null : router.pad(InputRole.PHYSICAL, stateFor(slot));
|
||||
for (TouchSource source : touchSources.values()) source.route = router == null || source.routed ? null : router.pad(source.role, source.raw);
|
||||
clearBackQamWrites();
|
||||
if (open) publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
}
|
||||
|
||||
public synchronized boolean hasInputRouter() { return inputRouter != null; }
|
||||
|
||||
public synchronized InputRouter getInputRouter() { return inputRouter; }
|
||||
|
||||
public synchronized void setInputProfile(InputProfile profile) {
|
||||
if (inputRouter == null) return;
|
||||
inputRouter.setProfile(profile);
|
||||
clearBackQamWrites();
|
||||
if (open) publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
}
|
||||
|
||||
public synchronized void setInputEnabled(boolean enabled) {
|
||||
if (inputRouter == null || !inputRouter.setEnabled(enabled)) return;
|
||||
if (!enabled) clearBackQamWrites();
|
||||
if (open) publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
DeckControls.INSTANCE.refresh();
|
||||
public void setSelectSteam(boolean on) {
|
||||
selectSteam = on;
|
||||
}
|
||||
|
||||
public void setExcluded(java.util.function.IntPredicate devices) {
|
||||
@@ -195,18 +147,10 @@ public final class PadBridge {
|
||||
homeGuidePressed = false;
|
||||
clearBackQamWrites();
|
||||
routedTouch.clear();
|
||||
for (TouchSource source : touchSources.values()) {
|
||||
source.raw.clear();
|
||||
if (source.route != null) source.route.release();
|
||||
}
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
backQamPressed = false;
|
||||
qamTapPressed = false;
|
||||
guideTapPressed = false;
|
||||
guideTapGeneration++;
|
||||
vrStickTapPressed = false;
|
||||
vrStickTapGeneration++;
|
||||
qamChordActive = false;
|
||||
chordButtonPressed = false;
|
||||
qamChordGeneration++;
|
||||
@@ -224,16 +168,6 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
guideTapPressed = false;
|
||||
guideTapGeneration++;
|
||||
vrStickTapPressed = false;
|
||||
vrStickTapGeneration++;
|
||||
if (inputRouter != null) inputRouter.releaseAll();
|
||||
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
|
||||
touchSources.clear();
|
||||
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
|
||||
Arrays.fill(physicalRoutes, null);
|
||||
inputRouter = null;
|
||||
homeGuidePressed = false;
|
||||
homeGuideSlot = -1;
|
||||
clearBackQamWrites();
|
||||
@@ -380,7 +314,6 @@ public final class PadBridge {
|
||||
if (slot < 0) return false;
|
||||
int side = JoyCon.side(event.getDevice());
|
||||
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
@@ -391,6 +324,9 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
if (selectSteam) pad.press(PadState.GUIDE, pressed);
|
||||
else pad.press(6, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
|
||||
@@ -405,14 +341,8 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_C:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.L4 : DeckControls.R4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2:
|
||||
pad.leftTrigger = keyTrigger(pad.leftTrigger, pressed, event.getRepeatCount(),
|
||||
analogTrigger(event.getDevice(), MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE));
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2:
|
||||
pad.rightTrigger = keyTrigger(pad.rightTrigger, pressed, event.getRepeatCount(),
|
||||
analogTrigger(event.getDevice(), MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS));
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
|
||||
@@ -425,7 +355,6 @@ public final class PadBridge {
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -493,7 +422,6 @@ public final class PadBridge {
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
if (slot < 0) return false;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
statAxes++;
|
||||
@@ -510,7 +438,6 @@ public final class PadBridge {
|
||||
if (chordStickInput) noteChordInput(slot);
|
||||
if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput();
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
float lx = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
@@ -549,20 +476,9 @@ public final class PadBridge {
|
||||
notePlayerInput();
|
||||
}
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
|
||||
static float keyTrigger(float current, boolean pressed, int repeatCount, boolean analog) {
|
||||
if (analog || repeatCount > 0) return current;
|
||||
return pressed ? 1f : 0f;
|
||||
}
|
||||
|
||||
static boolean analogTrigger(InputDevice device, int... axes) {
|
||||
return wiredXboxLayout(device, InputDevice.SOURCE_JOYSTICK)
|
||||
|| hasAxis(device, InputDevice.SOURCE_JOYSTICK, axes);
|
||||
}
|
||||
|
||||
static boolean hasAxis(InputDevice device, int source, int... axes) {
|
||||
if (device == null) return false;
|
||||
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
|
||||
@@ -577,70 +493,41 @@ public final class PadBridge {
|
||||
|
||||
public synchronized void setTouchRoute(TouchRoute route) {
|
||||
touchRoute = route;
|
||||
touchMouseActive = false;
|
||||
routedTouch.clear();
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
applyTouch(defaultTouchSource, InputRole.PRIMARY, 0, mutation);
|
||||
applyTouch(0, mutation);
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(int stickWrites, java.util.function.Consumer<PadState> mutation) {
|
||||
applyTouch(defaultTouchSource, InputRole.PRIMARY, stickWrites, mutation);
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
|
||||
applyTouch(owner, role, 0, mutation);
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(Object owner, InputRole role, int stickWrites, java.util.function.Consumer<PadState> mutation) {
|
||||
if (!builtinPad) return;
|
||||
long timing = InputTiming.begin();
|
||||
TouchSource source = touchSources.get(owner);
|
||||
if (source == null) {
|
||||
source = new TouchSource(role);
|
||||
if (inputRouter != null) source.route = inputRouter.pad(role);
|
||||
touchSources.put(owner, source);
|
||||
}
|
||||
float lx = source.raw.leftX, ly = source.raw.leftY, rx = source.raw.rightX, ry = source.raw.rightY;
|
||||
mutation.accept(source.raw);
|
||||
if (inputRouter == null && (touchRoute == null || !touchRoute.active())) {
|
||||
if ((stickWrites & LEFT_STICK) != 0 || source.raw.leftX != lx || source.raw.leftY != ly) {
|
||||
retainedSticks[0] &= ~LEFT_STICK;
|
||||
state.leftX = state.leftY = 0f;
|
||||
if (touchRoute != null && touchRoute.active()) {
|
||||
mutation.accept(routedTouch);
|
||||
touchRoute.accept(routedTouch);
|
||||
boolean changed = false;
|
||||
for (int button : ROUTED_PASS_THROUGH) {
|
||||
boolean down = routedTouch.isDown(button);
|
||||
if (state.isDown(button) == down) continue;
|
||||
state.press(button, down);
|
||||
changed = true;
|
||||
}
|
||||
if ((stickWrites & RIGHT_STICK) != 0 || source.raw.rightX != rx || source.raw.rightY != ry) {
|
||||
retainedSticks[0] &= ~RIGHT_STICK;
|
||||
state.rightX = state.rightY = 0f;
|
||||
if (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
publish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
routedTouch.clear();
|
||||
retainedSticks[0] &= ~stickWrites;
|
||||
mutateState(mutation);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
InputTiming.end(timing);
|
||||
}
|
||||
|
||||
public synchronized void releaseTouch(Object owner) {
|
||||
TouchSource source = touchSources.remove(owner);
|
||||
if (source == null) return;
|
||||
if (source.route != null) source.route.close();
|
||||
publish();
|
||||
}
|
||||
|
||||
public synchronized void applyRoutedTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
|
||||
if (!builtinPad) return;
|
||||
TouchSource source = touchSources.get(owner);
|
||||
if (source == null) {
|
||||
source = new TouchSource(role);
|
||||
source.routed = true;
|
||||
touchSources.put(owner, source);
|
||||
}
|
||||
mutation.accept(source.raw);
|
||||
notePlayerInput();
|
||||
publish();
|
||||
}
|
||||
|
||||
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
|
||||
@@ -665,30 +552,16 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void releaseAll() {
|
||||
guideTapGeneration++;
|
||||
vrStickTapPressed = false;
|
||||
vrStickTapGeneration++;
|
||||
routedQamPressed = false;
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
guideTapPressed = false;
|
||||
qamTapPressed = false;
|
||||
qamChordActive = false;
|
||||
chordButtonPressed = false;
|
||||
qamChordGeneration++;
|
||||
if (inputRouter != null) inputRouter.releaseAll();
|
||||
if (inputRouter == null) for (TouchSource source : touchSources.values()) source.raw.clear();
|
||||
routedTouch.clear();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
homeGuidePressed = false;
|
||||
homeGuideSlot = -1;
|
||||
clearBackQamWrites();
|
||||
routedTouch.clear();
|
||||
backQamPressed = false;
|
||||
if (inputRouter == null) releaseBackPaddles();
|
||||
releaseBackPaddles();
|
||||
clearPlayerState(0);
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
if (inputRouter == null) clearPlayerState(slot);
|
||||
clearPlayerState(slot);
|
||||
if (playerWriters[slot] == null) continue;
|
||||
publish(slot);
|
||||
}
|
||||
@@ -729,11 +602,7 @@ public final class PadBridge {
|
||||
if (!homeGuidePressed) return;
|
||||
clearBackQamWrites();
|
||||
homeGuidePressed = false;
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
else {
|
||||
inputRouter.releaseAll();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
}
|
||||
clearPlayerState(0);
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -772,11 +641,7 @@ public final class PadBridge {
|
||||
if (!backQamPressed && backQamWrites.isEmpty()) return;
|
||||
clearBackQamWrites();
|
||||
backQamPressed = false;
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
else {
|
||||
inputRouter.releaseAll();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
}
|
||||
clearPlayerState(0);
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -812,7 +677,6 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private void writePad(PadState report) {
|
||||
liveReport.copyFrom(report);
|
||||
if (backQamAxesBlocked && !hasQamAxes()) backQamAxesBlocked = false;
|
||||
long at = android.os.SystemClock.uptimeMillis();
|
||||
if (backQamPressed || homeGuidePressed || !backQamWrites.isEmpty()) at += backQamDelayMs;
|
||||
@@ -820,11 +684,11 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private boolean hasQamAxes() {
|
||||
boolean left = (retainedSticks[0] & 1) != 0 && liveReport.leftX == state.leftX && liveReport.leftY == state.leftY
|
||||
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : liveReport.leftX != 0f || liveReport.leftY != 0f;
|
||||
boolean right = (retainedSticks[0] & 2) != 0 && liveReport.rightX == state.rightX && liveReport.rightY == state.rightY
|
||||
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : liveReport.rightX != 0f || liveReport.rightY != 0f;
|
||||
return left || right || liveReport.leftTrigger != 0f || liveReport.rightTrigger != 0f;
|
||||
boolean left = (retainedSticks[0] & 1) != 0
|
||||
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : state.leftX != 0f || state.leftY != 0f;
|
||||
boolean right = (retainedSticks[0] & 2) != 0
|
||||
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : state.rightX != 0f || state.rightY != 0f;
|
||||
return left || right || state.leftTrigger != 0f || state.rightTrigger != 0f;
|
||||
}
|
||||
|
||||
private static void clearAxes(PadState pad) {
|
||||
@@ -845,9 +709,9 @@ public final class PadBridge {
|
||||
PadState live = new PadState();
|
||||
live.copyFrom(report);
|
||||
if (queued) {
|
||||
live.leftX = liveReport.leftX; live.leftY = liveReport.leftY;
|
||||
live.rightX = liveReport.rightX; live.rightY = liveReport.rightY;
|
||||
live.leftTrigger = liveReport.leftTrigger; live.rightTrigger = liveReport.rightTrigger;
|
||||
live.leftX = state.leftX; live.leftY = state.leftY;
|
||||
live.rightX = state.rightX; live.rightY = state.rightY;
|
||||
live.leftTrigger = state.leftTrigger; live.rightTrigger = state.rightTrigger;
|
||||
}
|
||||
boolean held = (backQamPressed && report.isDown(PadState.QAM)) || (homeGuidePressed && report.isDown(PadState.GUIDE));
|
||||
if ((queued && !held) || backQamAxesBlocked) clearAxes(live);
|
||||
@@ -868,34 +732,6 @@ public final class PadBridge {
|
||||
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
|
||||
}
|
||||
|
||||
public synchronized void triggerSteamMenu() {
|
||||
if (!builtinPad || guideTapPressed) return;
|
||||
guideTapPressed = true;
|
||||
int generation = ++guideTapGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> {
|
||||
synchronized (PadBridge.this) {
|
||||
if (generation != guideTapGeneration) return;
|
||||
guideTapPressed = false;
|
||||
publish();
|
||||
}
|
||||
}, 90);
|
||||
}
|
||||
|
||||
public synchronized void triggerVrStickSwitch() {
|
||||
if (!builtinPad || vrStickTapPressed) return;
|
||||
vrStickTapPressed = true;
|
||||
int generation = ++vrStickTapGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> {
|
||||
synchronized (PadBridge.this) {
|
||||
if (generation != vrStickTapGeneration) return;
|
||||
vrStickTapPressed = false;
|
||||
publish();
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamTap(int generation) {
|
||||
if (generation != qamChordGeneration || !qamTapPressed) return;
|
||||
qamTapPressed = false;
|
||||
@@ -1020,7 +856,6 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
if (physicalRoutes[slot] != null) physicalRoutes[slot].release();
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
partners[slot] = NO_CONTROLLER;
|
||||
@@ -1074,7 +909,6 @@ public final class PadBridge {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
PadState output = physicalRoutes[slot] == null ? playerStates[slot] : physicalRoutes[slot].route(playerStates[slot]);
|
||||
FakeInputWriter player = playerWriters[slot];
|
||||
if (player == null) {
|
||||
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
|
||||
@@ -1084,75 +918,43 @@ public final class PadBridge {
|
||||
}
|
||||
playerWriters[slot] = player;
|
||||
}
|
||||
PadState report = playerStates[slot];
|
||||
if (slot == homeGuideSlot) {
|
||||
homeGuideReport.copyFrom(output);
|
||||
homeGuideReport.copyFrom(report);
|
||||
homeGuideReport.press(PadState.GUIDE, true);
|
||||
output = homeGuideReport;
|
||||
report = homeGuideReport;
|
||||
}
|
||||
player.writePad(output);
|
||||
player.writePad(report);
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
|
||||
PadState output = physicalRoutes[0] == null ? state : physicalRoutes[0].route(state);
|
||||
boolean mouseRoute = (!touchSources.isEmpty() || touchMouseActive) && touchRoute != null && touchRoute.active();
|
||||
touchMouseActive = mouseRoute && !touchSources.isEmpty();
|
||||
if (mouseRoute) routedTouch.clear();
|
||||
if (!touchSources.isEmpty()) {
|
||||
combinedState.copyFrom(output);
|
||||
for (TouchSource source : touchSources.values()) {
|
||||
PadState touch = source.route == null ? source.raw : source.route.route(source.raw);
|
||||
if (mouseRoute) {
|
||||
routedTouch.merge(touch);
|
||||
source.passthrough.clear();
|
||||
for (int button : ROUTED_PASS_THROUGH) source.passthrough.press(button, touch.isDown(button));
|
||||
combinedState.merge(source.passthrough);
|
||||
} else combinedState.merge(touch);
|
||||
}
|
||||
output = combinedState;
|
||||
}
|
||||
if (mouseRoute) touchRoute.accept(routedTouch);
|
||||
boolean guide = systemGuidePressed || qamChordActive || guideTapPressed || homeGuidePressed;
|
||||
boolean routedQam = output.isDown(PadState.QAM);
|
||||
boolean routedQamStarted = routedQam && !routedQamPressed;
|
||||
routedQamPressed = routedQam;
|
||||
if (routedQamStarted && !nativeQam() && !qamChordActive) {
|
||||
startChord(PadState.A);
|
||||
return;
|
||||
}
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
if (!guide && !qam && !vrStickTapPressed && (!routedQam || nativeQam())) {
|
||||
writePad(output);
|
||||
boolean guide = systemGuidePressed || qamChordActive || homeGuidePressed;
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
|
||||
if (!guide && !qam) {
|
||||
writePad(state);
|
||||
return;
|
||||
}
|
||||
effectiveState.copyFrom(output);
|
||||
if (!nativeQam()) effectiveState.press(PadState.QAM, false);
|
||||
effectiveState.copyFrom(state);
|
||||
if (guide) effectiveState.press(PadState.GUIDE, true);
|
||||
if (qam) effectiveState.press(PadState.QAM, true);
|
||||
if (chordButtonPressed) effectiveState.press(chordButton, true);
|
||||
if (vrStickTapPressed) {
|
||||
effectiveState.press(PadState.L3, true);
|
||||
effectiveState.press(PadState.R3, true);
|
||||
}
|
||||
writePad(effectiveState);
|
||||
}
|
||||
|
||||
public static boolean wiredXboxLayout(MotionEvent event) {
|
||||
return wiredXboxLayout(event.getDevice(), event.getSource());
|
||||
}
|
||||
|
||||
static boolean wiredXboxLayout(InputDevice device, int source) {
|
||||
InputDevice device = event.getDevice();
|
||||
return device != null
|
||||
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|
||||
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RX, source) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RY, source) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_Z, source) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RZ, source) != null;
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
|
||||
}
|
||||
|
||||
public static float leftTrigger(MotionEvent event, boolean wired) {
|
||||
|
||||
@@ -36,19 +36,6 @@ class PadState {
|
||||
buttons = 0
|
||||
}
|
||||
|
||||
fun merge(other: PadState) {
|
||||
buttons = buttons or other.buttons
|
||||
up = up || other.up; right = right || other.right; down = down || other.down; left = left || other.left
|
||||
leftTrigger = maxOf(leftTrigger, other.leftTrigger)
|
||||
rightTrigger = maxOf(rightTrigger, other.rightTrigger)
|
||||
if (other.leftX * other.leftX + other.leftY * other.leftY > leftX * leftX + leftY * leftY) {
|
||||
leftX = other.leftX; leftY = other.leftY
|
||||
}
|
||||
if (other.rightX * other.rightX + other.rightY * other.rightY > rightX * rightX + rightY * rightY) {
|
||||
rightX = other.rightX; rightY = other.rightY
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val A = 0
|
||||
const val B = 1
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
|
||||
data class PhysicalInputKey(val device: String, val name: String, val keyCode: Int, val scanCode: Int) {
|
||||
val label: String get() = "$name · ${KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")} ($scanCode)"
|
||||
|
||||
companion object {
|
||||
fun identity(device: InputDevice) = "${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}"
|
||||
|
||||
fun capture(event: KeyEvent): PhysicalInputKey? {
|
||||
val device = event.device ?: return null
|
||||
if (device.isVirtual || PadBridge.isSystemVirtualInputName(device.name)) return null
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP,
|
||||
KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null
|
||||
}
|
||||
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null
|
||||
return PhysicalInputKey(identity(device), device.name, event.keyCode, event.scanCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PhysicalInputBinding(val key: PhysicalInputKey, val action: InputAction)
|
||||
|
||||
class PhysicalInputBindings(private val router: InputRouter) {
|
||||
private class Entry(val binding: PhysicalInputBinding, val channel: InputRouter.Channel) {
|
||||
var heldDevice = -1
|
||||
var suppressed = false
|
||||
}
|
||||
private var entries = emptyList<Entry>()
|
||||
private val released = ArrayList<Entry>()
|
||||
private val devices = HashMap<Int, String>()
|
||||
|
||||
fun configure(bindings: List<PhysicalInputBinding>) {
|
||||
if (entries.map { it.binding } == bindings) return
|
||||
for (entry in entries) {
|
||||
entry.channel.close()
|
||||
if (entry.heldDevice != -1) released.add(entry)
|
||||
}
|
||||
entries = bindings.map { Entry(it, router.channel(InputRole.PHYSICAL, InputControl.A, it.action)) }
|
||||
}
|
||||
|
||||
fun onKey(event: KeyEvent, enabled: Boolean): Boolean {
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
for (i in released.indices.reversed()) {
|
||||
val old = released[i]
|
||||
if (old.heldDevice == event.deviceId && old.binding.key.keyCode == event.keyCode && old.binding.key.scanCode == event.scanCode) {
|
||||
if (event.action == KeyEvent.ACTION_UP) released.removeAt(i)
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (entry in entries) {
|
||||
val key = entry.binding.key
|
||||
if (key.keyCode != event.keyCode || key.scanCode != event.scanCode) continue
|
||||
if (entry.heldDevice != -1 && entry.heldDevice == event.deviceId) {
|
||||
if (event.action == KeyEvent.ACTION_UP) {
|
||||
entry.channel.update(0f); entry.heldDevice = -1; entry.suppressed = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!enabled || event.action != KeyEvent.ACTION_DOWN || entry.binding.action is InputAction.Native) continue
|
||||
val identity = devices[event.deviceId] ?: event.device?.let { PhysicalInputKey.identity(it) }?.also { devices[event.deviceId] = it } ?: continue
|
||||
if (identity != key.device) continue
|
||||
entry.heldDevice = event.deviceId
|
||||
entry.suppressed = event.repeatCount != 0
|
||||
if (!entry.suppressed) entry.channel.update(1f)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun release() {
|
||||
for (entry in entries) {
|
||||
entry.channel.release()
|
||||
if (entry.heldDevice != -1) entry.suppressed = true
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnected(id: Int) {
|
||||
devices.remove(id)
|
||||
released.removeAll { it.heldDevice == id }
|
||||
for (entry in entries) if (entry.heldDevice == id) { entry.channel.release(); entry.heldDevice = -1 }
|
||||
}
|
||||
|
||||
fun close() { entries.forEach { it.channel.close() }; entries = emptyList(); released.clear(); devices.clear() }
|
||||
}
|
||||
@@ -105,10 +105,6 @@ object PhysicalShortcuts {
|
||||
write(context, read(context).filterNot { it == binding })
|
||||
}
|
||||
|
||||
fun restoreDefault(context: Context, action: Action) {
|
||||
write(context, read(context).filterNot { it.action == action })
|
||||
}
|
||||
|
||||
private fun write(context: Context, bindings: List<Binding>) {
|
||||
val array = JSONArray()
|
||||
bindings.forEach {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
|
||||
fun screenKeyLabel(context: Context, codes: List<Int>): String =
|
||||
codes.filter { it != 0 }.joinToString("+") { short(context, it) }
|
||||
|
||||
private fun short(context: Context, code: Int): String = when (code) {
|
||||
28 -> "⏎"
|
||||
14 -> "⌫"
|
||||
15 -> "Tab"
|
||||
57 -> "Space"
|
||||
58 -> "Caps"
|
||||
42, 54 -> "Shift"
|
||||
29, 97 -> "Ctrl"
|
||||
56 -> "Alt"
|
||||
100 -> "AltGr"
|
||||
125 -> "Super"
|
||||
else -> pcKeyLabel(context, code)
|
||||
}
|
||||
|
||||
const val SCREEN_KEY_PILL = 0
|
||||
const val SCREEN_KEY_ROUND = 1
|
||||
const val SCREEN_KEY_CIRCLE = 2
|
||||
const val SCREEN_KEY_RECT = 3
|
||||
const val SCREEN_KEY_TRIGGER = 4
|
||||
const val SCREEN_KEY_DPAD = 5
|
||||
const val SCREEN_KEY_STICK = 6
|
||||
|
||||
val SCREEN_KEY_DPAD_DIRS = listOf("↑", "→", "↓", "←")
|
||||
val SCREEN_KEY_STICK_DIRS = listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖")
|
||||
|
||||
fun screenKeyDirections(shape: Int): List<String> = when (shape) {
|
||||
SCREEN_KEY_DPAD -> SCREEN_KEY_DPAD_DIRS
|
||||
SCREEN_KEY_STICK -> SCREEN_KEY_STICK_DIRS
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
data class ScreenKey(
|
||||
val codes: List<Int>,
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val size: Int,
|
||||
val glyph: String = "",
|
||||
val text: String = "",
|
||||
val image: String = "",
|
||||
val opacity: Float = 1f,
|
||||
val shape: Int = SCREEN_KEY_PILL,
|
||||
val toggle: Boolean = false,
|
||||
val labels: Boolean = true,
|
||||
) {
|
||||
}
|
||||
+5
-38
@@ -1,49 +1,16 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object InputButtonIcons {
|
||||
// Persisted symbol IDs must not depend on Android resource IDs or overlap Linux key codes.
|
||||
const val MENU = 1001
|
||||
const val KEYBOARD = 1002
|
||||
const val STEAM = 1003
|
||||
const val QUICK_ACCESS = 1004
|
||||
object ScreenKeyIcons {
|
||||
fun has(code: Int): Boolean = code in SET
|
||||
|
||||
fun has(code: Int): Boolean = code in codes
|
||||
private val SET = setOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
|
||||
|
||||
private val keyCodes = listOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
|
||||
val codes = listOf(MENU, KEYBOARD, STEAM, QUICK_ACCESS) + keyCodes
|
||||
|
||||
fun label(context: Context, code: Int): String = when (code) {
|
||||
MENU -> context.getString(R.string.touch_droiddeck_menu)
|
||||
KEYBOARD -> context.getString(R.string.drawer_keyboard)
|
||||
STEAM -> context.getString(R.string.drawer_steam_menu)
|
||||
QUICK_ACCESS -> context.getString(R.string.drawer_open_qam)
|
||||
else -> pcKeyLabel(context, code)
|
||||
}
|
||||
|
||||
fun drawable(context: Context, code: Int): Drawable? = when (code) {
|
||||
MENU -> R.drawable.ic_stat_session
|
||||
KEYBOARD -> R.drawable.ic_bottom_keyboard
|
||||
STEAM -> R.drawable.ic_input_steam
|
||||
QUICK_ACCESS -> R.drawable.ic_input_quick_access
|
||||
else -> null
|
||||
}?.let { context.getDrawable(it)?.mutate() }
|
||||
|
||||
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint, drawable: Drawable? = null): Boolean {
|
||||
if (drawable != null) {
|
||||
drawable.setTint(paint.color)
|
||||
val half = box / 2f
|
||||
drawable.setBounds((cx - half).toInt(), (cy - half).toInt(), (cx + half).toInt(), (cy + half).toInt())
|
||||
drawable.draw(canvas)
|
||||
return true
|
||||
}
|
||||
if (code !in keyCodes) return false
|
||||
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint): Boolean {
|
||||
if (!has(code)) return false
|
||||
val h = box / 2f
|
||||
val stroke = box * 0.085f
|
||||
val path = Path()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,140 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val ICP_NAMES: Map<Int, String> = mapOf(
|
||||
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
|
||||
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
|
||||
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
|
||||
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
|
||||
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
|
||||
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
|
||||
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
|
||||
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
|
||||
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
|
||||
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
|
||||
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
|
||||
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
|
||||
21 to "KEY_Y", 44 to "KEY_Z",
|
||||
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
|
||||
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
|
||||
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
|
||||
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
|
||||
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
|
||||
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
|
||||
)
|
||||
|
||||
private val ICP_CODES: Map<String, Int> = ICP_NAMES.entries.associate { (code, name) -> name to code }
|
||||
|
||||
private fun icpShape(shape: Int): String = when (shape) {
|
||||
SCREEN_KEY_CIRCLE -> "CIRCLE"
|
||||
SCREEN_KEY_RECT -> "RECT"
|
||||
SCREEN_KEY_ROUND -> "ROUND_RECT"
|
||||
SCREEN_KEY_DPAD -> "ROUND_RECT"
|
||||
SCREEN_KEY_STICK -> "ROUND_RECT"
|
||||
SCREEN_KEY_TRIGGER -> "ROUND_RECT"
|
||||
else -> "ROUND_RECT"
|
||||
}
|
||||
|
||||
private fun screenKeyShape(shape: String): Int = when (shape) {
|
||||
"CIRCLE" -> SCREEN_KEY_CIRCLE
|
||||
"RECT" -> SCREEN_KEY_RECT
|
||||
"SQUARE" -> SCREEN_KEY_ROUND
|
||||
else -> SCREEN_KEY_PILL
|
||||
}
|
||||
|
||||
fun exportScreenLayout(keys: List<ScreenKey>, widthPx: Int, density: Float): String {
|
||||
val snap = widthPx / 100f
|
||||
val elements = JSONArray()
|
||||
keys.forEach { key ->
|
||||
val binds = JSONArray()
|
||||
val directional = key.shape == SCREEN_KEY_DPAD || key.shape == SCREEN_KEY_STICK
|
||||
key.codes.forEach { code ->
|
||||
val name = ICP_NAMES[code]
|
||||
if (name != null) binds.put(name) else if (directional) binds.put("")
|
||||
}
|
||||
if (binds.length() == 0) return@forEach
|
||||
val shape = icpShape(key.shape)
|
||||
val sizePx = key.size * density
|
||||
val scale = when {
|
||||
key.shape == SCREEN_KEY_DPAD -> sizePx * 2.3f / (14f * snap)
|
||||
key.shape == SCREEN_KEY_STICK -> sizePx * 2.3f / (14f * snap)
|
||||
shape == "CIRCLE" -> sizePx / (6f * snap)
|
||||
else -> sizePx / (4f * snap)
|
||||
}
|
||||
elements.put(
|
||||
JSONObject()
|
||||
.put(
|
||||
"type",
|
||||
when (key.shape) {
|
||||
SCREEN_KEY_DPAD -> "D_PAD"
|
||||
SCREEN_KEY_STICK -> "STICK"
|
||||
else -> "BUTTON"
|
||||
},
|
||||
)
|
||||
.put("shape", shape)
|
||||
.put("customColor", 0)
|
||||
.put("bindings", binds)
|
||||
.put("scale", scale)
|
||||
.put("x", key.x)
|
||||
.put("y", key.y)
|
||||
.put("toggleSwitch", key.toggle)
|
||||
.put("swipeable", false)
|
||||
.put("text", key.text)
|
||||
.put("iconId", 0)
|
||||
.apply { if (key.opacity < 1f) put("opacity", key.opacity) },
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("id", 0)
|
||||
.put("name", "DroidDeck")
|
||||
.put("cursorSpeed", 1.5f)
|
||||
.put("elements", elements)
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun importScreenLayout(raw: String, widthPx: Int, density: Float): List<ScreenKey>? {
|
||||
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val elements = root.optJSONArray("elements") ?: return null
|
||||
val snap = widthPx / 100f
|
||||
val keys = mutableListOf<ScreenKey>()
|
||||
for (i in 0 until elements.length()) {
|
||||
val element = elements.optJSONObject(i) ?: continue
|
||||
val type = element.optString("type", "BUTTON")
|
||||
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
|
||||
val binds = element.optJSONArray("bindings") ?: continue
|
||||
val raw = (0 until binds.length()).map { ICP_CODES[binds.optString(it)] ?: 0 }
|
||||
val codes = if (type == "D_PAD" || type == "STICK") {
|
||||
raw.dropLastWhile { it == 0 }.ifEmpty { listOf(0) }
|
||||
} else {
|
||||
raw.filter { it != 0 }
|
||||
}
|
||||
if (codes.isEmpty()) continue
|
||||
val dpad = type == "D_PAD"
|
||||
val stick = type == "STICK"
|
||||
val shape = when {
|
||||
dpad -> SCREEN_KEY_DPAD
|
||||
stick -> SCREEN_KEY_STICK
|
||||
else -> screenKeyShape(element.optString("shape"))
|
||||
}
|
||||
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it > 0f } ?: 1f
|
||||
val sizePx = when {
|
||||
dpad || stick -> 14f * snap * scale / 2.3f
|
||||
shape == SCREEN_KEY_CIRCLE -> 6f * snap * scale
|
||||
else -> 4f * snap * scale
|
||||
}
|
||||
keys += ScreenKey(
|
||||
codes = codes,
|
||||
x = element.optDouble("x", 0.5).toFloat().coerceIn(0f, 1f),
|
||||
y = element.optDouble("y", 0.5).toFloat().coerceIn(0f, 1f),
|
||||
size = (sizePx / density).roundToInt().coerceIn(ScreenKeys.MIN_SIZE, ScreenKeys.MAX_SIZE),
|
||||
text = element.optString("text").take(24),
|
||||
opacity = element.optDouble("opacity", 1.0).toFloat().coerceIn(0.2f, 1f),
|
||||
shape = shape,
|
||||
toggle = element.optBoolean("toggleSwitch"),
|
||||
)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
enum class SecondScreenMode(val id: String, @StringRes val label: Int, val bindings: String? = null) {
|
||||
enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
|
||||
NONE("none", R.string.second_mode_none),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad, InputStockLayouts.TRACKPAD),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
|
||||
TERMINAL("terminal", R.string.second_mode_terminal),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls, InputStockLayouts.DECK),
|
||||
CUSTOM_CONTROLS("custom-controls", R.string.input_profile_controls_mode),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
|
||||
DASHBOARD("dashboard", R.string.second_mode_dashboard),
|
||||
DESKTOP("desktop", R.string.second_mode_desktop);
|
||||
|
||||
val controls: Boolean get() = this == CUSTOM_CONTROLS || bindings != null
|
||||
|
||||
fun panel(context: Context): String? = if (this == DECK_CONTROLS) SessionPrefs.deckPanel(context).bindings else bindings
|
||||
|
||||
companion object {
|
||||
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, bottomDesktop: Boolean = false, desktopSession: Boolean = false): SecondScreenMode {
|
||||
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, desktop: Boolean = false): SecondScreenMode {
|
||||
entries.firstOrNull {
|
||||
it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) &&
|
||||
(it != DESKTOP || bottomDesktop)
|
||||
(it != DESKTOP || desktop)
|
||||
}?.let { return it }
|
||||
val picked = entries.any { it != NONE && it.id == remembered }
|
||||
return when {
|
||||
desktopSession -> KEYBOARD_TRACKPAD
|
||||
deckPad -> DECK_CONTROLS
|
||||
dashboard && !picked -> DASHBOARD
|
||||
else -> KEYBOARD_TRACKPAD
|
||||
@@ -52,7 +44,4 @@ object SecondScreenDisplays {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
|
||||
(listOf(selected) + available).distinct().filterNot(taken)
|
||||
}
|
||||
@@ -19,20 +19,11 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
private var published = false
|
||||
private var shared: String? = null
|
||||
private var echo: String? = null
|
||||
private var capture: ((String?) -> Unit)? = null
|
||||
private var captureBefore: String? = null
|
||||
private var captureLate: (() -> Unit)? = null
|
||||
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh(changed = true) }
|
||||
private val guestListener = object : WaylandCompositor.ClipboardListener {
|
||||
override fun onGuestClipboardText(text: String) {
|
||||
handler.post {
|
||||
val taken = capture?.takeIf { text != captureBefore }
|
||||
if (taken != null && WaylandCompositor.isClipboardListener(this)) {
|
||||
capture = null
|
||||
handler.removeCallbacks(captureTimeout)
|
||||
WaylandCompositor.setClipboardText(shared)
|
||||
taken(text)
|
||||
} else if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
|
||||
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
|
||||
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
|
||||
shared = text
|
||||
published = true
|
||||
@@ -45,21 +36,6 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
private val captureTimeout = Runnable {
|
||||
val taken = capture ?: return@Runnable
|
||||
capture = null
|
||||
captureLate?.invoke()
|
||||
taken(null)
|
||||
}
|
||||
|
||||
fun captureGuest(timeoutMs: Long, onTimeout: () -> Unit = {}, onText: (String?) -> Unit) {
|
||||
capture?.let { handler.removeCallbacks(captureTimeout); it(null) }
|
||||
capture = onText
|
||||
captureLate = onTimeout
|
||||
captureBefore = shared
|
||||
handler.postDelayed(captureTimeout, timeoutMs)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (active) return
|
||||
active = true
|
||||
@@ -71,8 +47,6 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
fun stop() {
|
||||
if (!active) return
|
||||
active = false
|
||||
handler.removeCallbacks(captureTimeout)
|
||||
capture?.let { capture = null; it(null) }
|
||||
clipboard.removePrimaryClipChangedListener(androidListener)
|
||||
WaylandCompositor.clearClipboardListener(guestListener)
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
|
||||
@@ -20,11 +20,7 @@ import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class SteamControllerBle(private val context: Context, router: InputRouter? = null) {
|
||||
private val inputLock = router ?: Any()
|
||||
private var routedInput = router?.let(::TritonInputRouter)
|
||||
@Volatile private var stopped = false
|
||||
private var started = false
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
@@ -40,54 +36,27 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
private var outputWritePending = false
|
||||
private var featureRead = 0L
|
||||
|
||||
fun start(): Unit = synchronized(inputLock) {
|
||||
if (stopped || started) return@synchronized
|
||||
started = true
|
||||
synchronized(slotLock) {
|
||||
slotOwner = this
|
||||
connected = false
|
||||
FakeInputWriter.writeTritonState(SLOT, 0x45, ByteArray(STATE_BYTES))
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
}
|
||||
handler.post {
|
||||
if (stopped || slotOwner !== this || running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
fun start() = handler.post {
|
||||
if (running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
|
||||
private inline fun withSlot(block: () -> Unit) = synchronized(inputLock) {
|
||||
synchronized(slotLock) {
|
||||
if (!stopped && slotOwner === this) block()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(inputLock) {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
routedInput?.close()
|
||||
routedInput = null
|
||||
synchronized(slotLock) {
|
||||
if (slotOwner === this) {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
slotOwner = null
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
fun stop() = handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (stopped || slotOwner !== this || !running || gatt != null) return
|
||||
if (!running || gatt != null) return
|
||||
if (android.os.Build.VERSION.SDK_INT >= 31 &&
|
||||
!com.droiddeck.launcher.core.AppPermissions.granted(context, android.Manifest.permission.BLUETOOTH_CONNECT)) {
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
if (SessionState.suspended) {
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
@@ -120,17 +89,11 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
featureRead = 0L
|
||||
queuedWrites.clear()
|
||||
outputReports.clear()
|
||||
synchronized(inputLock) {
|
||||
if (!stopped) {
|
||||
release()
|
||||
withSlot {
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
g.disconnect()
|
||||
g.close()
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
runCatching { g.disconnect() }
|
||||
runCatching { g.close() }
|
||||
release()
|
||||
Log.i(TAG, "disconnected: $why")
|
||||
listener?.invoke()
|
||||
}
|
||||
@@ -161,12 +124,10 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
withSlot {
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
FakeInputWriter.setTritonConnected(SLOT, connected)
|
||||
}
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
FakeInputWriter.setTritonConnected(SLOT, connected)
|
||||
listener?.invoke()
|
||||
if (connected) {
|
||||
refreshLizardMode()
|
||||
@@ -208,7 +169,6 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
}
|
||||
|
||||
private fun subscribe(g: BluetoothGatt) {
|
||||
if (stopped || slotOwner !== this) return
|
||||
val service = g.getService(SERVICE)
|
||||
if (service == null) {
|
||||
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
|
||||
@@ -252,31 +212,25 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
|
||||
}
|
||||
|
||||
private fun refreshLizardMode() = withSlot {
|
||||
if (!connected) return@withSlot
|
||||
private fun refreshLizardMode() {
|
||||
if (!connected) return
|
||||
val msg = ByteArray(FEATURE_BYTES)
|
||||
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
|
||||
enqueueOutput(REPORT, msg)
|
||||
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
|
||||
}
|
||||
|
||||
private fun onState(data: ByteArray) = withSlot {
|
||||
if (data.size < STATE_BYTES) return@withSlot
|
||||
val timing = InputTiming.begin()
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, routedInput?.route(data) ?: data)
|
||||
InputTiming.end(timing)
|
||||
private fun onState(data: ByteArray) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
}
|
||||
|
||||
private fun release() = synchronized(inputLock) {
|
||||
if (stopped) return@synchronized
|
||||
routedInput?.release()
|
||||
withSlot {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
private fun release() {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
|
||||
private fun pollOutput() = withSlot {
|
||||
if (!running || !connected || gatt == null) return@withSlot
|
||||
private fun pollOutput() {
|
||||
if (!running || !connected || gatt == null) return
|
||||
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
|
||||
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
|
||||
@@ -290,7 +244,7 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
|
||||
}
|
||||
|
||||
private fun onFeatureRead(value: ByteArray?) = withSlot {
|
||||
private fun onFeatureRead(value: ByteArray?) {
|
||||
outputWritePending = false
|
||||
if (featureRead != 0L) {
|
||||
if (value == null) Log.w(TAG, "feature read failed")
|
||||
@@ -318,14 +272,14 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun writeNextOutput() = withSlot {
|
||||
if (outputWritePending) return@withSlot
|
||||
val g = gatt ?: return@withSlot
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return@withSlot
|
||||
private fun writeNextOutput() {
|
||||
if (outputWritePending) return
|
||||
val g = gatt ?: return
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
|
||||
if (characteristic == null) {
|
||||
Log.w(TAG, "BLE output characteristic $uuid disappeared")
|
||||
return@withSlot
|
||||
return
|
||||
}
|
||||
if (value == null) {
|
||||
if (g.readCharacteristic(characteristic)) {
|
||||
@@ -334,7 +288,7 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
queuedWrites.addFirst(uuid to null)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
}
|
||||
return@withSlot
|
||||
return
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@@ -350,8 +304,6 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val slotLock = Any()
|
||||
@Volatile private var slotOwner: SteamControllerBle? = null
|
||||
private const val TAG = "SteamControllerBle"
|
||||
private const val SLOT = 0
|
||||
private const val RETRY_MS = 5_000L
|
||||
|
||||
@@ -197,7 +197,7 @@ object SteamTouchConfig {
|
||||
}
|
||||
return try {
|
||||
val text = file.readText()
|
||||
val kv = KeyValues.parse(text.removePrefix(""))
|
||||
val kv = KeyValues.parse(text.removePrefix("\uFEFF"))
|
||||
val mappings = kv.child("controller_mappings") ?: kv
|
||||
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
|
||||
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
|
||||
|
||||
@@ -991,7 +991,7 @@ class SteamTouchControls(
|
||||
BIG_PICTURE -> "Steam"
|
||||
else -> gameNames[appId] ?: run {
|
||||
val app = appId
|
||||
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app))
|
||||
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app).toString())
|
||||
loader.execute {
|
||||
val name = SteamTouchConfig.gameName(context, app) ?: return@execute
|
||||
handler.post { gameNames[app] = name; invalidate() }
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class TritonInputRouter(private val router: InputRouter) {
|
||||
private val channels = InputControl.entries.map { router.channel(InputRole.PHYSICAL, it) }.toTypedArray()
|
||||
private var last: ByteArray? = null
|
||||
private var buffer = ByteArray(45)
|
||||
private val snapshot: () -> Unit = { last?.let { read(it, false) } }
|
||||
|
||||
init { router.beforeProfileChange(snapshot) }
|
||||
|
||||
fun route(data: ByteArray): ByteArray = synchronized(router) {
|
||||
if (data.size < 45) return@synchronized data
|
||||
last = data
|
||||
if (channels.all { it.nativeIdentity() }) return@synchronized data
|
||||
if (buffer.size != data.size) buffer = ByteArray(data.size)
|
||||
data.copyInto(buffer)
|
||||
putBits(buffer, bits(data) and CONTROL_BITS.inv())
|
||||
for (offset in 5..27 step 2) putShort(buffer, offset, 0)
|
||||
read(data, true)
|
||||
buffer
|
||||
}
|
||||
|
||||
private fun read(data: ByteArray, emit: Boolean) {
|
||||
val bits = bits(data)
|
||||
for (control in InputControl.entries) {
|
||||
val channel = channels[control.ordinal]
|
||||
val offset = offset(control)
|
||||
val pair = control.dimensions == 2
|
||||
val x = if (offset != 0) axis(data, offset) else if (bits and bit(control) != 0) 1f else 0f
|
||||
val y = if (pair) axis(data, offset + 2) * if (control.isTrackpad) 1f else -1f else 0f
|
||||
val contact = control.isTrackpad && bits and bit(control) != 0
|
||||
if (!emit) { channel.observe(x, y, contact); continue }
|
||||
val target = channel.update(x, y, contact) ?: continue
|
||||
val targetOffset = offset(target)
|
||||
if (targetOffset != 0) {
|
||||
val value = shortValue(x)
|
||||
if (target.dimensions == 2) {
|
||||
val second = shortValue(y * if (target.isTrackpad) 1f else -1f)
|
||||
val oldX = short(buffer, targetOffset).toLong()
|
||||
val oldY = short(buffer, targetOffset + 2).toLong()
|
||||
if (value.toLong() * value + second.toLong() * second >= oldX * oldX + oldY * oldY) {
|
||||
putShort(buffer, targetOffset, value)
|
||||
putShort(buffer, targetOffset + 2, second)
|
||||
}
|
||||
val touch = if (control.isTrackpad) contact else bits and bit(control) != 0
|
||||
if (touch) putBits(buffer, bits(buffer) or bit(target))
|
||||
} else {
|
||||
if (value >= short(buffer, targetOffset)) putShort(buffer, targetOffset, value)
|
||||
if (bits and bit(control) != 0) putBits(buffer, bits(buffer) or bit(target))
|
||||
}
|
||||
} else if (x > 0.5f) putBits(buffer, bits(buffer) or bit(target))
|
||||
if (target == InputControl.LEFT_CLICK || target == InputControl.RIGHT_CLICK) {
|
||||
val pressureOffset = if (target == InputControl.LEFT_CLICK) 21 else 27
|
||||
val from = if (control == InputControl.LEFT_CLICK) 21 else if (control == InputControl.RIGHT_CLICK) 27 else 0
|
||||
if (from != 0) putShort(buffer, pressureOffset, short(data, from))
|
||||
else if (x > 0.5f) putShort(buffer, pressureOffset, 32767)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() = synchronized(router) { channels.forEach { it.release() }; last = null }
|
||||
fun close() = synchronized(router) { release(); channels.forEach { it.close() }; router.removeSnapshot(snapshot) }
|
||||
|
||||
private fun bits(data: ByteArray): Int = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8) or
|
||||
((data[3].toInt() and 255) shl 16) or ((data[4].toInt() and 255) shl 24)
|
||||
private fun putBits(data: ByteArray, value: Int) { for (i in 0..3) data[i + 1] = (value ushr (i * 8)).toByte() }
|
||||
private fun short(data: ByteArray, offset: Int) = ((data[offset].toInt() and 255) or (data[offset + 1].toInt() shl 8)).toShort().toInt()
|
||||
private fun axis(data: ByteArray, offset: Int): Float = short(data, offset).let { it / if (it < 0) 32768f else 32767f }
|
||||
private fun shortValue(value: Float) = (value * if (value < 0f) 32768f else 32767f).roundToInt().coerceIn(-32768, 32767)
|
||||
private fun putShort(data: ByteArray, offset: Int, value: Int) { data[offset] = value.toByte(); data[offset + 1] = (value shr 8).toByte() }
|
||||
|
||||
companion object {
|
||||
private const val CONTROL_BITS = 0x0fffffff
|
||||
private fun bit(c: InputControl): Int = when (c) {
|
||||
InputControl.A -> 0x1; InputControl.B -> 0x2; InputControl.X -> 0x4; InputControl.Y -> 0x8
|
||||
InputControl.QAM -> 0x10; InputControl.R3 -> 0x20; InputControl.START -> 0x40
|
||||
InputControl.R4 -> 0x80; InputControl.R5 -> 0x100; InputControl.RB -> 0x200
|
||||
InputControl.DOWN -> 0x400; InputControl.RIGHT -> 0x800; InputControl.LEFT -> 0x1000; InputControl.UP -> 0x2000
|
||||
InputControl.SELECT -> 0x4000; InputControl.L3 -> 0x8000; InputControl.GUIDE -> 0x10000
|
||||
InputControl.L4 -> 0x20000; InputControl.L5 -> 0x40000; InputControl.LB -> 0x80000
|
||||
InputControl.RS -> 0x100000; InputControl.RIGHT_PAD -> 0x200000; InputControl.RIGHT_CLICK -> 0x400000
|
||||
InputControl.RT -> 0x800000; InputControl.LS -> 0x1000000; InputControl.LEFT_PAD -> 0x2000000
|
||||
InputControl.LEFT_CLICK -> 0x4000000; InputControl.LT -> 0x8000000
|
||||
}
|
||||
private fun offset(c: InputControl): Int = when (c) {
|
||||
InputControl.LT -> 5; InputControl.RT -> 7; InputControl.LS -> 9; InputControl.RS -> 13
|
||||
InputControl.LEFT_PAD -> 17; InputControl.RIGHT_PAD -> 23
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,7 @@ object BwrapSpawner {
|
||||
val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull()
|
||||
if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) }
|
||||
plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) }
|
||||
LinuxRuntime.bindComponentBundles(context, plan.root, cmd)
|
||||
cmd.add(plan.envTool)
|
||||
cmd.add("-i")
|
||||
plan.env.forEach { (k, v) -> cmd.add("$k=$v") }
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.ComponentDownloads
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.core.ArchivePaths
|
||||
import java.io.BufferedInputStream
|
||||
@@ -101,7 +101,7 @@ object DeckyManager {
|
||||
}
|
||||
|
||||
fun releaseChannels(context: Context): ReleaseChannels {
|
||||
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
|
||||
val body = ComponentDownloads.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
|
||||
return try {
|
||||
val array = JSONArray(body)
|
||||
val stable = mutableListOf<Release>()
|
||||
@@ -141,11 +141,11 @@ object DeckyManager {
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "PluginLoader.download")
|
||||
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
|
||||
val ok = Downloader.downloadFile(release.url, temp, true, release.digest?.substringAfter(':')) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
val ok = ComponentDownloads.downloadFile(release.url, temp, true, release.digest?.substringAfter(':')) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
|
||||
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
|
||||
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
|
||||
val checksumBody = Downloader.downloadDirect(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
|
||||
val checksumBody = ComponentDownloads.downloadDirect(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
|
||||
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
|
||||
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
|
||||
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
|
||||
@@ -270,7 +270,7 @@ object DeckyManager {
|
||||
downloadingBinary = true
|
||||
progress(downloading, -1)
|
||||
val downloaded = try {
|
||||
Downloader.downloadFile(url, temp, true, hash) { fraction ->
|
||||
ComponentDownloads.downloadFile(url, temp, true, hash) { fraction ->
|
||||
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
|
||||
}
|
||||
} finally {
|
||||
|
||||
Loaded 100 of 654 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user