Compare commits

..
33 Commits
Author SHA1 Message Date
maxjivi05 4798ee84d5 fix(playstore): translate the strings merged from main and clear release lint
Every locale gets the 77 strings that main added without translations.
The percent HUD labels are marked unformatted, O_CLOEXEC is only used
from API 27, and the top-desktop key route matches the other
RestrictedApi call sites.
2026-11-06 07:35:58 -05:00
maxjivi05 95cb9159de Merge main into playstore for 0.6.9.2 (version code 20)
Brings the main fixes and features since 8a8bb2053 to the Play line.
Main's new code keeps the Play gates: bundled PRoot, AddonPack and
ComponentDownloads gates, the SDK guards, PackageInfoCompat and the
accessibility consent check, which now also covers system-key capture.
The accessibility disclosure is rewritten in every locale to describe
the hardware-keyboard keys it keeps in a session. The MaliBridge server
task uses the AGP NDK provider. Review notes are in tools/playstore/POLICY.md.
2026-11-06 06:15:35 -05:00
maxjivi05 6ea7b10d94 fix: drain archive padding before waiting on decoders 2026-11-04 14:13:56 -05:00
maxjivi05 527fadd3b3 ci: allow cold Play component preparation to finish 2026-11-04 14:04:39 -05:00
maxjivi05 6affa87442 test: stabilize hosted dependency and Vulkan checks 2026-11-04 13:07:41 -05:00
maxjivi05 5d10c8e96d PlayStore: deliver runtime modules and resources on demand
Preserve component file inventories, reuse setup controls, and keep Play-managed resources behind a read-only guest stream. Build version 0.6.9.2 with version code 19 and support large signed release assets.
2026-11-03 13:27:50 -05:00
Kirk d25f6940ee fix: the navigation bar stays dark when the launcher is not fullscreen
From Android 14 the Material theme leaves the navigation bar
transparent, so with launcher fullscreen off a white system bar showed
under the dark launcher. AppTheme now sets the navigation bar to black,
matching the status bar.
2026-11-03 13:25:08 -05:00
Kurt Himebauch d1707dc1b7 fix: Project Zomboid runs on G1 and the C1 JIT instead of ZGC (#2016) 2026-11-03 13:25:08 -05:00
MaxsTechReview 6588185e96 fix(runtime): keep the Linux desktop installed across a runtime update (#2021)
* fix(runtime): keep the Linux desktop installed across a runtime update

A runtime update replaces the whole runtime tree, and the desktop pack
is built against one runtime, so every update removed the installed
desktop. Since the runtime started updating by itself, this happened
without the user doing anything: the next Open showed "Install & open"
and downloaded the whole desktop again.

When the desktop was installed before the update, the same update job
now installs the new runtime's desktop pack right after the runtime,
with the update's progress. If that install fails, the runtime update
still counts as done and Open offers the install as before. A repair
from the session no longer installs the desktop a second time when the
runtime install already did.

* fix(runtime): roll back a failed desktop restore after a runtime update

If putting the desktop back fails after its archive started unpacking,
the install journal stayed behind, and every later launch, Steam
included, tried to resume it and stopped when that failed. The update
now rolls back the partial desktop with the desktop removal, which
restores the runtime's own files and clears the journal. Open then
offers the desktop install as before.

Whether the desktop was installed is now checked on its own, so a
repair of a damaged runtime also restores it.

* fix(session): do not install the desktop again after resuming it

Opening the desktop with an interrupted desktop install resumed that
install and then installed the desktop a second time. The second
install is now skipped when the resume already completed the desktop.
2026-11-03 13:25:08 -05:00
Kurt Himebauch dcd955e175 fix: a stale session's startup failure no longer stops the newer session (#1984)
* fix: a stale session's startup failure no longer stops the newer session

* fix: stale session failures and queued-launch cleanup are checked against the current session under the stop lock
2026-11-03 13:25:08 -05:00
Kurt Himebauch 77a332e615 fix: download monitor polls off the main thread so slow storage can't ANR the session (#2020)
* fix: download monitor polls off the main thread so slow storage can't ANR the session

* fix: cancel the queued download poll before the service shuts its executor down

* fix: ignore download monitor submissions that arrive after the service shut it down
2026-11-03 13:25:08 -05:00
maxjivi05 c59884b42e Use sequential Play version codes for 0.6.9.1 2026-11-03 07:43:48 -05:00
maxjivi05 d2298f4109 Use the pinned system compressor for Play payload preparation 2026-11-03 06:25:55 -05:00
maxjivi05 567ca34a48 Complete pinned Play bundle inputs and guest prerequisite delivery 2026-11-03 00:04:07 -05:00
maxjivi05 2f6e43e3d1 Prepare isolated Play bundle builds and distribution safeguards 2026-11-02 22:28:18 -05:00
maxjivi05 773d37ff21 Add privacy policy for Play distribution 2026-11-02 14:15:24 -05:00
maxjivi05 8a8bb2053c Merge main into playstore and harden SDK compatibility
Bring in the ten upstream commits through 55362d68 without rewriting contributor history.

Guard the bottom-screen accessibility watcher on API 33, remove a duplicate HOME query, translate 22 new strings across 32 locales and clean the extracted candidate when runtime installation is cancelled before replacement. Add cancellation coverage while retaining rollback protection.

Validation: 3,293 JVM tests, 1,623 Python tests (eight skips), sanitized GPU harness, release APK and app bundle builds, full lint with zero errors, runtime assets and native dependency/export checks.

Physical verification of this exact candidate, performance parity and native 16 KiB audio support remain pending.
2026-11-02 08:51:44 -05:00
maxjivi05 b14587baeb Target Android SDK 37 for Play Store review
Integrate current main while preserving the original SDK migration history.
Update the Android toolchain, runtime permissions, foreground service lifecycle, storage access, native execution and platform API compatibility. Complete translations and regression coverage.

Validation: 3,260 JVM tests, 1,620 Python tests (eight skips), 171 Rust tests, six native regression programs and release lint with zero errors. Signed APK upgrade installed on the requested device. Signed app bundle validated with bundletool.

Review candidate: physical before/after feature checks, performance parity and native 16 KiB audio compatibility remain pending. Play Console acceptance has not been verified.
2026-11-02 08:17:51 -05:00
Kurt Himebauch 28a5423629 Merge main and compile and target Android API 36 2026-10-23 10:32:35 -04:00
Kurt Himebauch 74977d1738 Merge current main into PR #564 2026-10-22 13:11:02 -04:00
Kurt Himebauch 184409f33c Merge current main into PR #564 2026-10-22 12:13:46 -04:00
xXJsonDeruloXx 42239e0097 fix: repair Android 36 runtime edge cases 2026-10-22 10:46:06 -04:00
xXJsonDeruloXx c117f6f4c7 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-18 16:14:27 -04:00
xXJsonDeruloXx 4a93663d14 chore: merge main to resolve conflicts with the API 36 target 2026-10-18 16:09:55 -04:00
xXJsonDeruloXx 1ca2d9bd49 Remove stale comments from statx patch 2026-10-13 21:44:00 -04:00
xXJsonDeruloXx c8baca10e6 Merge origin/main into feat/target-sdk-36 2026-10-13 21:38:55 -04:00
xXJsonDeruloXx 9922823090 fix: the Setup permission rows refresh when the app returns to the front 2026-10-11 10:44:19 -04:00
xXJsonDeruloXx a760aea276 perf: relocated Wine code is shared between processes through a per-session cache file 2026-10-11 10:08:10 -04:00
xXJsonDeruloXx e079f9602f fix: a session starts from the last added-games listing and refreshes it in the background 2026-10-11 10:08:10 -04:00
xXJsonDeruloXx dfc0b95557 feat: ask for all files access and offer storage and Bluetooth permissions in Setup 2026-10-11 10:08:10 -04:00
xXJsonDeruloXx 30270ca654 test: pin Robolectric to SDK 35 for the Java 17 build 2026-10-11 02:07:23 -04:00
xXJsonDeruloXx 8c16a04d8d fix: data sync services stop cleanly when Android 15 ends their daily time 2026-10-11 02:07:23 -04:00
xXJsonDeruloXx 0fd50d76b2 feat: target Android API 36 2026-10-11 02:07:23 -04:00
654 changed files with 27626 additions and 23412 deletions

No files matched your search

+3 -17
View File
@@ -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
View File
@@ -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
-60
View File
@@ -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"
+199
View File
@@ -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
+2 -10
View File
@@ -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
-57
View File
@@ -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"
+18 -2
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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.
+3 -5
View File
@@ -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
View File
@@ -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") }
}
}
+41 -8
View File
@@ -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"
+5 -9
View File
@@ -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,
) {
}
@@ -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