mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-08 08:04:47 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb279f635 | ||
|
|
3fef1f3fa1 | ||
|
|
a4f26799ab | ||
|
|
08a6a3cba8 | ||
|
|
005fec2125 | ||
|
|
2e70b8ed73 | ||
|
|
74c0f2127a | ||
|
|
2bd7df5e1a | ||
|
|
b7b96875a1 | ||
|
|
51e5e3a2a4 | ||
|
|
c755d05473 | ||
|
|
aa883830ca | ||
|
|
45e39b7aaf | ||
|
|
bc99cacd02 | ||
|
|
2beeb715fd | ||
|
|
39469e6e8f | ||
|
|
15baa296bb | ||
|
|
e4a4775be5 | ||
|
|
77ef10d601 | ||
|
|
914b3588e1 | ||
|
|
2ade269be3 | ||
|
|
0d034b1996 | ||
|
|
ef46c011db | ||
|
|
5b2f7a9a6d | ||
|
|
10cfc6c6b2 | ||
|
|
4948f73912 | ||
|
|
d29a1a2dc2 | ||
|
|
8fa30b99f9 | ||
|
|
a33954b8bd | ||
|
|
3526309f88 | ||
|
|
be0fe35271 | ||
|
|
7f0427b830 | ||
|
|
2476c824f6 | ||
|
|
d74575ed7f | ||
|
|
e4d4096342 | ||
|
|
11218f0b24 | ||
|
|
d9bf6d8a17 | ||
|
|
041c3b1b55 | ||
|
|
e5434117f2 | ||
|
|
b474cba79e | ||
|
|
ffb76240fd | ||
|
|
875f8e3ce2 | ||
|
|
5446497301 | ||
|
|
98a670fb81 | ||
|
|
a123af7c13 | ||
|
|
6026d72435 | ||
|
|
d5f3d1a0d5 | ||
|
|
4cb3a89483 | ||
|
|
22a675a664 | ||
|
|
ca6c409436 | ||
|
|
7c429f3a26 | ||
|
|
aba55031c3 | ||
|
|
18c7d56409 | ||
|
|
0b4bc827e7 | ||
|
|
a552f1e293 | ||
|
|
066834d17d | ||
|
|
aff9738748 | ||
|
|
f33d5b9e09 | ||
|
|
281c2aea1b | ||
|
|
c2d7765619 | ||
|
|
250f80fbb5 | ||
|
|
08526f8283 | ||
|
|
f44826ae3a | ||
|
|
31b8adbb55 | ||
|
|
542bbf89c3 | ||
|
|
e1987ca785 | ||
|
|
a81da1e476 | ||
|
|
116728917f | ||
|
|
5f180d909b | ||
|
|
88acdbc3ab | ||
|
|
42b2810822 | ||
|
|
1001bc00af | ||
|
|
69c6c14f3f | ||
|
|
6060f2288f | ||
|
|
915f373d82 | ||
|
|
7b7722bd25 | ||
|
|
63004567fd | ||
|
|
aa3757eff1 | ||
|
|
a1bc8467fd | ||
|
|
d6566f0dbe | ||
|
|
ba5876bf6d | ||
|
|
68b0396dfe | ||
|
|
d6b4b0589b | ||
|
|
b245017593 | ||
|
|
ed121f247c | ||
|
|
92b4361100 | ||
|
|
5ee3ed07be | ||
|
|
884671a065 | ||
|
|
1dae4bf8ee | ||
|
|
d202233beb | ||
|
|
6530faaf78 | ||
|
|
3dd6aeef46 | ||
|
|
45a496b802 | ||
|
|
ab8f41b80e |
No files matched your search
@@ -39,11 +39,25 @@ 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: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
GH_REPO: Droid-Deck/DroidDeck-Components
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
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
|
||||
@@ -136,11 +136,18 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
components:
|
||||
name: Prepare verified components
|
||||
components-runner:
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
uses: ./.github/workflows/pick-runner.yml
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
|
||||
components:
|
||||
name: Prepare verified components
|
||||
needs: [source, components-runner]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ${{ fromJSON(needs.components-runner.outputs.labels) }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -306,6 +313,11 @@ 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
|
||||
@@ -318,11 +330,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]
|
||||
needs: [source, components, xbox, build-runner]
|
||||
name: Compile APK and Android tests
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ fromJSON(needs.build-runner.outputs.labels) }}
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
@@ -544,9 +563,11 @@ jobs:
|
||||
- name: Cache checksum-pinned pack inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
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-
|
||||
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-
|
||||
- name: Build the add-on packs
|
||||
run: |
|
||||
python3 tools/powervr/fetch.py
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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"
|
||||
@@ -47,10 +47,18 @@ 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 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
|
||||
if grep -qxF "$tag" <<<"$existing"; then
|
||||
retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
echo "Deleted $tag"
|
||||
found=1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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"
|
||||
@@ -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 source withheld.</b></p>
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. 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>
|
||||
@@ -79,7 +79,8 @@ 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.
|
||||
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
|
||||
- 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 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`.
|
||||
- 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).
|
||||
@@ -94,6 +95,7 @@ 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).
|
||||
|
||||
+2
-2
@@ -131,8 +131,8 @@ android {
|
||||
applicationId "com.droiddeck.launcher"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
versionCode 17
|
||||
versionName "0.6.9"
|
||||
versionCode 18
|
||||
versionName "0.7.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
|
||||
@@ -195,7 +195,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".PinRequestActivity"
|
||||
android:exported="true"
|
||||
@@ -208,13 +207,6 @@
|
||||
</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"
|
||||
|
||||
@@ -2953,15 +2953,19 @@ 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;
|
||||
mode_t mode = 0;
|
||||
int fd;
|
||||
bool hasMode;
|
||||
|
||||
va_start(va, flags);
|
||||
|
||||
hasMode = flags & O_CREAT;
|
||||
hasMode = open_has_mode(flags);
|
||||
|
||||
if (hasMode) {
|
||||
mode = va_arg(va, mode_t);
|
||||
@@ -3033,13 +3037,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;
|
||||
mode_t mode = 0;
|
||||
int fd;
|
||||
bool hasMode;
|
||||
|
||||
va_start(va, flags);
|
||||
|
||||
hasMode = flags & O_CREAT;
|
||||
hasMode = open_has_mode(flags);
|
||||
|
||||
if (hasMode) {
|
||||
mode = va_arg(va, mode_t);
|
||||
@@ -4251,7 +4255,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 (flags & (O_CREAT | O_TMPFILE)) ? va_arg(va, mode_t) : 0;
|
||||
return open_has_mode(flags) ? va_arg(va, mode_t) : 0;
|
||||
}
|
||||
|
||||
EXPORT int open64(const char *pathname, int flags, ...) {
|
||||
|
||||
@@ -5,8 +5,11 @@ 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)
|
||||
@@ -20,6 +23,10 @@ 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) {
|
||||
@@ -29,7 +36,14 @@ 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) {
|
||||
@@ -42,6 +56,35 @@ 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,10 +79,12 @@ 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)
|
||||
@@ -127,8 +129,14 @@ 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 (SessionState.running) return
|
||||
if (restartBlocked()) 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"))
|
||||
@@ -595,11 +603,19 @@ 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 -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
else -> DriverBundle.selectDisplay(activity, id)
|
||||
}
|
||||
pairModeOpen = false
|
||||
setMode(auto = false, mix = true)
|
||||
|
||||
@@ -23,6 +23,7 @@ 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.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,6 +41,8 @@ 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
|
||||
@@ -61,9 +64,7 @@ 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
|
||||
@@ -94,7 +95,11 @@ 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) { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null }
|
||||
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 var installed by mutableStateOf<String?>(null)
|
||||
private var flood by mutableStateOf<PendingFlood?>(null)
|
||||
@@ -196,11 +201,13 @@ 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)
|
||||
@@ -618,6 +625,7 @@ 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 {
|
||||
@@ -866,6 +874,7 @@ 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)
|
||||
@@ -881,7 +890,15 @@ class FrontEndActivity : ComponentActivity() {
|
||||
putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
|
||||
}
|
||||
}, steamSession = true)
|
||||
}
|
||||
.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) }
|
||||
}
|
||||
|
||||
private fun Intent.returnTo(returnHome: Boolean): Intent {
|
||||
@@ -908,14 +925,17 @@ 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, returnHome) { pendingSwitch = next } -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1077,6 +1097,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
drivers.restartBlocked = ::runtimeChangesBlocked
|
||||
drivers.loadTpUbwcMode()
|
||||
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
|
||||
@@ -1103,6 +1125,7 @@ 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)
|
||||
@@ -1113,6 +1136,10 @@ 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) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
|
||||
@@ -1137,6 +1164,7 @@ 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,
|
||||
@@ -1148,6 +1176,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
|
||||
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,
|
||||
@@ -1167,6 +1196,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
dualScreenMode = dualScreenMode,
|
||||
dualScreenSessions = dualScreenSessions,
|
||||
dualScreenControls = dualScreenControls,
|
||||
androidTextFieldKeyboard = androidTextFieldKeyboard,
|
||||
packages = packageRows,
|
||||
packageCatalogLoading = catalogLoading,
|
||||
packageBusyId = pkgId,
|
||||
@@ -1189,6 +1219,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
downloadPrefix = downloadPrefix,
|
||||
downloadProxy = downloadProxy,
|
||||
animationsEnabled = animationsEnabled,
|
||||
lowStorageWarning = lowStorageWarning,
|
||||
launcherIcon = launcherIcon,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
@@ -1215,14 +1246,26 @@ class FrontEndActivity : ComponentActivity() {
|
||||
},
|
||||
onSteamGame = { g ->
|
||||
if (shortcutPicker) chooseGameShortcut(g)
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "store-play").start()
|
||||
}
|
||||
},
|
||||
onEpicLauncher = { openEpicLauncher() },
|
||||
@@ -1380,6 +1423,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
},
|
||||
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)
|
||||
@@ -1483,6 +1528,14 @@ 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
|
||||
@@ -1582,23 +1635,7 @@ 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,
|
||||
@@ -1648,6 +1685,8 @@ 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 },
|
||||
@@ -1743,8 +1782,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
private fun playSteam() {
|
||||
val storage = com.droiddeck.launcher.storage.StorageState
|
||||
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
|
||||
lowSpace = storage.freeBytes(this)
|
||||
val free = storage.freeBytes(this)
|
||||
if (storage.shouldWarnLowSpace(SessionPrefs.lowStorageWarning(this), storage.lowSpaceDismissed, SessionState.running, free)) {
|
||||
lowSpace = free
|
||||
storage.refresh(this)
|
||||
} else startSteamSession()
|
||||
}
|
||||
@@ -1880,6 +1920,13 @@ class FrontEndActivity : ComponentActivity() {
|
||||
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()
|
||||
@@ -1942,6 +1989,18 @@ 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() {
|
||||
gameLaunchRequest.incrementAndGet()
|
||||
cancelCompatLaunch()
|
||||
@@ -2005,6 +2064,7 @@ 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()
|
||||
@@ -2097,13 +2157,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun MappingHost() {
|
||||
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 },
|
||||
)
|
||||
com.droiddeck.launcher.ui.InputSettingsPage(onBack = { showMapping = false })
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
|
||||
@@ -2127,9 +2181,11 @@ 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(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() }
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2153,7 +2209,15 @@ 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() },
|
||||
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) },
|
||||
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,
|
||||
@@ -2177,7 +2241,6 @@ 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,
|
||||
@@ -2230,14 +2293,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) { pendingSwitch = next } -> {}
|
||||
handToBattleNet(full, next) -> {}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
}, "battlenet-open").start()
|
||||
}
|
||||
|
||||
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
|
||||
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): 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)
|
||||
@@ -2248,10 +2311,13 @@ 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) 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))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}, "battlenet-request").start()
|
||||
return true
|
||||
@@ -2389,6 +2455,7 @@ 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)
|
||||
@@ -2982,6 +3049,7 @@ 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) }
|
||||
@@ -3008,6 +3076,7 @@ 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()
|
||||
}
|
||||
@@ -3015,9 +3084,12 @@ 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 (release.version == installed || installed == null) 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 (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
|
||||
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
|
||||
Log.i(TAG, "runtime: installing ${release.version} over ${installed ?: "nothing"} by itself")
|
||||
install(release)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 {
|
||||
@@ -61,6 +62,7 @@ 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
@@ -26,6 +26,10 @@ 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
|
||||
@@ -43,7 +47,7 @@ object AgentGuest {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
main.post { steamKeyboard = false; desktopTextField = false }
|
||||
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@@ -59,6 +63,7 @@ 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) }
|
||||
}
|
||||
@@ -71,7 +76,7 @@ object AgentGuest {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
main.post { steamKeyboard = false; desktopTextField = false }
|
||||
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +134,16 @@ 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"),
|
||||
|
||||
@@ -58,7 +58,7 @@ public final class Downloader {
|
||||
|
||||
private static void closeAsync(InputStream active) {
|
||||
CANCEL_EXECUTOR.execute(() -> {
|
||||
try { active.close(); } catch (IOException ignored) { }
|
||||
try { active.close(); } catch (IOException | IllegalStateException ignored) { }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ 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)
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ object StorageRoots {
|
||||
.filter { present(it.state) }
|
||||
.forEachIndexed { index, volume ->
|
||||
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
|
||||
val dirName = volume.directory?.name?.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 descSlug = volume.getDescription(context)?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
|
||||
@@ -98,9 +99,7 @@ object StorageRoots {
|
||||
entry.removable = !volume.isPrimary && volume.isRemovable
|
||||
if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
volume.directory?.let { entry.candidates += it }
|
||||
}
|
||||
directory?.let { entry.candidates += it }
|
||||
val name = uuid ?: dirName
|
||||
if (name != null) {
|
||||
entry.candidates += File("/storage/$name")
|
||||
|
||||
@@ -475,15 +475,15 @@ object AutoComponents {
|
||||
return true
|
||||
}
|
||||
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame, onBlocked: () -> Unit = {}, 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,11 +18,13 @@ 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)
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
|
||||
|
||||
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)
|
||||
@@ -30,6 +32,7 @@ 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"
|
||||
@@ -220,6 +223,18 @@ 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()
|
||||
@@ -248,12 +263,13 @@ 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 = lastPlayed, gameFiles = g.folder,
|
||||
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), 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,6 +27,7 @@ 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) }
|
||||
@@ -44,7 +45,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).apply { g.storeId?.let { put("storeId", it) } },
|
||||
.put("source", g.source).put("installedAt", g.installedAt).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).orEmpty()
|
||||
fun current(context: Context, appId: Int): String? =
|
||||
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId)
|
||||
|
||||
enum class Saved { YES, NO, DESKTOP }
|
||||
|
||||
@@ -35,7 +35,8 @@ 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
|
||||
return launchOptions(text, appId)
|
||||
if (section(text, STEAM) == null) return null
|
||||
return launchOptions(text, appId).orEmpty()
|
||||
}
|
||||
|
||||
internal fun write(steamHome: File, appId: Int, options: String): Boolean {
|
||||
|
||||
@@ -27,12 +27,11 @@ object AutoPicks {
|
||||
}
|
||||
|
||||
val BUILT_IN = listOf(
|
||||
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.A8XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)),
|
||||
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
|
||||
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_ONEUI)),
|
||||
Rule(setOf(Family.A7XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.A6XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)),
|
||||
)
|
||||
|
||||
@@ -159,6 +159,12 @@ 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)
|
||||
|
||||
@@ -97,6 +97,7 @@ 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()) {
|
||||
@@ -114,12 +115,13 @@ 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
|
||||
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
found.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)
|
||||
@@ -127,7 +129,8 @@ object TurnipReleases {
|
||||
}
|
||||
}
|
||||
if (failed.size == sources.size) throw lastError ?: IOException("no source answered")
|
||||
runCatching { get(context, AutoPicks.URL, AutoPicks.URL) }
|
||||
carryOver(failed, prefs(context).getString(KEY_RELEASE, null)?.let { runCatching { JSONObject(it) }.getOrNull() }, assets, latest)
|
||||
runCatching { fetch(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() }
|
||||
@@ -137,12 +140,22 @@ 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 = get(context, source, url)
|
||||
val body = fetch(context, source, url)
|
||||
DownloadSource.succeeded(source)
|
||||
return body
|
||||
} catch (e: IOException) {
|
||||
|
||||
@@ -141,6 +141,8 @@ 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()
|
||||
@@ -274,9 +276,17 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
seenFpsUpdates = updates
|
||||
periodDone.trySend(Unit)
|
||||
}
|
||||
if (graphRect != null) invalidate()
|
||||
if (graphRect == null) return
|
||||
if (stripRect != null) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (now - lastGraphDrawMs < STRIP_GRAPH_REDRAW_MS) return
|
||||
lastGraphDrawMs = now
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private var lastGraphDrawMs = 0L
|
||||
|
||||
private fun cycleSize() {
|
||||
size = size.next()
|
||||
onSizeCycledListener?.accept(size.token)
|
||||
@@ -1340,10 +1350,15 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
canvas.drawText(g.text, g.x, g.baseline, paint)
|
||||
if (g.glow) paint.clearShadowLayer()
|
||||
}
|
||||
headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
|
||||
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)
|
||||
}
|
||||
headerWordmark?.let { WordmarkArt.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
|
||||
|
||||
graphRect?.let { drawGraph(canvas, it) }
|
||||
graphRect?.let {
|
||||
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedGraph(canvas, it) else drawGraph(canvas, it)
|
||||
}
|
||||
|
||||
lockController.drawBadge(canvas)
|
||||
}
|
||||
@@ -1368,18 +1383,66 @@ 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) {
|
||||
headerPaint.shader = null
|
||||
headerPaint.color = panelColor()
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
private fun outlineWidth(): Float = if (outlineIntensity > 0f) outlineIntensity * sp(3.5f) else 0f
|
||||
@@ -1499,6 +1562,7 @@ 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,6 +9,7 @@ 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
|
||||
@@ -33,6 +34,8 @@ 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 ||
|
||||
@@ -43,7 +46,7 @@ object BottomScreenGuests {
|
||||
}
|
||||
|
||||
class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private class Arrival(val pkg: String)
|
||||
private class Arrival(val pkg: String, val at: Long)
|
||||
|
||||
private val thread = HandlerThread("bottom-screen").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
@@ -52,6 +55,8 @@ 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() {
|
||||
@@ -72,7 +77,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)
|
||||
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
|
||||
else -> return
|
||||
@@ -97,19 +102,25 @@ 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 }
|
||||
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]
|
||||
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
|
||||
.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) }
|
||||
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
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
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 = p.getBoolean("snap", true),
|
||||
snap = snap(context),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
deadZone = deadZone(context),
|
||||
@@ -87,11 +87,6 @@ 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()
|
||||
}
|
||||
@@ -129,10 +124,6 @@ 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()
|
||||
}
|
||||
@@ -147,6 +138,8 @@ 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()
|
||||
}
|
||||
@@ -169,62 +162,7 @@ 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,53 +5,165 @@ object DeckControls {
|
||||
const val R4 = 2
|
||||
const val L5 = 4
|
||||
const val R5 = 8
|
||||
private const val LEFT_TOUCH = 16
|
||||
private const val RIGHT_TOUCH = 32
|
||||
private const val LEFT_CLICK = 64
|
||||
private const val RIGHT_CLICK = 128
|
||||
|
||||
private const val SLOT = 0
|
||||
|
||||
private var controls = 0
|
||||
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 val pads = ShortArray(4)
|
||||
private val pressure = ShortArray(2)
|
||||
private val touchOwners = arrayOfNulls<Source>(2)
|
||||
|
||||
@Synchronized
|
||||
fun setGrip(bit: Int, down: Boolean) {
|
||||
set(bit, down)
|
||||
fun setRouter(next: InputRouter?) {
|
||||
if (router === next) return
|
||||
for (source in sources) source.closeRoutes()
|
||||
router = next
|
||||
for (source in sources) source.configure()
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
|
||||
val at = if (right) 2 else 0
|
||||
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
|
||||
pads[at] = if (touching) axis(x) else 0
|
||||
pads[at + 1] = if (touching) axis(y) else 0
|
||||
publish()
|
||||
fun clearRouter(owner: InputRouter?) {
|
||||
if (owner != null && router === owner) setRouter(null)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) {
|
||||
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
|
||||
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
|
||||
publish()
|
||||
}
|
||||
@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)
|
||||
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
|
||||
controls = 0
|
||||
pads.fill(0)
|
||||
pressure.fill(0)
|
||||
for (source in sources) source.clear()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun set(bit: Int, on: Boolean) {
|
||||
controls = if (on) controls or bit else controls and bit.inv()
|
||||
@Synchronized
|
||||
fun suspend() {
|
||||
for (source in sources) source.suspend()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
@Synchronized
|
||||
fun refresh() {
|
||||
for (source in sources) source.route()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,14 @@ 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,6 +16,7 @@ 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>()
|
||||
@@ -85,9 +86,22 @@ object DualScreen {
|
||||
when (mode) {
|
||||
MODE_OFF -> null
|
||||
MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId)
|
||||
else -> builtInDisplay(displayManager, primaryDisplayId)
|
||||
else -> if (dockedToDisplay(displayManager, primaryDisplayId)) null 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
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.input
|
||||
import android.view.MotionEvent
|
||||
|
||||
internal class GestureTouchStreamGate {
|
||||
enum class Source { SURFACE, SCREEN_KEYS, ON_SCREEN_CONTROLS }
|
||||
enum class Source { SURFACE, ON_SCREEN_CONTROLS }
|
||||
|
||||
private var owner: Source? = null
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-5
@@ -1,16 +1,49 @@
|
||||
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 ScreenKeyIcons {
|
||||
fun has(code: Int): Boolean = code in SET
|
||||
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
|
||||
|
||||
private val SET = setOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
|
||||
fun has(code: Int): Boolean = code in codes
|
||||
|
||||
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint): Boolean {
|
||||
if (!has(code)) return false
|
||||
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
|
||||
val h = box / 2f
|
||||
val stroke = box * 0.085f
|
||||
val path = Path()
|
||||
@@ -0,0 +1,43 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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,21 +89,31 @@ 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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
type(text)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -123,12 +133,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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,809 +3,181 @@ 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.RectF
|
||||
import android.graphics.Typeface
|
||||
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 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
|
||||
import android.widget.FrameLayout
|
||||
import com.droiddeck.launcher.ui.InputSurfaceView
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class OnScreenControls(
|
||||
context: Context,
|
||||
private val pad: PadBridge?,
|
||||
private val editing: Boolean = false,
|
||||
private val onKeyboard: (() -> Unit)? = null,
|
||||
) : View(context) {
|
||||
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 }
|
||||
|
||||
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
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
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 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
|
||||
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
|
||||
}
|
||||
}
|
||||
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 = Rect(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
|
||||
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 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")
|
||||
const val QUICK_CENTER_DP = 34f
|
||||
const val QUICK_HIT_DP = 26f
|
||||
const val QUICK_RADIUS_DP = 18f
|
||||
const val PLACEMENT_CACHE = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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,11 +59,25 @@ 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;
|
||||
@@ -72,6 +86,7 @@ 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;
|
||||
@@ -79,6 +94,11 @@ 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;
|
||||
@@ -119,8 +139,36 @@ public final class PadBridge {
|
||||
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
|
||||
}
|
||||
|
||||
public void setSelectSteam(boolean on) {
|
||||
selectSteam = on;
|
||||
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 setExcluded(java.util.function.IntPredicate devices) {
|
||||
@@ -147,10 +195,18 @@ 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++;
|
||||
@@ -168,6 +224,16 @@ 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();
|
||||
@@ -314,6 +380,7 @@ 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()) {
|
||||
@@ -324,9 +391,6 @@ 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;
|
||||
@@ -341,8 +405,14 @@ 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 = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; 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_DPAD_UP: pad.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
|
||||
@@ -355,6 +425,7 @@ public final class PadBridge {
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -422,6 +493,7 @@ 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++;
|
||||
@@ -438,6 +510,7 @@ 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);
|
||||
@@ -476,9 +549,20 @@ 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;
|
||||
@@ -493,41 +577,70 @@ 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(0, mutation);
|
||||
applyTouch(defaultTouchSource, InputRole.PRIMARY, 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;
|
||||
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;
|
||||
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 (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
publish();
|
||||
if ((stickWrites & RIGHT_STICK) != 0 || source.raw.rightX != rx || source.raw.rightY != ry) {
|
||||
retainedSticks[0] &= ~RIGHT_STICK;
|
||||
state.rightX = state.rightY = 0f;
|
||||
}
|
||||
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) {
|
||||
@@ -552,16 +665,30 @@ 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;
|
||||
releaseBackPaddles();
|
||||
clearPlayerState(0);
|
||||
if (inputRouter == null) releaseBackPaddles();
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
clearPlayerState(slot);
|
||||
if (inputRouter == null) clearPlayerState(slot);
|
||||
if (playerWriters[slot] == null) continue;
|
||||
publish(slot);
|
||||
}
|
||||
@@ -602,7 +729,11 @@ public final class PadBridge {
|
||||
if (!homeGuidePressed) return;
|
||||
clearBackQamWrites();
|
||||
homeGuidePressed = false;
|
||||
clearPlayerState(0);
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
else {
|
||||
inputRouter.releaseAll();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -641,7 +772,11 @@ public final class PadBridge {
|
||||
if (!backQamPressed && backQamWrites.isEmpty()) return;
|
||||
clearBackQamWrites();
|
||||
backQamPressed = false;
|
||||
clearPlayerState(0);
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
else {
|
||||
inputRouter.releaseAll();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -677,6 +812,7 @@ 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;
|
||||
@@ -684,11 +820,11 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private boolean hasQamAxes() {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
private static void clearAxes(PadState pad) {
|
||||
@@ -709,9 +845,9 @@ public final class PadBridge {
|
||||
PadState live = new PadState();
|
||||
live.copyFrom(report);
|
||||
if (queued) {
|
||||
live.leftX = state.leftX; live.leftY = state.leftY;
|
||||
live.rightX = state.rightX; live.rightY = state.rightY;
|
||||
live.leftTrigger = state.leftTrigger; live.rightTrigger = state.rightTrigger;
|
||||
live.leftX = liveReport.leftX; live.leftY = liveReport.leftY;
|
||||
live.rightX = liveReport.rightX; live.rightY = liveReport.rightY;
|
||||
live.leftTrigger = liveReport.leftTrigger; live.rightTrigger = liveReport.rightTrigger;
|
||||
}
|
||||
boolean held = (backQamPressed && report.isDown(PadState.QAM)) || (homeGuidePressed && report.isDown(PadState.GUIDE));
|
||||
if ((queued && !held) || backQamAxesBlocked) clearAxes(live);
|
||||
@@ -732,6 +868,34 @@ 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;
|
||||
@@ -856,6 +1020,7 @@ 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;
|
||||
@@ -909,6 +1074,7 @@ 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);
|
||||
@@ -918,43 +1084,75 @@ public final class PadBridge {
|
||||
}
|
||||
playerWriters[slot] = player;
|
||||
}
|
||||
PadState report = playerStates[slot];
|
||||
if (slot == homeGuideSlot) {
|
||||
homeGuideReport.copyFrom(report);
|
||||
homeGuideReport.copyFrom(output);
|
||||
homeGuideReport.press(PadState.GUIDE, true);
|
||||
report = homeGuideReport;
|
||||
output = homeGuideReport;
|
||||
}
|
||||
player.writePad(report);
|
||||
player.writePad(output);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
boolean guide = systemGuidePressed || qamChordActive || homeGuidePressed;
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
|
||||
if (!guide && !qam) {
|
||||
writePad(state);
|
||||
if (!guide && !qam && !vrStickTapPressed && (!routedQam || nativeQam())) {
|
||||
writePad(output);
|
||||
return;
|
||||
}
|
||||
effectiveState.copyFrom(state);
|
||||
effectiveState.copyFrom(output);
|
||||
if (!nativeQam()) effectiveState.press(PadState.QAM, false);
|
||||
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) {
|
||||
InputDevice device = event.getDevice();
|
||||
return wiredXboxLayout(event.getDevice(), event.getSource());
|
||||
}
|
||||
|
||||
static boolean wiredXboxLayout(InputDevice device, int source) {
|
||||
return device != null
|
||||
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|
||||
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
|
||||
&& 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;
|
||||
&& 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;
|
||||
}
|
||||
|
||||
public static float leftTrigger(MotionEvent event, boolean wired) {
|
||||
|
||||
@@ -36,6 +36,19 @@ 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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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,6 +105,10 @@ 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 {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
) {
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,140 +0,0 @@
|
||||
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,26 +1,34 @@
|
||||
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) {
|
||||
enum class SecondScreenMode(val id: String, @StringRes val label: Int, val bindings: String? = null) {
|
||||
NONE("none", R.string.second_mode_none),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad, InputStockLayouts.TRACKPAD),
|
||||
TERMINAL("terminal", R.string.second_mode_terminal),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls, InputStockLayouts.DECK),
|
||||
CUSTOM_CONTROLS("custom-controls", R.string.input_profile_controls_mode),
|
||||
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, desktop: Boolean = false): SecondScreenMode {
|
||||
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, bottomDesktop: Boolean = false, desktopSession: Boolean = false): SecondScreenMode {
|
||||
entries.firstOrNull {
|
||||
it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) &&
|
||||
(it != DESKTOP || desktop)
|
||||
(it != DESKTOP || bottomDesktop)
|
||||
}?.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
|
||||
@@ -44,4 +52,7 @@ object SecondScreenDisplays {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
|
||||
(listOf(selected) + available).distinct().filterNot(taken)
|
||||
}
|
||||
@@ -19,11 +19,20 @@ 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 {
|
||||
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
|
||||
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)) {
|
||||
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
|
||||
shared = text
|
||||
published = true
|
||||
@@ -36,6 +45,21 @@ 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
|
||||
@@ -47,6 +71,8 @@ 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,7 +20,11 @@ import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
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
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
@@ -36,22 +40,54 @@ class SteamControllerBle(private val context: Context) {
|
||||
private var outputWritePending = false
|
||||
private var featureRead = 0L
|
||||
|
||||
fun start() = handler.post {
|
||||
if (running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
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 stop() = handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (!running || gatt != null) return
|
||||
if (stopped || slotOwner !== this || !running || gatt != null) return
|
||||
if (SessionState.suspended) {
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
@@ -84,11 +120,17 @@ class SteamControllerBle(private val context: Context) {
|
||||
featureRead = 0L
|
||||
queuedWrites.clear()
|
||||
outputReports.clear()
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
synchronized(inputLock) {
|
||||
if (!stopped) {
|
||||
release()
|
||||
withSlot {
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
g.disconnect()
|
||||
g.close()
|
||||
release()
|
||||
Log.i(TAG, "disconnected: $why")
|
||||
listener?.invoke()
|
||||
}
|
||||
@@ -119,10 +161,12 @@ class SteamControllerBle(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
listener?.invoke()
|
||||
if (connected) {
|
||||
refreshLizardMode()
|
||||
@@ -164,6 +208,7 @@ class SteamControllerBle(private val context: Context) {
|
||||
}
|
||||
|
||||
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: " +
|
||||
@@ -207,25 +252,31 @@ class SteamControllerBle(private val context: Context) {
|
||||
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
|
||||
}
|
||||
|
||||
private fun refreshLizardMode() {
|
||||
if (!connected) return
|
||||
private fun refreshLizardMode() = withSlot {
|
||||
if (!connected) return@withSlot
|
||||
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) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
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 release() {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
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 pollOutput() {
|
||||
if (!running || !connected || gatt == null) return
|
||||
private fun pollOutput() = withSlot {
|
||||
if (!running || !connected || gatt == null) return@withSlot
|
||||
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))
|
||||
@@ -239,7 +290,7 @@ class SteamControllerBle(private val context: Context) {
|
||||
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
|
||||
}
|
||||
|
||||
private fun onFeatureRead(value: ByteArray?) {
|
||||
private fun onFeatureRead(value: ByteArray?) = withSlot {
|
||||
outputWritePending = false
|
||||
if (featureRead != 0L) {
|
||||
if (value == null) Log.w(TAG, "feature read failed")
|
||||
@@ -267,14 +318,14 @@ class SteamControllerBle(private val context: Context) {
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun writeNextOutput() {
|
||||
if (outputWritePending) return
|
||||
val g = gatt ?: return
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return
|
||||
private fun writeNextOutput() = withSlot {
|
||||
if (outputWritePending) return@withSlot
|
||||
val g = gatt ?: return@withSlot
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return@withSlot
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
|
||||
if (characteristic == null) {
|
||||
Log.w(TAG, "BLE output characteristic $uuid disappeared")
|
||||
return
|
||||
return@withSlot
|
||||
}
|
||||
if (value == null) {
|
||||
if (g.readCharacteristic(characteristic)) {
|
||||
@@ -283,7 +334,7 @@ class SteamControllerBle(private val context: Context) {
|
||||
queuedWrites.addFirst(uuid to null)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
}
|
||||
return
|
||||
return@withSlot
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@@ -299,6 +350,8 @@ class SteamControllerBle(private val context: Context) {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ object DesktopCatalog {
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
|
||||
const val PROTON_SEED_ID = "proton-arm64"
|
||||
const val APPIMAGE_EXEC = "/usr/local/bin/droiddeck-appimage-exec"
|
||||
|
||||
class Entry(
|
||||
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
|
||||
@@ -216,7 +217,7 @@ object DesktopCatalog {
|
||||
target.setExecutable(true, false)
|
||||
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
|
||||
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Exec=$APPIMAGE_EXEC /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -21,9 +21,11 @@ object LegacyNames {
|
||||
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
|
||||
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
|
||||
)
|
||||
private const val MENU_DIR = "usr/local/share/applications"
|
||||
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
|
||||
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
|
||||
private val MENU_REWRITES = listOf(
|
||||
Triple("usr/local/share/applications", "/usr/local/bin/bannerlator-", "/usr/local/bin/droiddeck-"),
|
||||
Triple("usr/share/applications", "Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/",
|
||||
"Exec=${DesktopCatalog.APPIMAGE_EXEC} /opt/appimages/"),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
@@ -95,14 +97,16 @@ object LegacyNames {
|
||||
}
|
||||
|
||||
private fun rewriteMenuEntries(root: File) {
|
||||
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(OLD_BIN)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
for ((dir, old, new) in MENU_REWRITES) {
|
||||
val entries = File(root, dir).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: continue
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(old)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(old, new)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,12 @@ public final class LinuxRuntimeInstaller {
|
||||
private LinuxRuntimeInstaller() {}
|
||||
|
||||
public static String installedVersion(Context context) {
|
||||
return LinuxRuntime.isInstalled(context) ? rootfsVersion(context) : null;
|
||||
}
|
||||
|
||||
private static String rootfsVersion(Context context) {
|
||||
File marker = new File(LinuxRuntime.rootDir(context), VERSION_FILE);
|
||||
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
|
||||
if (!marker.isFile()) return null;
|
||||
String v = FileUtils.readString(marker);
|
||||
return v == null ? null : v.trim();
|
||||
}
|
||||
@@ -144,6 +148,10 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
};
|
||||
boolean desktop = DesktopCatalog.INSTANCE.desktopInstalled(app);
|
||||
if (packsOnly(app, release, progress)) {
|
||||
job.ok = true;
|
||||
return true;
|
||||
}
|
||||
job.ok = installOnce(app, release, progress);
|
||||
if (job.ok && desktop) keepDesktop(app, progress);
|
||||
return job.ok;
|
||||
@@ -283,6 +291,31 @@ public final class LinuxRuntimeInstaller {
|
||||
for (File file : files) file.delete();
|
||||
}
|
||||
|
||||
private static kotlin.jvm.functions.Function1<AddonPack.State, kotlin.Unit> packProgress(ProgressListener listener, String stage) {
|
||||
return state -> {
|
||||
if (listener != null && state instanceof AddonPack.State.Downloading) {
|
||||
listener.onProgress(Step.DOWNLOADING, stage, Math.round(((AddonPack.State.Downloading) state).getProgress() * 100f));
|
||||
}
|
||||
return kotlin.Unit.INSTANCE;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean packsOnly(Context context, Release release, ProgressListener listener) {
|
||||
if (!LinuxRuntime.hasRootfs(context) || LinuxRuntime.isInstalled(context) || !release.version.equals(rootfsVersion(context))) return false;
|
||||
File root = LinuxRuntime.rootDir(context);
|
||||
try {
|
||||
recoverInterruptedSwap(root, new File(root.getParentFile(), LinuxRuntime.DIR + ".new"), new File(root.getParentFile(), LinuxRuntime.DIR + ".old"));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "packs only: " + e);
|
||||
return false;
|
||||
}
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
return AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& LinuxRuntime.isInstalled(context);
|
||||
}
|
||||
|
||||
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
|
||||
File archive = new File(context.getCacheDir(), archiveName(release));
|
||||
boolean keepPartial = false;
|
||||
@@ -294,10 +327,10 @@ public final class LinuxRuntimeInstaller {
|
||||
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
|
||||
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& !SessionPrefs.allowStockProot(context))
|
||||
throw new IOException("Cannot download proot");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) throw new IOException("Cannot download DirectAudio");
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
|
||||
if (listener != null) {
|
||||
listener.onProgress(Step.DOWNLOADING, downloading,
|
||||
|
||||
@@ -8,8 +8,10 @@ object RuntimeAutoUpdate {
|
||||
const val MAX_ATTEMPTS = 3
|
||||
const val RETRY_MS = 10 * 60_000L
|
||||
|
||||
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS): Boolean =
|
||||
installed != null && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
|
||||
private const val SEEDED = "seeded"
|
||||
|
||||
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS, first: Boolean = false): Boolean =
|
||||
(installed != null || first) && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
|
||||
(attempts == 0 || now - lastAt !in 0 until RETRY_MS)
|
||||
|
||||
fun key(appVersion: Int, release: String) = "$appVersion:$release"
|
||||
@@ -20,11 +22,17 @@ object RuntimeAutoUpdate {
|
||||
val key = key(BuildConfig.VERSION_CODE, release)
|
||||
synchronized(this) {
|
||||
val attempts = prefs.getInt(key, 0)
|
||||
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false
|
||||
val first = installed == null && !prefs.getBoolean(SEEDED, false)
|
||||
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now, first)) return false
|
||||
val edit = prefs.edit()
|
||||
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.forEach { edit.remove(it) }
|
||||
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") || it == SEEDED }.forEach { edit.remove(it) }
|
||||
edit.putInt(key, attempts + 1).putLong("$key@at", now).apply()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun seed(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (!prefs.getBoolean(SEEDED, false)) prefs.edit().putBoolean(SEEDED, true).apply()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ object UserApps {
|
||||
private const val GITHUB_REPO = "github-repo"
|
||||
private const val GITHUB_TAG = "github-tag"
|
||||
private const val GITHUB_PUBLISHED = "github-published"
|
||||
private const val GITHUB_ASSET = "github-asset"
|
||||
|
||||
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
|
||||
|
||||
@@ -56,7 +57,7 @@ object UserApps {
|
||||
sealed class Source {
|
||||
class Script(val file: File) : Source()
|
||||
class AppImage(val file: File) : Source()
|
||||
class GitHub(val repo: String) : Source()
|
||||
class GitHub(val repo: String, val asset: String? = null) : Source()
|
||||
class Flatpak(val id: String) : Source()
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
@@ -113,7 +114,7 @@ object UserApps {
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, s.asset, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
}
|
||||
@@ -305,14 +306,26 @@ object UserApps {
|
||||
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
|
||||
}
|
||||
|
||||
internal fun pickAsset(names: List<String>): String? {
|
||||
fun githubAsset(input: String): String? =
|
||||
Regex("github\\.com/[\\w.-]+/[\\w.-]+/releases/(?:download/[^/]+|latest/download)/([^/?#]+\\.appimage)(?:[?#].*)?$", RegexOption.IGNORE_CASE)
|
||||
.find(input.trim())?.groupValues?.get(1)?.let { runCatching { java.net.URLDecoder.decode(it, "UTF-8") }.getOrNull() }
|
||||
|
||||
private fun assetVariant(name: String): Set<String> =
|
||||
name.lowercase().replace(Regex("x86[_-]64|amd64"), "x64").replace(Regex("x86[_-]32|i[36]86"), "x32")
|
||||
.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() && !Regex("[0-9a-f]{7,}|v?[0-9]+").matches(it) }.toSet()
|
||||
|
||||
internal fun pickAsset(names: List<String>, wanted: String? = null): String? {
|
||||
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
|
||||
if (wanted != null) {
|
||||
images.firstOrNull { it == wanted }?.let { return it }
|
||||
images.firstOrNull { assetVariant(it) == assetVariant(wanted) }?.let { return it }
|
||||
}
|
||||
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
}
|
||||
|
||||
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
|
||||
private fun latestRelease(context: Context, repo: String, wanted: String? = null): Pair<Release?, String?> {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
|
||||
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
|
||||
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
|
||||
@@ -321,7 +334,7 @@ object UserApps {
|
||||
if (r.optBoolean("draft")) return@mapNotNull null
|
||||
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
|
||||
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } }
|
||||
pickAsset(byName.keys.toList())?.let { asset ->
|
||||
pickAsset(byName.keys.toList(), wanted)?.let { asset ->
|
||||
r.optBoolean("prerelease") to byName.getValue(asset).let { a ->
|
||||
Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"),
|
||||
Hashes.githubSha256(a.optString("digest")))
|
||||
@@ -344,11 +357,11 @@ object UserApps {
|
||||
}
|
||||
|
||||
private fun githubMeta(repo: String, release: Release) =
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published, GITHUB_ASSET to release.asset)
|
||||
|
||||
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
private fun addFromGitHub(context: Context, repo: String, asset: String?, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
val (release, problem) = latestRelease(context, repo, asset)
|
||||
if (release == null) return problem
|
||||
return withDownload(context, release, onProgress) { file ->
|
||||
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
|
||||
@@ -381,9 +394,10 @@ object UserApps {
|
||||
|
||||
fun checkUpdate(context: Context, app: App): UpdateCheck {
|
||||
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
val dir = AppImageManager.dir(context, app.key.substringAfter(':'))
|
||||
val (release, problem) = latestRelease(context, repo, FileUtils.readString(File(dir, GITHUB_ASSET))?.trim()?.takeIf { it.isNotEmpty() })
|
||||
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
|
||||
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val published = FileUtils.readString(File(dir, GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
|
||||
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
|
||||
object WowAddonCatalog {
|
||||
private const val TAG = "WowAddonCatalog"
|
||||
enum class Sort { POPULAR, UPDATED, NAME, DOWNLOADS }
|
||||
|
||||
class Category(val id: String, val name: String)
|
||||
|
||||
class Client(val folder: String, val family: String?)
|
||||
|
||||
class Query(val text: String = "", val category: String? = null, val client: Client, val sort: Sort = Sort.POPULAR)
|
||||
|
||||
class Addon(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val author: String,
|
||||
val summary: String,
|
||||
val downloads: Long,
|
||||
val updated: Long,
|
||||
val thumb: String?,
|
||||
)
|
||||
|
||||
class Page(val items: List<Addon>, val more: Boolean)
|
||||
|
||||
internal class Entry(val addon: Addon, val category: String, val flavors: Set<String>?, val monthly: Long = 0L)
|
||||
|
||||
internal class Cat(val id: String, val name: String, val parents: List<String>)
|
||||
|
||||
private const val WOWI = "https://api.mmoui.com/v3/game/WOW/"
|
||||
private const val CURSEFORGE = "https://www.curseforge.com/wow/search?class=addons"
|
||||
private const val WOWI_FILES = "wowi-filelist.json"
|
||||
internal const val FRESH_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
internal const val RETAIL = "_retail_"
|
||||
internal const val ERA = "_classic_era_"
|
||||
internal const val CLASSIC = "_classic_"
|
||||
internal const val ANNIVERSARY = "_anniversary_"
|
||||
internal const val FOREVER = "_forever_"
|
||||
|
||||
private val TEST_SUFFIX = Regex("_(beta|ptr|xptr)_$")
|
||||
|
||||
internal fun flavorOf(version: String): String? {
|
||||
val major = version.substringBefore('.').trim().toIntOrNull() ?: return null
|
||||
return when {
|
||||
major == 1 -> ERA
|
||||
major == 2 -> ANNIVERSARY
|
||||
major in 3..5 -> CLASSIC
|
||||
major >= 10 -> RETAIL
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun baseFolder(folder: String): String = folder.replace(TEST_SUFFIX, "_")
|
||||
|
||||
internal fun folderFamily(folder: String): String? = when (val base = baseFolder(folder)) {
|
||||
FOREVER -> RETAIL
|
||||
RETAIL, ERA, CLASSIC, ANNIVERSARY -> base
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun buildInfoVersion(buildInfo: String, product: String): String? {
|
||||
val lines = buildInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()
|
||||
if (lines.isEmpty()) return null
|
||||
val header = lines.first().split('|').map { it.substringBefore('!').trim().lowercase() }
|
||||
val productAt = header.indexOf("product")
|
||||
val versionAt = header.indexOf("version")
|
||||
if (productAt < 0 || versionAt < 0) return null
|
||||
return lines.drop(1).map { it.split('|') }
|
||||
.firstOrNull { it.getOrNull(productAt)?.trim().equals(product, ignoreCase = true) }
|
||||
?.getOrNull(versionAt)?.trim()?.ifEmpty { null }
|
||||
}
|
||||
|
||||
internal fun flavorProduct(flavorInfo: String): String? =
|
||||
flavorInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() && '!' !in it }.firstOrNull()
|
||||
|
||||
internal fun clientVersion(flavorDir: File): String? {
|
||||
val product = runCatching { File(flavorDir, ".flavor.info").readText() }.getOrNull()?.let(::flavorProduct) ?: return null
|
||||
val info = runCatching { File(flavorDir.parentFile, ".build.info").readText() }.getOrNull() ?: return null
|
||||
return buildInfoVersion(info, product)
|
||||
}
|
||||
|
||||
internal fun client(folder: String, version: String?): Client =
|
||||
Client(folder, version?.let(::flavorOf) ?: folderFamily(folder))
|
||||
|
||||
fun client(target: WowAddons.Target): Client {
|
||||
val dir = target.addOns.parentFile?.parentFile
|
||||
return client(target.flavor, dir?.let(::clientVersion))
|
||||
}
|
||||
|
||||
internal fun flavorMatches(flavors: Set<String>?, family: String?): Boolean =
|
||||
flavors == null || family == null || family in flavors
|
||||
|
||||
internal fun parseWowiFiles(json: String): List<Entry> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<Entry>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val id = o.optString("UID")
|
||||
if (id.isEmpty()) continue
|
||||
val compat = o.optJSONArray("UICompatibility")
|
||||
val flavors = if (compat == null || compat.length() == 0) null else {
|
||||
val set = HashSet<String>()
|
||||
for (k in 0 until compat.length()) compat.optJSONObject(k)?.optString("version")?.let(::flavorOf)?.let(set::add)
|
||||
set
|
||||
}
|
||||
val thumbs = o.optJSONArray("UIIMG_Thumbs")
|
||||
val thumb = if (thumbs != null && thumbs.length() > 0) thumbs.optString(0).ifEmpty { null } else null
|
||||
val addon = Addon(
|
||||
id, o.optString("UIName"), o.optString("UIAuthorName"), o.optString("UIVersion"),
|
||||
o.optString("UIDownloadTotal").toLongOrNull() ?: 0L, o.optLong("UIDate"), thumb,
|
||||
)
|
||||
out.add(Entry(addon, o.optString("UICATID"), flavors, o.optString("UIDownloadMonthly").toLongOrNull() ?: 0L))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun parseWowiCategories(json: String): List<Cat> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<Cat>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val parents = o.optJSONArray("UICATParentIDs")
|
||||
out.add(Cat(o.optString("UICATID"), o.optString("UICATTitle"), (0 until (parents?.length() ?: 0)).map { parents!!.optString(it) }))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun filterWowi(entries: List<Entry>, cats: List<Cat>, query: Query): List<Addon> {
|
||||
val parents = cats.associate { it.id to it.parents }
|
||||
val text = query.text.trim().lowercase()
|
||||
val list = entries.filter { e ->
|
||||
flavorMatches(e.flavors, query.client.family) &&
|
||||
(query.category == null || e.category == query.category || query.category in (parents[e.category] ?: emptyList())) &&
|
||||
(text.isEmpty() || e.addon.name.lowercase().contains(text) || e.addon.author.lowercase().contains(text))
|
||||
}
|
||||
return when (query.sort) {
|
||||
Sort.POPULAR -> list.sortedWith(compareByDescending<Entry> { it.monthly }.thenByDescending { it.addon.downloads })
|
||||
Sort.DOWNLOADS -> list.sortedByDescending { it.addon.downloads }
|
||||
Sort.UPDATED -> list.sortedByDescending { it.addon.updated }
|
||||
Sort.NAME -> list.sortedBy { it.addon.name.lowercase() }
|
||||
}.map { it.addon }
|
||||
}
|
||||
|
||||
internal fun pageOf(all: List<Addon>, page: Int, pageSize: Int): Page {
|
||||
val from = (page * pageSize).coerceAtMost(all.size)
|
||||
val to = (from + pageSize).coerceAtMost(all.size)
|
||||
return Page(all.subList(from, to), to < all.size)
|
||||
}
|
||||
|
||||
internal fun cached(file: File, now: Long, fetch: () -> String?): String? {
|
||||
val kept = if (file.isFile) file.readText().takeIf(::isJsonArray) else null
|
||||
if (kept != null && isFresh(file, now)) return kept
|
||||
val body = fetch()?.takeIf(::isJsonArray)
|
||||
if (body != null) {
|
||||
val part = File(file.parentFile, "${file.name}.${java.util.UUID.randomUUID()}.part")
|
||||
runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
part.writeText(body)
|
||||
part.setLastModified(now)
|
||||
if (!part.renameTo(file)) Log.w(TAG, "could not keep ${file.name}")
|
||||
}.onFailure { Log.w(TAG, "could not keep ${file.name}", it) }
|
||||
part.delete()
|
||||
return body
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
private fun isJsonArray(body: String): Boolean =
|
||||
runCatching { org.json.JSONTokener(body).nextValue() }.getOrNull() is JSONArray
|
||||
|
||||
private fun isFresh(file: File, now: Long) = file.isFile && now - file.lastModified() < FRESH_MS
|
||||
|
||||
private fun cacheFile(context: Context, name: String) = File(File(context.cacheDir, "wowaddons"), name)
|
||||
|
||||
private fun cachedGet(context: Context, name: String, url: String): String? =
|
||||
cached(cacheFile(context, name), System.currentTimeMillis()) { StoreNet.get(url, timeoutMs = 60_000) }
|
||||
|
||||
private fun unreachable(context: Context) =
|
||||
IOException(context.getString(R.string.addons_unreachable, context.getString(R.string.addons_source_wowi)))
|
||||
|
||||
fun search(context: Context, query: Query, page: Int, pageSize: Int = 40): Page =
|
||||
pageOf(filterWowi(wowiEntries(context), wowiCategories(context), query), page, pageSize)
|
||||
|
||||
private class Memo(val stamp: Long, val size: Long, val entries: List<Entry>)
|
||||
|
||||
@Volatile private var wowiMemo: Memo? = null
|
||||
|
||||
private fun wowiEntries(context: Context): List<Entry> {
|
||||
val file = cacheFile(context, WOWI_FILES)
|
||||
val memo = wowiMemo
|
||||
if (memo != null && isFresh(file, System.currentTimeMillis()) && memo.stamp == file.lastModified() && memo.size == file.length()) return memo.entries
|
||||
val body = cachedGet(context, WOWI_FILES, WOWI + "filelist.json") ?: throw unreachable(context)
|
||||
return parseWowiFiles(body).also { wowiMemo = Memo(file.lastModified(), file.length(), it) }
|
||||
}
|
||||
|
||||
private fun wowiCategories(context: Context): List<Cat> =
|
||||
cachedGet(context, "wowi-categories.json", WOWI + "categorylist.json")?.let(::parseWowiCategories) ?: emptyList()
|
||||
|
||||
fun categories(context: Context): List<Category> = wowiCategories(context).map { Category(it.id, it.name) }
|
||||
|
||||
internal fun parseWowiDetails(json: String): Pair<String, String?>? {
|
||||
val o = JSONArray(json).optJSONObject(0) ?: return null
|
||||
val url = o.optString("UIDownload").ifEmpty { return null }
|
||||
return url to o.optString("UIMD5").ifEmpty { null }
|
||||
}
|
||||
|
||||
internal fun isHttps(url: String) = url.startsWith("https://", ignoreCase = true)
|
||||
|
||||
internal fun md5Matches(file: File, expected: String?): Boolean =
|
||||
expected.isNullOrBlank() || Hashes.md5(file).equals(expected.trim(), ignoreCase = true)
|
||||
|
||||
fun download(context: Context, addon: Addon, dest: File, progress: (Int) -> Unit): File {
|
||||
val body = StoreNet.get(WOWI + "filedetails/${URLEncoder.encode(addon.id, "UTF-8")}.json", timeoutMs = 30_000)
|
||||
?: throw unreachable(context)
|
||||
val (url, md5) = parseWowiDetails(body) ?: throw IOException(context.getString(R.string.addons_no_file))
|
||||
if (!isHttps(url)) throw IOException(context.getString(R.string.addons_no_file))
|
||||
dest.parentFile?.mkdirs()
|
||||
dest.delete()
|
||||
val ok = Downloader.downloadFile(url, dest, false) { f -> if (f >= 0) progress((f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok || !dest.isFile) {
|
||||
dest.delete()
|
||||
throw unreachable(context)
|
||||
}
|
||||
if (!md5Matches(dest, md5)) {
|
||||
dest.delete()
|
||||
throw IOException(context.getString(R.string.selfinst_checksum_mismatch))
|
||||
}
|
||||
progress(100)
|
||||
return dest
|
||||
}
|
||||
|
||||
internal fun curseForgeUrl(text: String): String =
|
||||
text.trim().let { if (it.isEmpty()) CURSEFORGE else CURSEFORGE + "&search=" + URLEncoder.encode(it, "UTF-8") }
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AutoComponents
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object ComponentUpdater {
|
||||
private const val TAG = "ComponentUpdater"
|
||||
private const val CHECK_EVERY_MS = 3_600_000L
|
||||
private const val BOOTED_KEPT = 200
|
||||
private const val HANDED_BACK = "handedBack"
|
||||
private const val PINNED_NOTE = "pinnedNote"
|
||||
internal const val HAND_BACK_FILES = "handBackFiles"
|
||||
private const val IMPORTED = "imported"
|
||||
private val lock = Any()
|
||||
private val io = Any()
|
||||
|
||||
data class Status(val line: String, val current: String, val previous: String, val protons: List<String>, val note: String)
|
||||
|
||||
private fun file(context: Context) = File(File(context.filesDir, "components").apply { mkdirs() }, "updates.json")
|
||||
|
||||
private fun load(context: Context): JSONObject = synchronized(io) {
|
||||
runCatching { JSONObject(String(AtomicFile(file(context)).readFully(), Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
}
|
||||
|
||||
private fun save(context: Context, state: JSONObject) {
|
||||
synchronized(io) {
|
||||
val atomic = AtomicFile(file(context))
|
||||
val output = runCatching { atomic.startWrite() }.onFailure { Log.w(TAG, "could not save", it) }.getOrNull() ?: return
|
||||
try {
|
||||
output.write(state.toString(1).toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
atomic.failWrite(output)
|
||||
Log.w(TAG, "could not save", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Map<String, ComponentsManager.Choice>>.choiceOf(p: ComponentsManager.Proton, comp: String): ComponentsManager.Choice? =
|
||||
this[p.id]?.get(comp)?.takeIf { it.protonVersion == p.version }
|
||||
|
||||
private fun Map<String, Map<String, ComponentsManager.Choice>>.fileOf(p: ComponentsManager.Proton, comp: String): String? = choiceOf(p, comp)?.file
|
||||
|
||||
private class Target(val comp: String, val p: ComponentsManager.Proton, val line: ComponentUpdates.Line, val ls: JSONObject) {
|
||||
val key: String get() = line.key
|
||||
}
|
||||
|
||||
private fun JSONObject.obj(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
|
||||
|
||||
private fun JSONArray?.strings(): Set<String> = this?.let { a -> (0 until a.length()).map { a.optString(it) }.toSet() } ?: emptySet()
|
||||
|
||||
private fun JSONObject.strings(key: String): Set<String> = optJSONArray(key).strings()
|
||||
|
||||
private fun JSONObject?.ints(): Map<String, Int> = this?.keys()?.asSequence()?.associateWith { optInt(it) } ?: emptyMap()
|
||||
|
||||
private fun JSONObject?.texts(): Map<String, String> = this?.keys()?.asSequence()?.associateWith { optString(it) } ?: emptyMap()
|
||||
|
||||
fun settle(context: Context) {
|
||||
val owned = load(context).optJSONObject("lines")?.let { lines -> lines.keys().asSequence().flatMap { lines.optJSONObject(it).strings("owned") }.toSet() } ?: emptySet()
|
||||
val own = (runCatching { ComponentsManager.protons(context) }.getOrNull() ?: return).filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
|
||||
val choices = ComponentsManager.activeChoices(context).filterKeys { it in own }.values.flatMap { it.entries }
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
if (SessionPrefs.componentModeChosen(context, comp)) continue
|
||||
val manual = choices.any { (c, choice) -> c == comp && ComponentUpdates.manual(choice, null, owned) }
|
||||
SessionPrefs.setComponentMode(context, comp, if (manual) ComponentUpdates.MANUAL else ComponentUpdates.AUTO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun managed(context: Context, comp: String): Boolean =
|
||||
SessionPrefs.componentMode(context, comp) == ComponentUpdates.AUTO && !(comp == "dxvk" && ComponentsManager.hardwareDxvk(context))
|
||||
|
||||
private fun logLines(context: Context): Sequence<String> {
|
||||
val dir = ComponentsManager.steamLogs(context)
|
||||
return sequenceOf("console_log.previous.txt", "console_log.txt").map { File(dir, it) }.filter { it.isFile }
|
||||
.flatMap { f -> runCatching { f.readLines() }.getOrDefault(emptyList()).asSequence() }
|
||||
}
|
||||
|
||||
private val STAMP = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)]""")
|
||||
|
||||
private fun latestStamp(context: Context): String =
|
||||
logLines(context).mapNotNull { STAMP.find(it)?.groupValues?.get(1) }.maxOrNull() ?: "0"
|
||||
|
||||
private fun lineOf(context: Context, state: JSONObject, p: ComponentsManager.Proton, comp: String): ComponentUpdates.Line? {
|
||||
if (!ComponentUpdates.follows(p) || !ComponentsManager.supports(p, comp)) return null
|
||||
if (comp == "fex") return ComponentUpdates.line("fex", false)
|
||||
val key = "${p.id}|${p.version}"
|
||||
val flavors = state.obj("flavors").obj(key)
|
||||
val arm64ec = if (flavors.has(comp)) flavors.optBoolean(comp) else ComponentsManager.arm64ec(context, p, comp)?.also { flavors.put(comp, it) }
|
||||
return arm64ec?.let { ComponentUpdates.line(comp, it) }
|
||||
}
|
||||
|
||||
fun ensure(context: Context, force: Boolean): List<String> = synchronized(lock) {
|
||||
if (SessionState.running) return emptyList()
|
||||
settle(context)
|
||||
val state = load(context)
|
||||
val notes = ArrayList<String>()
|
||||
val found = runCatching { ComponentsManager.protons(context) }.getOrNull()
|
||||
val protons = found ?: emptyList()
|
||||
if (found != null) handBack(context, state, found)
|
||||
judge(context, state, protons, notes)
|
||||
ComponentsManager.pinnedAutoDxvk(context)?.let { pinned ->
|
||||
runCatching { ComponentsManager.ensureForcedPackages(context) }
|
||||
.onSuccess { if (it != null) Log.i(TAG, it) }.onFailure { Log.w(TAG, "pinned DXVK", it) }
|
||||
val followers = protons.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, "dxvk") }.map { it.id to it.version }
|
||||
if (pinnedHeld(pinned, followers, ComponentsManager.activeChoices(context), pinnedBase())) state.remove(PINNED_NOTE)
|
||||
else state.put(PINNED_NOTE, context.getString(R.string.comp_auto_install_failed, pinned.removeSuffix(".wcp")))
|
||||
}
|
||||
val due = force || System.currentTimeMillis() - state.optLong("checkedAt") >= CHECK_EVERY_MS
|
||||
val wanted = ComponentsManager.COMPONENTS.filter { managed(context, it) }
|
||||
if (wanted.isEmpty() || protons.isEmpty()) {
|
||||
save(context, state)
|
||||
return notes
|
||||
}
|
||||
try {
|
||||
dropRemoved(state, protons)
|
||||
val checked = due && check(context, state, protons, wanted, force, notes)
|
||||
apply(context, state, protons, wanted, notes)
|
||||
prune(context, state)
|
||||
if (checked) state.put("checkedAt", System.currentTimeMillis())
|
||||
} finally {
|
||||
save(context, state)
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
private fun check(
|
||||
context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, force: Boolean, notes: MutableList<String>,
|
||||
): Boolean {
|
||||
if (!AutoComponents.isOnline(context)) {
|
||||
state.put("note", context.getString(R.string.comp_auto_waits_network))
|
||||
return false
|
||||
}
|
||||
val catalog = runCatching { ComponentsManager.catalog(context, true).items }
|
||||
.onFailure { Log.w(TAG, "catalog", it) }.getOrDefault(emptyList())
|
||||
if (catalog.none { it.release == ComponentsManager.DD_NIGHTLY }) {
|
||||
state.put("note", context.getString(R.string.comp_auto_check_failed))
|
||||
return false
|
||||
}
|
||||
val holdBack = !force && AutoComponents.constrained(context)
|
||||
val lines = state.obj("lines")
|
||||
var complete = true
|
||||
state.remove("note")
|
||||
for (comp in wanted) for (p in protons) {
|
||||
val line = lineOf(context, state, p, comp) ?: continue
|
||||
val ls = lines.obj(line.key)
|
||||
if (rollingBack(ls)) {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
val current = ls.optString("current")
|
||||
val missing = current.isNotEmpty() && !ComponentsManager.isStored(context, current)
|
||||
val relisted = if (missing) catalog.firstOrNull { it.file == current } else null
|
||||
if (relisted != null) {
|
||||
if (holdBack) {
|
||||
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
val got = runCatching { ComponentsManager.download(context, relisted) {} }
|
||||
if (got.isFailure) {
|
||||
Log.w(TAG, "download ${relisted.file} again", got.exceptionOrNull())
|
||||
state.put("note", context.getString(R.string.comp_auto_check_failed))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
own(state, ls, current)
|
||||
}
|
||||
val target = ComponentUpdates.pick(catalog, line, ls.strings("bad"))
|
||||
if (target == null || current == target.file) continue
|
||||
val stored = ComponentsManager.isStored(context, target.file)
|
||||
if (!stored) {
|
||||
if (holdBack) {
|
||||
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
val got = runCatching { ComponentsManager.download(context, target) {} }
|
||||
if (got.isFailure) {
|
||||
Log.w(TAG, "download ${target.file}", got.exceptionOrNull())
|
||||
state.put("note", context.getString(R.string.comp_auto_check_failed))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
val stocks = protons.filter { lineOf(context, state, it, comp)?.key == line.key }.map { ComponentsManager.arm64x(context, it, comp) }
|
||||
if (ComponentUpdates.needsArm64x(line, stocks) && !ComponentsManager.packageArm64x(context, target.file, comp)) {
|
||||
Log.w(TAG, "${target.file} is not ARM64X like ${p.name}'s own ${line.key}; skipped")
|
||||
ls.put("bad", JSONArray(ls.strings("bad") + target.file))
|
||||
if (!stored) runCatching { ComponentsManager.deletePackage(context, target.file) }
|
||||
continue
|
||||
}
|
||||
ls.put("previous", if (missing && relisted == null) ComponentUpdates.ORIGINAL else current.ifEmpty { ComponentUpdates.ORIGINAL })
|
||||
.put("current", target.file).put("since", latestStamp(context)).put("failed", JSONObject())
|
||||
if (!stored) own(state, ls, target.file)
|
||||
ls.remove("note")
|
||||
notes += context.getString(R.string.comp_auto_updated, ComponentsManager.LABEL[comp], target.file.removeSuffix(".wcp"))
|
||||
}
|
||||
return complete
|
||||
}
|
||||
|
||||
private fun apply(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, notes: MutableList<String>) {
|
||||
val lines = state.obj("lines")
|
||||
val auto = placed(state)
|
||||
val choices = ComponentsManager.activeChoices(context)
|
||||
val targets = wanted.flatMap { comp ->
|
||||
protons.mapNotNull { p -> lineOf(context, state, p, comp)?.let { line -> lines.optJSONObject(line.key)?.let { Target(comp, p, line, it) } } }
|
||||
}.filterNot { t -> ComponentUpdates.manual(choices.choiceOf(t.p, t.comp), t.ls.optJSONObject("applied")?.optString(t.p.id), auto, t.ls.optString("current")) }
|
||||
for (t in targets) {
|
||||
val from = t.ls.optJSONObject("restore")?.optString(t.p.id).orEmpty()
|
||||
val current = t.ls.optString("current")
|
||||
if (from.isNotEmpty()) {
|
||||
if (choices.fileOf(t.p, t.comp) == from) runCatching { ComponentsManager.restore(context, t.p.id, t.comp, ComponentsManager.safeName(t.p.version)) }
|
||||
.onFailure { Log.w(TAG, "restore ${t.p.name} ${t.key}", it) }
|
||||
} else if (current.isNotEmpty() && choices.fileOf(t.p, t.comp) != current) {
|
||||
if (ComponentUpdates.needsArm64x(t.line, listOf(ComponentsManager.arm64x(context, t.p, t.comp))) &&
|
||||
!ComponentsManager.packageArm64x(context, current, t.comp)) continue
|
||||
runCatching { ComponentsManager.swap(context, t.p.id, current) }
|
||||
.onFailure { Log.w(TAG, "${t.p.name} ${t.key}", it) }
|
||||
}
|
||||
}
|
||||
val after = ComponentsManager.activeChoices(context)
|
||||
val unfinished = HashSet<String>()
|
||||
for (t in targets) {
|
||||
val restore = t.ls.optJSONObject("restore")
|
||||
val from = restore?.optString(t.p.id).orEmpty()
|
||||
val current = t.ls.optString("current")
|
||||
if (from.isNotEmpty()) {
|
||||
if (after.fileOf(t.p, t.comp) == from) unfinished += t.key
|
||||
else {
|
||||
restore?.remove(t.p.id)
|
||||
if (after.choiceOf(t.p, t.comp)?.file?.startsWith("original ") != false) t.ls.optJSONObject("applied")?.remove(t.p.id)
|
||||
}
|
||||
} else if (current.isNotEmpty()) {
|
||||
if (after.fileOf(t.p, t.comp) == current) t.ls.obj("applied").put(t.p.id, current) else unfinished += t.key
|
||||
}
|
||||
}
|
||||
for (key in lines.keys().asSequence().toList()) {
|
||||
val ls = lines.optJSONObject(key) ?: continue
|
||||
val pending = ls.optString("pendingNote")
|
||||
if (pending.isEmpty() || key in unfinished || (ls.optJSONObject("restore")?.length() ?: 0) > 0) continue
|
||||
ls.remove("pendingNote")
|
||||
ls.put("note", pending)
|
||||
notes += pending
|
||||
}
|
||||
}
|
||||
|
||||
private fun own(state: JSONObject, ls: JSONObject, file: String) {
|
||||
ls.put("owned", JSONArray(ls.strings("owned") + file))
|
||||
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) - file))
|
||||
}
|
||||
|
||||
fun importPackage(context: Context, source: File, name: String): ComponentsManager.Package = synchronized(lock) {
|
||||
ComponentsManager.importPackage(context, source, name).also { disown(context, it.file) }
|
||||
}
|
||||
|
||||
fun disown(context: Context, file: String) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
state.optJSONObject("lines")?.let { lines ->
|
||||
lines.keys().asSequence().mapNotNull { lines.optJSONObject(it) }.filter { file in it.strings("owned") }
|
||||
.forEach { it.put("owned", JSONArray(it.strings("owned") - file)) }
|
||||
}
|
||||
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) + file))
|
||||
save(context, state)
|
||||
}
|
||||
|
||||
internal fun placed(state: JSONObject): Set<String> {
|
||||
val remembered = state.strings(HAND_BACK_FILES)
|
||||
val lines = state.optJSONObject("lines") ?: return remembered - state.strings(IMPORTED)
|
||||
return remembered + lines.keys().asSequence().flatMap { k ->
|
||||
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
|
||||
(ls.strings("owned") + ls.strings("bad") + ls.optString("current") + ls.optString("previous")).asSequence()
|
||||
}.filter { it.isNotEmpty() }.toSet() - state.strings(IMPORTED)
|
||||
}
|
||||
|
||||
private fun handBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>) {
|
||||
if (state.optBoolean(HANDED_BACK)) return
|
||||
val placed = placed(state)
|
||||
state.put(HAND_BACK_FILES, JSONArray(placed.sorted()))
|
||||
val own = protons.filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
|
||||
runCatching { ComponentsManager.dropQueuedPackages(context, placed) { it in own || ComponentUpdates.ownId(it) } }.onFailure { Log.w(TAG, "drop queued Auto builds", it) }.getOrNull() ?: return
|
||||
val strays = ComponentUpdates.strays(protons, ComponentsManager.activeChoices(context), placed)
|
||||
for ((p, comp) in strays) {
|
||||
runCatching { ComponentsManager.restoreUnlessQueued(context, p.id, comp, ComponentsManager.safeName(p.version)) }
|
||||
.onFailure { Log.w(TAG, "hand back ${p.name} $comp", it) }
|
||||
}
|
||||
val after = ComponentsManager.activeChoices(context)
|
||||
if (ComponentUpdates.strays(protons, after, placed).isEmpty() && !ComponentUpdates.unseen(protons, after, placed)) {
|
||||
state.put(HANDED_BACK, true)
|
||||
state.remove(HAND_BACK_FILES)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun dropRemoved(state: JSONObject, protons: List<ComponentsManager.Proton>) {
|
||||
val installed = protons.mapTo(HashSet()) { it.id }
|
||||
val lines = state.optJSONObject("lines") ?: return
|
||||
for (key in lines.keys().asSequence().toList()) {
|
||||
val restore = lines.optJSONObject(key)?.optJSONObject("restore") ?: continue
|
||||
for (id in restore.keys().asSequence().toList()) if (id !in installed) restore.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun rollingBack(ls: JSONObject): Boolean =
|
||||
(ls.optJSONObject("restore")?.length() ?: 0) > 0 || ls.optString("pendingNote").isNotEmpty()
|
||||
|
||||
private fun judge(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, notes: MutableList<String>) {
|
||||
val mark = state.optString("logMark")
|
||||
val runs = ComponentUpdates.launches(logLines(context), mark)
|
||||
if (runs.isEmpty()) return
|
||||
state.put("logMark", runs.maxOf { it.start })
|
||||
val byTool = protons.associateBy { it.dir.name }
|
||||
val lines = state.obj("lines")
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
val onLine = HashMap<String, MutableList<ComponentUpdates.Launch>>()
|
||||
for (run in runs) {
|
||||
val p = byTool[run.tool] ?: continue
|
||||
if (ComponentsManager.gameComponents(context, run.app).containsKey(comp)) continue
|
||||
val line = lineOf(context, state, p, comp) ?: continue
|
||||
onLine.getOrPut(line.key) { ArrayList() } += run
|
||||
}
|
||||
for ((key, list) in onLine) {
|
||||
val ls = lines.obj(key)
|
||||
val verdict = ComponentUpdates.judge(ComponentUpdates.Trial(
|
||||
ls.optString("current"), ls.optString("previous"), ls.optString("since"),
|
||||
ls.optJSONObject("booted").texts(), ls.optJSONObject("failed").ints(),
|
||||
), list)
|
||||
ls.put("booted", JSONObject(verdict.booted.entries.toList().takeLast(BOOTED_KEPT).associate { it.key to it.value } as Map<*, *>))
|
||||
ls.put("failed", JSONObject(verdict.failed as Map<*, *>))
|
||||
if (verdict.rollBack && managed(context, comp)) rollBack(context, state, protons, comp, key, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, comp: String, key: String, notes: MutableList<String>) {
|
||||
val ls = state.obj("lines").obj(key)
|
||||
val bad = ls.optString("current")
|
||||
val onLine = protons.filter { lineOf(context, state, it, comp)?.key == key }
|
||||
val line = ComponentUpdates.LINES.first { it.key == key }
|
||||
val back = ls.optString("previous").takeIf { prev -> prev == ComponentUpdates.ORIGINAL || (ComponentsManager.isStored(context, prev) &&
|
||||
(!ComponentUpdates.needsArm64x(line, onLine.map { ComponentsManager.arm64x(context, it, comp) }) || ComponentsManager.packageArm64x(context, prev, comp))) }
|
||||
?: ComponentUpdates.ORIGINAL
|
||||
val choices = ComponentsManager.activeChoices(context)
|
||||
val targets = onLine.filter { choices.fileOf(it, comp) == bad }
|
||||
for (p in targets) {
|
||||
runCatching {
|
||||
if (back == ComponentUpdates.ORIGINAL) ComponentsManager.restore(context, p.id, comp, ComponentsManager.safeName(p.version))
|
||||
else ComponentsManager.swap(context, p.id, back)
|
||||
}.onFailure { Log.w(TAG, "roll back ${p.name} $key", it) }
|
||||
}
|
||||
val after = ComponentsManager.activeChoices(context)
|
||||
val unfinished = targets.filter { after.fileOf(it, comp) == bad }
|
||||
for (p in targets - unfinished.toSet()) {
|
||||
if (back == ComponentUpdates.ORIGINAL) ls.optJSONObject("applied")?.remove(p.id)
|
||||
else if (after.fileOf(p, comp) == back) ls.obj("applied").put(p.id, back)
|
||||
}
|
||||
ls.put("bad", JSONArray(ls.strings("bad") + bad)).put("current", back).put("previous", ComponentUpdates.ORIGINAL)
|
||||
.put("since", latestStamp(context)).put("failed", JSONObject())
|
||||
if (back == ComponentUpdates.ORIGINAL) ls.obj("restore").let { restore -> unfinished.forEach { restore.put(it.id, bad) } }
|
||||
val label = ComponentsManager.LABEL[comp]
|
||||
val shown = back.ifEmpty { context.getString(R.string.comp_tag_original) }.removeSuffix(".wcp")
|
||||
val note = context.getString(R.string.comp_auto_rolled_back, label, bad.removeSuffix(".wcp"), shown)
|
||||
Log.w(TAG, note)
|
||||
if (unfinished.isNotEmpty()) {
|
||||
ls.put("pendingNote", note)
|
||||
return
|
||||
}
|
||||
ls.remove("pendingNote")
|
||||
ls.put("note", note)
|
||||
notes += note
|
||||
prune(context, state)
|
||||
}
|
||||
|
||||
private fun prune(context: Context, state: JSONObject) {
|
||||
val lines = state.obj("lines")
|
||||
val selected = ComponentsManager.selectedPackages(context)
|
||||
val keep = lines.keys().asSequence().flatMap { k ->
|
||||
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
|
||||
ComponentUpdates.keep(ls.optString("current"), ls.optString("previous"), selected).asSequence()
|
||||
}.toSet()
|
||||
val stored = ComponentsManager.storedFiles(context)
|
||||
val followed = ComponentUpdates.LINES.filter { managed(context, it.comp) }
|
||||
for (k in lines.keys().asSequence().toList()) {
|
||||
val ls = lines.optJSONObject(k) ?: continue
|
||||
val owned = ls.strings("owned")
|
||||
val stale = ComponentUpdates.stale(followed.firstOrNull { it.key == k }, owned, stored, keep, ls.optString("current"), ls.strings("bad").toSet())
|
||||
val gone = stale.filter { runCatching { ComponentsManager.deleteUnused(context, it) }.getOrDefault(false) }
|
||||
ls.put("owned", JSONArray(owned - gone.toSet()))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pinnedHeld(pinned: String, followers: List<Pair<String, String>>, choices: Map<String, Map<String, ComponentsManager.Choice>>, base: String?): Boolean =
|
||||
followers.all { (id, version) ->
|
||||
val choice = choices[id]?.get("dxvk")
|
||||
ownChoice(choice, version, pinned, base) || choice?.let { it.file == ComponentsManager.safeName(pinned) && it.protonVersion == version } == true
|
||||
}
|
||||
|
||||
internal fun ownChoice(choice: ComponentsManager.Choice?, version: String, pinned: String, base: String?): Boolean =
|
||||
choice != null && !choice.auto && choice.protonVersion == version && choice.file != ComponentsManager.safeName(pinned) &&
|
||||
(base == null || choice.file != ComponentsManager.safeName(base))
|
||||
|
||||
private fun pinnedBase(): String? = ComponentsManager.forcedDxvk(GpuInfo.detect())?.file
|
||||
|
||||
fun status(context: Context, comp: String): List<Status> {
|
||||
val pinned = if (comp == "dxvk") ComponentsManager.pinnedAutoDxvk(context) else null
|
||||
if (pinned != null) {
|
||||
val on = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
|
||||
.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, comp) }
|
||||
if (on.isEmpty()) return emptyList()
|
||||
val choices = ComponentsManager.activeChoices(context)
|
||||
val base = pinnedBase()
|
||||
val (own, auto) = on.partition { ownChoice(choices[it.id]?.get(comp), it.version, pinned, base) }
|
||||
val held = pinnedHeld(pinned, auto.map { it.id to it.version }, choices, base)
|
||||
val pinnedLine = if (auto.isEmpty()) emptyList()
|
||||
else listOf(Status(comp, pinned, pinned, auto.map { it.name }, if (held) "" else load(context).optString(PINNED_NOTE)))
|
||||
return pinnedLine + own.groupBy { choices.getValue(it.id).getValue(comp).file }.map { (file, ps) -> Status(comp, file, file, ps.map { it.name }, "") }
|
||||
}
|
||||
val state = load(context)
|
||||
val lines = state.optJSONObject("lines") ?: JSONObject()
|
||||
val protons = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
|
||||
val byLine = protons.groupBy { lineOf(context, state, it, comp)?.key }
|
||||
return ComponentUpdates.LINES.filter { it.comp == comp }.mapNotNull { line ->
|
||||
val on = byLine[line.key].orEmpty()
|
||||
val ls = lines.optJSONObject(line.key)
|
||||
if (on.isEmpty() && ls == null) return@mapNotNull null
|
||||
val note = ls?.optString("note").orEmpty().ifEmpty { state.optString("note") }
|
||||
Status(line.key, ls?.optString("current").orEmpty(), ls?.optString("previous").orEmpty(), on.map { it.name }, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
object ComponentUpdates {
|
||||
const val AUTO = "auto"
|
||||
const val MANUAL = "manual"
|
||||
const val ORIGINAL = ""
|
||||
const val BOOT_SECONDS = 30L
|
||||
const val BOOTED_SECONDS = 60L
|
||||
const val FAILED_BOOTS_BEFORE_ROLLBACK = 2
|
||||
|
||||
data class Line(val key: String, val comp: String, val prefix: String)
|
||||
|
||||
val LINES = listOf(
|
||||
Line("dxvk", "dxvk", "DD-dxvk-"),
|
||||
Line("dxvk-arm64ec", "dxvk", "DD-dxvk-arm64ec-"),
|
||||
Line("vkd3d", "vkd3d", "DD-vkd3d-proton-"),
|
||||
Line("vkd3d-arm64ec", "vkd3d", "DD-vkd3d-proton-arm64ec-"),
|
||||
Line("fex", "fex", "DD-FEXCore-"),
|
||||
)
|
||||
|
||||
private val OWN_PREFIX = ProtonExtras.tools.first { it.id == ProtonExtras.OWN }.prefix
|
||||
|
||||
fun follows(p: ComponentsManager.Proton): Boolean = !p.valve && p.dir.name.startsWith(OWN_PREFIX)
|
||||
|
||||
fun ownId(protonId: String): Boolean = protonId.startsWith(ComponentsManager.safeName(OWN_PREFIX))
|
||||
|
||||
fun strays(
|
||||
protons: List<ComponentsManager.Proton>,
|
||||
choices: Map<String, Map<String, ComponentsManager.Choice>>,
|
||||
placed: Set<String>,
|
||||
): List<Pair<ComponentsManager.Proton, String>> =
|
||||
protons.filterNot(::follows).flatMap { p ->
|
||||
choices[p.id].orEmpty().filter { (_, c) -> !c.auto && c.protonVersion == p.version && c.file in placed }.keys.map { p to it }
|
||||
}
|
||||
|
||||
fun unseen(
|
||||
protons: List<ComponentsManager.Proton>,
|
||||
choices: Map<String, Map<String, ComponentsManager.Choice>>,
|
||||
placed: Set<String>,
|
||||
): Boolean {
|
||||
val found = protons.mapTo(HashSet()) { it.id }
|
||||
return choices.any { (id, comps) -> id !in found && comps.values.any { !it.auto && it.file in placed } }
|
||||
}
|
||||
|
||||
fun line(comp: String, arm64ec: Boolean): Line = LINES.first {
|
||||
it.comp == comp && (comp == "fex" || it.key.endsWith("-arm64ec") == arm64ec)
|
||||
}
|
||||
|
||||
fun pick(items: List<ComponentsManager.CatalogItem>, line: Line, bad: Set<String>): ComponentsManager.CatalogItem? {
|
||||
val newestBad = bad.filter { belongs(it, line) }.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a, b) }
|
||||
return items.filter { it.release == ComponentsManager.DD_NIGHTLY && it.comp == line.comp && belongs(it.file, line) }
|
||||
.filter { newestBad == null || ProtonExtras.compareVersions(it.file, newestBad) > 0 }
|
||||
.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a.file, b.file) }
|
||||
}
|
||||
|
||||
fun belongs(file: String, line: Line): Boolean =
|
||||
file.startsWith(line.prefix) && file.removePrefix(line.prefix).firstOrNull()?.isDigit() == true
|
||||
|
||||
fun arm64ec(pe: ByteArray): Boolean? = runCatching {
|
||||
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val header = b.getInt(0x3c)
|
||||
if (b.getInt(header) != 0x4550) return null
|
||||
val sections = b.getShort(header + 6).toInt() and 0xffff
|
||||
val optionalSize = b.getShort(header + 20).toInt() and 0xffff
|
||||
val optional = header + 24
|
||||
if ((b.getShort(optional).toInt() and 0xffff) != 0x20b) return false
|
||||
val loadConfigRva = b.getInt(optional + 112 + 10 * 8)
|
||||
if (loadConfigRva == 0) return false
|
||||
val table = optional + optionalSize
|
||||
var offset = -1
|
||||
for (i in 0 until sections) {
|
||||
val s = table + i * 40
|
||||
val size = b.getInt(s + 8)
|
||||
val va = b.getInt(s + 12)
|
||||
if (loadConfigRva >= va && loadConfigRva < va + maxOf(size, 1)) offset = b.getInt(s + 20) + loadConfigRva - va
|
||||
}
|
||||
if (offset < 0) return null
|
||||
b.getInt(offset) >= 0xC8 + 8 && b.getLong(offset + 0xC8) != 0L
|
||||
}.getOrNull()
|
||||
|
||||
fun arm64x(pe: ByteArray): Boolean = runCatching {
|
||||
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val header = b.getInt(0x3c)
|
||||
b.getInt(header) == 0x4550 && (b.getShort(header + 4).toInt() and 0xffff) == 0xaa64 && arm64ec(pe) == true
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun allArm64x(dlls: Map<String, ByteArray>, owned: Set<String>): Boolean {
|
||||
val graphics = dlls.filterKeys { it.lowercase() in owned }
|
||||
return graphics.isNotEmpty() && graphics.values.all(::arm64x)
|
||||
}
|
||||
|
||||
fun needsArm64x(line: Line, stocks: List<Boolean?>): Boolean =
|
||||
line.key.endsWith("-arm64ec") && stocks.any { it != false }
|
||||
|
||||
data class Launch(val app: String, val tool: String, val start: String, val seconds: Long)
|
||||
|
||||
private val PROCESS = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)] Game process (added|removed)\s*: AppID (\d+)(?: "(.*))?""")
|
||||
private val TOOL = Regex("""compatibilitytools\.d/([^/'"]+)['"]?/|steamapps/common/([^/'"]+)['"]?/proton""")
|
||||
|
||||
fun launches(log: Sequence<String>, after: String): List<Launch> {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
val open = HashMap<String, Pair<String, String>>()
|
||||
val out = ArrayList<Launch>()
|
||||
for (line in log) {
|
||||
val m = PROCESS.find(line) ?: continue
|
||||
val (time, what, app) = m.destructured
|
||||
if (time <= after) continue
|
||||
if (what == "added") {
|
||||
val tool = TOOL.find(m.groupValues[4])?.let { t -> t.groupValues[1].ifEmpty { t.groupValues[2] } } ?: continue
|
||||
open[app] = time to tool
|
||||
} else {
|
||||
val (start, tool) = open.remove(app) ?: continue
|
||||
val seconds = runCatching { (format.parse(time)!!.time - format.parse(start)!!.time) / 1000 }.getOrNull() ?: continue
|
||||
out += Launch(app, tool, start, seconds)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
data class Trial(
|
||||
val current: String,
|
||||
val previous: String,
|
||||
val since: String,
|
||||
val booted: Map<String, String>,
|
||||
val failed: Map<String, Int>,
|
||||
)
|
||||
|
||||
data class Verdict(val booted: Map<String, String>, val failed: Map<String, Int>, val rollBack: Boolean)
|
||||
|
||||
fun judge(trial: Trial, runs: List<Launch>): Verdict {
|
||||
val booted = trial.booted.toMutableMap()
|
||||
val failed = trial.failed.toMutableMap()
|
||||
var rollBack = false
|
||||
for (run in runs) {
|
||||
val build = if (trial.since.isNotEmpty() && run.start > trial.since) trial.current else trial.previous
|
||||
if (run.seconds >= BOOTED_SECONDS) {
|
||||
booted[run.app] = build
|
||||
if (build == trial.current) failed.remove(run.app)
|
||||
} else if (run.seconds < BOOT_SECONDS && build == trial.current && trial.current != trial.previous &&
|
||||
booted[run.app] == trial.previous) {
|
||||
val n = (failed[run.app] ?: 0) + 1
|
||||
failed[run.app] = n
|
||||
if (n >= FAILED_BOOTS_BEFORE_ROLLBACK) rollBack = true
|
||||
}
|
||||
}
|
||||
return Verdict(booted, failed, rollBack)
|
||||
}
|
||||
|
||||
fun keep(current: String, previous: String, selected: Set<String>): Set<String> =
|
||||
(setOf(current, previous) + selected) - ORIGINAL
|
||||
|
||||
fun manual(choice: ComponentsManager.Choice?, applied: String?, auto: Set<String>, current: String? = null): Boolean =
|
||||
choice != null && !choice.auto && !choice.file.startsWith("original ") && choice.file != current &&
|
||||
(if (applied.isNullOrEmpty()) choice.file !in auto else choice.file != applied)
|
||||
|
||||
fun stale(line: Line?, owned: Set<String>, stored: List<String>, keep: Set<String>, current: String, bad: Set<String> = emptySet()): List<String> {
|
||||
val ranked = line?.takeIf { belongs(current, it) }
|
||||
fun newer(file: String) = ranked != null && belongs(file, ranked) && ProtonExtras.compareVersions(file, current) > 0
|
||||
return (owned + stored.filter { ranked != null && belongs(it, ranked) })
|
||||
.filter { it !in keep && ((it in bad && it in owned) || !newer(it)) }.distinct()
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ object ComponentsManager {
|
||||
|
||||
private fun root(context: Context) = LinuxRuntime.rootDir(context)
|
||||
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
|
||||
fun steamLogs(context: Context) = File(steam(context), "logs")
|
||||
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
|
||||
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
|
||||
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
|
||||
@@ -699,6 +700,12 @@ object ComponentsManager {
|
||||
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
|
||||
}
|
||||
|
||||
fun restoreUnlessQueued(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
|
||||
if (loadState(context).optJSONObject("queued")?.optJSONObject(protonId)?.has(comp) == true) return false
|
||||
restore(context, protonId, comp, protonVersion)
|
||||
true
|
||||
}
|
||||
|
||||
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
|
||||
val current = protonVersion == safeName(p.version)
|
||||
if (!current) ensureOriginal(context, p, comp, state)
|
||||
@@ -749,6 +756,25 @@ object ComponentsManager {
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
fun dropQueuedPackages(context: Context, files: Set<String>, keep: (String) -> Boolean): Int = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
val queued = state.sub("queued")
|
||||
var dropped = 0
|
||||
for (pid in queued.keys().asSequence().toList()) {
|
||||
if (keep(pid)) continue
|
||||
val comps = queued.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys().asSequence().toList()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (entry.optString("kind") != "package" || entry.optBoolean("auto") || entry.optString("file") !in files) continue
|
||||
comps.remove(comp)
|
||||
dropped++
|
||||
}
|
||||
if (comps.length() == 0) queued.remove(pid)
|
||||
}
|
||||
if (dropped > 0) saveState(context, state)
|
||||
dropped
|
||||
}
|
||||
|
||||
class StoredPackage(val pkg: Package, val inUse: Boolean)
|
||||
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
|
||||
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
|
||||
@@ -756,7 +782,11 @@ object ComponentsManager {
|
||||
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
|
||||
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
|
||||
|
||||
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): Set<String> {
|
||||
internal fun liveOnAny(key: String, entry: JSONObject, installs: List<Proton>?): Boolean =
|
||||
installs?.any { liveSelection(key, entry, it) } == true
|
||||
|
||||
internal fun packagesInUse(state: JSONObject, installed: List<Proton>): Set<String> {
|
||||
val byId = installed.groupBy { it.id }
|
||||
val names = HashSet<String>()
|
||||
for (key in listOf("active", "queued")) {
|
||||
val byProton = state.optJSONObject(key) ?: continue
|
||||
@@ -764,7 +794,7 @@ object ComponentsManager {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
|
||||
if (liveOnAny(key, entry, byId[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -786,7 +816,7 @@ object ComponentsManager {
|
||||
fun stored(context: Context): Stored = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
val used = packagesInUse(state, installed)
|
||||
val used = packagesInUse(state, protons(context))
|
||||
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
|
||||
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
|
||||
COMPONENTS.mapNotNull { comp ->
|
||||
@@ -813,15 +843,15 @@ object ComponentsManager {
|
||||
}
|
||||
|
||||
private fun checkPackageUnused(context: Context, state: JSONObject, name: String) {
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
val installed = protons(context).groupBy { it.id }
|
||||
for (key in listOf("queued", "active")) {
|
||||
val byProton = state.sub(key)
|
||||
for (pid in byProton.keys()) {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
check(entry.optString("file") != name || !liveSelection(key, entry, installed[pid])) {
|
||||
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid)
|
||||
check(entry.optString("file") != name || !liveOnAny(key, entry, installed[pid])) {
|
||||
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -911,7 +941,7 @@ object ComponentsManager {
|
||||
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val state = loadState(context)
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
val installed = protons(context).groupBy { it.id }
|
||||
var changed = false
|
||||
for (key in listOf("queued", "active")) {
|
||||
val byProton = state.sub(key)
|
||||
@@ -920,7 +950,7 @@ object ComponentsManager {
|
||||
for (comp in comps.keys().asSequence().toList()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (entry.optString("file") != name) continue
|
||||
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
|
||||
check(!liveOnAny(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid) }
|
||||
comps.remove(comp)
|
||||
changed = true
|
||||
}
|
||||
@@ -967,9 +997,16 @@ object ComponentsManager {
|
||||
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
if (!force) return Catalog(emptyList(), 0)
|
||||
val items = JSONArray()
|
||||
val previous = if (cache.isFile) runCatching { JSONObject(cache.readText()).optJSONArray("items") }.getOrNull() else null
|
||||
var fetched = 0
|
||||
for ((repo, tag) in SOURCES) {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
|
||||
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag")
|
||||
val assets = body?.let { runCatching { JSONObject(it).optJSONArray("assets") }.getOrNull() }
|
||||
if (assets == null) {
|
||||
keepSource(previous, tag, items)
|
||||
continue
|
||||
}
|
||||
fetched++
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name")
|
||||
@@ -980,13 +1017,18 @@ object ComponentsManager {
|
||||
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
|
||||
}
|
||||
}
|
||||
if (items.length() == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
if (fetched == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
|
||||
val tmp = File(cache.parentFile, cache.name + ".tmp")
|
||||
tmp.writeText(out.toString()); tmp.renameTo(cache)
|
||||
return parseCatalog(out)
|
||||
}
|
||||
|
||||
internal fun keepSource(previous: JSONArray?, tag: String, into: JSONArray) {
|
||||
previous ?: return
|
||||
for (i in 0 until previous.length()) previous.optJSONObject(i)?.takeIf { it.optString("release") == tag }?.let(into::put)
|
||||
}
|
||||
|
||||
private fun parseCatalog(o: JSONObject): Catalog {
|
||||
val arr = o.optJSONArray("items") ?: JSONArray()
|
||||
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
|
||||
@@ -1023,14 +1065,110 @@ object ComponentsManager {
|
||||
val file: String,
|
||||
val release: String,
|
||||
val models: Set<Int> = emptySet(),
|
||||
val autoFile: String? = null,
|
||||
val autoRelease: String? = null,
|
||||
) {
|
||||
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
|
||||
|
||||
fun autoRule(): ForcedPackage? =
|
||||
if (autoFile != null && autoRelease != null) copy(file = autoFile, release = autoRelease, autoFile = null, autoRelease = null) else null
|
||||
}
|
||||
|
||||
internal val FORCED_PACKAGES = listOf(
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux",
|
||||
autoFile = "dxvk-gplasync-2.7.1-1-linux.wcp", autoRelease = "Dxvk-gplasync-Linux"),
|
||||
)
|
||||
|
||||
internal fun autoDxvk(gpu: GpuInfo): ForcedPackage? = forcedDxvk(gpu)?.autoRule()
|
||||
|
||||
internal fun forcedDxvk(gpu: GpuInfo): ForcedPackage? = FORCED_PACKAGES.firstOrNull { it.comp == "dxvk" && it.matches(gpu) }
|
||||
|
||||
private val ARM64EC_PROBE = mapOf("dxvk" to "d3d11.dll", "vkd3d" to "d3d12.dll")
|
||||
|
||||
fun arm64ec(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64ec)
|
||||
|
||||
fun arm64x(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64x)
|
||||
|
||||
fun packageArm64x(context: Context, file: String, comp: String): Boolean {
|
||||
val dir = "${COMP_DIR[comp] ?: return false}/aarch64-windows/"
|
||||
val f = File(packagesDir(context), safeName(file)).takeIf { it.isFile } ?: return false
|
||||
val dlls = runCatching {
|
||||
readWcp(f) { tar ->
|
||||
val found = HashMap<String, ByteArray>()
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (!e.isDirectory && rel.startsWith(dir) && '/' !in rel.removePrefix(dir)) found[rel.removePrefix(dir)] = tar.readBytes()
|
||||
}
|
||||
found
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return ComponentUpdates.allArm64x(dlls, COMP_DLLS.getValue(comp))
|
||||
}
|
||||
|
||||
private fun readWcpEntry(f: File, rel: String): ByteArray? = readWcp(f) { tar ->
|
||||
var found: ByteArray? = null
|
||||
while (found == null) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
if (!e.isDirectory && normalize(e.name) == rel) found = tar.readBytes()
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
private fun stockProbe(context: Context, p: Proton, comp: String): ByteArray? {
|
||||
val rel = "${COMP_DIR[comp] ?: return null}/aarch64-windows/${ARM64EC_PROBE.getValue(comp)}"
|
||||
val original = synchronized(lock) {
|
||||
val swapped = loadState(context).optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(comp)
|
||||
?.let { liveSelection("active", it, p) } == true
|
||||
if (!swapped) return File(p.dir, rel).takeIf { it.isFile }?.let { runCatching { it.readBytes() }.getOrNull() }
|
||||
originalFile(context, p, comp).takeIf { it.isFile } ?: return null
|
||||
}
|
||||
return runCatching { readWcpEntry(original, rel) }.getOrNull()
|
||||
}
|
||||
|
||||
fun supports(p: Proton, comp: String): Boolean =
|
||||
if (comp == "fex") File(p.dir, FEX_FILES.first()).isFile else File(p.dir, "${COMP_DIR.getValue(comp)}/aarch64-windows").isDirectory
|
||||
|
||||
fun hardwareDxvk(context: Context): Boolean =
|
||||
runtimeManaged(context, "dxvk") || FORCED_PACKAGES.any { it.comp == "dxvk" && it.matches(GpuInfo.detect()) } ||
|
||||
(com.droiddeck.launcher.core.DeviceSupport.vkBridge(context) && !SessionPrefs.bridgeProtonDxvk(context) &&
|
||||
bridgeDxvk(AppVulkan.detect(context)) != null)
|
||||
|
||||
fun autoDxvkHeld(context: Context): Boolean =
|
||||
hardwareDxvk(context) && (runtimeManaged(context, "dxvk") || autoDxvk(GpuInfo.detect()) == null)
|
||||
|
||||
fun pinnedAutoDxvk(context: Context): String? =
|
||||
if (SessionPrefs.componentMode(context, "dxvk") == ComponentUpdates.AUTO) autoDxvk(GpuInfo.detect())?.file else null
|
||||
|
||||
data class Choice(val file: String, val auto: Boolean, val protonVersion: String)
|
||||
|
||||
fun activeChoices(context: Context): Map<String, Map<String, Choice>> = synchronized(lock) {
|
||||
val active = loadState(context).optJSONObject("active") ?: return emptyMap()
|
||||
active.keys().asSequence().toList().associateWith { pid ->
|
||||
val comps = active.optJSONObject(pid) ?: JSONObject()
|
||||
comps.keys().asSequence().toList().mapNotNull { c ->
|
||||
comps.optJSONObject(c)?.let { c to Choice(it.optString("file"), it.optBoolean("auto"), it.optString("protonVersion")) }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectedPackages(context: Context): Set<String> = synchronized(lock) {
|
||||
packagesInUse(loadState(context), protons(context))
|
||||
}
|
||||
|
||||
fun deleteUnused(context: Context, file: String): Boolean = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val f = File(packagesDir(context), name)
|
||||
if (!f.isFile || name in packagesInUse(loadState(context), protons(context))) return false
|
||||
deletePackage(context, name)
|
||||
true
|
||||
}
|
||||
|
||||
fun storedFiles(context: Context): List<String> =
|
||||
packagesDir(context).listFiles()?.filter { it.isFile && it.name.endsWith(".wcp") }?.map { it.name } ?: emptyList()
|
||||
|
||||
fun isStored(context: Context, file: String): Boolean = File(packagesDir(context), safeName(file)).isFile
|
||||
|
||||
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
|
||||
activeFile.isNullOrEmpty() || !protonVersionMatches
|
||||
|
||||
@@ -1046,6 +1184,14 @@ object ComponentsManager {
|
||||
internal const val DXVK_FOR_VULKAN_1_1 = "dxvk-1.10.3-linux.wcp"
|
||||
internal const val DXVK_FOR_VULKAN_1_3_NO_INT64 = "dxvk-2.6.2-linux.wcp"
|
||||
|
||||
private data class ForcedRule(
|
||||
val pkg: ForcedPackage,
|
||||
val auto: Boolean,
|
||||
val covers: (Proton) -> Boolean,
|
||||
val replaces: String? = null,
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
internal fun autoDxvkUndoWanted(bridgeSession: Boolean, keepProtonDxvk: Boolean, vulkan: AppVulkan?, bridgeRule: ForcedPackage?): Boolean =
|
||||
!bridgeSession || keepProtonDxvk || (vulkan != null && bridgeRule == null)
|
||||
|
||||
@@ -1078,6 +1224,7 @@ object ComponentsManager {
|
||||
undoAuto = { id, comp -> undoAutoChoice(context, id, comp) },
|
||||
keepProtonDxvk = SessionPrefs.bridgeProtonDxvk(context),
|
||||
autoPossible = !com.droiddeck.launcher.core.DeviceSupport.adreno(),
|
||||
dxvkAuto = pinnedAutoDxvk(context) != null,
|
||||
) }
|
||||
|
||||
fun settleAutoDxvk(context: Context) {
|
||||
@@ -1117,10 +1264,16 @@ object ComponentsManager {
|
||||
undoAuto: (protonId: String, comp: String) -> String = { _, _ -> "" },
|
||||
keepProtonDxvk: Boolean = false,
|
||||
autoPossible: Boolean = true,
|
||||
dxvkAuto: Boolean = false,
|
||||
follows: (Proton) -> Boolean = ComponentUpdates::follows,
|
||||
): String? {
|
||||
val chooseDxvk = bridgeSession && !keepProtonDxvk
|
||||
val bridgeRule = if (chooseDxvk) bridgeDxvk(vulkan) else null
|
||||
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }.map { it to false } + listOfNotNull(bridgeRule?.let { it to true })
|
||||
val family = FORCED_PACKAGES.filter { it.matches(gpu) }
|
||||
val pinned = if (dxvkAuto) family.mapNotNull { base -> base.autoRule()?.let { base to it } } else emptyList()
|
||||
val rules = family.map { base -> ForcedRule(base, false, if (pinned.any { it.first == base }) { p -> !follows(p) } else { _ -> true }) } +
|
||||
pinned.map { (base, rule) -> ForcedRule(rule, true, follows, replaces = base.file) } +
|
||||
listOfNotNull(bridgeRule?.let { ForcedRule(it, true, { true }, label = "${vulkan?.label}: DXVK ${it.file}") })
|
||||
if (rules.isEmpty() && !autoPossible) return null
|
||||
val installed = protons(context)
|
||||
val lines = mutableListOf<String>()
|
||||
@@ -1134,12 +1287,13 @@ object ComponentsManager {
|
||||
if (autoChoiceStale(entry, null)) lines.add(each(p) { undoAuto(p.id, "dxvk") })
|
||||
}
|
||||
}
|
||||
for ((rule, auto) in rules) {
|
||||
for ((rule, auto, covers, replaces, label) in rules) {
|
||||
val state = loadState(context)
|
||||
val targets = installed.filter { p ->
|
||||
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
|
||||
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version) ||
|
||||
(auto && autoChoiceStale(active, rule.file))
|
||||
val current = active?.optString("protonVersion") == p.version
|
||||
covers(p) && (forcedPackageWanted(active?.optString("file"), current) ||
|
||||
(auto && autoChoiceStale(active, rule.file)) || (replaces != null && current && active?.optString("file") == replaces))
|
||||
}
|
||||
if (targets.isEmpty()) continue
|
||||
val missing = missingPackage(context, rule, lookup, fetch)
|
||||
@@ -1147,7 +1301,7 @@ object ComponentsManager {
|
||||
lines.add(missing)
|
||||
continue
|
||||
}
|
||||
if (auto) lines.add("${vulkan?.label}: DXVK ${rule.file}")
|
||||
if (label != null) lines.add(label)
|
||||
lines.add(targets.joinToString("; ") { p -> if (auto) each(p) { applyAuto(p.id, rule.file) } else apply(p.id, rule.file) })
|
||||
}
|
||||
return lines.filter { it.isNotEmpty() }.joinToString("; ").ifEmpty { null }
|
||||
|
||||
@@ -57,8 +57,10 @@ object GameStorage {
|
||||
return pref.isNotEmpty() && pref != SessionPrefs.GAME_STORAGE_OFF && !attached(File(pref))
|
||||
}
|
||||
|
||||
internal fun attached(path: File): Boolean =
|
||||
attached(path, runCatching { Environment.getExternalStorageState(path) }.getOrNull())
|
||||
internal fun attached(
|
||||
path: File,
|
||||
stateOf: (File) -> String? = { runCatching { Environment.getExternalStorageState(it) }.getOrNull() },
|
||||
): Boolean = attached(path, stateOf(File(UsbLink.storagePath(path.path))))
|
||||
|
||||
internal fun attached(
|
||||
path: File,
|
||||
@@ -66,8 +68,9 @@ object GameStorage {
|
||||
externalStoragePath: Boolean = isExternalStoragePath(path),
|
||||
): Boolean = when {
|
||||
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY -> true
|
||||
path.isDirectory -> true
|
||||
externalStoragePath -> false
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.parentFile?.isDirectory == true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ object PerfMode {
|
||||
val cur = display.mode
|
||||
val cap = minOf(peakRefreshCap(a), refreshLimit.takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY)
|
||||
val best = selectMode(display.supportedModes, cur, fpsLimit, cap)
|
||||
if (best != null && a.window.attributes.preferredDisplayModeId != best.modeId) {
|
||||
if (best != null && needsChange(display.supportedModes, a.window.attributes.preferredDisplayModeId, best)) {
|
||||
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
|
||||
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
|
||||
} else "display ${cur.refreshRate.toInt()} Hz"
|
||||
@@ -64,6 +64,11 @@ object PerfMode {
|
||||
?: sameSize.minByOrNull { it.refreshRate }
|
||||
}
|
||||
|
||||
internal fun needsChange(modes: Array<Display.Mode>, preferredId: Int, best: Display.Mode): Boolean {
|
||||
val preferred = modes.firstOrNull { preferredId != 0 && it.modeId == preferredId } ?: return true
|
||||
return Math.abs(preferred.refreshRate - best.refreshRate) > 0.5f
|
||||
}
|
||||
|
||||
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
|
||||
if (fpsLimit <= 0) return true
|
||||
val ratio = hz / fpsLimit
|
||||
|
||||
@@ -11,12 +11,4 @@ object PipPolicy {
|
||||
height.toDouble() / width > 2.39 -> 100 to 239
|
||||
else -> width to height
|
||||
}
|
||||
|
||||
fun outputSize(width: Int, height: Int, outputWidth: Int, outputHeight: Int): Pair<Int, Int>? {
|
||||
if (width <= 0 || height <= 0 || outputWidth <= 0 || outputHeight <= 0) return null
|
||||
val scale = minOf(width.toFloat() / outputWidth, height.toFloat() / outputHeight)
|
||||
val fittedWidth = ((outputWidth * scale).toInt() and 1.inv()).coerceAtLeast(2)
|
||||
val fittedHeight = ((outputHeight * scale).toInt() and 1.inv()).coerceAtLeast(2)
|
||||
return fittedWidth to fittedHeight
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
|
||||
"usr/local/bin/droiddeck-flatpak-run" to "usr/local/bin/droiddeck-flatpak-run",
|
||||
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
|
||||
"usr/local/bin/droiddeck-appimage-exec" to "usr/local/bin/droiddeck-appimage-exec",
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
|
||||
@@ -23,6 +23,7 @@ object SessionPrefs {
|
||||
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
|
||||
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
|
||||
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
|
||||
private const val LOW_STORAGE_WARNING = "lowStorageWarning"
|
||||
private const val SLOW_USB_WARNING_OFF = "slowUsbWarningOff"
|
||||
private const val SLOW_USB_DISMISSED = "slowUsbDismissed"
|
||||
|
||||
@@ -110,12 +111,16 @@ object SessionPrefs {
|
||||
prefs(context).edit().remove("dualScreenLauncher").putString("dualScreenMode", launcher).putString("dualScreenSessionMode", mode).apply()
|
||||
}
|
||||
|
||||
fun secondScreenPanel(context: Context): String? = prefs(context).getString("secondScreenPanel", null)
|
||||
|
||||
fun setSecondScreenPanel(context: Context, mode: String) {
|
||||
prefs(context).edit().putString("secondScreenPanel", mode).apply()
|
||||
fun secondScreenPanel(context: Context, desktop: Boolean): String? = prefs(context).let {
|
||||
it.getString(secondScreenPanelKey(desktop), null) ?: if (desktop) null else it.getString("secondScreenPanel", null)?.takeIf { id -> id != "custom-controls" }
|
||||
}
|
||||
|
||||
fun setSecondScreenPanel(context: Context, desktop: Boolean, mode: String) {
|
||||
prefs(context).edit().putString(secondScreenPanelKey(desktop), mode).apply()
|
||||
}
|
||||
|
||||
private fun secondScreenPanelKey(desktop: Boolean) = if (desktop) "secondScreenPanel.desktop" else "secondScreenPanel.game"
|
||||
|
||||
/** The bottom-screen desktop takes touch as a trackpad unless the user switched it to direct touch. */
|
||||
fun bottomDesktopDirectTouch(context: Context): Boolean = prefs(context).getBoolean("bottomDesktopDirectTouch", false)
|
||||
|
||||
@@ -184,6 +189,12 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("dualScreenSessionControls", on).apply()
|
||||
}
|
||||
|
||||
fun androidTextFieldKeyboard(context: Context): Boolean = prefs(context).getBoolean("androidTextFieldKeyboard", false)
|
||||
|
||||
fun setAndroidTextFieldKeyboard(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("androidTextFieldKeyboard", on).apply()
|
||||
}
|
||||
|
||||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
|
||||
|
||||
fun setGameStoresEnabled(context: Context, on: Boolean) {
|
||||
@@ -351,6 +362,8 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
const val PC_KEYBOARD_SIZE_DEFAULT = 47
|
||||
const val WOW_CHAT_VOICE = "voice"
|
||||
const val WOW_CHAT_KEYBOARD = "keyboard"
|
||||
val PC_KEYBOARD_SIZE_RANGE = 25..70
|
||||
const val PC_KEYBOARD_OPACITY_DEFAULT = 100
|
||||
val PC_KEYBOARD_OPACITY_RANGE = 30..100
|
||||
@@ -362,6 +375,13 @@ object SessionPrefs {
|
||||
prefs(context).edit().putInt("pcKeyboardSize", percent.coerceIn(PC_KEYBOARD_SIZE_RANGE)).apply()
|
||||
}
|
||||
|
||||
fun wowChatInput(context: Context): String? =
|
||||
prefs(context).getString("wowChatInput", null)?.takeIf { it == WOW_CHAT_VOICE || it == WOW_CHAT_KEYBOARD }
|
||||
|
||||
fun setWowChatInput(context: Context, mode: String?) {
|
||||
prefs(context).edit().apply { if (mode == null) remove("wowChatInput") else putString("wowChatInput", mode) }.apply()
|
||||
}
|
||||
|
||||
fun pcKeyboardOpacity(context: Context): Int =
|
||||
prefs(context).getInt("pcKeyboardOpacity", PC_KEYBOARD_OPACITY_DEFAULT).takeIf { it in PC_KEYBOARD_OPACITY_RANGE } ?: PC_KEYBOARD_OPACITY_DEFAULT
|
||||
|
||||
@@ -499,6 +519,17 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("gpuDriverMode", mode).apply()
|
||||
}
|
||||
|
||||
fun componentMode(context: Context, comp: String): String =
|
||||
prefs(context).getString("componentMode.$comp", ComponentUpdates.AUTO)
|
||||
?.takeIf { it == ComponentUpdates.AUTO || it == ComponentUpdates.MANUAL } ?: ComponentUpdates.AUTO
|
||||
|
||||
fun componentModeChosen(context: Context, comp: String): Boolean = prefs(context).contains("componentMode.$comp")
|
||||
|
||||
fun setComponentMode(context: Context, comp: String, mode: String) {
|
||||
require(mode == ComponentUpdates.AUTO || mode == ComponentUpdates.MANUAL)
|
||||
prefs(context).edit().putString("componentMode.$comp", mode).commit()
|
||||
}
|
||||
|
||||
fun settleGpuDriverMode(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("gpuDriverMode")) return
|
||||
@@ -1524,8 +1555,11 @@ object SessionPrefs {
|
||||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||||
|
||||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||||
fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
|
||||
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
|
||||
|
||||
fun setDeckPanel(context: Context, panel: com.droiddeck.launcher.input.DeckPanel) {
|
||||
prefs(context).edit().putString("deckPanel", panel.id).apply()
|
||||
}
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
@@ -1556,6 +1590,12 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
|
||||
}
|
||||
|
||||
fun lowStorageWarning(context: Context): Boolean = prefs(context).getBoolean(LOW_STORAGE_WARNING, true)
|
||||
|
||||
fun setLowStorageWarning(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean(LOW_STORAGE_WARNING, on).apply()
|
||||
}
|
||||
|
||||
fun slowUsbWarningOff(context: Context): Boolean = prefs(context).getBoolean(SLOW_USB_WARNING_OFF, false)
|
||||
|
||||
fun setSlowUsbWarningOff(context: Context) {
|
||||
|
||||
@@ -93,6 +93,7 @@ object SessionState {
|
||||
@Volatile var secondScreenDisplay = -1
|
||||
@Volatile var secondScreenAutoOpened = false
|
||||
@Volatile var secondScreenProvisional = false
|
||||
@Volatile var secondScreenAutomatic = false
|
||||
@Volatile var gameOnBottom = false
|
||||
|
||||
@Volatile var steamLibraryMounts: Map<String, java.io.File> = emptyMap()
|
||||
|
||||
@@ -5,14 +5,15 @@ import android.util.Log
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.InputProfiles
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
|
||||
object SettingsReset {
|
||||
private const val TAG = "SettingsReset"
|
||||
|
||||
internal val SESSION_KEYS = setOf(
|
||||
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
|
||||
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls",
|
||||
"orientation", "launcherFullscreen", "animations", "lowStorageWarning", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
|
||||
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls", "androidTextFieldKeyboard",
|
||||
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
|
||||
"storesInstallLanguage",
|
||||
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backHold", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
|
||||
@@ -30,7 +31,7 @@ object SettingsReset {
|
||||
)
|
||||
|
||||
internal val SESSION_PREFIXES = listOf(
|
||||
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.",
|
||||
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.", "componentMode.",
|
||||
)
|
||||
|
||||
private val STEAM_TOUCH_KEYS = listOf("feedback", "autoFade", "gyro", "shake")
|
||||
@@ -43,6 +44,7 @@ object SettingsReset {
|
||||
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
session.edit().apply { session.all.keys.filter { isSessionSetting(it) }.forEach { remove(it) } }.commit()
|
||||
ControllerPrefs.resetAll(context)
|
||||
InputProfiles.resetAll(context)
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("steam_touch", Context.MODE_PRIVATE).edit()
|
||||
|
||||
@@ -96,7 +96,7 @@ object SteamLibraries {
|
||||
if (picked.none { overlaps(canonical(it.first), canonical(hit.first)) }) picked += hit
|
||||
}
|
||||
return picked.map { (dir, games) ->
|
||||
val shown = File(storagePath(dir.path))
|
||||
val shown = File(UsbLink.storagePath(dir.path))
|
||||
Found(dir.path, shown.path, defaultLabel(context, shown), games)
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ object SteamLibraries {
|
||||
}
|
||||
|
||||
fun defaultLabel(context: Context, picked: File): String {
|
||||
val folder = File(storagePath(picked.path))
|
||||
val folder = File(UsbLink.storagePath(picked.path))
|
||||
val volume = try {
|
||||
context.getSystemService(StorageManager::class.java)?.getStorageVolume(folder)?.getDescription(context)
|
||||
} catch (e: Exception) {
|
||||
@@ -171,9 +171,7 @@ object SteamLibraries {
|
||||
|
||||
internal fun overlaps(a: String, b: String): Boolean = a == b || a.startsWith("$b/") || b.startsWith("$a/")
|
||||
|
||||
private fun storagePath(path: String): String = if (path.startsWith("/mnt/media_rw/")) "/storage/" + path.removePrefix("/mnt/media_rw/") else path
|
||||
|
||||
private fun canonical(file: File): String = storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
|
||||
private fun canonical(file: File): String = UsbLink.storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
|
||||
|
||||
private const val SCAN_DEPTH = 2
|
||||
private const val SCAN_LIMIT = 4000
|
||||
|
||||
@@ -66,6 +66,9 @@ object StorageState {
|
||||
|
||||
fun lowOnSpace(context: Context): Boolean = freeBytes(context) < LOW_SPACE_BYTES
|
||||
|
||||
fun shouldWarnLowSpace(warningEnabled: Boolean, dismissed: Boolean, sessionRunning: Boolean, freeBytes: Long): Boolean =
|
||||
warningEnabled && !dismissed && !sessionRunning && freeBytes < LOW_SPACE_BYTES
|
||||
|
||||
fun bytes(report: StorageReport, item: CleanItem): Long = when (item) {
|
||||
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.sumOf { it.bytes }
|
||||
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.sumOf { it.bytes }
|
||||
|
||||
@@ -65,6 +65,8 @@ private fun releaseDateValue(raw: String): Long =
|
||||
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
|
||||
} ?: 0L
|
||||
|
||||
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
|
||||
|
||||
fun <T> sortGames(
|
||||
items: List<T>,
|
||||
sort: String,
|
||||
|
||||
@@ -204,7 +204,8 @@ object BattleNetBackend : StoreBackend {
|
||||
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
|
||||
val art = BattleNet.art(context, game.title)
|
||||
val cover = art.cover?.let { Uri.fromFile(it).toString() }
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
|
||||
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
}
|
||||
|
||||
private fun shelves(context: Context): StoreShelves {
|
||||
|
||||
@@ -39,7 +39,7 @@ object GogComet {
|
||||
var status by mutableStateOf("off")
|
||||
private set
|
||||
|
||||
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", false)
|
||||
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", true)
|
||||
|
||||
fun setEnabled(context: Context, on: Boolean) {
|
||||
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
|
||||
|
||||
@@ -151,7 +151,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (
|
||||
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it, UserApps.githubAsset(repo)), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.FLATPAK -> bundlePath?.takeIf { it.endsWith(".flatpak", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.FlatpakBundle(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
?: flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
|
||||
|
||||
@@ -49,7 +49,9 @@ import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -60,6 +62,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AddedGameArt
|
||||
@@ -192,7 +196,7 @@ internal fun SteamGameArtEditor(
|
||||
game: com.droiddeck.launcher.frontend.Library.SteamGame, onClose: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val target = remember(game.appId) { ArtTarget.of(game) }
|
||||
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged, launchAppId = game.appId)
|
||||
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -208,25 +212,17 @@ internal fun StoreGameArtEditor(folder: String, name: String, onClose: () -> Uni
|
||||
@Composable
|
||||
private fun ArtOnlyEditor(
|
||||
name: String, target: ArtTarget?, refresh: Any?, describe: (Context) -> List<AddedGameArt.SlotState>,
|
||||
onClose: () -> Unit, onChanged: () -> Unit, launchAppId: Int? = null,
|
||||
onClose: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val app = LocalContext.current.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
var version by remember { mutableIntStateOf(0) }
|
||||
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
|
||||
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
|
||||
var logoOpen by remember { mutableStateOf(false) }
|
||||
var launchOptions by remember { mutableStateOf<String?>(null) }
|
||||
var launchVersion by remember { mutableIntStateOf(0) }
|
||||
var editingLaunch by remember { mutableStateOf<String?>(null) }
|
||||
var launchSaving by remember { mutableStateOf(false) }
|
||||
var launchError by remember { mutableStateOf<Int?>(null) }
|
||||
val position = rememberLogoPosition(target, version)
|
||||
LaunchedEffect(version, refresh) { if (target != null) arts = withContext(Dispatchers.IO) { describe(app) } }
|
||||
LaunchedEffect(launchAppId, launchVersion) { if (launchAppId != null) launchOptions = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, launchAppId) } }
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
AppDialog(shown, close, "artEditor", wide = false, maxWidth = 460.dp) {
|
||||
@@ -236,19 +232,10 @@ private fun ArtOnlyEditor(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
if (launchAppId != null) {
|
||||
FieldRow(stringResource(R.string.edit_launch_options), focus = first, onClick = { if (!launchSaving) launchOptions?.let { editingLaunch = it } }) {
|
||||
val options = launchOptions
|
||||
if (options == null || launchSaving) Loading() else ValueText(options.ifEmpty { "—" }, muted = options.isEmpty())
|
||||
}
|
||||
val error = launchError
|
||||
if (error != null) Text(stringResource(error), fontSize = 12.sp, color = colors.error, modifier = Modifier.padding(start = 4.dp))
|
||||
else Text(stringResource(R.string.edit_launch_options_steam_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = if (launchAppId != null) 8.dp else 2.dp))
|
||||
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 2.dp))
|
||||
for (slot in AddedGameArt.Slot.values()) key(slot) {
|
||||
val state = arts.firstOrNull { it.slot == slot }
|
||||
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER && launchAppId == null) first else null, onClick = { if (target != null) artSlot = slot }) {
|
||||
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER) first else null, onClick = { if (target != null) artSlot = slot }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SlotThumb(slot, state?.file)
|
||||
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
|
||||
@@ -258,27 +245,6 @@ private fun ArtOnlyEditor(
|
||||
PickerRow(Icons.Outlined.Tune, stringResource(R.string.art_adjust_logo), logoPositionSummary(position?.value), enabled = target != null && position != null) { logoOpen = true }
|
||||
}
|
||||
}
|
||||
val before = editingLaunch
|
||||
if (before != null && launchAppId != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editingLaunch = null }) { value ->
|
||||
if (launchSaving || value == before.trim()) return@TextEntryDialog
|
||||
launchSaving = true
|
||||
scope.launch {
|
||||
try {
|
||||
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, launchAppId, value, before) }
|
||||
launchError = when (saved) {
|
||||
SteamLaunchOptions.Saved.YES -> null
|
||||
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
|
||||
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
|
||||
}
|
||||
if (saved == SteamLaunchOptions.Saved.YES) launchOptions = value else {
|
||||
launchOptions = null
|
||||
launchVersion++
|
||||
}
|
||||
} finally {
|
||||
launchSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
val chosen = target ?: return
|
||||
artSlot?.let { slot ->
|
||||
ArtChooserDialog(chosen, slot, arts.firstOrNull { it.slot == slot }, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
|
||||
@@ -287,6 +253,72 @@ private fun ArtOnlyEditor(
|
||||
if (logoOpen && loaded != null) LogoPositionHost(chosen, arts, loaded, onBack = { logoOpen = false }, onCloseAll = { logoOpen = false; close() }) { version++; onChanged() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SteamLaunchOptionsPanel(appId: Int) {
|
||||
val app = LocalContext.current.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
var options by remember(appId) { mutableStateOf<String?>(null) }
|
||||
var version by remember(appId) { mutableIntStateOf(0) }
|
||||
var editing by remember(appId) { mutableStateOf<String?>(null) }
|
||||
var saving by remember(appId) { mutableStateOf(false) }
|
||||
var loading by remember(appId) { mutableStateOf(false) }
|
||||
var error by remember(appId) { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(appId, version) {
|
||||
options = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, appId) } ?: options.orEmpty()
|
||||
loading = false
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle, appId) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !saving) {
|
||||
loading = true
|
||||
version++
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
val value = options
|
||||
MenuItem(
|
||||
stringResource(R.string.edit_launch_options), checked = false, enabled = value != null && !saving && !loading,
|
||||
detail = if (value == null || saving || loading) "…" else value.ifEmpty { "—" },
|
||||
) { if (!saving && !loading) options?.let { editing = it } }
|
||||
val message = error
|
||||
Text(
|
||||
stringResource(message ?: R.string.edit_launch_options_steam_hint),
|
||||
fontSize = 12.sp,
|
||||
color = if (message == null) colors.onSurfaceVariant else colors.error,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
val before = editing
|
||||
if (before != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editing = null }) { value ->
|
||||
if (saving || value == before.trim()) return@TextEntryDialog
|
||||
saving = true
|
||||
scope.launch {
|
||||
try {
|
||||
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, appId, value, before) }
|
||||
error = when (saved) {
|
||||
SteamLaunchOptions.Saved.YES -> null
|
||||
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
|
||||
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
|
||||
}
|
||||
if (saved == SteamLaunchOptions.Saved.YES) {
|
||||
options = value
|
||||
version++
|
||||
} else {
|
||||
options = null
|
||||
version++
|
||||
}
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoadedPosition(val value: LogoPosition.Value?)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.WowAddonCatalog
|
||||
import com.droiddeck.launcher.runtime.WowAddons
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
private class AddonResults {
|
||||
val items = mutableStateListOf<WowAddonCatalog.Addon>()
|
||||
var page by mutableIntStateOf(0)
|
||||
var loading by mutableStateOf(false)
|
||||
var failed by mutableStateOf<String?>(null)
|
||||
var more by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AddonBrowserDialog(targets: List<WowAddons.Target>, initial: WowAddons.Target, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
var target by remember { mutableStateOf(initial) }
|
||||
var text by remember { mutableStateOf("") }
|
||||
var category by remember { mutableStateOf<WowAddonCatalog.Category?>(null) }
|
||||
var sort by remember { mutableStateOf(WowAddonCatalog.Sort.POPULAR) }
|
||||
val results = remember(text, category, sort, target) { AddonResults() }
|
||||
val installing = remember { mutableStateMapOf<String, Int>() }
|
||||
val installed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val categories by produceState(emptyList<WowAddonCatalog.Category>()) {
|
||||
value = withContext(Dispatchers.IO) { runCatching { WowAddonCatalog.categories(context) }.getOrDefault(emptyList()) }
|
||||
}
|
||||
fun load(page: Int) {
|
||||
if (results.loading) return
|
||||
results.loading = true
|
||||
results.failed = null
|
||||
val into = target
|
||||
val words = text
|
||||
val cat = category?.id
|
||||
val order = sort
|
||||
scope.launch {
|
||||
val got = withContext(Dispatchers.IO) {
|
||||
runCatching { WowAddonCatalog.search(context, WowAddonCatalog.Query(words, cat, WowAddonCatalog.client(into), order), page) }
|
||||
}
|
||||
got.onSuccess {
|
||||
if (page == 0) results.items.clear()
|
||||
results.items.addAll(it.items.filter { addon -> results.items.none { seen -> seen.id == addon.id } })
|
||||
results.page = page
|
||||
results.more = it.more
|
||||
}.onFailure { results.failed = it.message ?: it.javaClass.simpleName }
|
||||
results.loading = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(results) {
|
||||
if (text.isNotEmpty()) delay(350)
|
||||
load(0)
|
||||
}
|
||||
fun install(addon: WowAddonCatalog.Addon) {
|
||||
val key = installKey(addon, target)
|
||||
if (key in installing) return
|
||||
if (SessionState.running) {
|
||||
Toast.makeText(context, R.string.main_addons_close_session, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
val into = target
|
||||
installing[key] = 0
|
||||
scope.launch {
|
||||
val message = withContext(Dispatchers.IO) { installAddon(context, addon, into) { pct -> scope.launch { if (key in installing) installing[key] = pct } } }
|
||||
installing.remove(key)
|
||||
if (message.second) installed[key] = true
|
||||
Toast.makeText(context, message.first, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
val sourceName = stringResource(R.string.addons_source_wowi)
|
||||
AppDialog(shown, close, "addonBrowser", wide = true, scrollable = false) {
|
||||
DialogTitleRow(stringResource(R.string.addons_download), close)
|
||||
ModeHeader(sourceName, text, { text = it }, stringResource(R.string.stores_search_catalog, sourceName)) {
|
||||
BrowseChip(stringResource(R.string.addons_source_curse), false) {
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(WowAddonCatalog.curseForgeUrl(text))).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
}
|
||||
ChipRow {
|
||||
BrowseDropdown(sortLabel(sort)) { first, done ->
|
||||
WowAddonCatalog.Sort.entries.forEachIndexed { index, option ->
|
||||
MenuItem(sortLabel(option), sort == option, focusRequester = if (index == 0) first else null) { sort = option; done() }
|
||||
}
|
||||
}
|
||||
if (categories.isNotEmpty()) BrowseDropdown(category?.name ?: stringResource(R.string.addons_category_all)) { first, done ->
|
||||
MenuItem(stringResource(R.string.addons_category_all), category == null, focusRequester = first) { category = null; done() }
|
||||
categories.forEach { option -> MenuItem(option.name, category?.id == option.id) { category = option; done() } }
|
||||
}
|
||||
if (targets.size > 1) BrowseDropdown(target.flavor) { first, done ->
|
||||
targets.forEachIndexed { index, option ->
|
||||
MenuItem(option.flavor, option.addOns == target.addOns, focusRequester = if (index == 0) first else null) { target = option; done() }
|
||||
}
|
||||
}
|
||||
}
|
||||
val height = (LocalConfiguration.current.screenHeightDp * 0.62f).dp
|
||||
LazyVerticalGrid(
|
||||
GridCells.Adaptive(libraryTileWidth() + 40.dp), Modifier.fillMaxWidth().height(height),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(results.items, key = { it.id }) { addon ->
|
||||
AddonCard(addon, installing[installKey(addon, target)], installed[installKey(addon, target)] == true) { install(addon) }
|
||||
}
|
||||
item(key = "tail", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
results.loading -> LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
results.failed != null -> BrowseChip(stringResource(R.string.stores_dl_retry), false) { load(if (results.items.isEmpty()) 0 else results.page + 1) }
|
||||
results.more -> BrowseChip(stringResource(R.string.stores_itch_load_more), false) { load(results.page + 1) }
|
||||
results.items.isEmpty() -> Text(stringResource(R.string.addons_none_found), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddonCard(addon: WowAddonCatalog.Addon, progress: Int?, done: Boolean, onInstall: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clip(shape).glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onInstall)
|
||||
.controllerConfirm(onClick = onInstall),
|
||||
) {
|
||||
ArtCard(addon.name, addon.thumb, null, null, hot, capsule = false, chip = progress?.let { "$it%" }) {
|
||||
if (done) Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.height(11.dp))
|
||||
else Text(stringResource(if (progress != null) R.string.store_installing else R.string.store_install), fontSize = 10.sp, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
Text(
|
||||
listOf(addon.author, formatCount(addon.downloads)).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun sortLabel(sort: WowAddonCatalog.Sort): String = stringResource(
|
||||
when (sort) {
|
||||
WowAddonCatalog.Sort.POPULAR -> R.string.stores_itch_sort_popular
|
||||
WowAddonCatalog.Sort.UPDATED -> R.string.stores_itch_sort_new
|
||||
WowAddonCatalog.Sort.NAME -> R.string.addons_sort_name
|
||||
WowAddonCatalog.Sort.DOWNLOADS -> R.string.addons_sort_downloads
|
||||
},
|
||||
)
|
||||
|
||||
private fun formatCount(count: Long): String = when {
|
||||
count <= 0 -> ""
|
||||
count >= 1_000_000 -> String.format(java.util.Locale.getDefault(), "%.1fM", count / 1_000_000.0)
|
||||
count >= 1_000 -> String.format(java.util.Locale.getDefault(), "%.1fK", count / 1_000.0)
|
||||
else -> count.toString()
|
||||
}
|
||||
|
||||
private fun installAddon(context: Context, addon: WowAddonCatalog.Addon, target: WowAddons.Target, progress: (Int) -> Unit): Pair<String, Boolean> {
|
||||
val zip = File(File(context.cacheDir, "wowaddons"), "download-wowi-${addon.id.filter(Char::isLetterOrDigit)}-${java.util.UUID.randomUUID()}.zip")
|
||||
return try {
|
||||
WowAddonCatalog.download(context, addon, zip, progress)
|
||||
if (SessionState.running) return context.getString(R.string.main_addons_close_session) to false
|
||||
val names = WowAddons.import(zip, target)
|
||||
if (names.isEmpty()) context.getString(R.string.main_addons_none, addon.name) to false
|
||||
else context.resources.getQuantityString(R.plurals.main_addons_imported, names.size, names.size, target.flavor) to true
|
||||
} catch (_: WowAddons.TooLarge) {
|
||||
context.getString(R.string.main_addons_too_large, addon.name) to false
|
||||
} catch (e: Exception) {
|
||||
context.getString(R.string.stores_dl_failed_reason, e.message ?: e.javaClass.simpleName) to false
|
||||
} finally {
|
||||
zip.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun installKey(addon: WowAddonCatalog.Addon, target: WowAddons.Target) = addon.id + "|" + target.addOns.path
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.DeckPanel
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
@@ -59,6 +61,8 @@ internal class BarExtra(
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
internal class BarTabs(val labels: List<String>, val selected: Int, val onSelect: (Int) -> Unit)
|
||||
|
||||
internal class BottomBarModel(
|
||||
val title: String?,
|
||||
val onClose: (() -> Unit)?,
|
||||
@@ -67,10 +71,22 @@ internal class BottomBarModel(
|
||||
val onQam: (() -> Unit)?,
|
||||
val extras: List<BarExtra> = emptyList(),
|
||||
val menuActive: Boolean = false,
|
||||
val tabs: BarTabs? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun BottomScreenBar(model: BottomBarModel, modifier: Modifier = Modifier) {
|
||||
val tabs = model.tabs
|
||||
if (tabs == null) BarRow(model, modifier) else Column(modifier.fillMaxWidth().blockTouches()) {
|
||||
BarRow(model)
|
||||
Box(Modifier.fillMaxWidth().padding(start = BAR_PAD, end = BAR_PAD, bottom = 8.dp), contentAlignment = Alignment.Center) {
|
||||
TabStrip(tabs.labels, tabs.selected, tabs.onSelect, Modifier.testTag("bottom-bar-tabs"), bumpers = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarRow(model: BottomBarModel, modifier: Modifier = Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
BoxWithConstraints(modifier.fillMaxWidth().height(BAR_HEIGHT).blockTouches()) {
|
||||
val buttons = listOfNotNull(model.onClose, model.onMenu, model.onQam).size + model.extras.size
|
||||
@@ -195,11 +211,15 @@ internal fun sessionBarModel(
|
||||
onSteam: () -> Unit,
|
||||
onQam: () -> Unit,
|
||||
extras: List<BarExtra> = emptyList(),
|
||||
tabs: BarTabs? = null,
|
||||
): BottomBarModel {
|
||||
val steam = SessionState.mode == SessionService.MODE_STEAM
|
||||
return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras)
|
||||
return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras, tabs = tabs)
|
||||
}
|
||||
|
||||
internal fun deckPanelTabs(context: Context, selected: DeckPanel, onSelect: (DeckPanel) -> Unit) =
|
||||
BarTabs(DeckPanel.entries.map { context.getString(it.label) }, selected.ordinal) { onSelect(DeckPanel.entries[it]) }
|
||||
|
||||
internal fun bottomScreenBarView(context: Context, model: () -> BottomBarModel): ComposeView = ComposeView(context).apply {
|
||||
setContent { DroidDeckTheme { BottomScreenBar(model()) } }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -110,7 +111,16 @@ internal class BottomScreenTextInput(private val fallback: TextInputService) : P
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
|
||||
internal fun TypingPanel(
|
||||
request: TypingRequest,
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
title: String = stringResource(R.string.dual_typing_title),
|
||||
done: String = stringResource(R.string.dual_typing_done),
|
||||
onCancel: (() -> Unit)? = null,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
showKeyboard: Boolean = true,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
var field by remember(request) { mutableStateOf(request.value) }
|
||||
LaunchedEffect(request.value) {
|
||||
@@ -120,19 +130,22 @@ internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(request) {
|
||||
kotlinx.coroutines.delay(120)
|
||||
if (!showKeyboard) return@LaunchedEffect
|
||||
runCatching { focus.requestFocus() }
|
||||
keyboard?.show()
|
||||
}
|
||||
val options = request.imeOptions
|
||||
val secret = options.keyboardType == KeyboardType.Password || options.keyboardType == KeyboardType.NumberPassword
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
modifier.background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.dual_typing_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
leading?.invoke()
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.weight(1f))
|
||||
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(stringResource(R.string.dual_typing_done)) }
|
||||
if (onCancel != null) OutlinedButton(onClick = onCancel, modifier = Modifier.testTag("dual-typing-cancel")) { Text(stringResource(R.string.common_cancel)) }
|
||||
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(done) }
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = field,
|
||||
|
||||
@@ -81,6 +81,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.ComponentUpdater
|
||||
import com.droiddeck.launcher.session.ComponentUpdates
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
|
||||
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
|
||||
@@ -163,6 +165,11 @@ fun ComponentsPage(
|
||||
protons: ProtonsState = ProtonsState(),
|
||||
protonActions: ProtonsActions = ProtonsActions(),
|
||||
protonOptions: @Composable () -> Unit = {},
|
||||
compModes: Map<String, String> = emptyMap(),
|
||||
compAuto: Map<String, List<ComponentUpdater.Status>> = emptyMap(),
|
||||
compAutoHeld: Set<String> = emptySet(),
|
||||
compAutoChecking: Boolean = false,
|
||||
onCompMode: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -521,6 +528,9 @@ fun ComponentsPage(
|
||||
else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size)
|
||||
}
|
||||
}
|
||||
val followed = view?.proton?.let(ComponentUpdates::follows) == true
|
||||
val moded: (String) -> Boolean = { followed && it in compModes && it !in compAutoHeld && !ComponentsManager.runtimeManaged(it, gpu.systemPowerVr) }
|
||||
val automatic: (String) -> Boolean = { moded(it) && compModes[it] == ComponentUpdates.AUTO }
|
||||
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
|
||||
when (l) {
|
||||
PROTON_TAB -> {
|
||||
@@ -535,7 +545,7 @@ fun ComponentsPage(
|
||||
(if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version))
|
||||
.let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it },
|
||||
v.proton.id == view?.proton?.id, removable = removable,
|
||||
modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
modifier = (if (i == 0 && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } },
|
||||
onDelete = {
|
||||
if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), ctx.getString(R.string.proton_remove_body), ctx.getString(R.string.store_remove), true, v.proton.name) {
|
||||
@@ -555,7 +565,7 @@ fun ComponentsPage(
|
||||
val key = "row:$l:${item.file ?: item.protonVersion}"
|
||||
ChoiceLine(
|
||||
item.name, item.detail, item.selected, item.removable, tag = item.tag, enabled = !managedDxvk,
|
||||
modifier = (if (i == 0 && !managedDxvk) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
modifier = (if (i == 0 && !managedDxvk && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
onSelect = {
|
||||
if (managedDxvk || item.selected || p == null) return@ChoiceLine
|
||||
val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long)
|
||||
@@ -680,15 +690,31 @@ fun ComponentsPage(
|
||||
label = "layerRows",
|
||||
) { k -> body(k) }
|
||||
}
|
||||
if (single) {
|
||||
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
|
||||
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
|
||||
if (moded(l)) slide { k ->
|
||||
if (moded(k)) Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(bottom = 14.dp)) {
|
||||
PanelHeading(stringResource(R.string.comp_mode_title))
|
||||
ModeChoices(
|
||||
listOf(
|
||||
Triple(ComponentUpdates.AUTO, R.string.common_auto, R.string.comp_mode_auto_detail),
|
||||
Triple(ComponentUpdates.MANUAL, R.string.drivers_manual, R.string.comp_mode_manual_detail),
|
||||
),
|
||||
if (automatic(k)) ComponentUpdates.AUTO else ComponentUpdates.MANUAL, single, firstRow,
|
||||
onPick = { onCompMode(k, it) },
|
||||
)
|
||||
if (automatic(k)) AutoComponentBody(ComponentsManager.LABEL[k] ?: k, compAuto[k].orEmpty(), compAutoChecking)
|
||||
}
|
||||
}
|
||||
if (!automatic(l)) {
|
||||
if (single) {
|
||||
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
|
||||
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
|
||||
}
|
||||
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
|
||||
}
|
||||
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
|
||||
if (l == "fex") {
|
||||
SettingsGroup(stringResource(R.string.fex_preset_title)) {
|
||||
SettingsRow(stringResource(R.string.fex_preset_title), null) {
|
||||
@@ -1078,6 +1104,25 @@ private fun RowGroup(title: String, content: @Composable ColumnScope.() -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutoComponentBody(label: String, lines: List<ComponentUpdater.Status>, checking: Boolean) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PanelTitle(stringResource(R.string.comp_auto_title, label), stringResource(R.string.comp_auto_hint))
|
||||
if (lines.isEmpty()) EmptyLine(stringResource(R.string.comp_auto_none))
|
||||
for (line in lines) {
|
||||
val previous = line.previous.removeSuffix(".wcp").ifEmpty { stringResource(R.string.comp_tag_original) }
|
||||
val fallback = if (line.current.isEmpty() || line.current == line.previous) null else stringResource(R.string.comp_auto_fallback, previous)
|
||||
PanelRow(
|
||||
line.current.removeSuffix(".wcp").ifEmpty { stringResource(if (checking) R.string.comp_auto_checking else R.string.comp_tag_original) },
|
||||
listOfNotNull(line.protons.joinToString(", ").ifEmpty { null }, fallback).joinToString(" · "),
|
||||
selected = true, onClick = null,
|
||||
)
|
||||
}
|
||||
lines.map { it.note }.filter { it.isNotEmpty() }.distinct().forEach { Text(it, fontSize = 13.sp, color = colors.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLine(text: String) {
|
||||
Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp))
|
||||
@@ -1138,6 +1183,7 @@ private fun GameComponentOverrides(
|
||||
open = fexMenu,
|
||||
enabled = config != null && !busy,
|
||||
allowInheritance = true,
|
||||
generalPreset = generalFexPreset,
|
||||
onToggle = { onFexMenu(!fexMenu) },
|
||||
onDismiss = { onFexMenu(false) },
|
||||
onPick = onFexPreset,
|
||||
@@ -1304,15 +1350,16 @@ internal fun FexPresetChip(
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String?) -> Unit,
|
||||
allowInheritance: Boolean = false,
|
||||
generalPreset: String = preset,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val chosen = FexPreset.all.firstOrNull { it.id == preset }
|
||||
val chosen = FexPreset.all.firstOrNull { it.id == if (inherited) generalPreset else preset }
|
||||
val label = when {
|
||||
preset == GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom)
|
||||
chosen == null -> stringResource(FexPreset.byId("").label)
|
||||
inherited -> stringResource(R.string.comp_game_use_general)
|
||||
else -> stringResource(chosen.label)
|
||||
}
|
||||
val general = FexPreset.all.firstOrNull { it.id == generalPreset }
|
||||
Box {
|
||||
ValueChip(label, open, enabled = enabled, modifier = modifier.widthIn(max = 260.dp), onClick = onToggle)
|
||||
AnchoredMenu(open && enabled, onDismiss = onDismiss, title = stringResource(R.string.fex_preset_title)) { first ->
|
||||
@@ -1320,6 +1367,7 @@ internal fun FexPresetChip(
|
||||
MenuItem(
|
||||
stringResource(R.string.comp_game_use_general),
|
||||
checked = inherited,
|
||||
detail = general?.let { stringResource(it.label) },
|
||||
focusRequester = first,
|
||||
) { onPick(null); onDismiss() }
|
||||
}
|
||||
|
||||
@@ -1,111 +1,20 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
class ControllerActions(
|
||||
val onOsc: (String) -> Unit,
|
||||
val onTint: (Int) -> Unit,
|
||||
val onOpacity: (Int) -> Unit,
|
||||
val onSize: (Int) -> Unit,
|
||||
val onStickClick: (Boolean) -> Unit,
|
||||
val onAdaptiveSticks: (Boolean) -> Unit,
|
||||
val onRumble: (Boolean) -> Unit,
|
||||
val onDeadZone: (Int) -> Unit,
|
||||
val onSteamButton: (Boolean) -> Unit,
|
||||
val onQamButton: (Boolean) -> Unit,
|
||||
val onKeyboardButton: (Boolean) -> Unit,
|
||||
val onSelectSteam: (Boolean) -> Unit,
|
||||
val onJoyConVr: (Boolean) -> Unit,
|
||||
val onEditLayout: () -> Unit,
|
||||
val onResetLayout: () -> Unit,
|
||||
val onMapping: () -> Unit,
|
||||
val onResetAll: () -> Unit,
|
||||
)
|
||||
class ControllerActions(val onMapping: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun Swatch(color: Int) {
|
||||
Box(Modifier.size(14.dp).background(Color(color), CircleShape).border(1.dp, Color.White.copy(alpha = 0.35f), CircleShape))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
|
||||
ChoiceRow(
|
||||
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
|
||||
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
val tintOpen = host.open == "controller-tint"
|
||||
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
|
||||
Box {
|
||||
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second?.let { stringResource(it) } ?: stringResource(R.string.ctrl_custom), tintOpen) {
|
||||
host.open = if (tintOpen) null else "controller-tint"
|
||||
}
|
||||
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
|
||||
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
|
||||
MenuItem(stringResource(name), checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTint(color)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ChoiceRow(host, "controller-opacity", stringResource(R.string.ctrl_opacity), null, ControllerPrefs.opacities.map { it to stringResource(R.string.ctrl_percent, it) }, c.opacity, onPick = a.onOpacity)
|
||||
ChoiceRow(host, "controller-size", stringResource(R.string.ctrl_size), stringResource(R.string.ctrl_size_hint), ControllerPrefs.sizes.map { it to stringResource(R.string.ctrl_percent, it) }, c.size, onPick = a.onSize)
|
||||
ToggleRow(host, "controller-stick-click", stringResource(R.string.ctrl_stick_click), stringResource(R.string.ctrl_stick_click_hint), c.stickClick, onChange = a.onStickClick)
|
||||
ToggleRow(host, "controller-adaptive", stringResource(R.string.ctrl_adaptive), stringResource(R.string.ctrl_adaptive_hint), c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
ToggleRow(host, "controller-rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, onChange = a.onRumble)
|
||||
ChoiceRow(host, "controller-dead-zone", stringResource(R.string.ctrl_dead_zone), null, ControllerPrefs.deadZones.map { it to stringResource(R.string.ctrl_percent, it) }, c.deadZone, onPick = a.onDeadZone)
|
||||
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
|
||||
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
|
||||
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
|
||||
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
|
||||
ToggleRow(host, "controller-joycon-vr", stringResource(R.string.ctrl_joycon_vr), stringResource(R.string.ctrl_joycon_vr_hint), c.joyConVr, onChange = a.onJoyConVr)
|
||||
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
|
||||
SecondaryButton(stringResource(R.string.ctrl_reset), enabled = c.customLayout) { a.onResetLayout() }
|
||||
}
|
||||
}
|
||||
val remapped = c.mapping.count { (id, target) -> id != target }
|
||||
ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping)
|
||||
ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String) -> Unit, onReset: () -> Unit, onBack: () -> Unit) {
|
||||
val host = rememberMenuHost()
|
||||
SettingsPage(
|
||||
host, title = stringResource(R.string.ctrl_mapping), eyebrow = stringResource(R.string.setup_controller),
|
||||
lede = stringResource(R.string.ctrl_mapping_lede),
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsGroup(stringResource(R.string.shortcuts_title)) {
|
||||
PhysicalShortcutRows()
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
|
||||
val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) }
|
||||
for ((id, name) in ControllerPrefs.mappable) {
|
||||
ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) }
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
|
||||
ActionRow(stringResource(R.string.ctrl_reset_mapping), stringResource(R.string.ctrl_mapping_default), stringResource(R.string.ctrl_reset), onReset)
|
||||
}
|
||||
internal fun rememberControllerSettings(): ControllerPrefs.Settings {
|
||||
val context = LocalContext.current
|
||||
var settings by remember { mutableStateOf(ControllerPrefs.read(context)) }
|
||||
DisposableEffect(context) {
|
||||
val prefs = ControllerPrefs.prefs(context)
|
||||
val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> settings = ControllerPrefs.read(context) }
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}
|
||||
return settings
|
||||
}
|
||||
@@ -1,720 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class DeckControlsPanel(
|
||||
context: Context,
|
||||
private val onClose: () -> Unit,
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
private val portraitDock: Boolean = false,
|
||||
private val onMenu: (() -> Unit)? = null,
|
||||
private val onSwap: (() -> Unit)? = null,
|
||||
) : LinearLayout(context) {
|
||||
private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
|
||||
|
||||
private val content = FrameLayout(context)
|
||||
val deckControls = pad == null || SessionState.deckPad
|
||||
private val tabViews = HashMap<Tab, TextView>()
|
||||
private val topBar: View by lazy { tabsRow() }
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
val title = context.getString(if (deckControls) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad)
|
||||
val extras = listOfNotNull(onSwap?.let { BarExtra(context.getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, onClick = it) })
|
||||
addView(bottomScreenBarView(context) { sessionBarModel(title, onClose, onMenu, onSteamMenu, onQam, extras) },
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(52)))
|
||||
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
select(if (pad != null && !deckControls) Tab.GAMEPAD else if (pad != null) selectedWithPad else selected)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
DeckControls.releaseAll()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
private fun tabsRow(): View = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(dp(12), 0, dp(12), dp(8))
|
||||
val tabs = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setPadding(dp(4), dp(4), dp(4), dp(4))
|
||||
for (tab in Tab.values()) {
|
||||
if (!deckControls && tab != Tab.GAMEPAD) continue
|
||||
if (tab == Tab.GAMEPAD && pad == null) continue
|
||||
val view = TextView(context).apply {
|
||||
text = context.getString(tab.label)
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
isSingleLine = true
|
||||
setPadding(dp(14), 0, dp(14), 0)
|
||||
setOnClickListener { select(tab) }
|
||||
}
|
||||
tabViews[tab] = view
|
||||
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
}
|
||||
val centre = HorizontalScrollView(context).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44)))
|
||||
}
|
||||
addView(centre, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
|
||||
private fun select(tab: Tab) {
|
||||
if (pad != null) selectedWithPad = tab else selected = tab
|
||||
DeckControls.releaseAll()
|
||||
for ((each, view) in tabViews) {
|
||||
val on = each == tab
|
||||
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
|
||||
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
|
||||
}
|
||||
tabViews[tab]?.let { selectedView ->
|
||||
(selectedView.parent?.parent as? HorizontalScrollView)?.post {
|
||||
val left = selectedView.left
|
||||
val right = selectedView.right
|
||||
val scroll = selectedView.parent?.parent as? HorizontalScrollView ?: return@post
|
||||
when {
|
||||
left < scroll.scrollX -> scroll.smoothScrollTo(left, 0)
|
||||
right > scroll.scrollX + scroll.width -> scroll.smoothScrollTo(right - scroll.width, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
content.removeAllViews()
|
||||
topBar.visibility = if (tab == Tab.GAMEPAD || tabViews.size < 2) View.GONE else View.VISIBLE
|
||||
content.addView(
|
||||
when (tab) {
|
||||
Tab.GRIPS -> gripsGrid()
|
||||
Tab.LEFT -> singlePad(right = false)
|
||||
Tab.RIGHT -> singlePad(right = true)
|
||||
Tab.BOTH -> bothPads()
|
||||
Tab.GAMEPAD -> gamepad(pad ?: return)
|
||||
},
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gripsGrid(): View = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
fun column(upper: View, lower: View) = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) })
|
||||
addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) })
|
||||
}
|
||||
addView(column(GripButton(context, "L4", context.getString(R.string.deck_left_upper), DeckControls.L4), GripButton(context, "L5", context.getString(R.string.deck_left_lower), DeckControls.L5)),
|
||||
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
|
||||
addView(column(GripButton(context, "R4", context.getString(R.string.deck_right_upper), DeckControls.R4), GripButton(context, "R5", context.getString(R.string.deck_right_lower), DeckControls.R5)),
|
||||
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
|
||||
}
|
||||
|
||||
private fun singlePad(right: Boolean): View = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
|
||||
addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76)))
|
||||
}
|
||||
|
||||
private fun bothPads(): View = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
val pads = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
|
||||
addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
|
||||
}
|
||||
addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
|
||||
val grips = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4)
|
||||
row.forEachIndexed { index, (label, bit) ->
|
||||
addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
|
||||
if (index > 0) leftMargin = dp(6)
|
||||
if (index < row.size - 1) rightMargin = dp(6)
|
||||
})
|
||||
}
|
||||
}
|
||||
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
|
||||
}
|
||||
|
||||
private fun gamepad(pad: PadBridge): View = GamepadLayout(context, portraitDock).apply {
|
||||
fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write)
|
||||
place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN)
|
||||
place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND)
|
||||
place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN)
|
||||
place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER)
|
||||
place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER)
|
||||
place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER)
|
||||
place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER)
|
||||
place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW)
|
||||
place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU)
|
||||
if (deckControls) place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) },
|
||||
GamepadGeometry.Slot.PANELS)
|
||||
if (portraitDock && deckControls) {
|
||||
place(GripButton(context, "L4", null, DeckControls.L4), GamepadGeometry.Slot.L4)
|
||||
place(GripButton(context, "L5", null, DeckControls.L5), GamepadGeometry.Slot.L5)
|
||||
place(GripButton(context, "R5", null, DeckControls.R5), GamepadGeometry.Slot.R5)
|
||||
place(GripButton(context, "R4", null, DeckControls.R4), GamepadGeometry.Slot.R4)
|
||||
}
|
||||
}
|
||||
|
||||
private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = 13f
|
||||
maxLines = 1
|
||||
setAutoSizeTextTypeUniformWithConfiguration(9, 13, 1, android.util.TypedValue.COMPLEX_UNIT_SP)
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = 0.08f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply {
|
||||
if (!deckControls) {
|
||||
addView(StickView(context, pad, right),
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
return@apply
|
||||
}
|
||||
val slot = this
|
||||
fun show() {
|
||||
slot.removeAllViews()
|
||||
val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK
|
||||
slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false),
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
val chip = TextView(context).apply {
|
||||
text = "\u21c4"
|
||||
contentDescription = context.getString(R.string.deck_surface_switch)
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT_DIM)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
SessionPrefs.setGamepadSurface(context, right,
|
||||
if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK)
|
||||
show()
|
||||
}
|
||||
}
|
||||
slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30),
|
||||
(if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) })
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
fun takesTouchAt(x: Float, y: Float): Boolean = controlUnder(this, x, y)
|
||||
|
||||
private fun controlUnder(view: View, x: Float, y: Float): Boolean {
|
||||
if (view.visibility != VISIBLE || x < 0f || y < 0f || x >= view.width || y >= view.height) return false
|
||||
if (view.isClickable || view is HorizontalScrollView || view is Surface || view is DirectionPad || view is FaceButtons ||
|
||||
view is androidx.compose.ui.platform.ComposeView) return true
|
||||
val group = view as? ViewGroup ?: return false
|
||||
return (group.childCount - 1 downTo 0).any { i ->
|
||||
val child = group.getChildAt(i)
|
||||
controlUnder(child, x + group.scrollX - child.x, y + group.scrollY - child.y)
|
||||
}
|
||||
}
|
||||
|
||||
private fun outline(radius: Float, fill: Int) = outline(context, radius, fill)
|
||||
|
||||
private fun filled(radius: Float, fill: Int) = GradientDrawable().apply {
|
||||
cornerRadius = radius * resources.displayMetrics.density
|
||||
setColor(fill)
|
||||
}
|
||||
|
||||
private fun dp(value: Int): Int = dp(context, value)
|
||||
|
||||
|
||||
private abstract class Surface(context: Context) : View(context) {
|
||||
protected val density = resources.displayMetrics.density
|
||||
protected val rect = RectF()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
color = TEXT_DIM
|
||||
}
|
||||
|
||||
protected fun drawSurface(canvas: Canvas, lit: Boolean) {
|
||||
val inset = density
|
||||
rect.set(inset, inset, width - inset, height - inset)
|
||||
val radius = CORNER * density
|
||||
fill.color = if (lit) ACCENT_WASH else SURFACE
|
||||
canvas.drawRoundRect(rect, radius, radius, fill)
|
||||
stroke.color = if (lit) ACCENT else LINE
|
||||
stroke.strokeWidth = (if (lit) 2f else 1f) * density
|
||||
canvas.drawRoundRect(rect, radius, radius, stroke)
|
||||
}
|
||||
}
|
||||
|
||||
private open class HoldButton(
|
||||
context: Context,
|
||||
private val label: String,
|
||||
private val caption: String?,
|
||||
private val onChange: (Boolean) -> Unit,
|
||||
) : Surface(context) {
|
||||
private var down = false
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, down)
|
||||
val cx = width / 2f
|
||||
labelPaint.textSize = min(height * 0.3f, 30 * density)
|
||||
labelPaint.color = if (down) ACCENT else TEXT
|
||||
val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f)
|
||||
canvas.drawText(label, cx, labelY, labelPaint)
|
||||
if (caption != null) {
|
||||
captionPaint.textSize = 12 * density
|
||||
canvas.drawText(caption, cx, labelY + 22 * density, captionPaint)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> set(true)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(pressed: Boolean) {
|
||||
if (down == pressed) return
|
||||
down = pressed
|
||||
if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onChange(pressed)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
|
||||
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
|
||||
|
||||
private class ClickBar(context: Context, right: Boolean) :
|
||||
HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad), { DeckControls.setClick(right, it) })
|
||||
|
||||
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicked = false
|
||||
private var fingerX = 0f
|
||||
private var fingerY = 0f
|
||||
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
|
||||
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
|
||||
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicked)
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
guide.strokeWidth = density
|
||||
val reach = min(width, height) / 2f - 24 * density
|
||||
canvas.drawCircle(cx, cy, reach, guide)
|
||||
canvas.drawCircle(cx, cy, reach / 2f, guide)
|
||||
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
|
||||
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
|
||||
captionPaint.textSize = 12 * density
|
||||
captionPaint.color = if (touching) ACCENT else TEXT_DIM
|
||||
if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (touching) {
|
||||
val radius = 56 * density
|
||||
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
canvas.drawCircle(fingerX, fingerY, radius, glow)
|
||||
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
touch(event.getX(0), event.getY(0))
|
||||
click(event.pointerCount > 1)
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> click(true)
|
||||
MotionEvent.ACTION_POINTER_UP -> {
|
||||
val stays = if (event.actionIndex == 0) 1 else 0
|
||||
touch(event.getX(stays), event.getY(stays))
|
||||
click(event.pointerCount - 1 > 1)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
click(false)
|
||||
touching = false
|
||||
DeckControls.setPad(right, touching = false)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun touch(x: Float, y: Float) {
|
||||
fingerX = x.coerceIn(0f, width.toFloat())
|
||||
fingerY = y.coerceIn(0f, height.toFloat())
|
||||
touching = true
|
||||
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun click(down: Boolean) {
|
||||
if (clicked == down) return
|
||||
clicked = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
DeckControls.setClick(right, down)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private class PadButton(
|
||||
context: Context,
|
||||
private val pad: PadBridge,
|
||||
label: String,
|
||||
private val write: (PadState, Boolean) -> Unit,
|
||||
) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) {
|
||||
override fun onDetachedFromWindow() {
|
||||
pad.applyTouch { write(it, false) }
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicking = false
|
||||
private var lastUp = 0L
|
||||
private var originX = 0f
|
||||
private var originY = 0f
|
||||
private var knobX = 0f
|
||||
private var knobY = 0f
|
||||
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private fun reach() = min(width, height) * 0.30f
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicking)
|
||||
val cx = if (touching) originX else width / 2f
|
||||
val cy = if (touching) originY else height / 2f
|
||||
ring.strokeWidth = 2 * density
|
||||
ring.color = if (touching) ACCENT else LINE
|
||||
canvas.drawCircle(cx, cy, reach(), ring)
|
||||
knob.color = if (touching) ACCENT else KNOB
|
||||
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val r = reach().coerceAtLeast(1f)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r))
|
||||
originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r))
|
||||
touching = true
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
if (event.eventTime - lastUp < CLICK_MS) setClick(true)
|
||||
follow(event.x, event.y, r)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
touching = false
|
||||
lastUp = event.eventTime
|
||||
setClick(false)
|
||||
move(0f, 0f, 1f)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun follow(x: Float, y: Float, r: Float) {
|
||||
var dx = x - originX
|
||||
var dy = y - originY
|
||||
val length = hypot(dx, dy)
|
||||
if (length > r) { dx *= r / length; dy *= r / length }
|
||||
move(dx, dy, r)
|
||||
}
|
||||
|
||||
private fun move(dx: Float, dy: Float, r: Float) {
|
||||
knobX = dx
|
||||
knobY = dy
|
||||
pad.applyTouch(if (right) PadBridge.RIGHT_STICK else PadBridge.LEFT_STICK) { s ->
|
||||
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun setClick(down: Boolean) {
|
||||
if (clicking == down) return
|
||||
clicking = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) }
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
setClick(false)
|
||||
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val rect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val size = min(width, height) * 0.86f
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val half = size / 2f
|
||||
val thick = size / 3f / 2f
|
||||
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
|
||||
arm.color = if (held and bit != 0) ACCENT else KNOB
|
||||
rect.set(l, t, r, b)
|
||||
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
|
||||
}
|
||||
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
|
||||
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
|
||||
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
|
||||
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
|
||||
arm.color = KNOB
|
||||
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val bits = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
val dx = event.getX(0) - width / 2f
|
||||
val dy = event.getY(0) - height / 2f
|
||||
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
|
||||
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
|
||||
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
|
||||
}
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
set(bits)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(bits: Int) {
|
||||
if (bits == held) return
|
||||
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
held = bits
|
||||
pad.applyTouch { s ->
|
||||
s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(0)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X")
|
||||
private var held = emptySet<Int>()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
private fun layout(): Pair<List<Pair<Float, Float>>, Float> {
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val spread = min(width, height) * 0.30f
|
||||
val radius = min(width, height) * 0.16f
|
||||
return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val (centres, radius) = layout()
|
||||
text.textSize = radius * 0.9f
|
||||
buttons.forEachIndexed { i, (button, label) ->
|
||||
val (x, y) = centres[i]
|
||||
val down = button in held
|
||||
fill.color = if (down) ACCENT else KNOB
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
text.color = if (down) ON_ACCENT else TEXT
|
||||
canvas.drawText(label, x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val (centres, radius) = layout()
|
||||
val now = HashSet<Int>()
|
||||
val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
|
||||
if (!ending) for (p in 0 until event.pointerCount) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue
|
||||
val x = event.getX(p)
|
||||
val y = event.getY(p)
|
||||
val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue
|
||||
if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first)
|
||||
}
|
||||
set(now)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(now: Set<Int>) {
|
||||
if (now == held) return
|
||||
if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
val before = held
|
||||
held = now
|
||||
pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) }
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(emptySet())
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class GamepadLayout(context: Context, private val portraitDock: Boolean) : ViewGroup(context) {
|
||||
private val slots = LinkedHashMap<View, GamepadGeometry.Slot>()
|
||||
|
||||
fun place(view: View, slot: GamepadGeometry.Slot) {
|
||||
slots[view] = slot
|
||||
addView(view)
|
||||
}
|
||||
|
||||
private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density, portraitDock)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(w, h)
|
||||
val boxes = boxes(w, h)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot]
|
||||
view.visibility = if (box == null) View.GONE else View.VISIBLE
|
||||
if (box == null) continue
|
||||
view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
val boxes = boxes(r - l, b - t)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot] ?: continue
|
||||
val left = box.left.toInt()
|
||||
val top = box.top.toInt()
|
||||
view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CLICK_MS = 300L
|
||||
|
||||
private var selected = Tab.BOTH
|
||||
private var selectedWithPad = Tab.BOTH
|
||||
|
||||
private const val CORNER = 20f
|
||||
private const val CORNER_PILL = 22f
|
||||
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
|
||||
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
|
||||
private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
|
||||
private val LINE = Color.rgb(0x26, 0x29, 0x2E)
|
||||
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
|
||||
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
|
||||
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
|
||||
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
|
||||
|
||||
internal fun dp(context: Context, value: Int): Int = (value * context.resources.displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
internal fun outline(context: Context, radius: Float, fill: Int) = GradientDrawable().apply {
|
||||
cornerRadius = radius * context.resources.displayMetrics.density
|
||||
setColor(fill)
|
||||
setStroke(dp(context, 1), LINE)
|
||||
}
|
||||
|
||||
internal fun roundButton(context: Context, label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
|
||||
TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = if (wide) 13f else 20f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = if (wide) 0.08f else 0f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(context, CORNER_PILL, SURFACE)
|
||||
setPadding(if (wide) dp(context, 16) else 0, 0, if (wide) dp(context, 16) else 0, 0)
|
||||
minWidth = dp(context, 44)
|
||||
layoutParams = LinearLayout.LayoutParams(if (wide) LinearLayout.LayoutParams.WRAP_CONTENT else dp(context, 44), dp(context, 44))
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun menuButton(context: Context, onClick: () -> Unit) =
|
||||
iconButton(context, R.drawable.ic_stat_session, context.getString(R.string.touch_droiddeck_menu), onClick)
|
||||
|
||||
internal fun iconButton(context: Context, icon: Int, description: String, onClick: () -> Unit) = android.widget.ImageView(context).apply {
|
||||
setImageResource(icon)
|
||||
setColorFilter(TEXT)
|
||||
contentDescription = description
|
||||
scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE
|
||||
val pad = dp(context, 11)
|
||||
setPadding(pad, pad, pad, pad)
|
||||
background = outline(context, CORNER_PILL, SURFACE)
|
||||
layoutParams = LinearLayout.LayoutParams(dp(context, 44), dp(context, 44))
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,9 @@ class FrontEndState(
|
||||
val adbPaired: Boolean = false,
|
||||
val backgroundAppLimit: Int = -1,
|
||||
val memoryHud: String = "off",
|
||||
val tpUbwcMode: String? = null,
|
||||
val tpUbwcRestartRequired: Boolean = false,
|
||||
val canRestartApp: Boolean = false,
|
||||
val steamGames: List<Library.SteamGame>,
|
||||
val steamGamesX64: Boolean = false,
|
||||
val emulators: List<Library.Emulator>,
|
||||
@@ -122,6 +125,7 @@ class FrontEndState(
|
||||
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
|
||||
val lossless: Lossless.State = Lossless.State.NONE,
|
||||
val shortcutPicker: Boolean = false,
|
||||
val preparingGames: Set<String> = emptySet(),
|
||||
val shortcutLibraryScanning: Boolean = false,
|
||||
val gameSyncFolder: String? = null,
|
||||
val pageKey: String? = null,
|
||||
@@ -140,6 +144,7 @@ class FrontEndState(
|
||||
val dualScreenMode: String = com.droiddeck.launcher.input.DualScreen.MODE_AUTO,
|
||||
val dualScreenSessions: String = com.droiddeck.launcher.input.DualScreen.MODE_AUTO,
|
||||
val dualScreenControls: Boolean = true,
|
||||
val androidTextFieldKeyboard: Boolean = false,
|
||||
val packages: List<PackageRow>? = null,
|
||||
val packageCatalogLoading: Boolean = false,
|
||||
val packageBusyId: String? = null,
|
||||
@@ -169,6 +174,7 @@ class FrontEndState(
|
||||
val downloadPrefix: String = "",
|
||||
val downloadProxy: String = "",
|
||||
val animationsEnabled: Boolean = true,
|
||||
val lowStorageWarning: Boolean = true,
|
||||
val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK,
|
||||
val gameStoresEnabled: Boolean = true,
|
||||
val gameStoresSpeedTier: String = "fast",
|
||||
@@ -188,6 +194,7 @@ class FrontEndActions(
|
||||
val onPlayDesktopUi: () -> Unit,
|
||||
val onSteamUrl: (String) -> Unit = {},
|
||||
val onSteamGame: (Library.SteamGame) -> Unit,
|
||||
val onStoreGame: (com.droiddeck.launcher.stores.Store, String) -> Unit = { _, _ -> },
|
||||
val onGameShortcut: (Library.SteamGame) -> Unit = {},
|
||||
val onExportGameFile: (Library.SteamGame) -> Unit = {},
|
||||
val onSyncGameFiles: () -> Unit = {},
|
||||
@@ -243,6 +250,8 @@ class FrontEndActions(
|
||||
val onSteamGpuDrawing: () -> Unit = {},
|
||||
val onClearCachedApps: (Boolean) -> Unit = {},
|
||||
val onMemoryHud: (String) -> Unit = {},
|
||||
val onTpUbwcMode: (String) -> Unit = {},
|
||||
val onRestartApp: () -> Unit = {},
|
||||
val onBackgroundAppLimit: (Int) -> Unit = {},
|
||||
val onShareLogs: () -> Unit = {},
|
||||
val onShareSessionLog: (File) -> Unit = {},
|
||||
@@ -260,9 +269,11 @@ class FrontEndActions(
|
||||
val onLauncherOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onLowStorageWarning: (Boolean) -> Unit = {},
|
||||
val onDualScreenMode: (String) -> Unit = {},
|
||||
val onDualScreenSessions: (String) -> Unit = {},
|
||||
val onDualScreenControls: (Boolean) -> Unit = {},
|
||||
val onAndroidTextFieldKeyboard: (Boolean) -> Unit = {},
|
||||
val onDownloadSource: (String) -> Unit = {},
|
||||
val onDownloadMirror: (String) -> Unit = {},
|
||||
val onDownloadPrefix: (String) -> Unit = {},
|
||||
|
||||
@@ -245,7 +245,7 @@ internal fun SetupPanel(
|
||||
PageHeader(stringResource(R.string.setup_title)) {
|
||||
Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, onPick = pick) }
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().then(if (tab == 1) Modifier else Modifier.verticalScroll(rememberScrollState())).padding(bottom = if (tab == 1) 0.dp else 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
// Setup's "Safe mode" row, under the GPU row while the bridge is on and can be used here.
|
||||
@@ -413,9 +413,7 @@ internal fun SetupPanel(
|
||||
}
|
||||
1 -> {
|
||||
val controller = s.controller
|
||||
if (controller != null && a.controller != null) SettingsGroup(stringResource(R.string.setup_controller)) {
|
||||
ControllerRows(host, s.oscMode, controller, a.controller)
|
||||
}
|
||||
if (controller != null && a.controller != null) InputSettingsPage(onBack = { pick(0) }, embedded = true)
|
||||
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
|
||||
}
|
||||
2 -> {
|
||||
@@ -452,6 +450,8 @@ internal fun SetupPanel(
|
||||
)
|
||||
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
|
||||
s.animationsEnabled, onChange = a.onAnimationsEnabled)
|
||||
ToggleRow(host, "low-storage-warning", stringResource(R.string.setup_low_storage_warning), stringResource(R.string.setup_low_storage_warning_hint),
|
||||
s.lowStorageWarning, onChange = a.onLowStorageWarning)
|
||||
val railContext = androidx.compose.ui.platform.LocalContext.current
|
||||
remember { RailLabels.load(railContext) }
|
||||
ToggleRow(host, "rail-labels", stringResource(R.string.setup_rail_labels), stringResource(R.string.setup_rail_labels_hint), RailLabels.shown) { RailLabels.set(railContext, it) }
|
||||
@@ -474,6 +474,20 @@ internal fun SetupPanel(
|
||||
com.droiddeck.launcher.core.AppUiPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.launcherOrientation, onPick = a.onLauncherOrientation,
|
||||
)
|
||||
s.tpUbwcMode?.let { ubwc ->
|
||||
ChoiceRow(
|
||||
host, "tp-ubwc", stringResource(R.string.gpu_ubwc_title), stringResource(R.string.gpu_ubwc_hint),
|
||||
listOf(
|
||||
SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on),
|
||||
SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off),
|
||||
), ubwc, onPick = a.onTpUbwcMode,
|
||||
)
|
||||
if (s.tpUbwcRestartRequired) {
|
||||
if (s.canRestartApp) ActionRow(stringResource(R.string.gpu_ubwc_restart), null, stringResource(R.string.gpu_restart_now), a.onRestartApp)
|
||||
else Note(stringResource(R.string.gpu_ubwc_restart))
|
||||
}
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_session_orientation), stringResource(R.string.setup_session_orientation_hint),
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
@@ -502,6 +516,8 @@ internal fun SetupPanel(
|
||||
ToggleRow(host, "dual-screen-controls", stringResource(R.string.setup_dual_screen_controls),
|
||||
stringResource(R.string.setup_dual_screen_controls_hint), s.dualScreenControls) { a.onDualScreenControls(it) }
|
||||
}
|
||||
ToggleRow(host, "android-text-field-keyboard", stringResource(R.string.setup_android_text_field_keyboard),
|
||||
null, s.androidTextFieldKeyboard) { a.onAndroidTextFieldKeyboard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,6 +851,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
|
||||
)
|
||||
val appSync = syncLabels[com.droiddeck.launcher.session.SessionPrefs.syncBackend(appContext)]
|
||||
val useDefault = stringResource(R.string.comp_game_use_general)
|
||||
val generalFex = com.droiddeck.launcher.core.FexPreset.byId(com.droiddeck.launcher.session.SessionPrefs.fexPreset(appContext))
|
||||
val custom = stringResource(R.string.comp_game_fex_custom)
|
||||
val customSync = stringResource(R.string.game_sync_custom)
|
||||
val resetDone = stringResource(R.string.comp_game_reset_done, game.name)
|
||||
@@ -849,7 +866,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
|
||||
MenuItem(
|
||||
stringResource(R.string.fex_preset_title), checked = false, enabled = ready,
|
||||
detail = when {
|
||||
fex == null -> useDefault
|
||||
fex == null -> "$useDefault · ${stringResource(generalFex.label)}"
|
||||
chosen == null -> custom
|
||||
else -> stringResource(chosen.label)
|
||||
},
|
||||
@@ -881,7 +898,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
|
||||
}
|
||||
}
|
||||
AnchoredMenu(host.open == fexKey, onDismiss = { if (host.open == fexKey) host.open = null }, title = stringResource(R.string.fex_preset_title)) { first ->
|
||||
MenuItem(useDefault, checked = fex == null, enabled = ready, focusRequester = first) {
|
||||
MenuItem(useDefault, checked = fex == null, enabled = ready, detail = stringResource(generalFex.label), focusRequester = first) {
|
||||
host.open = key
|
||||
save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameFexPreset(it, scope, null) }
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -150,6 +151,27 @@ internal fun Chip(t: String, ok: Boolean) {
|
||||
@Composable
|
||||
internal fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
|
||||
@Composable
|
||||
internal fun DialogToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val flip = { onChange(!checked) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape12)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape12)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, role = Role.Switch, onClick = flip)
|
||||
.controllerConfirm(onClick = flip)
|
||||
.padding(start = 14.dp, end = 4.dp),
|
||||
) {
|
||||
Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
|
||||
ToggleSwitch(checked, label = label, modifier = Modifier.focusProperties { canFocus = false }) { onChange(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PrimaryButton(
|
||||
text: String,
|
||||
|
||||
@@ -408,11 +408,13 @@ internal fun GameActions(
|
||||
!cloud && (game != null || installed != null) -> {
|
||||
val gone = storeKey?.let { StoresState.uninstalling[it] }
|
||||
val xboxBusy = store == Store.XBOX && id != null && XboxContent.busy(id)
|
||||
val preparing = (game?.launchKey ?: storeKey)?.let { it in s.preparingGames } == true
|
||||
PrimaryButton(
|
||||
stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
|
||||
stringResource(if (preparing) R.string.store_starting else R.string.stores_play),
|
||||
enabled = !s.busy && !preparing && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
|
||||
main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn,
|
||||
) {
|
||||
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(ctx, store, id, s, a)
|
||||
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(store, id, s, a)
|
||||
}
|
||||
if (game != null) {
|
||||
if (compactMenu) GameMoreMenu(game, a) else {
|
||||
@@ -808,11 +810,17 @@ private fun LaunchTab(game: Library.SteamGame, s: FrontEndState, a: FrontEndActi
|
||||
val host = rememberMenuHost()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
LaunchSettings(s, a, host, game, hopRail ?: GameRef.key(game))
|
||||
if (canEditSteamLaunchOptions(game, s.shortcutPicker)) {
|
||||
SteamLaunchOptionsPanel(game.appId)
|
||||
}
|
||||
val folder = game.gameFiles
|
||||
if (game.library == Library.ADDED && game.source == Library.ADDED && !s.shortcutPicker && folder != null) ShortcutFields(game, folder)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun canEditSteamLaunchOptions(game: Library.SteamGame, shortcutPicker: Boolean): Boolean =
|
||||
!shortcutPicker && game.source == Library.SOURCE_STEAM && game.library != Library.ADDED
|
||||
|
||||
@Composable
|
||||
private fun ShortcutFields(game: Library.SteamGame, folder: File) {
|
||||
val ctx = LocalContext.current
|
||||
@@ -866,12 +874,15 @@ private fun SavesTab(g: Library.SteamGame, prefix: File, a: FrontEndActions) {
|
||||
|
||||
@Composable
|
||||
private fun AddonsTab(g: Library.SteamGame, targets: List<WowAddons.Target>, a: FrontEndActions) {
|
||||
var browsing by remember(g.gameId) { mutableStateOf<WowAddons.Target?>(null) }
|
||||
browsing?.let { AddonBrowserDialog(targets, it) { browsing = null } }
|
||||
Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
targets.forEach { target ->
|
||||
val names by produceState(emptyList<String>(), target.addOns, WowAddons.imports) {
|
||||
value = withContext(Dispatchers.IO) { WowAddons.installed(target) }
|
||||
}
|
||||
if (targets.size > 1) SectionLabel(target.flavor)
|
||||
MenuItem(stringResource(R.string.addons_download), checked = false) { browsing = target }
|
||||
MenuItem(
|
||||
stringResource(R.string.games_import_addons), checked = false,
|
||||
detail = pluralStringResource(R.plurals.games_addons_count, names.size, names.size),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
@@ -134,8 +133,6 @@ class GpuDriversState(
|
||||
val runtime: DriverSlot = DriverSlot(),
|
||||
val canRestart: Boolean = true,
|
||||
val checkedAt: Long = 0L,
|
||||
val tpUbwcMode: String? = null,
|
||||
val tpUbwcRestartRequired: Boolean = false,
|
||||
) {
|
||||
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
|
||||
}
|
||||
@@ -156,7 +153,6 @@ class GpuDriversActions(
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
val onMode: (String) -> Unit = {},
|
||||
val onRestart: () -> Unit = {},
|
||||
val onTpUbwcMode: (String) -> Unit = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -250,45 +246,14 @@ internal fun GpuDriversBody(
|
||||
Triple(GPU_MODE_PAIR, R.string.gpu_mode_pair, R.string.gpu_mode_pair_detail),
|
||||
Triple(GPU_MODE_MIX, R.string.gpu_mode_mix, R.string.gpu_mode_mix_detail),
|
||||
)
|
||||
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
|
||||
val (id, title, detail) = modes[i]
|
||||
PanelRow(
|
||||
stringResource(title), stringResource(detail), gpu.mode == id,
|
||||
modifier = mod.then(if (gpu.mode == id) Modifier.focusRequester(first) else Modifier)
|
||||
.then(if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier),
|
||||
onClick = { if (gpu.mode != id) actions.onMode(id) },
|
||||
)
|
||||
}
|
||||
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
|
||||
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
|
||||
ModeChoices(modes, gpu.mode, single, first, onPick = actions.onMode) { i ->
|
||||
if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier
|
||||
}
|
||||
when (gpu.mode) {
|
||||
GPU_MODE_AUTO -> AutoBody(gpu, actions)
|
||||
GPU_MODE_PAIR -> PairBody(gpu, actions)
|
||||
else -> MixBody(single, actions, slotFocus, onOpenSlot)
|
||||
}
|
||||
gpu.tpUbwcMode?.let { selected ->
|
||||
var advanced by rememberSaveable { mutableStateOf(false) }
|
||||
SecondaryButton(stringResource(R.string.gpu_advanced) + if (advanced) " ▴" else " ▾", modifier = Modifier.fillMaxWidth()) { advanced = !advanced }
|
||||
AnimatedVisibility(advanced) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ChoiceRow(
|
||||
rememberMenuHost(), "tp-ubwc", stringResource(R.string.gpu_ubwc_title),
|
||||
stringResource(R.string.gpu_ubwc_hint),
|
||||
listOf(
|
||||
SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on),
|
||||
SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off),
|
||||
), selected, onPick = actions.onTpUbwcMode,
|
||||
)
|
||||
if (gpu.tpUbwcRestartRequired) {
|
||||
Text(stringResource(R.string.gpu_ubwc_restart), fontSize = 13.sp, color = AttentionAmber)
|
||||
if (gpu.canRestart) SecondaryButton(stringResource(R.string.gpu_restart_now), onClick = actions.onRestart)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,12 +404,35 @@ internal const val SLOT_DISPLAY = "panel"
|
||||
internal const val SLOT_RUNTIME = "rt"
|
||||
|
||||
@Composable
|
||||
private fun PanelHeading(text: String) {
|
||||
internal fun ModeChoices(
|
||||
modes: List<Triple<String, Int, Int>>,
|
||||
selected: String,
|
||||
single: Boolean,
|
||||
first: FocusRequester,
|
||||
onPick: (String) -> Unit,
|
||||
extra: (Int) -> Modifier = { Modifier },
|
||||
) {
|
||||
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
|
||||
val (id, title, detail) = modes[i]
|
||||
PanelRow(
|
||||
stringResource(title), stringResource(detail), selected == id,
|
||||
modifier = mod.then(if (selected == id) Modifier.focusRequester(first) else Modifier).then(extra(i)),
|
||||
onClick = { if (selected != id) onPick(id) },
|
||||
)
|
||||
}
|
||||
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
|
||||
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PanelHeading(text: String) {
|
||||
Text(text.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelTitle(title: String, hint: String) {
|
||||
internal fun PanelTitle(title: String, hint: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(title, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(hint, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -461,7 +449,7 @@ private fun Pill(text: String, fill: Color, ink: Color) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelRow(
|
||||
internal fun PanelRow(
|
||||
title: String,
|
||||
detail: String,
|
||||
selected: Boolean,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
|
||||
@Composable
|
||||
internal fun InputActionEditor(
|
||||
control: InputControl, current: InputAction, title: String, onBack: () -> Unit, onSave: (InputAction) -> Unit,
|
||||
element: InputSurfaceElement? = null, nativeTargets: Boolean = true, detail: String? = null,
|
||||
properties: (@Composable ColumnScope.(InputAction) -> Unit)? = null,
|
||||
click: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val host = rememberMenuHost()
|
||||
var draft by remember(current, title) { mutableStateOf(current) }
|
||||
val drafts = remember(current, title) { mutableMapOf<String, InputAction>() }
|
||||
fun typeOf(action: InputAction): String = when (action) {
|
||||
is InputAction.Native -> "native"; InputAction.Disabled -> "disabled"
|
||||
is InputAction.MouseButton -> "mouse"; is InputAction.Command -> "command"
|
||||
is InputAction.Pointer -> "pointer"; is InputAction.Scroll -> "scroll"
|
||||
is InputAction.Directions -> "directions"; is InputAction.Keys -> "keys"
|
||||
}
|
||||
var localType by remember(current, title) { mutableStateOf(typeOf(current).takeUnless { it == "native" || it == "disabled" } ?: "keys") }
|
||||
fun switch(type: String, initial: () -> InputAction) {
|
||||
drafts[typeOf(draft)] = draft
|
||||
draft = drafts[type] ?: initial()
|
||||
if (type != "native" && type != "disabled") localType = type
|
||||
}
|
||||
var keys by remember { mutableStateOf(false) }
|
||||
var direction by remember { mutableStateOf<Int?>(null) }
|
||||
fun valid(action: InputAction) = if (click) action.validForClick() else if (element != null) action.validFor(element) else action.validFor(control)
|
||||
val nativeOptions = remember(control, element, nativeTargets, click) {
|
||||
(listOf(InputAction.Native()) + if (nativeTargets) InputControl.entries.map { InputAction.Native(it) } else emptyList()).filter(::valid)
|
||||
}
|
||||
val route = when (draft) { is InputAction.Native -> 0; InputAction.Disabled -> 2; else -> 1 }
|
||||
BackHandler(onBack = onBack)
|
||||
SettingsPage(host, title, onBack, eyebrow = stringResource(R.string.input_properties), lede = detail,
|
||||
action = { PrimaryButton(stringResource(R.string.gesture_save_changes), enabled = valid(draft),
|
||||
modifier = Modifier.testTag("input-action-save")) { onSave(draft) } }) {
|
||||
properties?.invoke(this, draft)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SheetChips(listOf(stringResource(R.string.input_route_controller), stringResource(R.string.input_route_local), stringResource(R.string.input_disabled)), route) {
|
||||
when (it) {
|
||||
0 -> switch("native") { nativeOptions.firstOrNull() ?: InputAction.Disabled }
|
||||
2 -> switch("disabled") { InputAction.Disabled }
|
||||
else -> switch(localType) { InputAction.Keys(listOf(28)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
when (route) {
|
||||
0 -> SettingsGroup(stringResource(R.string.input_route_controller)) {
|
||||
ChoiceRow(host, "native-target", stringResource(R.string.input_remap_native), stringResource(R.string.input_controller_route_hint),
|
||||
nativeOptions.map { it to (it.target?.label ?: stringResource(if (nativeTargets) R.string.input_native else R.string.input_normal_binding)) },
|
||||
draft as InputAction.Native) { draft = it }
|
||||
}
|
||||
2 -> Text(stringResource(R.string.input_disabled_hint), Modifier.padding(16.dp))
|
||||
else -> {
|
||||
val types = listOf("keys" to stringResource(R.string.input_keys), "mouse" to stringResource(R.string.input_mouse_click),
|
||||
"command" to stringResource(R.string.input_command)) + if (control.dimensions == 2) listOf(
|
||||
"pointer" to stringResource(R.string.input_pointer), "scroll" to stringResource(R.string.input_scroll),
|
||||
"directions" to stringResource(R.string.input_direction_keys)) else emptyList()
|
||||
val type = when (draft) { is InputAction.MouseButton -> "mouse"; is InputAction.Command -> "command";
|
||||
is InputAction.Pointer -> "pointer"; is InputAction.Scroll -> "scroll"; is InputAction.Directions -> "directions"; else -> "keys" }
|
||||
SettingsGroup(stringResource(R.string.input_action)) {
|
||||
ChoiceRow(host, "action-type", stringResource(R.string.input_action), null, types, type) {
|
||||
switch(it) { when (it) { "mouse" -> InputAction.MouseButton(272); "command" -> InputAction.Command(InputCommand.MENU);
|
||||
"pointer" -> InputAction.Pointer(); "scroll" -> InputAction.Scroll();
|
||||
"directions" -> InputAction.Directions(103, 106, 108, 105); else -> InputAction.Keys(listOf(28)) } }
|
||||
}
|
||||
if (!click && draft !is InputAction.Pointer && draft !is InputAction.Scroll && draft !is InputAction.Directions) {
|
||||
val hint = when {
|
||||
control.isTrackpad -> R.string.input_contact_action_hint
|
||||
control.dimensions == 2 -> R.string.input_axis_action_hint
|
||||
control == InputControl.LT || control == InputControl.RT -> R.string.input_trigger_action_hint
|
||||
else -> null
|
||||
}
|
||||
hint?.let { Text(stringResource(it), Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
when (val action = draft) {
|
||||
is InputAction.Keys -> ActionRow(stringResource(R.string.input_keys), actionLabel(action), stringResource(R.string.ctrl_edit), { keys = true })
|
||||
is InputAction.MouseButton -> ChoiceRow(host, "mouse-button", stringResource(R.string.input_mouse_click), null,
|
||||
(272..274).map { it to actionLabel(InputAction.MouseButton(it)) }, action.code) { draft = InputAction.MouseButton(it) }
|
||||
is InputAction.Command -> ChoiceRow(host, "command", stringResource(R.string.input_command), null,
|
||||
InputCommand.entries.map { it to commandLabel(it) }, action.command) { draft = InputAction.Command(it) }
|
||||
is InputAction.Pointer -> {
|
||||
Text(stringResource(R.string.input_pointer_speed, action.speed.toInt()), Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.onSurface)
|
||||
Slider(action.speed, { draft = action.copy(speed = it) }, valueRange = 100f..3000f, modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
is InputAction.Scroll -> {
|
||||
ChoiceRow(host, "scroll-axis", stringResource(R.string.input_scroll_axis), null,
|
||||
listOf(false to stringResource(R.string.input_vertical), true to stringResource(R.string.input_horizontal)), action.horizontal) {
|
||||
draft = action.copy(horizontal = it)
|
||||
}
|
||||
Text(stringResource(R.string.input_scroll_speed, action.speed.toInt()), Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.onSurface)
|
||||
Slider(action.speed, { draft = action.copy(speed = it) }, valueRange = 1f..60f, modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
is InputAction.Directions -> {
|
||||
ToggleRow(host, "direction-diagonals", stringResource(R.string.input_diagonal_keys), stringResource(R.string.input_diagonal_keys_hint), action.diagonals != null) {
|
||||
draft = action.copy(diagonals = if (it) action.diagonals ?: listOf(0, 0, 0, 0) else null)
|
||||
}
|
||||
val codes = listOf(action.up, action.right, action.down, action.left) + action.diagonals.orEmpty()
|
||||
val arrows = listOf("↑", "→", "↓", "←", "↗", "↘", "↙", "↖")
|
||||
codes.forEachIndexed { i, code ->
|
||||
ActionRow(arrows[i], if (code == 0) stringResource(if (i < 4) R.string.input_disabled else R.string.input_diagonal_neighbors) else pcKeyLabel(context, code), stringResource(R.string.ctrl_edit), { direction = i })
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (keys) KeyCombinationPicker(onDismiss = { keys = false }, initial = (draft as? InputAction.Keys)?.codes.orEmpty(),
|
||||
sequence = true, sequenceLimit = 8, title = stringResource(R.string.input_keys), confirm = stringResource(R.string.gesture_save_changes), onAdd = {
|
||||
draft = InputAction.Keys(it.sortedBy { code -> if (code in PC_MODIFIERS) 0 else 1 }); keys = false
|
||||
})
|
||||
direction?.let { at ->
|
||||
val action = draft as? InputAction.Directions ?: return@let
|
||||
val codes = listOf(action.up, action.right, action.down, action.left) + action.diagonals.orEmpty()
|
||||
KeyCombinationPicker(onDismiss = { direction = null }, initial = listOf(codes[at]).filter { it != 0 }, sequence = true, sequenceLimit = 1, allowEmpty = true,
|
||||
title = stringResource(R.string.input_direction_keys), confirm = stringResource(R.string.gesture_save_changes), onAdd = {
|
||||
val next = codes.toMutableList().also { values -> values[at] = it.firstOrNull() ?: 0 }
|
||||
draft = InputAction.Directions(next[0], next[1], next[2], next[3], if (action.diagonals != null) next.drop(4) else null); direction = null
|
||||
})
|
||||
}
|
||||
}
|
||||
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user