Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+3 -17
View File
@@ -39,25 +39,11 @@ jobs:
path: |
gamescope.tzst
gamescope.tzst.sha256
- uses: actions/create-github-app-token@v2
id: components-token
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
GH_REPO: Droid-Deck/DroidDeck-Components
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
exit 1
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" --notes "gamescope 3.16.29 with tools/gamescope/patches ($GITHUB_REPOSITORY $GITHUB_SHA, run $GITHUB_RUN_ID); staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256
gh release edit "$TAG" --latest=false --prerelease=false
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
+104 -15
View File
@@ -2,6 +2,17 @@ name: Build APK
run-name: ${{ github.event_name == 'pull_request' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }}
on:
workflow_call:
inputs:
play_bundle:
type: boolean
default: false
source_sha:
type: string
default: ''
play_version_code:
type: string
default: ''
push:
branches: [ main ]
paths-ignore:
@@ -32,7 +43,7 @@ permissions:
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.run_id }}
group: build-${{ github.event.pull_request.number || (github.event_name == 'push' && github.ref_name) || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
jobs:
@@ -47,13 +58,27 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Validate Play distribution inputs
if: inputs.play_bundle == true
env:
PLAY_SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
[[ $PLAY_SOURCE_SHA =~ ^[0-9a-f]{40}$ ]]
test "$(git rev-parse HEAD)" = "$PLAY_SOURCE_SHA"
git merge-base --is-ancestor "$PLAY_SOURCE_SHA" origin/playstore
test -f tools/playstore/components.lock.json || {
echo "::error::Production component manifest is incomplete; the Play bundle cannot be built or signed."
exit 1
}
python3 tools/playstore/payloads.py validate --play-only --manifest tools/playstore/components.lock.json
- name: Pin and merge the APK source
id: merge
if: github.event_name == 'pull_request'
@@ -86,7 +111,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
fetch-depth: 0
sparse-checkout: |
/*
@@ -145,7 +170,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
fetch-depth: 0
sparse-checkout: |
/*
@@ -323,13 +348,13 @@ jobs:
name: Compile APK and Android tests
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: ${{ inputs.play_bundle && 90 || 45 }}
outputs:
variants: ${{ steps.variants.outputs.list }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
fetch-depth: 0
sparse-checkout: |
/*
@@ -338,6 +363,16 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Reserve hosted runner space for Play components
if: inputs.play_bundle == true
run: |
set -euo pipefail
test "$GITHUB_ACTIONS" = true
test "$RUNNER_ENVIRONMENT" = github-hosted
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/share/boost /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
df -h "$GITHUB_WORKSPACE" "$RUNNER_TEMP"
- name: Check human authorship
env:
EVENT_NAME: ${{ github.event_name }}
@@ -453,7 +488,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
java-version: '21'
- uses: gradle/actions/setup-gradle@v4
with:
@@ -469,8 +504,8 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BUILD_REF: ${{ github.ref_name }}
BUILD_SHA: ${{ github.sha }}
BUILD_REF: ${{ inputs.play_bundle && 'playstore' || github.ref_name }}
BUILD_SHA: ${{ inputs.source_sha || github.sha }}
run: |
if [[ -n "$(git status --porcelain)" ]]; then
tree_state=dirty
@@ -685,6 +720,7 @@ jobs:
- uses: actions/upload-artifact@v4
id: apk-upload
if: inputs.play_bundle != true
with:
name: droiddeck-apk
path: app/build/outputs/apk/release/*.apk
@@ -692,7 +728,60 @@ jobs:
retention-days: 30
compression-level: 0
- name: Stage verified Play components
if: inputs.play_bundle == true
env:
DROIDDECK_ZSTD: /usr/bin/zstd
run: |
set -euo pipefail
bash tools/ci/install-ubuntu-packages.sh zstd
"$DROIDDECK_ZSTD" --version
python3 tools/playstore/payloads.py stage --play-only --manifest tools/playstore/components.lock.json \
--cache "$RUNNER_TEMP/play-component-cache" --output build/playstore-payloads
- name: Build and check the Play bundle
if: inputs.play_bundle == true
env:
PLAY_VERSION_CODE: ${{ inputs.play_version_code }}
run: |
set -euo pipefail
bash tools/ci/install-ubuntu-packages.sh autoconf automake libtool autopoint gettext pkg-config cmake ninja-build
python3 tools/playstore/build_audio.py build --output app/build/play-audio \
--ndk "$ANDROID_HOME/ndk/$NDK_VERSION" --cache "$RUNNER_TEMP/play-audio-cache"
./gradlew :app:bundleRelease :app:lintRelease :app:testReleaseUnitTest -PplayDistribution=true \
-PndkVersion="$NDK_VERSION" -PplayVersionCode="$PLAY_VERSION_CODE" --tests com.droiddeck.launcher.core.ComponentPayloadStoreTest \
--tests com.droiddeck.launcher.core.PlayDistributionTest \
--tests com.droiddeck.launcher.core.PlayArchiveFramesTest \
--tests com.droiddeck.launcher.PlayUpdatesTest \
--tests com.droiddeck.launcher.ui.BuildWatermarkTest --console=plain
python3 tools/playstore/payloads.py verify-bundle --play-only --manifest tools/playstore/components.lock.json \
--bundle app/build/outputs/bundle/release/app-release.aab
python3 tools/release/check_android_libraries.py app/build/outputs/bundle/release/app-release.aab --page-size 16384
python3 tools/playstore/check_audio_bundle.py --bundle app/build/outputs/bundle/release/app-release.aab \
--output app/build/play-audio --ndk "$ANDROID_HOME/ndk/$NDK_VERSION"
curl --fail --location --retry 3 --output "$RUNNER_TEMP/bundletool.jar" \
https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar
echo "a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 $RUNNER_TEMP/bundletool.jar" | sha256sum -c -
java -jar "$RUNNER_TEMP/bundletool.jar" validate --bundle=app/build/outputs/bundle/release/app-release.aab
java -jar "$RUNNER_TEMP/bundletool.jar" dump manifest --bundle=app/build/outputs/bundle/release/app-release.aab \
--module=base > app/build/play-manifest.xml
python3 -c 'import xml.etree.ElementTree as E; root=E.parse("app/build/play-manifest.xml").getroot(); assert not any(x.get("{http://schemas.android.com/apk/res/android}name") == "android.permission.REQUEST_INSTALL_PACKAGES" for x in root.findall("uses-permission"))'
- uses: actions/upload-artifact@v4
if: inputs.play_bundle == true
with:
name: droiddeck-play-bundle-review
path: |
app/build/outputs/bundle/release/*.aab
build/playstore-payloads/provenance.json
build/playstore-payloads/index.json
app/build/play-manifest.xml
if-no-files-found: error
retention-days: 30
compression-level: 0
- uses: actions/upload-artifact@v4
if: inputs.play_bundle != true
with:
name: droiddeck-packs
path: app/build/packs/*.tar.zst
@@ -726,7 +815,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
fetch-depth: 0
sparse-checkout: |
/*
@@ -780,7 +869,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.source_sha || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}
fetch-depth: 0
sparse-checkout: |
/*
@@ -852,7 +941,7 @@ jobs:
sign:
needs: [build, checks]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
if: ${{ !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
permissions:
contents: read
actions: read
@@ -864,7 +953,7 @@ jobs:
packs:
needs: [build, checks]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
if: ${{ !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
permissions:
contents: read
actions: read
@@ -873,7 +962,7 @@ jobs:
announce:
needs: [build, sign, packs]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
if: ${{ !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
@@ -923,6 +1012,6 @@ jobs:
catalog:
needs: announce
if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }}
if: ${{ always() && !inputs.play_bundle && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }}
uses: ./.github/workflows/update-catalog.yml
secrets: inherit
+199
View File
@@ -0,0 +1,199 @@
name: Build PlayStore Bundle
run-name: PlayStore ${{ inputs.version || 'branch build' }} from playstore
on:
workflow_call:
inputs:
version:
type: string
required: true
source_branch:
type: string
default: playstore
push:
branches: [playstore]
workflow_dispatch:
inputs:
version:
description: Version for the file names, e.g. 0.1.7
required: true
source_branch:
description: Branch to build
type: choice
options: [playstore]
default: playstore
required: true
permissions:
contents: read
actions: read
pull-requests: write
concurrency:
group: playstore-bundle
cancel-in-progress: false
jobs:
checks:
name: Play distribution checks
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'push' && github.sha || inputs.source_branch }}
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Install test dependencies
run: |
sudo apt-get update
sudo apt-get install -y zstd
python3 -m pip install PyYAML==6.0.3
- name: Test Play distribution safeguards
run: |
python3 -m unittest \
tools.tests.test_playstore_release_assets \
tools.tests.test_playstore_split_archive \
tools.tests.test_playstore_payloads \
tools.tests.test_playstore_manifest \
tools.tests.test_playstore_signing \
tools.tests.test_playstore_workflow \
tools.tests.test_playstore_archive_preparation \
tools.tests.test_playstore_audio_bundle \
tools.tests.test_playstore_emulators \
tools.tests.test_android_page_alignment
source:
runs-on: ubuntu-24.04
outputs:
sha: ${{ steps.source.outputs.sha }}
version: ${{ steps.source.outputs.version }}
version_code: ${{ steps.source.outputs.version_code }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'push' && github.sha || inputs.source_branch }}
persist-credentials: false
- id: source
env:
VERSION: ${{ inputs.version }}
SOURCE_BRANCH: ${{ inputs.source_branch || 'playstore' }}
run: |
set -euo pipefail
test "$SOURCE_BRANCH" = playstore
test "$GITHUB_REF" = refs/heads/playstore
sha=$(git rev-parse HEAD)
name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle)
[[ $name =~ ^[0-9A-Za-z._-]+$ ]]
if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::invalid version"; exit 1; }
[[ $VERSION == "$name" || $VERSION == "$name"-* ]] || {
echo "::error::version must match versionName in app/build.gradle; bump versionName and versionCode first"
exit 1
}
else
VERSION="$name"
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
source_code=$(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
[[ $source_code =~ ^[1-9][0-9]*$ ]]
[ "$source_code" -le 2100000000 ]
echo "version_code=$source_code" >> "$GITHUB_OUTPUT"
bundle:
needs: source
uses: ./.github/workflows/build.yml
with:
play_bundle: true
source_sha: ${{ needs.source.outputs.sha }}
play_version_code: ${{ needs.source.outputs.version_code }}
secrets: inherit
sign:
needs: [checks, source, bundle]
runs-on: ubuntu-24.04
environment: signing-playstore
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.source.outputs.sha }}
persist-credentials: false
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: actions/download-artifact@v4
with:
name: droiddeck-play-bundle-review
path: incoming
- name: Sign the verified Play bundle
env:
RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }}
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
VERSION: ${{ needs.source.outputs.version }}
run: |
set -euo pipefail
test -n "$RELEASE_KEYSTORE_B64"
key="$RUNNER_TEMP/play-release.p12"
(umask 077; printf '%s' "$RELEASE_KEYSTORE_B64" | base64 -d > "$key")
mapfile -t bundles < <(find incoming -type f -name '*.aab')
test "${#bundles[@]}" = 1
mkdir -p signed
python3 tools/playstore/sign_bundle.py "${bundles[0]}" "signed/DroidDeck-$VERSION-PlayStore.aab" \
--keystore "$key"
(cd signed && sha256sum *.aab > SHA256SUMS)
- uses: actions/upload-artifact@v4
with:
name: droiddeck-play-bundle-signed
path: signed/*
if-no-files-found: error
compression-level: 0
publish:
needs: [source, sign]
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.source.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: droiddeck-play-bundle-signed
path: signed
- name: Publish the PlayStore release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.source.outputs.version }}
SOURCE_SHA: ${{ needs.source.outputs.sha }}
VERSION_CODE: ${{ needs.source.outputs.version_code }}
run: |
set -euo pipefail
tag="playstore-$VERSION"
if gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::error::PlayStore release $tag already exists"
exit 1
fi
printf 'Signed Android App Bundle for Google Play upload.\n\nSource: `%s` on `playstore`.\nVersion code: `%s`.\n' "$SOURCE_SHA" "$VERSION_CODE" > "$RUNNER_TEMP/play-release.md"
bundles=(signed/*.aab)
[ "${#bundles[@]}" -eq 1 ] && [ -f "${bundles[0]}" ]
python3 tools/playstore/release_assets.py --bundle "${bundles[0]}" --checksums signed/SHA256SUMS --output publish
if compgen -G 'publish/*.part*' >/dev/null; then
name=$(basename "${bundles[0]}")
printf '\nGitHub release assets are split to stay below its per-file limit. Download every part and PART-SHA256SUMS, verify the parts, combine them in numeric order, and verify SHA256SUMS before uploading the resulting AAB to Google Play.\n\nThe complete signed AAB is also available in the droiddeck-play-bundle-signed artifact for this workflow run.\n\n```sh\nsha256sum -c PART-SHA256SUMS\nset -o noclobber\ncat "%s".part??? > "%s"\nsha256sum -c SHA256SUMS\n```\n' "$name" "$name" >> "$RUNNER_TEMP/play-release.md"
fi
gh release create "$tag" -R "$GITHUB_REPOSITORY" publish/* \
--target "$SOURCE_SHA" --title "DroidDeck $VERSION · PlayStore" \
--notes-file "$RUNNER_TEMP/play-release.md" --latest=false
+2 -10
View File
@@ -47,18 +47,10 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
retry() {
for attempt in 1 2 3 4; do
"$@" && return 0
[ "$attempt" = 4 ] || sleep $((attempt * 20))
done
return 1
}
existing=$(retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name')
found=0
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
if grep -qxF "$tag" <<<"$existing"; then
retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
echo "Deleted $tag"
found=1
fi
-57
View File
@@ -1,57 +0,0 @@
name: PR Release Sweep
run-name: sweep test builds of closed PRs
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: pr-release-sweep
cancel-in-progress: false
jobs:
sweep:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Delete test builds whose PR is no longer open
env:
CI_TOKEN: ${{ steps.ci-token.outputs.token }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
retry() {
for attempt in 1 2 3 4; do
"$@" && return 0
[ "$attempt" = 4 ] || sleep $((attempt * 20))
done
return 1
}
tags=$(GH_TOKEN="$CI_TOKEN" retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name' |
grep -E '^pr-[0-9]+(\.0)?$' || true)
failed=0
for tag in $tags; do
number=${tag#pr-}
number=${number%.0}
state=$(retry gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .state) || { failed=1; continue; }
if [ "$state" != open ]; then
if GH_TOKEN="$CI_TOKEN" retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag; then
echo "Deleted $tag (PR #$number is $state)"
else
failed=1
fi
fi
done
exit "$failed"
+18 -2
View File
@@ -1,5 +1,5 @@
name: Release APKs
run-name: release ${{ inputs.version }} from main ${{ github.sha }}
run-name: ${{ github.ref == 'refs/heads/playstore' && format('PlayStore {0} from playstore {1}', inputs.version, github.sha) || format('release {0} from main {1}', inputs.version, github.sha) }}
on:
workflow_dispatch:
@@ -13,10 +13,23 @@ permissions:
actions: read
concurrency:
group: release
group: ${{ github.ref == 'refs/heads/playstore' && 'release-playstore' || 'release' }}
cancel-in-progress: false
jobs:
playstore:
name: Build PlayStore Bundle
if: github.ref == 'refs/heads/playstore'
permissions:
contents: write
actions: read
pull-requests: write
uses: ./.github/workflows/playstore.yml
with:
version: ${{ inputs.version }}
source_branch: playstore
secrets: inherit
find:
name: find the main build
if: github.ref == 'refs/heads/main'
@@ -53,6 +66,7 @@ jobs:
echo "Signing Build APK run $id (main ${GITHUB_SHA::7}) as $VERSION: $variants" >> "$GITHUB_STEP_SUMMARY"
sign:
if: github.ref == 'refs/heads/main'
needs: find
uses: ./.github/workflows/sign.yml
with:
@@ -64,6 +78,7 @@ jobs:
secrets: inherit
packs:
if: github.ref == 'refs/heads/main'
needs: find
permissions:
contents: read
@@ -74,6 +89,7 @@ jobs:
secrets: inherit
publish:
if: github.ref == 'refs/heads/main'
name: publish the release
needs: [find, sign, packs]
runs-on: ubuntu-24.04
+5
View File
@@ -1,3 +1,8 @@
# Rules
- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants.
## Google Play distribution
For `playstore` and all changes destined for Google Play, read and enforce [the mandatory distribution policy gate](tools/playstore/POLICY.md) before editing or integrating changes. Refuse known policy violations and hold uncertain changes for authoritative evidence. Review incoming main commits individually; never overwrite Play delivery, permission, updater, signing, versioning, catalog, or release protections. A successful build is not policy clearance.
+50
View File
@@ -0,0 +1,50 @@
# DroidDeck Privacy Policy
Effective date: 3 November 2026
DroidDeck is an open-source game launcher maintained by the Droid-Deck project. A separate DroidDeck online account is not required. DroidDeck does not include advertising analytics or automatically submit usage reports or crash reports to the project maintainers. The maintainers do not operate a central database of users' game libraries or sign-in credentials, and do not sell users' personal data.
## Information kept on your device
DroidDeck stores information needed to run its features locally. Depending on what you use, this includes settings, controller and display configurations, game-library metadata, artwork caches, installed components, game saves, download state, and diagnostic logs. Store integrations and the clients running inside DroidDeck may also retain account names, authentication tokens, cookies, and sign-in sessions. These are used to access the services you choose.
Game performance and racing telemetry can be processed on your device for HUD and secondary-screen displays. This is separate from sending analytics to the project maintainers.
## Permissions and optional features
DroidDeck accesses device information and resources to provide the features you enable:
- File access supports browsing and managing games, saves, libraries, and other files. Broad file access, when granted, allows access outside the app's private storage, including shared and removable storage.
- The launcher reads launchable Android app names, icons, and package identifiers to display and launch those apps.
- The accessibility service handles Home/Guide shortcuts and identifies apps occupying a secondary display. It reads app package names and window bounds locally. This service does not read text, capture screens, or transmit the information it reads.
- Notification access lets the optional notification display read notification titles and content on the device.
- Camera, microphone, motion, accessory, and input data support features such as tracking, glasses, capture, game audio, and controls. Screenshots or recordings you create are saved locally unless you choose to share or synchronize them.
- Bluetooth, USB, Wi-Fi, and local-network access support accessories, network controls, synchronization, streaming, and connections you configure. Android can require location permission for Wi-Fi discovery.
You can manage permissions through Android settings. Turning off a permission or optional feature can limit the functionality that uses it.
## Connections to other services
DroidDeck connects to the stores, game services, cloud-streaming providers, artwork sources, and download services used by your selected features. Those services can receive information such as your IP address, requests, game identifiers, authentication information, or information you enter directly into their sign-in and purchase pages. Steam, games, and other software running through DroidDeck have their own network activity and privacy policies. Their providers determine how they handle that information.
The Google Play version downloads runtime modules and non-executable resource packs through Google Play. Google's Play Feature Delivery and Play Asset Delivery services process device metadata and the application version to deliver the correct files and preserve them across updates. Google documents encrypted processing and a fixed retention period. See [Play Feature Delivery data handling](https://developer.android.com/guide/playcore/feature-delivery#data-safety) and [Play Asset Delivery data handling](https://developer.android.com/guide/playcore/asset-delivery#terms-of-service).
The Google Play version contacts Google Play to check whether an app update is available. Selecting Update opens the app's Google Play listing. Google handles Play delivery and updates under its own [privacy policy](https://policies.google.com/privacy).
Optional Windows prerequisites can be downloaded from the sources identified in the app's pinned component inventory, including GitHub-hosted runtime packages. These providers receive the download request and IP address. DroidDeck verifies the package against the inventory before installing it into the guest runtime.
Optional Syncthing synchronization transfers selected files to the devices you configure and can use discovery and relay services. DroidDeck disables Syncthing usage reporting and crash reporting. Selected games or other guest software may use files and resources available within DroidDeck's runtime.
DroidDeck's privacy policy does not replace the policies of these separate providers. Review the relevant provider's policy when signing in, purchasing content, streaming, installing software, or enabling synchronization.
## Retention, removal, and reports
Local information remains until you clear it, sign out, remove the relevant files or feature data, or use Android's app-data controls. Removing DroidDeck or clearing its app storage removes Android-managed app data; libraries, saves, screenshots, and other files in shared storage or on other devices may remain. Signing out of a store integration does not delete the account held by that store. Manage or delete those accounts through the provider.
Diagnostic logs are kept locally. If you choose to send a report or export logs, the recipient receives what you submit. Public GitHub issues and discussions are public. Review reports and attachments before sharing them, and exclude passwords, authentication tokens, and other sensitive information.
## Contact and updates
For privacy questions, contact the Droid-Deck maintainers through the project's [GitHub issue form](https://github.com/Droid-Deck/DroidDeck/issues/new?title=Privacy%20question). Keep the initial public inquiry free of sensitive information; request a private contact method if necessary. GitHub handles information submitted through its service under its own [privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).
Updates to this policy will appear on this page with a revised effective date.
+1 -1
View File
@@ -47,7 +47,7 @@ Help, Preview builds and device reports are on [Discord](https://discord.gg/JRGA
## Build
Needs Docker, Java 17, the Android SDK and NDK, Rust via rustup, GitHub CLI, Python 3 and `zstd`.
Needs Docker, Java 21, the Android SDK and NDK, Rust via rustup, GitHub CLI, Python 3 and `zstd`.
```bash
tools/build_local.sh
+112 -26
View File
@@ -1,7 +1,6 @@
plugins {
id 'com.android.application'
}
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
def localBuildOptions = new Properties()
@@ -19,6 +18,7 @@ if (uvcDir) {
if (!new File(uvcDir, required).isFile()) throw new GradleException("Incomplete UVC build: missing ${required}")
}
}
if (vitureSdkDir) {
['android/arm64-v8a/libglasses.so', 'android/arm64-v8a/libcarina_vio.so',
'android/arm64-v8a/libcloud_protocol.so', 'include/viture_glasses_provider.h',
@@ -82,6 +82,9 @@ def buildBranch = providers.environmentVariable('GITHUB_HEAD_REF').getOrNull()?.
?: gitValue(['branch', '--show-current']) ?: ''
def escapeJava = { String value -> value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ' ').replace('\r', ' ') }
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
def playDistribution = providers.gradleProperty('playDistribution').getOrElse('false').toBoolean()
def playManifest = playDistribution ? new groovy.json.JsonSlurper().parse(rootProject.file('tools/playstore/components.lock.json')) : null
def playLegacyDesktop = playManifest?.entries?.find { it.id == 'desktop-legacy-kde' }
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' }
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
@@ -124,29 +127,30 @@ def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
android {
namespace 'com.droiddeck.launcher'
compileSdk 34
compileSdk 37
ndkVersion project.findProperty('ndkVersion') ?: '27.3.13750724'
defaultConfig {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 17
versionName "0.6.9"
targetSdkVersion 37
versionCode 20
versionName "0.6.9.2"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
buildConfigField "String", "PR_TITLE", "\"${escapeJava(prTitle)}\""
buildConfigField "String", "BUILD_BRANCH", "\"${escapeJava(buildBranch)}\""
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
buildConfigField "boolean", "PLAY_DISTRIBUTION", "${playDistribution}"
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
buildConfigField "String", "SGDB_API_KEY", "\"${System.getenv("SGDB_API_KEY") ?: project.findProperty("sgdbApiKey") ?: ""}\""
buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\""
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${playLegacyDesktop?.sha256 ?: desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${playLegacyDesktop?.size ?: desktopKde.DESKTOP_KDE_SIZE}L"
addonPacks.each { name, pack ->
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
@@ -215,7 +219,7 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
excludes += ['**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -233,8 +237,18 @@ android {
outputs.upToDateWhen { false }
outputs.doNotCacheIf('CI executes the test suite on every run') { true }
}
jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED',
'--add-opens=java.base/java.util=ALL-UNNAMED',
'--add-opens=java.base/java.io=ALL-UNNAMED',
'--add-opens=java.base/java.net=ALL-UNNAMED',
'--add-opens=java.base/java.security=ALL-UNNAMED',
'--add-opens=java.base/java.text=ALL-UNNAMED',
'--add-opens=java.base/jdk.internal.access=ALL-UNNAMED',
'--add-opens=java.desktop/java.awt.font=ALL-UNNAMED',
'--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED'
maxHeapSize = '2g'
maxParallelForks = 2
systemProperty 'robolectric.dependency.repo.url', 'https://repo.maven.apache.org/maven2'
def logTarget = providers.systemProperty('robolectric.logging').orNull
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
}
@@ -244,11 +258,37 @@ android {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
def verifyPlayAudio
if (playDistribution) {
android.buildTypes.debug.signingConfig = null
android.buildTypes.release.signingConfig = null
def requestedCode = providers.gradleProperty('playVersionCode').orNull
if (requestedCode != null && requestedCode != android.defaultConfig.versionCode.toString()) {
throw new GradleException('Play version code must match app/build.gradle')
}
def playAudio = layout.buildDirectory.dir('play-audio')
def playNdk = androidComponents.sdkComponents.ndkDirectory.get().asFile
verifyPlayAudio = tasks.register('verifyPlayAudio', Exec) {
workingDir rootProject.projectDir
commandLine pythonExecutable, 'tools/playstore/build_audio.py', 'verify',
'--output', playAudio.get().asFile, '--ndk', playNdk
}
def stagePlayJni = tasks.register('stagePlayJni', Sync) {
dependsOn(verifyPlayAudio)
from(layout.projectDirectory.dir('src/main/jniLibs')) {
exclude(['libltdl.so', 'libpulseaudio.so', 'libpulse.so', 'libpulsecommon-13.0.so',
'libpulsecore-13.0.so', 'libsndfile.so'].collect { "arm64-v8a/${it}".toString() })
}
from(playAudio.map { it.dir('jni') })
into(layout.buildDirectory.dir('playJni'))
}
android.sourceSets.main.jniLibs.setSrcDirs([layout.buildDirectory.dir('playJni')])
tasks.named('preBuild').configure { dependsOn(stagePlayJni) }
}
// No instrumented tests; the unused androidTest variant breaks the VS Code Kotlin import.
androidComponents {
beforeVariants(selector().all()) { it.androidTest.enable = false }
@@ -266,7 +306,7 @@ if (vitureSdkDir) {
def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
from(rootProject.file('tools/linuxfs/overlay')) {
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**'
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**', 'usr/local/lib/droiddeck/*.py'
}
into(layout.projectDirectory.dir('src/main/assets/linuxfs'))
}
@@ -277,18 +317,34 @@ def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
dependsOn(stageSessionScripts)
from(layout.projectDirectory.dir('src/main/assets')) { exclude 'pulseaudio.tzst' }
from(rootProject.file('tools/linuxfs/innoextract'))
from(layout.buildDirectory.dir('prepared-assets'))
from(layout.buildDirectory.dir('prepared-assets')) {
if (playDistribution) exclude 'pulseaudio.tzst'
}
if (playDistribution) {
dependsOn(verifyPlayAudio)
from(layout.buildDirectory.dir('play-audio/assets'))
}
into(runtimeAssetsDir)
}
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
if (vitureSdkDir) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('vitureAssets'))
if (playDistribution) {
def payloads = rootProject.file('build/playstore-payloads')
def modules = new groovy.json.JsonSlurper().parse(new File(payloads, 'modules.json'))
android.dynamicFeatures = modules.collect { ":${it}".toString() }.toSet()
def packs = new groovy.json.JsonSlurper().parse(new File(payloads, 'assetpacks.json'))
android.assetPacks = packs.collect { ":${it}".toString() }.toSet()
android.sourceSets.main.assets.srcDir(new File(payloads, 'base/src/main/assets'))
android.sourceSets.debug.manifest.srcFile('src/play/AndroidManifest.xml')
android.sourceSets.release.manifest.srcFile('src/play/AndroidManifest.xml')
}
if (vitureSdkDir) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('vitureAssets').get().asFile)
if (uvcDir) {
def stageUvcNotices = tasks.register('stageUvcNotices', Copy) {
from(new File(uvcDir, 'licenses'))
into(layout.buildDirectory.dir('uvcAssets/licenses/uvc'))
}
tasks.named('preBuild').configure { dependsOn(stageUvcNotices) }
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('uvcAssets'))
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('uvcAssets').get().asFile)
}
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
@@ -296,15 +352,15 @@ def stageGogNotices = tasks.register('stageGogNotices', Copy) {
from(layout.projectDirectory.dir('src/main/rust/stores/vendor/xdelta3')) { include 'LICENSE', 'README.droiddeck.md' }
into(layout.buildDirectory.dir('gogNotices/licenses/xdelta3'))
}
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices'))
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices').get().asFile)
tasks.named('preBuild').configure { dependsOn(stageGogNotices) }
if (!ciBuild) {
if (!ciBuild || playDistribution) {
def stageLocalPacks = tasks.register('stageLocalPacks', Sync) {
from(layout.buildDirectory.dir('packs')) { include '*.tar.zst' }
into(layout.buildDirectory.dir('localPackAssets/packs'))
}
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('localPackAssets'))
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('localPackAssets').get().asFile)
tasks.named('preBuild').configure { dependsOn(stageLocalPacks) }
}
@@ -339,7 +395,7 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
workingDir rustCrateDir
def cargoBin = "${System.getProperty('user.home')}/.cargo/bin"
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
environment 'ANDROID_NDK_HOME', androidComponents.sdkComponents.ndkDirectory.get().asFile.absolutePath
environment 'CARGO_TERM_COLOR', 'never'
if (!skipRust) environment 'RUSTC', providers.exec {
commandLine 'rustup', 'which', '--toolchain', 'stable', 'rustc'
@@ -348,7 +404,7 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
'build', '--release', '--locked'
}
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir.get().asFile)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
def maliBridgeDir = rootProject.file('external/MaliBridge')
@@ -361,20 +417,30 @@ def buildVkBridgeServer = tasks.register('buildVkBridgeServer', Exec) {
inputs.file(new File(maliBridgeDir, 'scripts/build-server-android.sh'))
outputs.dir(vkbridgeJniLibsDir)
commandLine 'bash', new File(maliBridgeDir, 'scripts/build-server-android.sh').absolutePath,
android.ndkDirectory.absolutePath, vkbridgeJniLibsDir.get().asFile.absolutePath
androidComponents.sdkComponents.ndkDirectory.get().asFile.absolutePath, vkbridgeJniLibsDir.get().asFile.absolutePath
}
android.sourceSets.main.jniLibs.srcDir(vkbridgeJniLibsDir)
android.sourceSets.main.jniLibs.srcDir(vkbridgeJniLibsDir.get().asFile)
tasks.named('preBuild').configure { dependsOn(buildVkBridgeServer) }
dependencies {
implementation 'com.google.android.play:app-update:2.1.0'
implementation 'com.google.android.play:feature-delivery:2.1.0'
implementation 'com.google.android.play:asset-delivery:2.3.0'
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
implementation 'org.tukaani:xz:1.10'
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation platform('androidx.compose:compose-bom:2025.02.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation('androidx.compose.material3:material3') {
version { strictly '1.2.0' }
}
constraints {
implementation('androidx.compose.material3:material3-android') {
version { strictly '1.2.0' }
}
}
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.activity:activity-compose:1.8.2'
@@ -394,7 +460,27 @@ dependencies {
implementation 'com.google.zxing:core:3.5.3'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'org.robolectric:robolectric:4.17'
testImplementation 'androidx.compose.ui:ui-test-junit4'
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.7-4@jar'
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
androidComponents {
beforeVariants(selector().all()) { variant ->
variant.enableUnitTest = true
}
}
configurations { hostZstd }
dependencies { hostZstd 'com.github.luben:zstd-jni:1.5.7-4@jar' }
androidComponents.onVariants(androidComponents.selector().all()) { variant ->
variant.unitTest?.configureTestTask { test ->
test.classpath = files(configurations.hostZstd) + test.classpath.filter { !it.name.contains("zstd-jni") }
}
}
+33 -8
View File
@@ -3,6 +3,15 @@
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="com.xiaoji.egggame" />
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
<package android:name="cn.nubia.gamelauncher" />
<intent>
<action android:name="android.content.pm.action.CONFIRM_INSTALL" />
@@ -10,10 +19,6 @@
<intent>
<action android:name="android.content.pm.action.CONFIRM_PERMISSIONS" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.SECONDARY_HOME" />
@@ -22,6 +27,7 @@
<uses-permission android:name="android.permission.REORDER_TASKS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
@@ -30,6 +36,10 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -41,7 +51,8 @@
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
@@ -63,6 +74,9 @@
android:hardwareAccelerated="true"
android:largeHeap="true"
android:extractNativeLibs="true"
android:pageSizeCompat="enabled"
android:requestLegacyExternalStorage="true"
android:enableOnBackInvokedCallback="false"
android:appCategory="game"
android:isGame="true">
@@ -321,7 +335,12 @@
<service
android:name=".session.SessionService"
android:exported="false"
android:stopWithTask="false" />
android:foregroundServiceType="specialUse|mediaPlayback|microphone"
android:stopWithTask="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Runs the user's Linux game session (compositor, Steam client, audio) while the app is not on screen" />
</service>
<!-- The compositor's native state is one per process, so the bottom-screen desktop's compositor runs in its own. -->
<service
android:name=".wayland.BottomDesktopCompositorService"
@@ -329,10 +348,16 @@
android:exported="false" />
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Waits for the wireless debugging pairing code the user enters from the notification" />
</service>
<service
android:name=".stores.download.StoreDownloadService"
android:exported="false"
@@ -7,8 +7,10 @@ import com.droiddeck.launcher.session.EnhancedSessionLogShare
import com.droiddeck.launcher.session.GpuClockPin
class App : Application() {
override fun attachBaseContext(base: android.content.Context) =
override fun attachBaseContext(base: android.content.Context) {
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
if (BuildConfig.PLAY_DISTRIBUTION) com.google.android.play.core.splitcompat.SplitCompat.install(this)
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
@@ -17,6 +19,7 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
com.droiddeck.launcher.core.ComponentDownloads.init(this)
Thread({ runCatching { EnhancedSessionLogShare.cleanupAbandonedStages(this) } }, "diagnostic-stage-cleanup").start()
com.droiddeck.launcher.ui.Themes.loadGradient(this)
com.droiddeck.launcher.ui.BuildWatermark.install(this)
@@ -68,7 +71,7 @@ class App : Application() {
Thread({
runCatching {
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
if (!com.droiddeck.launcher.runtime.LinuxRuntime.patchedProot(app)) com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
}
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
@@ -5,11 +5,8 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ProtonDefault
import com.droiddeck.launcher.session.SessionPrefs
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
@@ -23,10 +20,6 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
get() = downloads
private set(value) { downloads = value }
val compDownloadRevision: Long get() = downloadRevision
var compModes by mutableStateOf<Map<String, String>>(emptyMap())
var compAuto by mutableStateOf<Map<String, List<ComponentUpdater.Status>>>(emptyMap())
var compAutoHeld by mutableStateOf<Set<String>>(emptySet())
var compAutoChecking by mutableStateOf(false)
private var shownDefault: String? = null
fun refreshComponents(snapshotFirst: Boolean = false) {
@@ -36,14 +29,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
runCatching { ComponentUpdater.settle(activity) }
val modes = ComponentsManager.COMPONENTS.associateWith { SessionPrefs.componentMode(activity, it) }
val auto = ComponentsManager.COMPONENTS.associateWith { runCatching { ComponentUpdater.status(activity, it) }.getOrDefault(emptyList()) }
val held = if (runCatching { ComponentsManager.hardwareDxvk(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
ui.post {
compModes = modes
compAuto = auto
compAutoHeld = held
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (chosen != null && chosen != shownDefault) {
@@ -56,35 +42,6 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
}, "components").start()
}
fun chooseComponentMode(comp: String, mode: String) {
SessionPrefs.setComponentMode(activity, comp, mode)
compModes = compModes + (comp to mode)
if (mode != ComponentUpdates.AUTO) return
checkComponentUpdates(force = true)
}
private var checkAgain: Boolean? = null
fun checkComponentUpdates(force: Boolean) {
if (compAutoChecking) {
checkAgain = force || checkAgain == true
return
}
compAutoChecking = true
Thread({
val notes = runCatching { ComponentUpdater.ensure(activity, force) }.onFailure { Log.w(TAG, "component updates", it) }.getOrDefault(emptyList())
ui.post {
compAutoChecking = false
checkAgain?.let { again ->
checkAgain = null
checkComponentUpdates(again)
}
if (notes.isNotEmpty()) android.widget.Toast.makeText(activity, notes.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "component-updates").start()
}
fun chooseProton(id: String) {
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
compProton = id
@@ -22,6 +22,7 @@ import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -40,8 +41,6 @@ import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.ui.exposeTestTags
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
@@ -96,11 +95,7 @@ class FrontEndActivity : ComponentActivity() {
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(
this, ui,
idle = { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null },
onInstalled = { if (!OfflineMode.enabled(this)) components.checkComponentUpdates(force = false) },
)
private val protons = ProtonMenu(this, ui) { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null }
private var installed by mutableStateOf<String?>(null)
private var flood by mutableStateOf<PendingFlood?>(null)
@@ -125,6 +120,7 @@ class FrontEndActivity : ComponentActivity() {
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showAccessibilityConsent by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
@@ -207,7 +203,6 @@ class FrontEndActivity : ComponentActivity() {
private var gameStoresSpeedTier by mutableStateOf("fast")
private var navRequest by mutableStateOf<String?>(null)
private val gameLaunchRequest = java.util.concurrent.atomic.AtomicLong()
private var preparingGames by mutableStateOf(emptySet<String>())
private var storesShowMature by mutableStateOf(false)
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
private var storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
@@ -354,6 +349,10 @@ class FrontEndActivity : ComponentActivity() {
SteamIntegration.Feature.BRIGHTNESS -> integrationSettings(SteamIntegration.brightnessSettings(this))
SteamIntegration.Feature.HOME -> if (HomeApp.isDefault(this)) HomeApp.openSystemHomeSettings(this) else offerHomeScreen()
SteamIntegration.Feature.SHORTCUTS -> {
if (!SteamIntegration.accessibilityConsented(this)) {
showAccessibilityConsent = true
return
}
if (!integrationState.shortcuts) shortcutsTried = true
SteamIntegration.setShortcutsListed(this, true)
integrationSettings(SteamIntegration.shortcutSettings(this), android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
@@ -591,6 +590,9 @@ class FrontEndActivity : ComponentActivity() {
private var gameDebugLogsNeedsRestart by mutableStateOf(false)
private var vacDiagnostics by mutableStateOf(false)
private var agentCommands by mutableStateOf(false)
private var bluetoothAllowed by mutableStateOf<Boolean?>(null)
private var allFilesAllowed by mutableStateOf<Boolean?>(null)
private var localNetworkAllowed by mutableStateOf<Boolean?>(null)
private var graphicsLogs by mutableStateOf(false)
private var steamGpuDrawing by mutableStateOf(false)
private var bridgeFellBack by mutableStateOf(false)
@@ -653,6 +655,11 @@ class FrontEndActivity : ComponentActivity() {
private var gameSpaceOffered = false
override fun startActivity(intent: Intent?) {
if (BuildConfig.PLAY_DISTRIBUTION && !com.droiddeck.launcher.core.PlayDelivery.state.value.ready &&
intent?.component?.className == SessionActivity::class.java.name) {
com.droiddeck.launcher.core.PlayDelivery.defer(intent)
return
}
val compat = protons.protonBusyId != null || ProtonExtras.installInProgress
if (intent?.component?.className == SessionActivity::class.java.name) {
if (compatLaunch != null) return
@@ -1084,6 +1091,11 @@ class FrontEndActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (BuildConfig.PLAY_DISTRIBUTION) {
@Suppress("DEPRECATION")
val pending = savedInstanceState?.getParcelable<Intent>("play_pending_session")
if (pending?.component?.className == SessionActivity::class.java.name) com.droiddeck.launcher.core.PlayDelivery.defer(pending)
}
pendingSessionLogExport = savedInstanceState?.getString(KEY_PENDING_SESSION_LOG_EXPORT)?.let(::File)
homeChoiceOpen = savedInstanceState?.getBoolean(STATE_HOME_CHOICE_OPEN) == true
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
@@ -1121,6 +1133,27 @@ class FrontEndActivity : ComponentActivity() {
updates.start()
setContent {
DroidDeckTheme(theme, appScale = appScale) {
val delivery by com.droiddeck.launcher.core.PlayDelivery.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(delivery.ready) {
if (delivery.ready) {
com.droiddeck.launcher.core.PlayDelivery.takePendingSession()?.let { startActivity(it) }
if (BuildConfig.PLAY_DISTRIBUTION) {
refresh()
refreshDeliveredComponents(force = true)
}
}
}
if (BuildConfig.PLAY_DISTRIBUTION && !delivery.ready) ConfirmDialog(
title = getString(R.string.app_name),
text = if (delivery.busy && delivery.percent >= 0)
getString(R.string.setup_runtime_progress, getString(R.string.rtinst_downloading), delivery.percent)
else getString(if (delivery.failed) R.string.user_apps_download_failed else R.string.rtinst_downloading),
confirm = getString(if (delivery.busy) R.string.common_working else R.string.stores_dl_retry),
onConfirm = { com.droiddeck.launcher.core.PlayDelivery.start(this) },
onDismiss = { com.droiddeck.launcher.core.PlayDelivery.cancel(); finish() },
progress = if (delivery.busy) delivery.percent else null,
confirmEnabled = !delivery.busy, closeOnConfirm = false,
)
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
@@ -1144,7 +1177,6 @@ class FrontEndActivity : ComponentActivity() {
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
vkBridge = vkBridge, venus = venus, venusHostVulkan = venusHostVulkan, vkBridgeSafe = vkBridgeSafe, bridgeProtonDxvk = bridgeProtonDxvk, adreno = adrenoGpu,
shortcutPicker = shortcutPicker,
preparingGames = preparingGames,
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
busy = busy, stage = stage, percent = percent,
@@ -1154,6 +1186,7 @@ class FrontEndActivity : ComponentActivity() {
enhancedDiagnosticsArmed = enhancedDiagnosticsArmed,
gameDebugLogsEnabled = gameDebugLogsEnabled, gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
bluetoothAllowed = bluetoothAllowed, allFilesAllowed = allFilesAllowed, localNetworkAllowed = localNetworkAllowed,
graphicsLogs = graphicsLogs, steamGpuDrawing = steamGpuDrawing, bridgeFellBack = bridgeFellBack, bridgeFellBackMemory = bridgeFellBackMemory,
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
@@ -1223,26 +1256,14 @@ class FrontEndActivity : ComponentActivity() {
},
onSteamGame = { g ->
if (shortcutPicker) chooseGameShortcut(g)
else if (g.launchKey !in preparingGames) {
preparingGames = preparingGames + g.launchKey
prepareAndLaunch(g, g.launchKey)
}
},
onStoreGame = { store, id ->
val key = "${store.id}:$id"
if (key !in preparingGames) {
preparingGames = preparingGames + key
val app = applicationContext
Thread({
val g = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
runOnUiThread {
if (g != null && !isFinishing && !isDestroyed) prepareAndLaunch(g, key)
else {
preparingGames = preparingGames - key
if (g == null && !isFinishing && !isDestroyed) android.widget.Toast.makeText(this, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
}
else {
val request = gameLaunchRequest.incrementAndGet()
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g) {
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) return@beforeLaunch
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
}
}, "store-play").start()
}
}
},
onEpicLauncher = { openEpicLauncher() },
@@ -1398,6 +1419,9 @@ class FrontEndActivity : ComponentActivity() {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
},
onBluetoothAllowed = { askBluetooth() },
onLocalNetworkAllowed = { com.droiddeck.launcher.core.AppPermissions.requestLocalNetwork(this, 4) },
onAllFilesAllowed = { com.droiddeck.launcher.files.AllFilesAccess.openSettings(this) },
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
onBackgroundAppLimit = { limit ->
@@ -1593,7 +1617,10 @@ class FrontEndActivity : ComponentActivity() {
refreshPhantomStatus()
showPhantomGate = true
},
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onStartWirelessAdbPairing = {
if (com.droiddeck.launcher.core.AppPermissions.localNetwork(this)) WirelessAdbPairingService.start(this)
else com.droiddeck.launcher.core.AppPermissions.requestLocalNetwork(this, 5)
},
onStartPairingGuide = { displayId, toggleOnly -> PairingGuideActivity.start(this, displayId, toggleOnly) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
@@ -1624,6 +1651,17 @@ class FrontEndActivity : ComponentActivity() {
page = page,
)
}
if (showAccessibilityConsent) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.integration_shortcuts),
text = androidx.compose.ui.res.stringResource(R.string.integration_shortcuts_service_description),
confirm = androidx.compose.ui.res.stringResource(R.string.glasses_allow),
onConfirm = {
SteamIntegration.acceptAccessibility(this@FrontEndActivity)
integrationState = readIntegration()
configureIntegration(SteamIntegration.Feature.SHORTCUTS)
},
onDismiss = { showAccessibilityConsent = false },
)
if (showRoms) RomsDialog(
path = romsDir,
onChoose = {
@@ -1713,15 +1751,24 @@ class FrontEndActivity : ComponentActivity() {
}
val wanted = ArrayList<String>()
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
for (permission in arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) wanted.add(permission)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !SessionPrefs.bluetoothAsked(this)
&& checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.BLUETOOTH_CONNECT)
SessionPrefs.setBluetoothAsked(this)
}
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.RECORD_AUDIO)
SessionPrefs.setMicAsked(this)
}
wanted.addAll(com.droiddeck.launcher.core.AppPermissions.startup(this))
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
else askSharedStorage()
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
@@ -1736,6 +1783,23 @@ class FrontEndActivity : ComponentActivity() {
}, "finish-abandoned").start()
}
private fun askSharedStorage() {
if (!com.droiddeck.launcher.files.AllFilesAccess.granted() && !SessionPrefs.allFilesAsked(this)) {
Thread({
if (com.droiddeck.launcher.files.AllFilesAccess.usesSharedStorage(this)) runOnUiThread {
if (!SessionPrefs.allFilesAsked(this) && com.droiddeck.launcher.files.AllFilesAccess.ask(this)) SessionPrefs.setAllFilesAsked(this)
}
}, "all-files-access").start()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
refreshPermissionRows()
if (requestCode == 5 && com.droiddeck.launcher.core.AppPermissions.localNetwork(this)) WirelessAdbPairingService.start(this)
askSharedStorage()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
@@ -1827,6 +1891,7 @@ class FrontEndActivity : ComponentActivity() {
}
}
@android.annotation.SuppressLint("RestrictedApi")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (compatLaunch != null) {
if (event.keyCode == KeyEvent.KEYCODE_BACK || event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
@@ -1852,6 +1917,27 @@ class FrontEndActivity : ComponentActivity() {
}
}
private fun refreshPermissionRows() {
localNetworkAllowed = if (Build.VERSION.SDK_INT >= 37) com.droiddeck.launcher.core.AppPermissions.localNetwork(this) else null
bluetoothAllowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED else null
allFilesAllowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) com.droiddeck.launcher.files.AllFilesAccess.granted() else null
}
private fun askBluetooth() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val permission = Manifest.permission.BLUETOOTH_CONNECT
val granted = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
if (granted || (SessionPrefs.bluetoothAsked(this) && !shouldShowRequestPermissionRationale(permission))) {
runCatching {
startActivity(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.parse("package:$packageName")))
}
} else {
SessionPrefs.setBluetoothAsked(this)
requestPermissions(arrayOf(permission), 3)
}
}
override fun dispatchGenericMotionEvent(event: android.view.MotionEvent): Boolean {
if (compatLaunch != null && !compatLaunch!!.loading.ended &&
com.droiddeck.launcher.input.PadBridge.isControllerMotionEvent(event)) return true
@@ -1866,6 +1952,7 @@ class FrontEndActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.core.PlayDelivery.start(this)
if (!homeChoiceOpen) {
HomeApp.releaseUnusedHomeEntry(this)
refreshHomeAppState()
@@ -1879,6 +1966,7 @@ class FrontEndActivity : ComponentActivity() {
requestedOrientation = launcherOrientation
SteamIntegration.syncShortcutsListing(this)
refreshWifiDiscovery()
refreshPermissionRows()
com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = { args -> openBattleNet(args) }
@@ -1889,31 +1977,7 @@ class FrontEndActivity : ComponentActivity() {
refreshPhantomStatus()
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
catalogFresh = false
if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) {
componentsQueueBusy = true
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
if (!OfflineMode.enabled(this)) {
val updated = runCatching { ComponentUpdater.ensure(this, force = false) }
.onFailure { android.util.Log.w("ComponentUpdater", "automatic component updates", it) }.getOrDefault(emptyList())
if (updated.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, updated.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
}
}
ui.post {
componentsQueueBusy = false
if (showComponents) components.refreshComponents()
autoUpdateRuntime()
}
}, "components-queue").start()
} else if (showComponents) components.refreshComponents()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
@@ -1923,6 +1987,28 @@ class FrontEndActivity : ComponentActivity() {
DeckyManager.syncCefMarker(this)
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
refreshAddedGames()
refreshDeliveredComponents()
}
private fun refreshDeliveredComponents(force: Boolean = false) {
if (BuildConfig.PLAY_DISTRIBUTION && !com.droiddeck.launcher.core.PlayDelivery.state.value.ready) return
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates(force)
if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) {
componentsQueueBusy = true
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post {
componentsQueueBusy = false
if (showComponents) components.refreshComponents()
autoUpdateRuntime()
}
}, "components-queue").start()
} else if (showComponents) components.refreshComponents()
if (catalog == null) loadDesktopCatalog()
if (!busy) Thread({ checkCatalog() }, "catalog").start()
}
@@ -1946,6 +2032,10 @@ class FrontEndActivity : ComponentActivity() {
@Suppress("OVERRIDE_DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == 7619 && BuildConfig.PLAY_DISTRIBUTION) {
if (resultCode != RESULT_OK) com.droiddeck.launcher.core.PlayDelivery.cancel()
return
}
if (HomeWidgets.onActivityResult(this, requestCode, resultCode)) return
@Suppress("DEPRECATION")
super.onActivityResult(requestCode, resultCode, data)
@@ -1953,6 +2043,9 @@ class FrontEndActivity : ComponentActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (BuildConfig.PLAY_DISTRIBUTION) com.droiddeck.launcher.core.PlayDelivery.peekPendingSession()?.let {
outState.putParcelable("play_pending_session", it)
}
outState.putBoolean(STATE_HOME_CHOICE_OPEN, homeChoiceOpen)
outState.putBoolean(STATE_GAME_SPACE_OFFERED, gameSpaceOffered)
pendingSessionLogExport?.let { outState.putString(KEY_PENDING_SESSION_LOG_EXPORT, it.absolutePath) }
@@ -1969,19 +2062,8 @@ class FrontEndActivity : ComponentActivity() {
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
else @Suppress("DEPRECATION") state.getParcelable(key)
private fun prepareAndLaunch(g: Library.SteamGame, key: String) {
val request = gameLaunchRequest.incrementAndGet()
val done = { preparingGames = preparingGames - key }
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g, onBlocked = done) {
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) { done(); return@beforeLaunch }
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
done()
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
}
}
}
override fun onDestroy() {
com.droiddeck.launcher.core.PlayDelivery.detach(this)
gameLaunchRequest.incrementAndGet()
cancelCompatLaunch()
updates.unregister()
@@ -2058,7 +2140,7 @@ class FrontEndActivity : ComponentActivity() {
private fun launchPending(): Boolean = gameLinkReading || (pendingGameLink != null && !gameLinkTried)
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
val onOwnScreen = displayId == null || displayId == (display?.displayId ?: Display.DEFAULT_DISPLAY)
val onOwnScreen = displayId == null || displayId == ((if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.displayId ?: Display.DEFAULT_DISPLAY)
if (onOwnScreen) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
try {
HomeApp.launch(this, app, displayId)
@@ -2104,6 +2186,7 @@ class FrontEndActivity : ComponentActivity() {
}
private fun loadDesktopCatalog() {
if (BuildConfig.PLAY_DISTRIBUTION && !com.droiddeck.launcher.core.PlayDelivery.state.value.ready) return
if (catalogLoading || catalog != null) return
catalogLoading = true
Thread({
@@ -2166,11 +2249,9 @@ class FrontEndActivity : ComponentActivity() {
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
components.componentAction(getString(R.string.comp_busy_importing)) {
val tmp = File.createTempFile("component-import", ".wcp", cacheDir)
try {
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
getString(R.string.comp_imported, ComponentUpdater.importPackage(this, tmp, name).version)
} finally { tmp.delete() }
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
}
}
@@ -2194,15 +2275,7 @@ class FrontEndActivity : ComponentActivity() {
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = {
components.refreshComponentCatalog()
if (components.compModes.values.any { it == ComponentUpdates.AUTO }) components.checkComponentUpdates(force = true)
},
compModes = components.compModes,
compAuto = components.compAuto,
compAutoHeld = components.compAutoHeld,
compAutoChecking = components.compAutoChecking,
onCompMode = { comp, mode -> components.chooseComponentMode(comp, mode) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
@@ -3031,7 +3104,6 @@ class FrontEndActivity : ComponentActivity() {
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
ready = false
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { removal.run(it) }
@@ -3058,7 +3130,6 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
}
refresh()
if (ok == true && LinuxRuntime.isInstalled(this)) protons.checkUpdates()
}
}, "runtime-operation").start()
}
@@ -3066,12 +3137,9 @@ class FrontEndActivity : ComponentActivity() {
private fun autoUpdateRuntime() {
val release = available?.takeIf { catalogFresh } ?: return
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
if (installed != null) com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
if (release.version == installed) return
if (installed == null && (OfflineMode.enabled(this) || !com.droiddeck.launcher.core.DeviceSupport.canDraw(this) ||
!com.droiddeck.launcher.frontend.AutoComponents.online(this) || com.droiddeck.launcher.frontend.AutoComponents.unfavourable(this))) return
if (release.version == installed || installed == null) return
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
Log.i(TAG, "runtime: installing ${release.version} over ${installed ?: "nothing"} by itself")
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
install(release)
}
}
@@ -200,10 +200,11 @@ object HomeApp {
runCatching {
@Suppress("DEPRECATION")
fun idOf(task: android.app.ActivityManager.AppTask) =
if (Build.VERSION.SDK_INT >= 29) task.taskInfo.taskId else task.taskInfo.persistentId
task.taskInfo?.let { if (Build.VERSION.SDK_INT >= 29) it.taskId else it.persistentId } ?: -1
val tasks = activity.getSystemService(android.app.ActivityManager::class.java).appTasks
val task = tasks.firstOrNull { idOf(it) == activity.taskId } ?: return
val excluded = task.taskInfo.baseIntent.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS != 0
val baseIntent = task.taskInfo?.baseIntent ?: return@runCatching
val excluded = baseIntent.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS != 0
val step = recentsStep(hidden, id in owned, excluded)
if (step == RecentsStep.NONE) return
if (step != RecentsStep.FORGET) task.setExcludeFromRecents(step == RecentsStep.HIDE)
@@ -18,7 +18,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
internal class ProtonMenu(
private val activity: android.app.Activity,
private val ui: Handler,
private val onInstalled: () -> Unit = {},
private val idle: () -> Boolean = { true },
) {
private object Operation {
@@ -62,7 +61,6 @@ internal class ProtonMenu(
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
if (problem == null) onInstalled()
if (automatic) ensureAutoUpdates()
}
}, "install-proton-$id").start()
@@ -4,7 +4,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import android.hardware.input.InputManager
import android.hardware.display.DisplayManager
import android.content.Intent
@@ -234,10 +233,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var screenKeyBind: List<Int>? = null
private var screenKeyDir: Int? = null
private var screenKeyTextFocus = false
private val appTextFocus get() = screenKeyTextFocus || wowChat.request != null
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
private var wowChatBottom: SecondScreenPresentation? = null
private var wowChatOnTop by mutableStateOf(false)
private var screenKeyEdit by mutableStateOf<com.droiddeck.launcher.input.ScreenKey?>(null)
private var screenLayoutsOpen by mutableStateOf(false)
private var screenKeysShown by mutableStateOf(true)
@@ -584,7 +579,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
joyConVr?.refresh()
if (Build.VERSION.SDK_INT >= 31) joyConVr?.refresh()
padBridge?.syncConnectedControllers()
refreshControls()
updatePointerCapture()
@@ -597,7 +592,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
dropMouseCursor()
backQamInput.onDeviceRemoved(deviceId)
mouseBackInput.onDeviceRemoved(deviceId)
joyConVr?.refresh()
if (Build.VERSION.SDK_INT >= 31) joyConVr?.refresh()
padBridge?.onDeviceRemoved(deviceId)
if (::padMouse.isInitialized) padMouse.reset()
refreshControls()
@@ -606,7 +601,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
refreshSecondScreenDisplays()
}
override fun onInputDeviceChanged(deviceId: Int) {
joyConVr?.refresh()
if (Build.VERSION.SDK_INT >= 31) joyConVr?.refresh()
refreshControls()
refreshSecondScreenDisplays()
}
@@ -616,6 +611,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (BuildConfig.PLAY_DISTRIBUTION && !com.droiddeck.launcher.core.PlayDelivery.state.value.ready) {
runtimeRemovalBlocked = true
com.droiddeck.launcher.core.PlayDelivery.defer(intent)
startActivity(Intent(this, FrontEndActivity::class.java))
finish()
return
}
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
@@ -831,10 +833,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
}
sessionOverlay = createSessionOverlay()
root.addView(object : FrameLayout(this) {
override fun onResolvePointerIcon(event: MotionEvent, pointerIndex: Int): android.view.PointerIcon? =
if (platformPointerShown == true) super.onResolvePointerIcon(event, pointerIndex) else null
}.apply { addView(sessionOverlay) })
root.addView(sessionOverlay)
setContentView(root)
glassesScope.launch { Glasses.state.collect {
glasses = it
@@ -918,8 +917,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
androidx.compose.runtime.LaunchedEffect(AgentGuest.desktopTextField) { onDesktopTextField(AgentGuest.desktopTextField) }
androidx.compose.runtime.LaunchedEffect(AgentGuest.gameTextField, AgentGuest.gameProgram) { onGameTextField(AgentGuest.gameTextField) }
androidx.compose.runtime.LaunchedEffect(wowChat.request) { placeWowChat() }
androidx.compose.runtime.LaunchedEffect(pipUi, pcKeyboardOpen) { updateDesktopKeyboardButton() }
val keyboardRequestsSeen = androidx.compose.runtime.remember { intArrayOf(AgentGuest.keyboardRequests) }
androidx.compose.runtime.LaunchedEffect(AgentGuest.keyboardRequests) {
@@ -964,7 +961,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!phonePanel && GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) &&
headTracking && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesRecenterButton(
controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100,
onRecenterJoyCons = if (steamVrStatus == SteamVr.Status.RUNNING &&
onRecenterJoyCons = if (Build.VERSION.SDK_INT >= 31 && steamVrStatus == SteamVr.Status.RUNNING &&
glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
joyConVr?.let { { it.recenter() } } else null,
) { headTracker?.recenter() }
@@ -1043,10 +1040,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
}
if (wowChat.asking) com.droiddeck.launcher.ui.WowChatChoiceDialog(wowChat)
if (wowChatOnTop && wowChat.request != null) androidx.compose.foundation.layout.Box(
Modifier.fillMaxSize().imePadding(), contentAlignment = androidx.compose.ui.Alignment.BottomCenter,
) { com.droiddeck.launcher.ui.WowChatPanel(wowChat) }
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
game = gestureGame,
onDismiss = { gestureSettingsOpen = false },
@@ -1324,9 +1317,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = ::togglePcKeyboard,
wowChatShown = com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram),
wowChat = wowChat.remembered,
onWowChat = wowChat::choose,
controller = controllerSettings,
onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } },
onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } },
@@ -1540,7 +1530,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val pads = com.droiddeck.launcher.input.VrControllers.settings.collectAsState().value
com.droiddeck.launcher.ui.GlassesPhonePanel(glasses.model, battery, com.droiddeck.launcher.ui.GlassesPhoneActions(
onRecenter = if (headTracking) ({ headTracker?.recenter() }) else null,
onRecenterJoyCons = if (vr && glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
onRecenterJoyCons = if (Build.VERSION.SDK_INT >= 31 && vr && glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
joyConVr?.let { { it.recenter() } } else null,
onSteamVrMenu = if (vr && SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onSwitchSticks = if (vr && pads.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ({ switchVrSticks() }) else null,
@@ -2062,7 +2052,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "proton.installing", mapOf("component" to "proton"))
val protonProblem = installProton()
if (protonProblem == null) SessionEvents.record("proton.ready")
else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
else {
SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
if (BuildConfig.PLAY_DISTRIBUTION) {
problem = protonProblem
failedComponent = "proton"
}
}
}
uiHandler.post {
localInstalling = false
@@ -2081,6 +2077,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
"proton" -> "PROTON_INSTALL_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
@@ -2166,6 +2163,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val cancellation = com.droiddeck.launcher.core.Downloader.Cancellation { SessionState.stopRequested }
SessionState.installCancellation = cancellation
return try {
if (BuildConfig.PLAY_DISTRIBUTION) {
var last = "" to Int.MIN_VALUE
return com.droiddeck.launcher.session.ProtonExtras.install(
this, com.droiddeck.launcher.session.ProtonExtras.tools.single(), cancellation,
) { message, percent ->
val next = message to percent
if (next != last) {
last = next
uiHandler.post {
loading.percent = percent
loading.say(message, PROTON_LINES.topic, readable = true)
}
}
}
}
val entry = catalog.fetch(catalog.STEAM_SEED_URL, cancellation)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
?: return if (cancellation.asBoolean) "cancelled" else "catalog unreachable"
catalog.install(this, entry, cancellation, progressFor(PROTON_LINES, entry.size / 1_000_000))
@@ -2287,7 +2299,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun windowOutputSize(width: Int, height: Int): Pair<Int, Int>? {
if (width <= 0 || height <= 0 || isInPictureInPictureMode) return null
if (width <= 0 || height <= 0) return null
if (isInPictureInPictureMode) return com.droiddeck.launcher.session.PipPolicy.outputSize(
width, height, SessionState.outputSize.first, SessionState.outputSize.second)
if (bottomGame != null) return when {
SessionState.followScreen ->
com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, false, SessionState.followScale)
@@ -2535,7 +2549,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.input.HomeButtonShortcuts.cancel(this)
backQamInput.cancel()
padBridge?.releaseAll()
joyConVr?.releaseButtons()
if (Build.VERSION.SDK_INT >= 31) joyConVr?.releaseButtons()
}
private fun focusEndedScreen() {
@@ -2650,6 +2664,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
@android.annotation.SuppressLint("RestrictedApi")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> {
@@ -2657,7 +2672,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.mode == SessionService.MODE_STEAM && SessionState.running &&
SessionState.firstFrameSeen && !SessionState.suspended && !ended &&
!pipUi && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
!gestureSettingsOpen && !appTextFocus && window.decorView.hasWindowFocus()
!gestureSettingsOpen && !screenKeyTextFocus && window.decorView.hasWindowFocus()
if (steamVolume) {
val audio = getSystemService(android.media.AudioManager::class.java)
if (com.droiddeck.launcher.input.SteamVolumeKeys.dispatch(event, !audio.isVolumeFixed) { direction ->
@@ -2671,10 +2686,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, String.format(java.util.Locale.ROOT, "back key device=%d (%s) source=0x%x flags=0x%x",
event.deviceId, event.device?.name, event.source, event.flags))
}
val mouseToGuest = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen &&
val mouseToGuest = !pipUi && !screenKeyTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen &&
!SessionState.suspended && !(::loading.isInitialized && (loading.visible || loading.ended))
if (mouseBackInput.onKeyEvent(event, mouseToGuest)) return true
val backToQam = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
val backToQam = !pipUi && !screenKeyTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.running && SessionState.firstFrameSeen &&
!SessionState.suspended && padBridge?.canHoldQam() == true
if (backQamInput.onKeyEvent(event, backToQam)) {
@@ -2683,7 +2698,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (pipUi) return true
if (appTextFocus) return super.dispatchKeyEvent(event)
if (screenKeyTextFocus) return super.dispatchKeyEvent(event)
if (physicalShortcuts.onKey(
event,
enabled = SessionState.running && !SessionState.suspended &&
@@ -2774,7 +2789,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event)
if (!fromController && event.keyCode != KeyEvent.KEYCODE_BACK && sendToTopDesktop(event)) return true
if (joyConVr?.onKeyEvent(event) == true) return true
if (Build.VERSION.SDK_INT >= 31 && joyConVr?.onKeyEvent(event) == true) return true
if (fromController && event.keyCode != KeyEvent.KEYCODE_BACK && padMouseMode() && padMouse.onKey(event)) return true
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
val keyboardKey = event.keyCode != KeyEvent.KEYCODE_BACK ||
@@ -2787,6 +2802,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchKeyEvent(event)
}
@android.annotation.SuppressLint("RestrictedApi")
private fun sendToTopDesktop(event: KeyEvent): Boolean {
val key = event.deviceId to event.keyCode
val owned = if (event.action == KeyEvent.ACTION_UP) topDesktopKeys.remove(key) else topDesktopFocused()
@@ -2820,7 +2836,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if (joyConVr?.onMotionEvent(event) == true) return true
if (Build.VERSION.SDK_INT >= 31 && joyConVr?.onMotionEvent(event) == true) return true
if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true
if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen) {
@@ -2936,6 +2952,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun dockedMenu(): SecondScreenPresentation? = secondScreenPresentation?.takeIf { drawerDocked && it.menuShown }
@android.annotation.SuppressLint("RestrictedApi")
private fun dispatchToDrawer(event: KeyEvent): Boolean {
val bottom = dockedMenu()
if (bottom != null && event.keyCode != KeyEvent.KEYCODE_BACK) return bottom.dispatchMenuKey(event)
@@ -3567,105 +3584,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
showCursor(px, py)
}
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
if (!started) return null
private fun openBottomKeyboard() {
if (!started) return
if (secondScreenPresentation?.showsKeyboard != true && dockDrawer()) {
val target = com.droiddeck.launcher.input.DualScreen.sessionDisplay(this, displayManager, shownOnDisplayId) ?: return null
val target = com.droiddeck.launcher.input.DualScreen.sessionDisplay(this, displayManager, shownOnDisplayId) ?: return
if (drawerOpen || drawerPinned) drawerOpen = false
typingReturnMode = secondScreenMode
selectedSecondScreenDisplay = target.displayId
selectSecondScreenMode(SecondScreenMode.KEYBOARD_TRACKPAD)
}
return secondScreenPresentation?.takeIf { it.showsKeyboard && it.display != null }
}
private fun openBottomKeyboard() {
val presentation = bottomKeyboardPresentation() ?: return
val presentation = secondScreenPresentation ?: return
if (!presentation.showsKeyboard) return
val display = presentation.display?.displayId ?: return
presentation.requestKeyboard(forField = true)
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, presentation.display.displayId)
}
private fun onGameTextField(focused: Boolean) {
if (!focused || !com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram)) wowChat.chatClosed()
else if (resumed && !pipUi && !pcKeyboardOpen &&
resources.configuration.hardKeyboardHidden != android.content.res.Configuration.HARDKEYBOARDHIDDEN_NO) {
keyboard?.takeIf { it.shown }?.hide()
wowChat.chatOpened()
}
}
private fun placeWowChat() {
val open = wowChat.request != null
val shown = wowChatBottom
wowChatOnTop = false
if (open && shown == null) {
val presentation = bottomKeyboardPresentation()
if (presentation == null) {
wowChatOnTop = true
return
}
wowChatBottom = presentation
presentation.showChat(onLost = {
if (wowChatBottom === presentation) {
wowChatBottom = null
wowChatOnTop = wowChat.request != null
}
}) {
androidx.compose.runtime.CompositionLocalProvider(
androidx.activity.compose.LocalActivityResultRegistryOwner provides this,
androidx.activity.compose.LocalOnBackPressedDispatcherOwner provides this,
) { DroidDeckTheme { com.droiddeck.launcher.ui.WowChatPanel(wowChat) } }
}
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, presentation.display.displayId)
} else if (!open && shown != null) {
wowChatBottom = null
shown.hideChat()
if (secondScreenPresentation === shown) bottomKeyboardClosed()
}
}
private fun sendWowChat(text: String, replace: Boolean) {
val host = keyboard ?: return
if (replace) {
selectGameField()
if (text.isEmpty()) host.tap(KeyEvent.KEYCODE_DEL)
} else {
endGameField()
}
host.type(text)
host.tap(KeyEvent.KEYCODE_ENTER)
uiHandler.postDelayed({
if (AgentGuest.gameTextField && com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram)) endGameField()
}, FIELD_SETTLE_MS)
}
private fun endGameField() {
WaylandCompositor.nativeSendKey(KEY_END, 1)
WaylandCompositor.nativeSendKey(KEY_END, 0)
}
private fun selectGameField(vararg then: Int) {
val held = KEY_LEFTCTRL in guestKeysDown || KEY_LEFTCTRL in gestureKeysDown
if (!held) WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 1)
for (key in intArrayOf(KEY_A) + then) {
WaylandCompositor.nativeSendKey(key, 1)
WaylandCompositor.nativeSendKey(key, 0)
}
if (!held) WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 0)
}
private fun readGameField(done: (String?) -> Unit) {
val opened = wowChat.request
val stillOpen = { wowChat.request === opened && AgentGuest.gameTextField }
sessionClipboard.captureGuest(FIELD_READ_MS, onTimeout = { if (stillOpen()) endGameField() }) { text ->
if (text != null && stillOpen()) endGameField()
done(text)
}
selectGameField(KEY_C)
}
private fun cancelWowChat() {
keyboard?.tap(KeyEvent.KEYCODE_ESCAPE)
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, display)
}
private fun bottomKeyboardClosed() {
@@ -4090,7 +4022,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun appUiOwnsPointer(): Boolean =
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen
private fun applyPlatformPointer(shown: Boolean) {
if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) return
@@ -4385,7 +4317,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun showPipUi(show: Boolean) {
pipUi = show
if (show) {
if (::hud.isInitialized) hud.setHidden(true)
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
@@ -4536,7 +4467,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padBridge?.setStickDeadZone(ControllerPrefs.deadZone(this))
refreshSecondScreenDisplays()
if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers()
joyConVr?.refresh()
if (Build.VERSION.SDK_INT >= 31) joyConVr?.refresh()
onScreenControls?.reload()
controllerSettings = ControllerPrefs.read(this)
resumed = true
@@ -4673,13 +4604,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
glasses.usesSpatialControllers(glassesSettings)
private fun updateJoyConTracking() {
joyConVr?.setActive(steamVrStatus == SteamVr.Status.RUNNING &&
if (Build.VERSION.SDK_INT >= 31) joyConVr?.setActive(steamVrStatus == SteamVr.Status.RUNNING &&
(ControllerPrefs.joyConVr(this) ||
(usesSpatialControllers() && glassesSettings.joyConTracking)))
}
private fun updateHandCamera() {
val joyCons = joyConVr?.holding == true
val joyCons = Build.VERSION.SDK_INT >= 31 && joyConVr?.holding == true
val bare = !joyCons && !controllerConnected()
val want = started && steamVrStatus == SteamVr.Status.RUNNING && (joyCons || bare) &&
!usesSpatialControllers() &&
@@ -4700,7 +4631,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun controllerConnected(): Boolean = InputDevice.getDeviceIds().any { id ->
joyConVr?.owns(id) != true && com.droiddeck.launcher.input.PadBridge.isFromController(InputDevice.getDevice(id))
(Build.VERSION.SDK_INT < 31 || joyConVr?.owns(id) != true) && com.droiddeck.launcher.input.PadBridge.isFromController(InputDevice.getDevice(id))
}
private fun onSteamVrStatus(now: SteamVr.Status) {
@@ -5203,7 +5134,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
headTracker?.stop()
if (::hud.isInitialized) hud.stop()
if (::memoryHud.isInitialized) memoryHud.stop()
joyConVr?.close()
if (Build.VERSION.SDK_INT >= 31) joyConVr?.close()
joyConVr = null
handCamera?.stop()
handCamera = null
@@ -5255,7 +5186,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val tasks = runCatching { getSystemService(android.app.ActivityManager::class.java)?.appTasks }.getOrNull().orEmpty()
for (task in tasks) {
runCatching {
val info = task.taskInfo
val info = task.taskInfo ?: return@runCatching
@Suppress("DEPRECATION")
val id = if (android.os.Build.VERSION.SDK_INT >= 29) info.taskId else info.persistentId
val base = info.baseIntent
@@ -5300,11 +5231,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private const val AXIS_UNITS_PER_PIXEL = 3f
private const val KEY_LEFTCTRL = 29
private const val KEY_RIGHTCTRL = 97
private const val KEY_A = 30
private const val KEY_C = 46
private const val KEY_END = 107
private const val FIELD_READ_MS = 600L
private const val FIELD_SETTLE_MS = 200L
private const val BTN_SIDE = 0x113
private const val BTN_EXTRA = 0x114
private const val DESKTOP_GRAB_TAP_MS = 500L
@@ -8,6 +8,7 @@ import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.Handler
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -17,8 +18,12 @@ import com.droiddeck.launcher.ui.UpdatesActions
import com.droiddeck.launcher.ui.UpdatesState
import com.droiddeck.launcher.update.AppUpdates
import com.droiddeck.launcher.update.SelfInstaller
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.install.model.UpdateAvailability
internal class UpdatesMenu(private val activity: Activity, private val ui: Handler) {
internal class UpdatesMenu(private val activity: Activity, private val ui: Handler,
private val playManager: () -> AppUpdateManager = { AppUpdateManagerFactory.create(activity) }) {
var catalog by mutableStateOf<AppUpdates.Catalog?>(null)
var follow by mutableStateOf(AppUpdates.Follow(AppUpdates.Channel.STABLE))
var checking by mutableStateOf(false)
@@ -26,12 +31,15 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
var stage by mutableStateOf<String?>(null)
var percent by mutableIntStateOf(-1)
var askPermission by mutableStateOf(false)
var playUpdateAvailable by mutableStateOf<Boolean?>(null)
private var playCheckedAt by mutableStateOf(0L)
private var pending: AppUpdates.Release? = null
private var lastCheck = 0L
private var request = 0
@Volatile private var session = -1
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission,
playUpdateAvailable, playCheckedAt)
fun actions() = UpdatesActions(
onCheck = { check(force = true) },
@@ -39,15 +47,22 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
onInstall = ::install,
onAllowInstalls = {
askPermission = false
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
if (!BuildConfig.PLAY_DISTRIBUTION) {
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
}
},
onDismissPermission = { askPermission = false; pending = null },
onOpenPlay = ::openPlay,
)
val hasUpdate: Boolean get() = AppUpdates.hasUpdate(catalog, follow)
val hasUpdate: Boolean get() = if (BuildConfig.PLAY_DISTRIBUTION) playUpdateAvailable == true else AppUpdates.hasUpdate(catalog, follow)
fun onResume() {
if (BuildConfig.PLAY_DISTRIBUTION) {
if (!SessionState.running) check(force = false)
return
}
pending?.let { r ->
if (SelfInstaller.canInstall(activity)) { pending = null; install(r) }
}
@@ -55,6 +70,10 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
}
fun check(force: Boolean) {
if (BuildConfig.PLAY_DISTRIBUTION) {
checkPlay(force)
return
}
if (checking || stage != null) return
val now = System.currentTimeMillis()
if (!force && now - lastCheck < 60_000) return
@@ -75,6 +94,45 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
}, "app-updates").start()
}
private fun checkPlay(force: Boolean) {
if (checking) return
val now = System.currentTimeMillis()
if (!force && now - lastCheck < 60_000) return
lastCheck = now
checking = true
error = null
val id = ++request
fun failed(failure: Throwable) {
if (id != request || activity.isDestroyed) return
checking = false
if (force) error = activity.getString(R.string.upd_couldnt_check, failure.message ?: failure.javaClass.simpleName)
}
runCatching {
playManager().appUpdateInfo.addOnSuccessListener { info ->
if (id != request || activity.isDestroyed) return@addOnSuccessListener
checking = false
val availability = info.updateAvailability()
playUpdateAvailable = when (availability) {
UpdateAvailability.UPDATE_AVAILABLE, UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> true
UpdateAvailability.UPDATE_NOT_AVAILABLE -> false
else -> null
}
playCheckedAt = now
}.addOnFailureListener { failed(it) }
}.onFailure { failed(it) }
}
private fun openPlay() {
if (!BuildConfig.PLAY_DISTRIBUTION) return
val listing = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${activity.packageName}"))
.setPackage("com.android.vending")
runCatching { activity.startActivity(listing) }.onFailure {
runCatching {
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=${activity.packageName}")))
}.onFailure { error = activity.getString(R.string.upd_couldnt_check, it.message ?: it.javaClass.simpleName) }
}
}
private fun choose(f: AppUpdates.Follow) {
AppUpdates.setFollow(activity, f)
follow = f
@@ -82,6 +140,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
}
private fun install(r: AppUpdates.Release) {
if (BuildConfig.PLAY_DISTRIBUTION) { openPlay(); return }
if (stage != null) return
val apk = r.apk ?: run { error = activity.getString(R.string.upd_no_download); return }
AppUpdates.installBlock(r)?.let { error = it.message(activity); return }
@@ -122,7 +181,9 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
}
}
@android.annotation.SuppressLint("UnspecifiedRegisterReceiverFlag")
fun start() {
if (BuildConfig.PLAY_DISTRIBUTION) return
catalog = AppUpdates.cached(activity)
follow = AppUpdates.follow(activity, catalog)
val filter = IntentFilter(SelfInstaller.ACTION_STATUS)
@@ -131,6 +192,8 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
}
fun unregister() {
request++
checking = false
runCatching { activity.unregisterReceiver(receiver) }
}
}
@@ -26,10 +26,6 @@ object AgentGuest {
private set
var keyboardRequests by mutableIntStateOf(0)
private set
var gameTextField by mutableStateOf(false)
private set
var gameProgram by mutableStateOf<String?>(null)
private set
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
@@ -47,7 +43,7 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
main.post { steamKeyboard = false; desktopTextField = false }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@@ -63,7 +59,6 @@ object AgentGuest {
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
"ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) }
"wine-ime.json" -> readJson(File(dir, path))?.let { recordGameTextField(this, it) }
"keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) }
"keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) }
}
@@ -76,7 +71,7 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
main.post { steamKeyboard = false; desktopTextField = false }
}
}
@@ -134,16 +129,6 @@ object AgentGuest {
SessionEvents.record("ime.changed", mapOf("textField" to focused, "seq" to snapshot.optInt("seq")))
}
private fun recordGameTextField(source: FileObserver, snapshot: JSONObject) {
val focused = snapshot.optBoolean("textField")
val program = snapshot.optString("program").takeIf { it.isNotEmpty() }
synchronized(lock) {
if (source !== observer) return
main.post { gameProgram = program; gameTextField = focused }
}
SessionEvents.record("game.ime.changed", mapOf("textField" to focused, "program" to program, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -8,6 +8,7 @@ import com.droiddeck.launcher.core.FileUtils;
import com.droiddeck.launcher.core.SessionPart;
import com.droiddeck.launcher.core.HostProcess;
import com.droiddeck.launcher.core.TarZst;
import com.droiddeck.launcher.BuildConfig;
import java.io.File;
import java.io.InputStream;
@@ -113,7 +114,8 @@ public class PulseAudioComponent extends SessionPart {
env.add("HOME=" + workingDir);
env.add("TMPDIR=" + workingDir);
String command = workingDir.getAbsolutePath() + "/libpulseaudio.so"
String command = HostProcess.LINKER + " " + workingDir.getAbsolutePath() + "/libpulseaudio.so"
+ (BuildConfig.PLAY_DISTRIBUTION ? " --dl-search-path=" + modules.getAbsolutePath() : "")
+ " --system=false --disable-shm=true --fail=false"
+ " -n --file=default.pa --daemonize=false --use-pid-file=false --exit-idle-time=-1"
+ " --log-level=info";
@@ -194,6 +196,7 @@ public class PulseAudioComponent extends SessionPart {
try {
File modules = new File(workingDir, "modules/arm64");
ArrayList<String> cmd = new ArrayList<>();
cmd.add(HostProcess.LINKER);
cmd.add(pactl.getAbsolutePath());
java.util.Collections.addAll(cmd, args);
ProcessBuilder builder = new ProcessBuilder(cmd);
@@ -255,7 +258,7 @@ public class PulseAudioComponent extends SessionPart {
try {
File modules = new File(workingDir, "modules/arm64");
ProcessBuilder builder = new ProcessBuilder(
pactl.getAbsolutePath(), "suspend-" + kind, name, Boolean.toString(suspended));
HostProcess.LINKER, pactl.getAbsolutePath(), "suspend-" + kind, name, Boolean.toString(suspended));
builder.directory(workingDir);
builder.redirectErrorStream(true);
builder.redirectOutput(new File("/dev/null"));
@@ -95,8 +95,8 @@ internal class AddonPack(
}
val root = root(context).apply { mkdirs() }
val archive = File(root, "$sha256.tar.zst")
val downloaded = bundled(context, archive) || urls.any { url ->
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
val downloaded = bundled(context, archive) || !BuildConfig.PLAY_DISTRIBUTION && urls.any { url ->
ComponentDownloads.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
.also { if (!it) archive.delete() }
}
if (!downloaded) return false
@@ -0,0 +1,50 @@
package com.droiddeck.launcher.core
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
object AppPermissions {
fun granted(context: Context, permission: String): Boolean =
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
fun localNetwork(context: Context): Boolean = Build.VERSION.SDK_INT < 37 ||
granted(context, Manifest.permission.ACCESS_LOCAL_NETWORK)
fun startup(activity: Activity): List<String> = buildList {
if (Build.VERSION.SDK_INT >= 37 && !localNetwork(activity) &&
!activity.getSharedPreferences("runtime-permissions", Context.MODE_PRIVATE).getBoolean("local-network", false)) {
add(Manifest.permission.ACCESS_LOCAL_NETWORK)
activity.getSharedPreferences("runtime-permissions", Context.MODE_PRIVATE).edit().putBoolean("local-network", true).apply()
}
if (Build.VERSION.SDK_INT >= 33 && !granted(activity, Manifest.permission.POST_NOTIFICATIONS) &&
!activity.getSharedPreferences("stores", Context.MODE_PRIVATE).getBoolean("asked_notifications", false)) {
add(Manifest.permission.POST_NOTIFICATIONS)
activity.getSharedPreferences("stores", Context.MODE_PRIVATE).edit().putBoolean("asked_notifications", true).apply()
}
}
fun requestLocalNetwork(activity: Activity, requestCode: Int) {
if (Build.VERSION.SDK_INT < 37) return
val prefs = activity.getSharedPreferences("runtime-permissions", Context.MODE_PRIVATE)
if (localNetwork(activity) || prefs.getBoolean("local-network", false) &&
!activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK)) {
openSettings(activity)
} else {
prefs.edit().putBoolean("local-network", true).apply()
activity.requestPermissions(arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK), requestCode)
}
}
fun openSettings(context: Context) {
runCatching {
context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
@@ -0,0 +1,91 @@
package com.droiddeck.launcher.core;
import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.BuildConfig;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
public final class ComponentDownloads {
private static volatile ComponentPayloadStore store;
private ComponentDownloads() {}
public static void init(Context context) {
if (BuildConfig.PLAY_DISTRIBUTION) {
PlayDelivery.INSTANCE.init(context);
store = new ComponentPayloadStore(path -> {
try {
return PlayDelivery.INSTANCE.open(path);
} catch (IOException error) {
throw new java.io.UncheckedIOException(error);
}
});
}
}
private static ComponentPayloadStore bundled() throws IOException {
ComponentPayloadStore current = store;
if (current == null) throw new IOException("Bundled component delivery is unavailable");
return current;
}
public static String catalogName(String name) {
return BuildConfig.PLAY_DISTRIBUTION ? "play-" + BuildConfig.SOURCE_COMMIT + "-" + name : name;
}
public static String downloadString(String url) {
return downloadString(url, null);
}
public static String downloadString(String url, BooleanSupplier cancelled) {
if (!BuildConfig.PLAY_DISTRIBUTION) return Downloader.downloadString(url, cancelled);
try {
return bundled().read(url, cancelled);
} catch (Exception error) {
Log.w("ComponentDownloads", "Bundled catalog unavailable: " + url, error);
return null;
}
}
public static String downloadDirect(String url) {
return BuildConfig.PLAY_DISTRIBUTION ? downloadString(url) : Downloader.downloadDirect(url);
}
public static long copyBundled(String url, OutputStream output, long limit, Consumer<Float> progress) throws IOException {
if (!BuildConfig.PLAY_DISTRIBUTION) throw new IOException("Bundled delivery requires a Play distribution");
try {
return bundled().copy(url, output, limit, null, null, progress);
} catch (java.io.UncheckedIOException error) {
throw error.getCause();
} catch (Exception error) {
if (error instanceof IOException) throw (IOException) error;
throw new IOException("Invalid bundled component index", error);
}
}
public static boolean downloadFile(String url, File destination, boolean resume, Consumer<Float> progress) {
return downloadFile(url, destination, resume, null, null, progress);
}
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256, Consumer<Float> progress) {
return downloadFile(url, destination, resume, expectedSha256, null, progress);
}
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
BooleanSupplier cancelled, Consumer<Float> progress) {
if (!BuildConfig.PLAY_DISTRIBUTION) {
return Downloader.downloadFile(url, destination, resume, expectedSha256, cancelled, progress);
}
try {
bundled().install(url, destination, expectedSha256, cancelled, progress);
return true;
} catch (Exception error) {
Log.w("ComponentDownloads", "Bundled component unavailable: " + url, error);
return false;
}
}
}
@@ -0,0 +1,161 @@
package com.droiddeck.launcher.core
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.function.BooleanSupplier
import java.util.function.Consumer
internal class ComponentPayloadStore @JvmOverloads constructor(
private val download: (String, File, String, BooleanSupplier?, Consumer<Float>?) -> Boolean = { url, file, digest, cancelled, progress ->
Downloader.downloadCandidates(url, listOf(url), file, false, digest, cancelled, progress)
},
private val assets: (String) -> InputStream,
) {
private val entries by lazy {
val index = readObject("play-components/index.json", 2 * 1024 * 1024)
if (index.getInt("schema") != 1) throw IOException("Unsupported component index")
index.getJSONObject("entries")
}
private fun readObject(path: String, limit: Int = INDEX_LIMIT): JSONObject {
val bytes = assets(path).use { input ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(65536)
while (true) {
val count = input.read(buffer)
if (count < 0) break
if (output.size() + count > limit) throw IOException("Component metadata exceeds its size limit")
output.write(buffer, 0, count)
}
output.toByteArray()
}
return JSONObject(String(bytes, Charsets.UTF_8))
}
fun copy(url: String, output: OutputStream, limit: Long, expectedSha256: String? = null,
cancelled: BooleanSupplier? = null, progress: Consumer<Float>? = null): Long {
checkCancelled(cancelled)
val entry = entries.optJSONObject(url) ?: throw IOException("Component is absent from this build: $url")
val size = entry.getLong("size")
val digest = entry.getString("sha256")
if (size <= 0 || size > limit || !SHA256.matches(digest)) throw IOException("Invalid component metadata")
if (expectedSha256 != null && !digest.equals(expectedSha256, ignoreCase = true)) {
throw IOException("Component version does not match this build")
}
val recipe = "play-components/recipes/$digest.json"
if (entry.getString("recipe") != recipe) throw IOException("Invalid component recipe path")
val chunks = readObject(recipe).getJSONArray("chunks")
if (chunks.length() == 0) throw IOException("Component contains no payload")
val fullDigest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(65536)
var total = 0L
var lastPercent = -1
for (index in 0 until chunks.length()) {
checkCancelled(cancelled)
val chunk = chunks.getJSONObject(index)
val path = chunk.getString("asset")
val chunkSize = chunk.getLong("size")
val chunkSha256 = chunk.getString("sha256")
if (!ASSET.matches(path) || chunkSize <= 0 || chunkSize > size - total || !SHA256.matches(chunkSha256)) {
throw IOException("Invalid component chunk metadata")
}
val chunkDigest = MessageDigest.getInstance("SHA-256")
var copied = 0L
assets(path).use { input ->
while (true) {
checkCancelled(cancelled)
val count = input.read(buffer)
if (count < 0) break
if (count.toLong() > chunkSize - copied) throw IOException("Component chunk exceeds pinned size")
chunkDigest.update(buffer, 0, count)
fullDigest.update(buffer, 0, count)
output.write(buffer, 0, count)
copied += count
total += count
val percent = (total * 100 / size).toInt().coerceAtMost(99)
if (percent != lastPercent) {
lastPercent = percent
progress?.accept(percent / 100f)
}
}
}
if (copied != chunkSize || hex(chunkDigest.digest()) != chunkSha256) {
throw IOException("Component chunk failed verification")
}
}
checkCancelled(cancelled)
if (total != size || hex(fullDigest.digest()) != digest) throw IOException("Component failed verification")
progress?.accept(1f)
return total
}
fun read(url: String, cancelled: BooleanSupplier? = null): String {
val output = ByteArrayOutputStream()
copy(url, output, INDEX_LIMIT.toLong(), cancelled = cancelled)
return output.toString(Charsets.UTF_8.name())
}
fun install(url: String, destination: File, expectedSha256: String? = null,
cancelled: BooleanSupplier? = null, progress: Consumer<Float>? = null) {
val parent = destination.absoluteFile.parentFile ?: throw IOException("Component destination has no parent")
Files.createDirectories(parent.toPath())
val temporary = Files.createTempFile(parent.toPath(), destination.name + ".", ".component-part")
try {
val entry = entries.optJSONObject(url) ?: throw IOException("Component is absent from this build: $url")
if (entry.optString("delivery") == "windows-guest") {
val digest = entry.getString("sha256")
val size = entry.getLong("size")
val source = java.net.URI(entry.getString("download_url"))
if (!entry.getString("id").startsWith("windows-") || !SHA256.matches(digest) || size <= 0 ||
source.scheme != "https" || source.host.isNullOrEmpty() || source.userInfo != null || source.fragment != null ||
expectedSha256 != null && !digest.equals(expectedSha256, ignoreCase = true)) {
throw IOException("Invalid Windows prerequisite identity")
}
checkCancelled(cancelled)
if (!download(source.toString(), temporary.toFile(), digest, cancelled, progress) || Files.size(temporary) != size) {
throw IOException("Windows prerequisite download failed verification")
}
val actual = Files.newInputStream(temporary).use { input ->
val hash = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(65536)
while (true) {
checkCancelled(cancelled)
val count = input.read(buffer)
if (count < 0) break
hash.update(buffer, 0, count)
}
hex(hash.digest())
}
if (actual != digest) throw IOException("Windows prerequisite failed verification")
} else {
Files.newOutputStream(temporary).use { output ->
copy(url, output, Long.MAX_VALUE, expectedSha256, cancelled, progress)
}
}
checkCancelled(cancelled)
Files.move(temporary, destination.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} finally {
Files.deleteIfExists(temporary)
Files.deleteIfExists(temporary.resolveSibling(temporary.fileName.toString() + ".resume.properties"))
}
}
private fun checkCancelled(cancelled: BooleanSupplier?) {
if (Thread.currentThread().isInterrupted || cancelled?.asBoolean == true) throw IOException("Component copy cancelled")
}
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it.toInt() and 255) }
companion object {
private const val INDEX_LIMIT = 16 * 1024 * 1024
private val SHA256 = Regex("[0-9a-f]{64}")
private val ASSET = Regex("play-components/[0-9a-f]{64}/[0-9]{4,}")
}
}
@@ -111,7 +111,7 @@ object DeviceReport {
h("App")
runCatching {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
k("Version", "${info.versionName} (${info.longVersionCode})")
k("Version", "${info.versionName} (${androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info)})")
k("App build", "${com.droiddeck.launcher.BuildConfig.BUILD_LABEL} (source ${com.droiddeck.launcher.BuildConfig.SOURCE_COMMIT})")
}
k("Package", context.packageName)
@@ -139,11 +139,13 @@ public final class Downloader {
java.util.function.Consumer<Float> progress) {
Identity identity = loadIdentity(destination, resume, expectedSha256);
for (int i = 0; i < sources.size(); i++) {
if (Thread.currentThread().isInterrupted()) return false;
String source = sources.get(i);
if (isCancelled(cancelled)) return false;
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress);
if (outcome == Outcome.CANCELLED) return false;
if (Thread.currentThread().isInterrupted()) return false;
if (outcome == Outcome.DONE) {
if (expectedSha256 != null) {
String actualSha256 = Hashes.sha256(destination);
@@ -296,6 +298,7 @@ public final class Downloader {
byte[] buffer = new byte[1 << 16];
long lastReport = 0;
for (int read = in.read(buffer); read > 0; read = in.read(buffer)) {
if (Thread.currentThread().isInterrupted()) return Outcome.RETRY;
out.write(buffer, 0, read);
written += read;
if (isCancelled(cancelled)) return Outcome.CANCELLED;
@@ -0,0 +1,18 @@
package com.droiddeck.launcher.core
import android.app.Notification
import android.app.Service
import android.os.Build
import android.util.Log
object ForegroundServices {
fun start(service: Service, id: Int, notification: Notification, types: Int = 0): Boolean = try {
if (Build.VERSION.SDK_INT >= 29 && types != 0) service.startForeground(id, notification, types)
else service.startForeground(id, notification)
true
} catch (error: RuntimeException) {
if (Build.VERSION.SDK_INT < 31 || error !is android.app.ForegroundServiceStartNotAllowedException) throw error
Log.w("ForegroundServices", "Foreground work cannot start in the current lifecycle", error)
false
}
}
@@ -5,6 +5,8 @@ import java.io.File
import java.util.function.Consumer
object HostProcess {
const val LINKER = "/system/bin/linker64"
private const val TAG = "HostProcess"
@JvmStatic
@@ -53,14 +55,17 @@ object HostProcess {
}
@JvmStatic
fun pidOf(process: Process): Int = try {
val field = process.javaClass.getDeclaredField("pid")
field.isAccessible = true
val pid = field.getInt(process)
field.isAccessible = false
pid
} catch (e: Exception) {
-1
fun pidOf(process: Process): Int {
Regex("""\bpid=(\d+)\b""").find(process.toString())?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
return try {
val field = process.javaClass.getDeclaredField("pid")
field.isAccessible = true
val pid = field.getInt(process)
field.isAccessible = false
pid
} catch (e: Exception) {
-1
}
}
@JvmStatic
@@ -0,0 +1,253 @@
package com.droiddeck.launcher.core
import android.app.Activity
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.droiddeck.launcher.BuildConfig
import com.google.android.gms.tasks.Tasks
import com.google.android.gms.tasks.Task
import java.util.concurrent.TimeUnit
import com.google.android.play.core.assetpacks.AssetPackManagerFactory
import com.google.android.play.core.assetpacks.model.AssetPackStatus
import com.google.android.play.core.splitcompat.SplitCompat
import com.google.android.play.core.splitinstall.SplitInstallManagerFactory
import com.google.android.play.core.splitinstall.SplitInstallRequest
import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.io.File
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipFile
internal object PlayDelivery {
data class State(val ready: Boolean = !BuildConfig.PLAY_DISTRIBUTION, val busy: Boolean = false,
val failed: Boolean = false, val percent: Int = -1)
private val mutableState = MutableStateFlow(State())
val state = mutableState.asStateFlow()
private lateinit var app: Context
private val handler = Handler(Looper.getMainLooper())
private val running = AtomicBoolean(false)
private val cancelled = AtomicBoolean(false)
private var activity = WeakReference<Activity>(null)
@Volatile private var session = 0
@Volatile private var confirmation = false
@Volatile private var attempt = 0
private var pendingSession: android.content.Intent? = null
private var server: android.net.LocalServerSocket? = null
private val clients = java.util.concurrent.Semaphore(2)
private val splits by lazy { SplitInstallManagerFactory.create(app) }
private val assets by lazy { AssetPackManagerFactory.getInstance(app) }
private val definition by lazy {
app.assets.open("play-components/delivery.json").bufferedReader().use { JSONObject(it.readText()) }
}
private fun names(key: String): List<String> = definition.getJSONArray(key).let { list ->
(0 until list.length()).map { list.getString(it) }
}
fun init(context: Context) {
if (!BuildConfig.PLAY_DISTRIBUTION) return
app = context.applicationContext
}
@Synchronized
fun defer(intent: android.content.Intent) { pendingSession = android.content.Intent(intent) }
@Synchronized
fun peekPendingSession(): android.content.Intent? = pendingSession?.let { android.content.Intent(it) }
@Synchronized
fun takePendingSession(): android.content.Intent? = pendingSession.also { pendingSession = null }
fun start(owner: Activity) {
if (!BuildConfig.PLAY_DISTRIBUTION) return
activity = WeakReference(owner)
if (mutableState.value.ready) return
if (!running.compareAndSet(false, true)) return
cancelled.set(false)
attempt++
confirmation = false
mutableState.value = State(busy = true)
Thread({
var completed = State(failed = true)
try {
installModules()
confirmation = false
installAssets()
checkCancelled()
startServer()
completed = State(ready = true, percent = 100)
} catch (error: Exception) {
android.util.Log.w("PlayDelivery", "Play component delivery failed", error)
} finally {
if (cancelled.get()) cancel()
running.set(false)
mutableState.value = completed
}
}, "play-components").start()
}
fun detach(owner: Activity) {
if (activity.get() === owner) activity.clear()
}
fun cancel() {
cancelled.set(true)
synchronized(this) { pendingSession = null }
if (session > 0) splits.cancelInstall(session)
if (::app.isInitialized) runCatching { assets.cancel(names("packs")) }
}
private fun <T> await(task: Task<T>): T = Tasks.await(task, 30, TimeUnit.SECONDS)
private fun checkCancelled() {
if (cancelled.get() || Thread.currentThread().isInterrupted) throw IOException("Play delivery cancelled")
}
private fun installModules() {
val missing = names("modules").filterNot { it in splits.installedModules }
if (missing.isEmpty()) return
val previous = await(splits.sessionStates).firstOrNull {
it.moduleNames().containsAll(missing) && it.status() in setOf(
SplitInstallSessionStatus.PENDING, SplitInstallSessionStatus.DOWNLOADING,
SplitInstallSessionStatus.DOWNLOADED, SplitInstallSessionStatus.INSTALLING,
SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION)
}
session = previous?.sessionId() ?: run {
val request = attempt
val task = splits.startInstall(SplitInstallRequest.newBuilder().apply { missing.forEach(::addModule) }.build())
task.addOnSuccessListener { id -> if (request == attempt && cancelled.get()) splits.cancelInstall(id) }
await(task)
}
while (true) {
checkCancelled()
val current = await(splits.getSessionState(session))
val request = attempt
when (current.status()) {
SplitInstallSessionStatus.INSTALLED -> {
if (!SplitCompat.install(app) || !splits.installedModules.containsAll(missing))
throw IOException("Play modules could not be activated")
session = 0
return
}
SplitInstallSessionStatus.FAILED, SplitInstallSessionStatus.CANCELED ->
throw IOException("Play module delivery failed: ${current.errorCode()}")
SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> handler.post {
val owner = activity.get()
if (request == attempt && session == current.sessionId() && running.get() && !cancelled.get() &&
!confirmation && owner != null && !owner.isFinishing && !owner.isDestroyed) {
runCatching { splits.startConfirmationDialogForResult(current, owner, 7619) }
.onSuccess { confirmation = it }.onFailure { cancel() }
}
}
}
val total = current.totalBytesToDownload()
mutableState.value = State(busy = true, percent = if (total > 0)
(current.bytesDownloaded() * 80 / total).toInt().coerceIn(0, 80) else -1)
Thread.sleep(350)
}
}
private fun installAssets() {
val packs = names("packs")
val missing = packs.filter { assets.getPackLocation(it) == null }
if (missing.isEmpty()) return
val requestAttempt = attempt
val fetch = assets.fetch(missing)
fetch.addOnSuccessListener { if (requestAttempt == attempt && cancelled.get()) assets.cancel(missing) }
await(fetch)
while (true) {
checkCancelled()
val states = await(assets.getPackStates(missing)).packStates().values
if (states.any { it.status() == AssetPackStatus.FAILED || it.status() == AssetPackStatus.CANCELED })
throw IOException("Play data delivery failed")
if (missing.all { assets.getPackLocation(it) != null }) return
val request = attempt
if (states.any { it.status() == AssetPackStatus.WAITING_FOR_WIFI || it.status() == AssetPackStatus.REQUIRES_USER_CONFIRMATION })
handler.post {
val owner = activity.get()
if (request == attempt && running.get() && !cancelled.get() &&
!confirmation && owner != null && !owner.isFinishing && !owner.isDestroyed) {
confirmation = true
assets.showConfirmationDialog(owner).addOnSuccessListener { result ->
if (request == attempt && result != Activity.RESULT_OK) cancel()
}.addOnFailureListener { if (request == attempt) cancel() }
}
}
val total = states.sumOf { it.totalBytesToDownload() }
val downloaded = states.sumOf { it.bytesDownloaded() }
mutableState.value = State(busy = true, percent = if (total > 0)
(80 + downloaded * 19 / total).toInt().coerceIn(80, 99) else -1)
Thread.sleep(500)
}
}
@Synchronized
private fun startServer() {
if (server != null) return
val listener = android.net.LocalServerSocket(app.packageName + ".play-components")
server = listener
Thread({
while (true) {
val client = try { listener.accept() } catch (_: IOException) { break }
if (!clients.tryAcquire()) { client.close(); continue }
Thread({
try {
client.use {
client.soTimeout = 30_000
if (client.peerCredentials.uid != android.os.Process.myUid()) return@use
val input = java.io.DataInputStream(client.inputStream)
val length = input.readInt()
if (length !in 1..256) return@use
val path = ByteArray(length).also(input::readFully).toString(Charsets.UTF_8)
if (!Regex("play-components/[0-9a-f]{64}/[0-9]{4,}").matches(path)) return@use
val output = client.outputStream
open(path).use { source ->
output.write(1)
source.copyTo(output, 65536)
}
}
} catch (error: IOException) {
android.util.Log.w("PlayDelivery", "Component stream closed", error)
} finally {
clients.release()
}
}, "play-component-reader").apply { isDaemon = true; start() }
}
}, "play-component-server").apply { isDaemon = true; start() }
}
@Throws(IOException::class)
fun open(path: String): InputStream {
try {
return app.assets.open(path)
} catch (_: java.io.FileNotFoundException) {
val info = app.packageManager.getApplicationInfo(app.packageName, 0)
for (split in info.splitSourceDirs.orEmpty()) {
val archive = ZipFile(split)
val entry = archive.getEntry("assets/$path")
if (entry == null) {
archive.close()
continue
}
return object : FilterInputStream(archive.getInputStream(entry)) {
override fun close() { try { super.close() } finally { archive.close() } }
}
}
for (name in names("packs")) {
val directory = assets.getPackLocation(name)?.assetsPath()?.let(::File) ?: continue
val file = File(directory, path)
if (file.canonicalPath.startsWith(directory.canonicalPath + File.separator) && file.isFile)
return file.inputStream()
}
if (!running.get()) mutableState.value = State(failed = true)
throw IOException("Play component is not installed: $path")
}
}
}
@@ -13,6 +13,7 @@ import com.droiddeck.launcher.input.HomeButtonService
import com.droiddeck.launcher.session.SessionPrefs
object SteamIntegration {
private const val ACCESSIBILITY_CONSENT_VERSION = 1
const val ACTION_SETUP = "com.droiddeck.launcher.STEAM_INTEGRATION"
const val EXTRA_FEATURE = "integration_feature"
enum class Feature { WIFI, BRIGHTNESS, HOME, SHORTCUTS }
@@ -36,7 +37,7 @@ object SteamIntegration {
locationOn = WifiDiscovery.locationEnabled(context),
brightness = Settings.System.canWrite(context),
home = HomeApp.isDefault(context),
shortcuts = context.getSystemService(AccessibilityManager::class.java)
shortcuts = accessibilityConsented(context) && context.getSystemService(AccessibilityManager::class.java)
.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).any {
val service = it.resolveInfo.serviceInfo
ComponentName(service.packageName, service.name) == ComponentName(context, HomeButtonService::class.java)
@@ -56,6 +57,14 @@ object SteamIntegration {
private fun shortcutsComponent(context: Context) = ComponentName(context, HomeButtonService::class.java)
fun accessibilityConsented(context: Context): Boolean =
SessionPrefs.sessionPrefs(context).getInt("accessibility_consent", 0) == ACCESSIBILITY_CONSENT_VERSION
fun acceptAccessibility(context: Context) {
SessionPrefs.sessionPrefs(context).edit().putInt("accessibility_consent", ACCESSIBILITY_CONSENT_VERSION).apply()
HomeButtonService.consentChanged()
}
fun shortcutsChosen(context: Context): Boolean = shortcutsSelection(context) == true
private fun shortcutsSelection(context: Context): Boolean? {
@@ -0,0 +1,23 @@
package com.droiddeck.launcher.core
import android.os.Build
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
object SystemInsets {
fun fit(view: View) {
if (Build.VERSION.SDK_INT < 35) return
val left = view.paddingLeft
val top = view.paddingTop
val right = view.paddingRight
val bottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { target, insets ->
val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or
WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime())
target.setPadding(left + safe.left, top + safe.top, right + safe.right, bottom + safe.bottom)
insets
}
ViewCompat.requestApplyInsets(view)
}
}
@@ -10,9 +10,10 @@ import com.droiddeck.launcher.session.SessionPrefs
object WifiDiscovery {
val permissions = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
fun permissionGranted(context: Context): Boolean = permissions.any {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
fun permissionGranted(context: Context): Boolean =
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) else permissions).any {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
fun locationEnabled(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
(context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager)?.isLocationEnabled == true
@@ -60,7 +60,10 @@ class WirelessAdbPairingService : Service() {
pairingPort = null
working = false
publish(Stage.Waiting)
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
if (!ForegroundServices.start(this, NOTIFICATION_ID, notification(Stage.Waiting))) {
finish(Stage.Failed(getString(R.string.adbpair_title_failed)))
return
}
startDiscovery()
main.removeCallbacks(timeout)
main.postDelayed(timeout, TIMEOUT_MS)
@@ -256,6 +259,10 @@ class WirelessAdbPairingService : Service() {
private fun publish(stage: Stage) { current.value = stage }
fun start(context: Context) {
if (!AppPermissions.localNetwork(context)) {
publish(Stage.Failed(context.getString(R.string.setup_local_network)))
return
}
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
publish(Stage.Waiting)
val intent = Intent(context, WirelessAdbPairingService::class.java)
@@ -1,21 +1,52 @@
package com.droiddeck.launcher.files
import android.app.Activity
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AddedExes
import com.droiddeck.launcher.stores.StoreInstallRoot
object AllFilesAccess {
fun missing(sdk: Int = Build.VERSION.SDK_INT, granted: () -> Boolean = { Environment.isExternalStorageManager() }): Boolean =
fun granted(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
fun usesSharedStorage(context: Context): Boolean {
val own = (listOf(context.filesDir.parentFile) + context.getExternalFilesDirs(null).map { it?.parentFile })
.filterNotNull().map { it.absolutePath + "/" }
fun shared(path: String) = own.none { (path + "/").startsWith(it) }
return AddedExes.list(context).any { shared(it.folder) } ||
StoreInstallRoot.roots(context).any { shared(it.absolutePath) }
}
fun ask(activity: Activity): Boolean {
if (granted() || activity.isFinishing || activity.isDestroyed) return false
AlertDialog.Builder(activity)
.setTitle(R.string.setup_all_files)
.setMessage(R.string.fm_drive_access_text)
.setPositiveButton(R.string.upd_open_settings) { _, _ -> openSettings(activity) }
.setNegativeButton(R.string.common_cancel, null)
.show()
return true
}
fun missing(sdk: Int = Build.VERSION.SDK_INT, granted: () -> Boolean = { AllFilesAccess.granted() }): Boolean =
sdk >= Build.VERSION_CODES.R && !granted()
fun openSettings(activity: Activity) = open(activity)
fun open(context: Context) {
val app = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:" + context.packageName))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(app) }.recoverCatching {
context.startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
val forApp = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:" + context.packageName)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(forApp)
} catch (e: ActivityNotFoundException) {
runCatching { context.startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.files
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -22,8 +23,11 @@ class FileManagerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
com.droiddeck.launcher.files.AllFilesAccess.ask(this)
} else if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
@@ -1210,7 +1210,7 @@ fun FileManagerScreen(
)
}
}
} else
} else {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
if (canGoUp) item(key = "..") {
ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.UP) })
@@ -1290,6 +1290,7 @@ fun FileManagerScreen(
}
}
}
}
if (pullState.verticalOffset > 0.5f || pullState.isRefreshing) {
PullToRefreshContainer(
state = pullState,
@@ -4,6 +4,7 @@ import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -26,8 +27,11 @@ class FilePickerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
com.droiddeck.launcher.files.AllFilesAccess.ask(this)
} else if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
}
val extensions = intent.getStringArrayExtra(EXTRA_EXTENSIONS)?.toList() ?: emptyList()
@@ -90,7 +90,7 @@ 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 dirName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) volume.directory?.name?.takeIf { it.isNotBlank() } else null
val descSlug = volume.getDescription(context)?.trim()
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
@@ -523,9 +523,12 @@ object AddedGames {
}
}
fun listingFile(context: Context): File = File(context.filesDir, "session/added-games.json")
@Synchronized
fun writeListing(context: Context, games: List<Game>,
extra: List<JSONObject> = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
val file = listingFile(context).apply { parentFile?.mkdirs() }
val json = StringBuilder("[")
games.forEachIndexed { i, g ->
if (i > 0) json.append(',')
@@ -545,7 +548,13 @@ object AddedGames {
if (games.isNotEmpty() || i > 0) json.append(',')
json.append(entry.toString())
}
file.writeText(json.append(']').toString())
val staged = File(file.path + ".tmp")
staged.writeText(json.append(']').toString())
java.nio.file.Files.move(
staged.toPath(), file.toPath(),
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
return file
}
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import org.json.JSONArray
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
object AddedGamesPublisher {
private const val TAG = "AddedGamesPublisher"
@@ -13,6 +15,7 @@ object AddedGamesPublisher {
internal var now = false
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "added-games-publish") }
private val retries = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "added-games-publish-retry") }
fun later(context: Context, live: () -> Unit = {}) {
val app = context.applicationContext
@@ -23,6 +26,43 @@ object AddedGamesPublisher {
if (now) work.run() else worker.execute(work)
}
fun refreshDuringSession(context: Context, active: () -> Boolean) {
val app = context.applicationContext
worker.execute {
try {
if (!active()) return@execute
val before = AddedGames.listingFile(app).takeIf { it.isFile }?.readText().orEmpty()
val known = runCatching {
val listing = JSONArray(before)
List(listing.length()) { listing.getJSONObject(it).optString("exe") }.toSet()
}.getOrDefault(emptySet())
val games = AddedGames.scan(app)
if (!active()) return@execute
AddedGames.writeListing(app, games)
val arch = com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(app)
LibraryCache.save(app, Library.launchableGames(app, added = games, arch = arch), arch == "x86_64")
val fresh = games.filter { it.guestExe !in known }
if (fresh.isEmpty()) return@execute
Log.i(TAG, "session refresh: ${fresh.size} game(s) not in the listing the session started with")
retries.scheduleWithFixedDelay({
try {
if (!active()) throw java.util.concurrent.CancellationException()
if (com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, fresh) { true } ||
!com.droiddeck.launcher.session.SessionState.running) {
throw java.util.concurrent.CancellationException()
}
} catch (e: java.util.concurrent.CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "session shortcut retry: ${e.message}")
}
}, 5, 5, TimeUnit.SECONDS)
} catch (e: Exception) {
Log.w(TAG, "session refresh: ${e.message}")
}
}
}
fun republish(context: Context) {
val games = AddedGames.scan(context)
AddedGames.writeListing(context, games)
@@ -475,15 +475,15 @@ object AutoComponents {
return true
}
fun beforeLaunch(context: Context, game: Library.SteamGame, onBlocked: () -> Unit = {}, onReady: () -> Unit) {
fun beforeLaunch(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
val app = context.applicationContext
Thread({
val ready = runCatching { prepareLaunch(app, game) }.getOrElse {
Log.w(TAG, "${game.name}: launch repair discovery failed: ${it.javaClass.simpleName}: ${it.message}")
false
}
Handler(Looper.getMainLooper()).post(if (ready) onReady else onBlocked)
queueGame(app, game, asked = false)
if (ready) Handler(Looper.getMainLooper()).post(onReady)
}, "auto-components-launch").apply { isDaemon = true; start() }
}
@@ -23,7 +23,6 @@ object Library {
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
val profileKey: String get() = when {
source == com.droiddeck.launcher.stores.Store.BATTLENET.id -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
library == ADDED -> java.lang.Integer.toUnsignedString(appId)
@@ -14,8 +14,8 @@ object SteamLaunchOptions {
private const val TAG = "SteamLaunchOptions"
private val STEAM = listOf("UserLocalConfigStore", "Software", "Valve", "Steam")
fun current(context: Context, appId: Int): String? =
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId)
fun current(context: Context, appId: Int): String =
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId).orEmpty()
enum class Saved { YES, NO, DESKTOP }
@@ -35,8 +35,7 @@ object SteamLaunchOptions {
internal fun steam(steamHome: File, appId: Int): String? {
val config = AddedGames.accountConfig(steamHome) ?: return null
val text = runCatching { File(config, "localconfig.vdf").readText() }.getOrNull() ?: return null
if (section(text, STEAM) == null) return null
return launchOptions(text, appId).orEmpty()
return launchOptions(text, appId)
}
internal fun write(steamHome: File, appId: Int, options: String): Boolean {
@@ -162,6 +162,7 @@ object Glasses {
private val simulating = java.util.concurrent.atomic.AtomicReference<Thread?>(null)
private val frames = ArrayDeque<String>()
@android.annotation.SuppressLint("UnspecifiedRegisterReceiverFlag")
fun init(context: Context) {
if (::handler.isInitialized) return
app = context.applicationContext
@@ -33,7 +33,7 @@ internal class AndroidXrealConnector(context: Context) : XrealConnector {
companion object {
internal fun hasDirectRoute(properties: LinkProperties, address: InetAddress): Boolean =
properties.routes.any { !it.hasGateway() && it.destination.prefixLength > 0 && it.matches(address) }
properties.routes.any { (it.gateway?.isAnyLocalAddress != false) && it.destination.prefixLength > 0 && it.matches(address) }
}
override fun connect(): XrealChannels {
@@ -1,9 +1,10 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.ComponentDownloads
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.BuildConfig
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
@@ -17,9 +18,9 @@ import java.net.URL
object TurnipReleases {
private const val TAG = "TurnipReleases"
private const val PREFS = "turnip_releases"
private const val KEY_RELEASE = "latest"
private val KEY_RELEASE = ComponentDownloads.catalogName("latest")
private const val KEY_DOWNLOADS = "downloads"
private const val KEY_PICKS = "autoPicks"
private val KEY_PICKS = ComponentDownloads.catalogName("autoPicks")
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
val sha256: String = "", val pair: String = "", val bundle: Boolean = false,
@@ -155,6 +156,8 @@ object TurnipReleases {
}
private fun get(context: Context, source: String, url: String): String {
if (BuildConfig.PLAY_DISTRIBUTION) return ComponentDownloads.downloadString(url)
?: throw IOException("Driver catalog is absent from this build")
val c = URL(source).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
c.readTimeout = 20_000
@@ -248,7 +251,7 @@ object TurnipReleases {
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
val target = File(context.cacheDir, asset.name)
val ok = Downloader.downloadFile(asset.url, target, true, asset.sha256) { f -> progress(if (f < 0) -1 else (f * 100).toInt()) }
val ok = ComponentDownloads.downloadFile(asset.url, target, true, asset.sha256) { f -> progress(if (f < 0) -1 else (f * 100).toInt()) }
if (!ok) throw IOException(context.getString(R.string.turnip_download_failed))
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
FileUtils.delete(target)
@@ -37,8 +37,11 @@ internal object TrackingPack {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
for (name in listOf(OPENCV, MEDIAPIPE)) {
val library = file(context, name)
check(library.isFile && library.setReadOnly()) { "Tracking library cannot be made read-only: $name" }
System.load(library.absolutePath)
}
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
@@ -141,8 +141,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private var dDividerTop = -1f
private var dFrame: Bitmap? = null
private var dFrameKey = ""
private var sFrame: Bitmap? = null
private var sFrameKey = ""
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val colGpu = 0xFF5EE08A.toInt()
@@ -276,17 +274,9 @@ class DroidDeckHudView(context: Context) : View(context) {
seenFpsUpdates = updates
periodDone.trySend(Unit)
}
if (graphRect == null) return
if (stripRect != null) {
val now = SystemClock.uptimeMillis()
if (now - lastGraphDrawMs < STRIP_GRAPH_REDRAW_MS) return
lastGraphDrawMs = now
}
invalidate()
if (graphRect != null) invalidate()
}
private var lastGraphDrawMs = 0L
private fun cycleSize() {
size = size.next()
onSizeCycledListener?.accept(size.token)
@@ -534,9 +524,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private val inkRect = android.graphics.Rect()
private fun rebuild() {
val renderingLayer = if (size == DroidDeckHudSize.STRIP && dock == DroidDeckHudDock.NONE)
LAYER_TYPE_SOFTWARE else LAYER_TYPE_NONE
if (layerType != renderingLayer) setLayerType(renderingLayer, null)
glyphs.clear(); tileRects.clear(); graphRect = null
headerMark = null; headerWordmark = null; chipRect = null; dPanel = null; dDividerTop = -1f; tabRect = null; stripRect = null
val s = snap
@@ -1382,30 +1369,17 @@ class DroidDeckHudView(context: Context) : View(context) {
}
private fun drawStrip(canvas: Canvas, rect: RectF) {
val p = parent as? View
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
stripFrame(rect, atBottom)?.let { canvas.drawBitmap(it, 0f, 0f, null) }
}
private fun stripFrame(rect: RectF, atBottom: Boolean): Bitmap? {
val w = rect.width().roundToInt(); val h = rect.height().roundToInt()
if (w <= 0 || h <= 0) return null
val stroke = outlineWidth()
val key = "$w,$h,$stroke,$atBottom,$surfaceRgb,$bgOpacity,$outlineIntensity,${outlineColor()}"
if (key == sFrameKey) sFrame?.let { return it }
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
headerPaint.shader = null
headerPaint.color = panelColor()
c.drawRect(rect, headerPaint)
if (stroke > 0f) {
val line = max(stroke / 2f, density)
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
if (atBottom) c.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
else c.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
}
sFrame = bmp; sFrameKey = key
return bmp
canvas.drawRect(rect, headerPaint)
val stroke = outlineWidth()
if (stroke <= 0f) return
val line = max(stroke / 2f, density)
val p = parent as? View
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
if (atBottom) canvas.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
else canvas.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
}
private fun outlineWidth(): Float = if (outlineIntensity > 0f) outlineIntensity * sp(3.5f) else 0f
@@ -1525,7 +1499,6 @@ class DroidDeckHudView(context: Context) : View(context) {
const val TAB_PAD_X_SP = 7f
const val TAB_PAD_Y_SP = 5.5f
const val REFRESH_MS = 500L
const val STRIP_GRAPH_REDRAW_MS = 66L
private const val HUE_IDLE_MS = 500L
const val MIN_SCALE = 0.5f
const val MAX_SCALE = 2f
@@ -42,6 +42,7 @@ object BottomScreenGuests {
sdk >= 33 && DualScreen.yieldsBottom(model)
}
@androidx.annotation.RequiresApi(33)
class BottomScreenWatch(private val service: AccessibilityService) {
private class Arrival(val pkg: String)
@@ -65,7 +65,8 @@ class DesktopKeyboardButton(context: Context, private val onTap: () -> Unit) : V
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
place(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetBottom ?: 0)
if (Build.VERSION.SDK_INT >= 28) place(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetBottom ?: 0)
else place(0, 0)
return super.onApplyWindowInsets(insets)
}
@@ -9,6 +9,8 @@ import android.os.Looper
import android.view.KeyEvent
import android.view.accessibility.AccessibilityEvent
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.core.SteamIntegration
import java.lang.ref.WeakReference
object HomeButtonShortcuts {
private var owner: Any? = null
@@ -103,26 +105,38 @@ object SystemKeyCapture {
class HomeButtonService : AccessibilityService(), InputManager.InputDeviceListener {
private var bottomScreen: BottomScreenWatch? = null
private var consented = false
override fun onServiceConnected() {
current = WeakReference(this)
getSystemService(InputManager::class.java).registerInputDeviceListener(this, Handler(Looper.getMainLooper()))
if (bottomScreen == null && BottomScreenGuests.supported()) bottomScreen = BottomScreenWatch(this).also { it.start() }
updateConsent()
}
private fun updateConsent() {
consented = SteamIntegration.accessibilityConsented(this)
if (consented && android.os.Build.VERSION.SDK_INT >= 33 && bottomScreen == null && BottomScreenGuests.supported()) {
bottomScreen = BottomScreenWatch(this).also { it.start() }
}
if (!consented) stopBottomScreen()
}
private fun stopBottomScreen() {
bottomScreen?.stop()
if (android.os.Build.VERSION.SDK_INT >= 33) bottomScreen?.stop()
bottomScreen = null
}
override fun onKeyEvent(event: KeyEvent) =
SystemKeyCapture.onKeyEvent(event) || HomeButtonShortcuts.onKeyEvent(this, event)
override fun onAccessibilityEvent(event: AccessibilityEvent?) { event?.let { bottomScreen?.onEvent(it) } }
consented && (SystemKeyCapture.onKeyEvent(event) || HomeButtonShortcuts.onKeyEvent(this, event))
override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (android.os.Build.VERSION.SDK_INT >= 33) event?.let { bottomScreen?.onEvent(it) } }
override fun onInterrupt() { HomeButtonShortcuts.release() }
override fun onInputDeviceAdded(deviceId: Int) {}
override fun onInputDeviceChanged(deviceId: Int) {}
override fun onInputDeviceRemoved(deviceId: Int) { HomeButtonShortcuts.onDeviceRemoved(deviceId) }
override fun onUnbind(intent: Intent?): Boolean {
if (current?.get() === this) current = null
consented = false
HomeButtonShortcuts.release()
stopBottomScreen()
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
@@ -130,9 +144,16 @@ class HomeButtonService : AccessibilityService(), InputManager.InputDeviceListen
}
override fun onDestroy() {
if (current?.get() === this) current = null
HomeButtonShortcuts.release()
stopBottomScreen()
getSystemService(InputManager::class.java).unregisterInputDeviceListener(this)
super.onDestroy()
}
companion object {
private var current: WeakReference<HomeButtonService>? = null
fun consentChanged() { current?.get()?.updateConsent() }
}
}
@@ -89,31 +89,21 @@ class KeyboardHost(
clearFocus()
}
fun type(text: CharSequence?) {
if (text.isNullOrEmpty()) return
val events = map.getEvents(text.toString().toCharArray())
if (events != null) {
for (event in events) sendKeyEvent(event)
} else {
for (ch in text) {
val t = System.currentTimeMillis()
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
sendKeyEvent(CharKeyEvent(down, ch))
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
}
}
}
fun tap(keyCode: Int) {
val t = System.currentTimeMillis()
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
}
private inner class KeyConnection : BaseInputConnection(this, false) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
type(text)
if (text.isNullOrEmpty()) return true
val events = map.getEvents(text.toString().toCharArray())
if (events != null) {
for (event in events) sendKeyEvent(event)
} else {
for (ch in text) {
val t = System.currentTimeMillis()
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
sendKeyEvent(CharKeyEvent(down, ch))
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
}
}
return true
}
@@ -133,6 +123,12 @@ class KeyboardHost(
return true
}
private fun tap(keyCode: Int) {
val t = System.currentTimeMillis()
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
}
override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence = ""
override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""
override fun getSelectedText(flags: Int): CharSequence? = null
@@ -15,7 +15,6 @@ import android.view.View
import android.view.WindowInsets
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
@@ -49,10 +48,6 @@ class OnScreenControls(
var dirty = false
var clicked = false
var lastUp = 0L
var downAt = 0L
var downX = 0f
var downY = 0f
var travel = 0f
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
@@ -66,7 +61,6 @@ class OnScreenControls(
}
fun drag(x: Float, y: Float): Boolean {
travel = max(travel, hypot(x - downX, y - downY))
var dx = x - ax
var dy = y - ay
val d = sqrt(dx * dx + dy * dy)
@@ -79,14 +73,6 @@ class OnScreenControls(
}
private val stickRedraw = ThrottledRedraw(this, STICK_REDRAW_MS)
private val tapSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat()
private fun stickTap(control: Control, event: MotionEvent): Boolean {
val i = event.actionIndex
val travel = max(control.travel, hypot(event.getX(i) - control.downX, event.getY(i) - control.downY))
return stickTap(control.downAt, event.eventTime, travel, tapSlop, control.clicked,
event.actionMasked == MotionEvent.ACTION_CANCEL || (event.flags and MotionEvent.FLAG_CANCELED) != 0)
}
private val controls = listOf(
Control("up", "dpad", -1, 26f, 0f, -52f),
@@ -225,7 +211,7 @@ class OnScreenControls(
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)
val fresh = if (Build.VERSION.SDK_INT >= 28) Rect(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0) else Rect()
if (fresh != safe) { safe = fresh; relayout() }
return super.onApplyWindowInsets(insets)
}
@@ -559,10 +545,6 @@ class OnScreenControls(
control.pressedBy = event.getPointerId(index)
if (control.stick >= 0) {
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
control.downAt = event.eventTime
control.downX = event.getX(index)
control.downY = event.getY(index)
control.travel = 0f
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
@@ -631,7 +613,7 @@ class OnScreenControls(
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 (stickTap(control, event)) event.eventTime else 0L
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
@@ -812,10 +794,6 @@ class OnScreenControls(
private companion object {
const val DOUBLE_TAP_MS = 300L
const val STICK_TAP_MS = 200L
internal fun stickTap(downAt: Long, upAt: Long, travel: Float, slop: Float, clicked: Boolean, cancelled: Boolean): Boolean =
!clicked && !cancelled && upAt - downAt <= STICK_TAP_MS && travel <= slop
const val STICK_REDRAW_MS = 33L
const val MIN_STICK_MOVE_PX = 1f
const val MIN_FACE_MM = 9.5f
@@ -302,10 +302,10 @@ class ScreenKeys(context: Context, private val sendKey: (Int, Boolean) -> Unit)
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
val fresh = Rect(
val fresh = if (Build.VERSION.SDK_INT >= 28) Rect(
cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0,
cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0,
)
) else Rect()
if (fresh != safe) {
safe = fresh
placeToolbar()
@@ -19,20 +19,11 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
private var published = false
private var shared: String? = null
private var echo: String? = null
private var capture: ((String?) -> Unit)? = null
private var captureBefore: String? = null
private var captureLate: (() -> Unit)? = null
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh(changed = true) }
private val guestListener = object : WaylandCompositor.ClipboardListener {
override fun onGuestClipboardText(text: String) {
handler.post {
val taken = capture?.takeIf { text != captureBefore }
if (taken != null && WaylandCompositor.isClipboardListener(this)) {
capture = null
handler.removeCallbacks(captureTimeout)
WaylandCompositor.setClipboardText(shared)
taken(text)
} else if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
shared = text
published = true
@@ -45,21 +36,6 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
}
}
private val captureTimeout = Runnable {
val taken = capture ?: return@Runnable
capture = null
captureLate?.invoke()
taken(null)
}
fun captureGuest(timeoutMs: Long, onTimeout: () -> Unit = {}, onText: (String?) -> Unit) {
capture?.let { handler.removeCallbacks(captureTimeout); it(null) }
capture = onText
captureLate = onTimeout
captureBefore = shared
handler.postDelayed(captureTimeout, timeoutMs)
}
fun start() {
if (active) return
active = true
@@ -71,8 +47,6 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
fun stop() {
if (!active) return
active = false
handler.removeCallbacks(captureTimeout)
capture?.let { capture = null; it(null) }
clipboard.removePrimaryClipChangedListener(androidListener)
WaylandCompositor.clearClipboardListener(guestListener)
handler.removeCallbacksAndMessages(null)
@@ -52,6 +52,11 @@ class SteamControllerBle(private val context: Context) {
private fun connect() {
if (!running || gatt != null) return
if (android.os.Build.VERSION.SDK_INT >= 31 &&
!com.droiddeck.launcher.core.AppPermissions.granted(context, android.Manifest.permission.BLUETOOTH_CONNECT)) {
handler.postDelayed(::connect, RETRY_MS)
return
}
if (SessionState.suspended) {
handler.postDelayed(::connect, RETRY_MS)
return
@@ -86,8 +91,8 @@ class SteamControllerBle(private val context: Context) {
outputReports.clear()
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
g.disconnect()
g.close()
runCatching { g.disconnect() }
runCatching { g.close() }
release()
Log.i(TAG, "disconnected: $why")
listener?.invoke()
@@ -197,7 +197,7 @@ object SteamTouchConfig {
}
return try {
val text = file.readText()
val kv = KeyValues.parse(text.removePrefix(""))
val kv = KeyValues.parse(text.removePrefix("\uFEFF"))
val mappings = kv.child("controller_mappings") ?: kv
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
@@ -991,7 +991,7 @@ class SteamTouchControls(
BIG_PICTURE -> "Steam"
else -> gameNames[appId] ?: run {
val app = appId
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app))
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app).toString())
loader.execute {
val name = SteamTouchConfig.gameName(context, app) ?: return@execute
handler.post { gameNames[app] = name; invalidate() }
@@ -352,6 +352,7 @@ object BwrapSpawner {
val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull()
if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) }
plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) }
LinuxRuntime.bindComponentBundles(context, plan.root, cmd)
cmd.add(plan.envTool)
cmd.add("-i")
plan.env.forEach { (k, v) -> cmd.add("$k=$v") }
@@ -4,7 +4,7 @@ import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.ComponentDownloads
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.core.ArchivePaths
import java.io.BufferedInputStream
@@ -101,7 +101,7 @@ object DeckyManager {
}
fun releaseChannels(context: Context): ReleaseChannels {
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
val body = ComponentDownloads.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
return try {
val array = JSONArray(body)
val stable = mutableListOf<Release>()
@@ -141,11 +141,11 @@ object DeckyManager {
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "PluginLoader.download")
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
val ok = Downloader.downloadFile(release.url, temp, true, release.digest?.substringAfter(':')) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
val ok = ComponentDownloads.downloadFile(release.url, temp, true, release.digest?.substringAfter(':')) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
val checksumBody = Downloader.downloadDirect(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
val checksumBody = ComponentDownloads.downloadDirect(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
@@ -270,7 +270,7 @@ object DeckyManager {
downloadingBinary = true
progress(downloading, -1)
val downloaded = try {
Downloader.downloadFile(url, temp, true, hash) { fraction ->
ComponentDownloads.downloadFile(url, temp, true, hash) { fraction ->
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}
} finally {
@@ -6,6 +6,7 @@ import android.util.Log
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.ComponentDownloads
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
@@ -22,8 +23,10 @@ object DesktopCatalog {
val icon: String, val category: String,
)
internal fun offered(entry: Entry): Boolean = !BuildConfig.PLAY_DISTRIBUTION || entry.id == DESKTOP_ID || entry.id == "desktop"
fun fetch(url: String = CATALOG_URL, cancelled: java.util.function.BooleanSupplier? = null): List<Entry>? {
val body = Downloader.downloadString(url, cancelled) ?: return null
val body = ComponentDownloads.downloadString(url, cancelled) ?: return null
return try {
val json = JSONObject(body)
val arr = json.getJSONArray("packages")
@@ -39,7 +42,7 @@ object DesktopCatalog {
file.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
}
}.filter(::offered)
} catch (e: Exception) {
Log.w(TAG, "catalog: $e"); null
}
@@ -51,7 +54,7 @@ object DesktopCatalog {
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context))
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context)).filter(::offered)
fun interrupted(root: File): List<Entry> =
root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty()
@@ -63,7 +66,7 @@ object DesktopCatalog {
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
fun pruneDownloads(context: Context) = synchronized(installLock) {
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
val keep = interrupted(LinuxRuntime.rootDir(context)).map { downloadFile(context, it).name }.toSet()
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() }
}
@@ -107,9 +110,13 @@ object DesktopCatalog {
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
fun protonSeedNeeded(context: Context): Boolean =
installed(context, PROTON_SEED_ID) == null &&
fun protonSeedNeeded(context: Context): Boolean {
if (BuildConfig.PLAY_DISTRIBUTION) return com.droiddeck.launcher.session.ProtonExtras.installed(
context, com.droiddeck.launcher.session.ProtonExtras.tools.single(),
) == null
return installed(context, PROTON_SEED_ID) == null &&
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
}
const val DESKTOP_ID = "desktop-kde"
const val EMULATORS_ID = "emulators"
@@ -170,8 +177,11 @@ object DesktopCatalog {
installLocked(context, entry, cancelled, listener)
}
private fun installLocked(context: Context, entry: Entry, cancelled: Downloader.Cancellation?,
private fun installLocked(context: Context, requested: Entry, cancelled: Downloader.Cancellation?,
listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val current = if (BuildConfig.PLAY_DISTRIBUTION && requested.id == DESKTOP_ID) desktopEntry(context) else null
val entry = current?.takeIf { it.version == requested.version } ?: requested
if (!offered(entry)) return context.getString(R.string.deskpkg_install_failed)
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.id == EMULATORS_ID && desktopFromPack(root)) return installEmulators(context, entry, cancelled, listener)
@@ -195,16 +205,16 @@ object DesktopCatalog {
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
val ok = if (cancelled == null) {
Downloader.downloadFile(entry.url, download, true, entry.sha256, progress)
ComponentDownloads.downloadFile(entry.url, download, true, entry.sha256, progress)
} else {
Downloader.downloadFile(entry.url, download, true, entry.sha256, cancelled, progress)
ComponentDownloads.downloadFile(entry.url, download, true, entry.sha256, cancelled, progress)
}
if (!ok || cancelled?.getAsBoolean() == true) return context.getString(R.string.user_apps_download_failed)
report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
}
if (cancelled?.getAsBoolean() == true) return context.getString(R.string.user_apps_download_failed)
if (Thread.currentThread().isInterrupted || cancelled?.getAsBoolean() == true) return context.getString(R.string.user_apps_download_failed)
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
@@ -10,6 +10,7 @@ import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.function.BooleanSupplier
object GuestCommand {
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
@@ -22,14 +23,19 @@ object GuestCommand {
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath, cancelled) { line -> log?.println(line); log?.flush(); onLine(line) }
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath,
cancelled?.let { flag -> BooleanSupplier { flag.get() } }) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
fun runCancellable(context: Context, argv: List<String>, cancelled: BooleanSupplier?, onLine: (String) -> Unit): Int =
runLogged(context, argv, false, null, null, false, cancelled, onLine)
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
fastPath: Boolean, cancelled: AtomicBoolean?, onLine: (String) -> Unit): Int {
fastPath: Boolean, cancelled: BooleanSupplier?, onLine: (String) -> Unit): Int {
if (cancelled?.asBoolean == true || Thread.currentThread().isInterrupted) return 130
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
@@ -65,15 +71,17 @@ object GuestCommand {
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
if (cancelled?.get() == true) return 130
if (cancelled?.asBoolean == true) return 130
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
val watcher = cancelled?.let { flag ->
val caller = Thread.currentThread()
val watcher = run {
val flag = cancelled
Thread({
try {
while (process.isAlive && !flag.get()) Thread.sleep(200)
if (flag.get()) {
while (process.isAlive && flag?.asBoolean != true && !caller.isInterrupted) Thread.sleep(200)
if (flag?.asBoolean == true || caller.isInterrupted) {
if (pid > 0) {
runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) }
} else {
@@ -186,7 +186,7 @@ class LinuxNetworkLinkComponent(
val state = JSONObject().apply {
put("transport", transport)
put("interface", properties?.interfaceName ?: "android")
put("mtu", properties?.mtu?.takeIf { it > 0 } ?: DEFAULT_MTU)
put("mtu", (if (android.os.Build.VERSION.SDK_INT >= 29) properties?.mtu else null)?.takeIf { it > 0 } ?: DEFAULT_MTU)
put("connected", properties != null && capabilities != null)
put("validated", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true)
put("captivePortal", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) == true)
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import com.droiddeck.launcher.BuildConfig;
import com.droiddeck.launcher.core.ArchivePaths;
import android.content.Context;
import android.os.Process;
import android.system.ErrnoException;
@@ -65,6 +67,7 @@ public final class LinuxRuntime {
private static final String HOST_DIR = "opt/android-host";
public static final String PROOT_PACK = "add-on pack";
public static final String PROOT_BUNDLED = "APK";
public static final String PROOT_ROOTFS = "rootfs fallback";
public static final String PROOT_MISSING = "missing";
@@ -80,17 +83,31 @@ public final class LinuxRuntime {
return rootfsProot(context).isFile() && rootfsLoader(context).isFile();
}
private static File bundledProot(Context context, String name) {
return new File(context.getApplicationInfo().nativeLibraryDir, name);
}
private static boolean bundledProotReady(Context context) {
return context.getApplicationInfo().nativeLibraryDir != null &&
bundledProot(context, "libproot.so").isFile() && bundledProot(context, "libproot-loader.so").isFile();
}
public static boolean patchedProot(Context context) {
return AddonPack.PROOT.installed(context);
if (BuildConfig.PLAY_DISTRIBUTION) return bundledProotReady(context);
return bundledProotReady(context) || AddonPack.PROOT.installed(context);
}
public static File prootBinary(Context context) {
if (BuildConfig.PLAY_DISTRIBUTION) return bundledProot(context, "libproot.so");
if (bundledProotReady(context)) return bundledProot(context, "libproot.so");
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot.so");
if (SessionPrefs.allowStockProot(context)) return rootfsProot(context);
return AddonPack.PROOT.file(context, "libproot.so");
}
public static File prootLoader(Context context) {
if (BuildConfig.PLAY_DISTRIBUTION) return bundledProot(context, "libproot-loader.so");
if (bundledProotReady(context)) return bundledProot(context, "libproot-loader.so");
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot-loader.so");
if (SessionPrefs.allowStockProot(context)) return rootfsLoader(context);
return AddonPack.PROOT.file(context, "libproot-loader.so");
@@ -99,6 +116,7 @@ public final class LinuxRuntime {
public static String prootSource(Context context) {
File bin = prootBinary(context);
if (!bin.isFile()) return PROOT_MISSING;
if (bundledProotReady(context)) return PROOT_BUNDLED;
if (AddonPack.PROOT.installed(context)
&& bin.getPath().equals(AddonPack.PROOT.file(context, "libproot.so").getPath())) return PROOT_PACK;
return PROOT_ROOTFS;
@@ -278,6 +296,7 @@ public final class LinuxRuntime {
}
bindAdrenoStats(cmd, extraBinds);
bindCpuTemps(cmd, root);
bindComponentBundles(context, root, cmd);
if (extraBinds != null) {
for (String spec : extraBinds) bind(cmd, spec);
}
@@ -286,6 +305,32 @@ public final class LinuxRuntime {
return specs;
}
public static void bindComponentBundles(Context context, File root, List<String> command) {
if (!BuildConfig.PLAY_DISTRIBUTION) return;
java.util.LinkedHashSet<File> directories = new java.util.LinkedHashSet<>();
directories.add(new File(context.getApplicationInfo().sourceDir).getParentFile());
String[] splits;
try {
splits = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0).splitSourceDirs;
} catch (android.content.pm.PackageManager.NameNotFoundException error) {
throw new IllegalStateException(error);
}
if (splits != null) for (String split : splits) directories.add(new File(split).getParentFile());
int index = 0;
for (File directory : directories) {
File target;
try {
target = ArchivePaths.inside(root, "opt/droiddeck-bundles/" + index++);
} catch (IOException error) {
throw new IllegalStateException("Installed component bundle mount is invalid", error);
}
if (directory == null || !directory.isDirectory() || target == null || !target.isDirectory() && !target.mkdirs()) {
throw new IllegalStateException("Installed component bundles cannot be mounted");
}
bind(command, directory.getPath() + ":/" + root.toPath().relativize(target.toPath()).toString());
}
}
private static void bindPowerVrGpuNode(Context context, List<String> cmd) {
if (new File("/dev/dri").list() != null) return;
String node = SystemVulkanDriver.renderNodePath();
@@ -8,7 +8,7 @@ import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.session.SessionPrefs;
import com.droiddeck.launcher.core.ArchivePaths;
import com.droiddeck.launcher.core.Downloader;
import com.droiddeck.launcher.core.ComponentDownloads;
import com.droiddeck.launcher.core.Hashes;
import com.droiddeck.launcher.core.FileUtils;
@@ -23,6 +23,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
@@ -70,6 +71,8 @@ public final class LinuxRuntimeInstaller {
volatile Step step = Step.OTHER;
volatile int percent = -1;
volatile boolean ok;
volatile Thread worker;
volatile boolean cancelled;
final boolean removal;
boolean maintenance;
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
@@ -101,7 +104,7 @@ public final class LinuxRuntimeInstaller {
}
public static Release fetchRelease() {
String body = Downloader.downloadString(catalogUrl());
String body = ComponentDownloads.downloadString(catalogUrl());
if (body == null || body.isEmpty()) return null;
try {
JSONObject json = new JSONObject(body);
@@ -126,6 +129,7 @@ public final class LinuxRuntimeInstaller {
owner = running == null;
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
job = running;
if (owner) job.worker = Thread.currentThread();
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
@@ -187,6 +191,15 @@ public final class LinuxRuntimeInstaller {
synchronized (JOB_LOCK) { return running != null; }
}
public static void cancelActiveOperation() {
synchronized (JOB_LOCK) {
if (running != null) {
running.cancelled = true;
if (running.worker != null) running.worker.interrupt();
}
}
}
public static boolean isRemoving() {
synchronized (JOB_LOCK) { return running != null && running.removal; }
}
@@ -216,8 +229,11 @@ public final class LinuxRuntimeInstaller {
private Maintenance(Job job) { this.job = job; }
public boolean run(ProgressListener listener, MaintenanceWork work) {
if (!started.compareAndSet(false, true)) return join(job, listener);
job.worker = Thread.currentThread();
if (job.cancelled) job.worker.interrupt();
if (listener != null) job.listeners.add(listener);
try {
requireNotInterrupted();
removalError = work.run((stage, percent) -> {
job.stage = stage;
job.percent = percent;
@@ -298,7 +314,7 @@ public final class LinuxRuntimeInstaller {
&& !SessionPrefs.allowStockProot(context))
throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
boolean ok = ComponentDownloads.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
fraction < 0 ? -1 : Math.round(fraction * 100f));
@@ -309,6 +325,7 @@ public final class LinuxRuntimeInstaller {
keepPartial = true;
return false;
}
requireNotInterrupted();
if (listener != null) listener.onProgress(Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1);
String actual = Hashes.sha256(archive);
@@ -327,6 +344,12 @@ public final class LinuxRuntimeInstaller {
RuntimeFileTree.delete(staging, null);
return false;
}
try {
requireNotInterrupted();
} catch (InterruptedIOException e) {
discard(staging, null);
throw e;
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
if (!discard(old, null)) {
@@ -339,6 +362,11 @@ public final class LinuxRuntimeInstaller {
RuntimeFileTree.delete(staging, null);
return false;
}
if (Thread.currentThread().isInterrupted()) {
if (old.isDirectory()) old.renameTo(root);
RuntimeFileTree.delete(staging, null);
return false;
}
File carryMarker = new File(staging, CARRY_MARKER);
java.util.List<String> encodedPaths = new java.util.ArrayList<>();
for (String rel : carryPaths) encodedPaths.add(java.net.URLEncoder.encode(rel, "UTF-8"));
@@ -366,7 +394,7 @@ public final class LinuxRuntimeInstaller {
return false;
}
if (!staging.renameTo(root)) {
if (Thread.currentThread().isInterrupted() || !staging.renameTo(root)) {
if (old.isDirectory()) {
try {
RuntimeFileTree.carryBack(staging, old, carried);
@@ -489,6 +517,8 @@ public final class LinuxRuntimeInstaller {
public boolean run(ProgressListener listener) {
if (!started.compareAndSet(false, true)) return join(job, listener);
job.worker = Thread.currentThread();
if (job.cancelled) job.worker.interrupt();
if (listener != null) job.listeners.add(listener);
try {
requireStopped();
@@ -558,6 +588,7 @@ public final class LinuxRuntimeInstaller {
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
TarArchiveEntry entry;
while ((entry = tar.getNextTarEntry()) != null) {
requireNotInterrupted();
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) {
Log.w(TAG, "skipping entry outside the rootfs: " + entry.getName());
@@ -596,7 +627,10 @@ public final class LinuxRuntimeInstaller {
try (OutputStream out = new FileOutputStream(part)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
while ((read = tar.read(buffer)) != -1) {
requireNotInterrupted();
out.write(buffer, 0, read);
}
}
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
place(part, file);
@@ -626,6 +660,10 @@ public final class LinuxRuntimeInstaller {
}
}
private static void requireNotInterrupted() throws InterruptedIOException {
if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("Runtime installation was interrupted");
}
static File hardLinkTarget(File destination, String name) throws IOException {
java.nio.file.Path path = java.nio.file.Paths.get(name).normalize();
java.nio.file.Path last = path.getFileName();
@@ -13,7 +13,7 @@ object ProotFastPath {
const val MAX_BINDS = 256
fun allows(preference: Boolean, noSeccomp: Boolean, source: String): Boolean =
preference && !noSeccomp && source == LinuxRuntime.PROOT_PACK
preference && !noSeccomp && (source == LinuxRuntime.PROOT_PACK || source == LinuxRuntime.PROOT_BUNDLED)
fun enabled(context: Context): Boolean {
val preference = SessionPrefs.prootFastPath(context)
@@ -145,9 +145,9 @@ object RootSpawner {
else runCatching { process.outputStream.close() }
}
if (streams[1].redirect == null) pumps.add(pump(process.inputStream,
streams[1].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-out"))
streams[1].fd?.let { FileOutputStream(it) } ?: FileOutputStream("/dev/null"), "root-$id-out"))
if (streams[2].redirect == null) pumps.add(pump(process.errorStream,
streams[2].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-err"))
streams[2].fd?.let { FileOutputStream(it) } ?: FileOutputStream("/dev/null"), "root-$id-err"))
Thread({
try {
while (true) {
@@ -8,10 +8,8 @@ object RuntimeAutoUpdate {
const val MAX_ATTEMPTS = 3
const val RETRY_MS = 10 * 60_000L
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 &&
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 &&
(attempts == 0 || now - lastAt !in 0 until RETRY_MS)
fun key(appVersion: Int, release: String) = "$appVersion:$release"
@@ -22,17 +20,11 @@ object RuntimeAutoUpdate {
val key = key(BuildConfig.VERSION_CODE, release)
synchronized(this) {
val attempts = prefs.getInt(key, 0)
val first = installed == null && !prefs.getBoolean(SEEDED, false)
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now, first)) return false
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false
val edit = prefs.edit()
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") || it == SEEDED }.forEach { edit.remove(it) }
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.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()
}
}
@@ -13,44 +13,67 @@ import com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
class RuntimeInstallService : Service() {
@Volatile private var activeWork: Thread? = null
@Volatile private var generation = 0L
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
override fun onBind(intent: Intent?): IBinder? = null
override fun onTimeout(startId: Int, fgsType: Int) {
generation++
LinuxRuntimeInstaller.cancelActiveOperation()
activeWork?.interrupt()
packageWorkers.keys.forEach { it.interrupt() }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
if (!com.droiddeck.launcher.core.ForegroundServices.start(this, NOTIFICATION_ID,
notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))) {
stopSelf(startId)
return START_NOT_STICKY
}
if (release == null && !removal) {
stopSelf(startId)
return START_NOT_STICKY
}
Thread({
val workGeneration = ++generation
activeWork = Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
val progress = LinuxRuntimeInstaller.ProgressListener { stage, percent ->
if ("$stage:$percent" != shown) {
if (workGeneration == generation && "$stage:$percent" != shown) {
shown = "$stage:$percent"
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
}
}
if (removal) LinuxRuntimeInstaller.attach(progress)
else LinuxRuntimeInstaller.install(applicationContext, release!!, progress)
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "runtime-install").start()
if (workGeneration == generation) {
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}
}, "runtime-install").also { it.start() }
return START_NOT_STICKY
}
private fun holdPackage(startId: Int): Int {
startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
if (!com.droiddeck.launcher.core.ForegroundServices.start(this, NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))) {
packageWorkers.keys.forEach { it.interrupt() }
stopSelf(startId)
return START_NOT_STICKY
}
val workGeneration = ++generation
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
while (packageHolds.get() > 0) {
while (packageHolds.get() > 0 && workGeneration == generation) {
val now = "$packageStage:$packagePercent"
if (now != shown) {
shown = now
@@ -58,9 +81,11 @@ class RuntimeInstallService : Service() {
}
Thread.sleep(500)
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
if (workGeneration == generation) {
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}
}, "package-install").start()
return START_NOT_STICKY
}
@@ -95,6 +120,7 @@ class RuntimeInstallService : Service() {
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE"
private val packageHolds = java.util.concurrent.atomic.AtomicInteger()
private val packageWorkers = java.util.concurrent.ConcurrentHashMap<Thread, java.util.concurrent.atomic.AtomicInteger>()
@Volatile private var packageTitle = ""
@Volatile private var packageStage = ""
@Volatile private var packagePercent = -1
@@ -122,11 +148,18 @@ class RuntimeInstallService : Service() {
packageStage = title
packagePercent = -1
packageHolds.incrementAndGet()
val worker = Thread.currentThread()
packageWorkers.compute(worker) { _, count -> (count ?: java.util.concurrent.atomic.AtomicInteger()).also { it.incrementAndGet() } }
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE)
runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) }
.onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") }
val released = java.util.concurrent.atomic.AtomicBoolean()
return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() }
return {
if (released.compareAndSet(false, true)) {
packageHolds.decrementAndGet()
packageWorkers.computeIfPresent(worker) { _, count -> if (count.decrementAndGet() == 0) null else count }
}
}
}
fun packageProgress(stage: String, percent: Int) {
@@ -1,401 +0,0 @@
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 org.json.JSONArray
import org.json.JSONObject
import java.io.File
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"
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)
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()))
}
}
fun status(context: Context, comp: String): List<Status> {
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)
}
}
}
@@ -1,167 +0,0 @@
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()
}
}
@@ -4,7 +4,7 @@ import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.ComponentDownloads
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.gpu.AppVulkan
@@ -84,13 +84,12 @@ 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() }
private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() }
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
private fun catalogFile(context: Context) = File(dataDir(context), ComponentDownloads.catalogName("catalog.json"))
private const val LAUNCH_DIR = "root/.local/share/droiddeck-components"
private const val LEGACY_LAUNCH_DIR = "root/.local/share/bannerlator-components"
private fun launchDir(context: Context): File {
@@ -290,6 +289,7 @@ object ComponentsManager {
for ((base, valve) in bases) {
val entries = base.listFiles()?.sortedBy { it.name } ?: continue
for (dir in entries) {
if (com.droiddeck.launcher.BuildConfig.PLAY_DISTRIBUTION && (valve || !dir.name.startsWith("DroidDeck-Proton"))) continue
if (!File(dir, WINE).isDirectory || !File(dir, "proton").isFile) continue
val real = runCatching { dir.canonicalPath }.getOrDefault(dir.path)
if (found.containsKey(real)) continue
@@ -700,12 +700,6 @@ 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)
@@ -756,25 +750,6 @@ 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>)
@@ -782,11 +757,7 @@ object ComponentsManager {
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
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 }
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): Set<String> {
val names = HashSet<String>()
for (key in listOf("active", "queued")) {
val byProton = state.optJSONObject(key) ?: continue
@@ -794,7 +765,7 @@ object ComponentsManager {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
if (liveOnAny(key, entry, byId[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
}
}
}
@@ -816,7 +787,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, protons(context))
val used = packagesInUse(state, installed)
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
@@ -843,15 +814,15 @@ object ComponentsManager {
}
private fun checkPackageUnused(context: Context, state: JSONObject, name: String) {
val installed = protons(context).groupBy { it.id }
val installed = protons(context).distinctBy { it.id }.associateBy { 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 || !liveOnAny(key, entry, installed[pid])) {
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid)
check(entry.optString("file") != name || !liveSelection(key, entry, installed[pid])) {
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid)
}
}
}
@@ -941,7 +912,7 @@ object ComponentsManager {
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val installed = protons(context).groupBy { it.id }
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
var changed = false
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
@@ -950,7 +921,7 @@ object ComponentsManager {
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("file") != name) continue
check(!liveOnAny(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid) }
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
comps.remove(comp)
changed = true
}
@@ -997,16 +968,9 @@ 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")
val assets = body?.let { runCatching { JSONObject(it).optJSONArray("assets") }.getOrNull() }
if (assets == null) {
keepSource(previous, tag, items)
continue
}
fetched++
val body = ComponentDownloads.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
@@ -1017,18 +981,13 @@ object ComponentsManager {
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
if (fetched == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (items.length() == 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 {
@@ -1073,86 +1032,6 @@ object ComponentsManager {
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
)
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)
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
@@ -1305,7 +1184,7 @@ object ComponentsManager {
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) {
check(ComponentDownloads.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) {
context.getString(if (item.release == DD_NIGHTLY) R.string.cmgr_nightly_replaced else R.string.user_apps_download_failed)
}
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
@@ -513,7 +513,8 @@ internal object EnhancedSessionLogShare {
private fun openDescriptor(path: String): java.io.FileDescriptor? = runCatching {
Os.open(
path,
OsConstants.O_RDONLY or OsConstants.O_CLOEXEC or OsConstants.O_NOFOLLOW or OsConstants.O_NONBLOCK,
OsConstants.O_RDONLY or OsConstants.O_NOFOLLOW or OsConstants.O_NONBLOCK or
if (android.os.Build.VERSION.SDK_INT >= 27) OsConstants.O_CLOEXEC else 0,
0,
)
}.getOrNull()
@@ -2,6 +2,8 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.core.ComponentDownloads
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.Hashes
@@ -861,8 +863,17 @@ object EsyncPacks {
return null
}
private fun fetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean =
fetcher(url, limit, out, progress)
private fun fetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean {
if (!BuildConfig.PLAY_DISTRIBUTION) return fetcher(url, limit, out, progress)
return try {
val bytes = ComponentDownloads.copyBundled(url, out, limit, null)
progress?.invoke(bytes)
true
} catch (error: IOException) {
Log.w(TAG, "Bundled synchronization component unavailable: $url", error)
false
}
}
private fun httpFetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean {
var connection: HttpURLConnection? = null
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.ComponentDownloads
import org.json.JSONObject
import java.io.File
@@ -20,7 +20,7 @@ object FexCatalog {
@Volatile private var refreshedAt = 0L
private fun file(context: Context) = File(context.filesDir, "fex-catalog.json")
private fun file(context: Context) = File(context.filesDir, ComponentDownloads.catalogName("fex-catalog.json"))
internal fun parse(text: String): Build? = runCatching {
val o = JSONObject(text)
@@ -42,7 +42,7 @@ object FexCatalog {
fun refresh(context: Context, now: Long = System.currentTimeMillis()): Build? {
if (now - refreshedAt < REFRESH_MS) return cached(context)
refreshedAt = now
val text = Downloader.downloadDirect(CATALOG_URL) ?: return cached(context)
val text = ComponentDownloads.downloadDirect(CATALOG_URL) ?: return cached(context)
val build = parse(text) ?: run {
Log.w(TAG, "fex.json not usable; keeping the last one")
return cached(context)
@@ -47,6 +47,7 @@ object GameThreadHints {
worker = null
}
@androidx.annotation.RequiresApi(31)
private fun run(hm: PerformanceHintManager, pm: PowerManager) {
val scanner = Scanner()
var session: PerformanceHintManager.Session? = null
@@ -88,6 +89,7 @@ object GameThreadHints {
}
}
@androidx.annotation.RequiresApi(31)
private fun update(hm: PerformanceHintManager, session: PerformanceHintManager.Session?, tids: Set<Int>): PerformanceHintManager.Session? {
session?.close()
if (tids.isEmpty()) return null
@@ -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 && needsChange(display.supportedModes, a.window.attributes.preferredDisplayModeId, best)) {
if (best != null && a.window.attributes.preferredDisplayModeId != best.modeId) {
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,11 +64,6 @@ 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,4 +11,12 @@ 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
}
}
@@ -2,13 +2,15 @@ package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.BuildConfig
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.ComponentDownloads
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.util.function.BooleanSupplier
import org.json.JSONArray
import org.json.JSONObject
@@ -30,17 +32,19 @@ object ProtonExtras {
const val OWN = "droiddeck"
val tools = listOf(
internal val sources = listOf(
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "Droid-Deck/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "Droid-Deck/DroidDeck-Proton", Regex(if (BuildConfig.PLAY_DISTRIBUTION) "aarch64\\.tar\\.(gz|xz|zst)$" else "aarch64\\.tar\\.(gz|xz)$")),
)
val tools = sources.filter { !BuildConfig.PLAY_DISTRIBUTION || it.id == OWN }
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
private fun requests(context: Context) = File(home(context), ".bl-proton-extra")
private fun toolsDir(context: Context) = File(home(context), ".local/share/Steam/compatibilitytools.d")
private fun releasesFile(context: Context) = File(context.filesDir, "proton-extras.json")
private fun latestFile(context: Context) = File(context.filesDir, "proton-latest.json")
private fun latestFile(context: Context) = File(context.filesDir, ComponentDownloads.catalogName("proton-latest.json"))
fun installed(context: Context, tool: Tool): String? = installedIn(toolsDir(context), tool).lastOrNull()
@@ -200,13 +204,17 @@ object ProtonExtras {
}
@Synchronized
fun install(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
fun install(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? = install(context, tool, null, onProgress)
@Synchronized
fun install(context: Context, tool: Tool, cancelled: BooleanSupplier?, onProgress: (String, Int) -> Unit): String? {
if (installInProgress) return context.getString(R.string.pextra_busy)
installInProgress = true
return try { installNow(context, tool, onProgress) } finally { installInProgress = false }
return try { installNow(context, tool, cancelled, onProgress) } finally { installInProgress = false }
}
private fun installNow(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
private fun installNow(context: Context, tool: Tool, cancelled: BooleanSupplier?, onProgress: (String, Int) -> Unit): String? {
if (cancelled?.asBoolean == true || Thread.currentThread().isInterrupted) return context.getString(R.string.pextra_interrupted)
if (SessionState.running) return context.getString(R.string.pextra_stop_session)
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
@@ -223,10 +231,12 @@ object ProtonExtras {
onProgress(downloading, 0)
var downloaded = false
for (attempt in 0 until 5) {
downloaded = Downloader.downloadFile(asset.url, archive, true, asset.sha256) { fraction ->
if (cancelled?.asBoolean == true || Thread.currentThread().isInterrupted) return context.getString(R.string.pextra_interrupted)
downloaded = ComponentDownloads.downloadFile(asset.url, archive, true, asset.sha256, cancelled) { fraction ->
onProgress(downloading, if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
}
if (downloaded) break
if (cancelled?.asBoolean == true) return context.getString(R.string.pextra_interrupted)
if (attempt < 4) {
onProgress(context.getString(R.string.pextra_retrying, attempt + 2, 5), -1)
try { Thread.sleep(3_000) } catch (_: InterruptedException) {
@@ -248,7 +258,7 @@ object ProtonExtras {
val verified = when {
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
asset.sha512 != null -> {
val expected = Downloader.downloadDirect(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
val expected = ComponentDownloads.downloadDirect(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
?: return context.getString(R.string.pextra_checksum_unreadable)
expected.equals(Hashes.sha512(archive), ignoreCase = true)
}
@@ -263,38 +273,23 @@ object ProtonExtras {
}
if (SessionState.running) return context.getString(R.string.pextra_session_started)
if (cancelled?.asBoolean == true || Thread.currentThread().isInterrupted) return context.getString(R.string.pextra_interrupted)
val installing = context.getString(R.string.pextra_installing, tool.name)
onProgress(installing, -1)
return try {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
com.droiddeck.launcher.session.SessionFiles.stage(context, root)
val runtimeDir = File(context.filesDir, ".proton-install-rt").apply { mkdirs() }
val guest = listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "PATH=/usr/local/bin:/usr/bin:/bin",
"LANG=C.UTF-8", "XDG_DATA_HOME=/root/.local/share",
"/usr/local/bin/droiddeck-proton-extra", "/root/.local/share/Steam", archive.absolutePath,
)
val command = LinuxRuntime.command(context, null, runtimeDir, null, guest)
val process = ProcessBuilder(command)
.directory(root)
.redirectErrorStream(true)
val hostEnv = process.environment()
hostEnv["PROOT_LOADER"] = LinuxRuntime.prootLoader(context).path
hostEnv["PROOT_TMP_DIR"] = LinuxRuntime.prootTempDir(context).path
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { hostEnv["LD_LIBRARY_PATH"] = it }
val child = process.start()
var placed: String? = null
child.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
Log.i(TAG, line)
line.substringAfter("droiddeck-proton-extra: installed ", "").takeIf { it.isNotEmpty() }?.let { placed = it }
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
onProgress(installing, -1)
}
val status = com.droiddeck.launcher.runtime.GuestCommand.runCancellable(context, guest, cancelled) { line ->
Log.i(TAG, line)
line.substringAfter("droiddeck-proton-extra: installed ", "").takeIf { it.isNotEmpty() }?.let { placed = it }
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
onProgress(installing, -1)
}
}
val status = child.waitFor()
if (cancelled?.asBoolean == true || Thread.currentThread().isInterrupted) return context.getString(R.string.pextra_interrupted)
if (status != 0) context.getString(R.string.pextra_install_failed, tool.name, status)
else {
archive.delete()
@@ -318,7 +313,7 @@ object ProtonExtras {
}
private fun findLatestAsset(tool: Tool): Asset? {
val releases = Downloader.downloadString(RELEASES.format(tool.repo)) ?: return null
val releases = ComponentDownloads.downloadString(RELEASES.format(tool.repo)) ?: return null
return try {
pickAsset(tool, JSONArray(releases))
} catch (e: Exception) {
@@ -341,7 +336,9 @@ object ProtonExtras {
}?.optString("browser_download_url")?.takeIf { it.startsWith("http") }
return Asset(
release.optString("tag_name"), name,
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
archive.optString("browser_download_url").takeIf {
it.startsWith("http") || BuildConfig.PLAY_DISTRIBUTION && it.startsWith("droiddeck-bundle://")
} ?: continue, checksum,
archive.optLong("size", 0L),
Hashes.githubSha256(archive.optString("digest")),
)
@@ -47,6 +47,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
"usr/local/bin/droiddeck-steam-taskset" to "usr/local/libexec/droiddeck-steam/taskset",
"usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex",
"usr/local/lib/droiddeck/component_delivery.py" to "usr/local/lib/droiddeck/component_delivery.py",
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
@@ -351,8 +351,6 @@ 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
@@ -364,13 +362,6 @@ 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
@@ -508,17 +499,6 @@ 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
@@ -611,6 +591,18 @@ object SessionPrefs {
prefs(context).edit().putBoolean("micAsked", true).apply()
}
fun allFilesAsked(context: Context): Boolean = prefs(context).getBoolean("allFilesAsked", false)
fun setAllFilesAsked(context: Context) {
prefs(context).edit().putBoolean("allFilesAsked", true).apply()
}
fun bluetoothAsked(context: Context): Boolean = prefs(context).getBoolean("bluetoothAsked", false)
fun setBluetoothAsked(context: Context) {
prefs(context).edit().putBoolean("bluetoothAsked", true).apply()
}
fun setMicEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("mic", on).apply()
}
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
@@ -329,7 +331,20 @@ class SessionService : Service() {
stopSelf(startId)
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification())
if (Build.VERSION.SDK_INT >= 34) {
var types = android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
if (SessionPrefs.micEnabled(this) && checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
types = types or android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
if (!com.droiddeck.launcher.core.ForegroundServices.start(this, NOTIFICATION_ID, buildNotification(), types)) {
stopSelf(startId)
return START_NOT_STICKY
}
} else if (!com.droiddeck.launcher.core.ForegroundServices.start(this, NOTIFICATION_ID, buildNotification())) {
stopSelf(startId)
return START_NOT_STICKY
}
if (SessionState.running) return START_NOT_STICKY
if (SessionState.stopRequested) {
SessionState.running = true
@@ -621,12 +636,19 @@ class SessionService : Service() {
.onFailure { Log.w(TAG, "Epic install directory setup failed", it) }
}
if (steamHere || noSteamGame) {
val added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
val cached = com.droiddeck.launcher.frontend.AddedGames.listingFile(this)
val listing = if (steamHere && cached.isFile) {
com.droiddeck.launcher.frontend.AddedGamesPublisher.refreshDuringSession(this, stillStarting)
Log.i(TAG, "added games: the last listing, refreshed in the background")
cached
} else {
val added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
}
guest.add("BL_ADDED_GAMES=" + listing.path)
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1")
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
guest.add("BL_LAUNCH_DIR=" + sessionRoot.path)
GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) }
@@ -949,7 +971,8 @@ class SessionService : Service() {
SessionArtifacts.markSteamLogs(it, File(SessionPrefs.steamRoot(this, arch), "logs"))
}
}
FexCatalog.cached(this)?.let { guest.addAll(FexCatalog.environment(it)) }
(if (BuildConfig.PLAY_DISTRIBUTION) FexCatalog.refresh(this) else FexCatalog.cached(this))
?.let { guest.addAll(FexCatalog.environment(it)) }
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
}
@@ -30,7 +30,7 @@ object SettingsReset {
)
internal val SESSION_PREFIXES = listOf(
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.", "componentMode.",
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.",
)
private val STEAM_TOUCH_KEYS = listOf("feedback", "autoFade", "gyro", "shake")
@@ -4,7 +4,7 @@ import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.core.ArchivePaths
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.ComponentDownloads
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.GuestCommand
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -87,13 +87,13 @@ object WinComponents {
private val SNAPSHOT_COVERS = INSTALLER_STEPS + setOf("download_archive", "archive_extract", "copy_dll", "copy_file") + OFFLINE_STEPS
fun fetch(): List<Component>? = Downloader.downloadString(CATALOG_URL)?.let { parse(it) }
fun fetch(): List<Component>? = ComponentDownloads.downloadString(CATALOG_URL)?.let { parse(it) }
private const val CATALOG_CACHE = "wincomponents-catalog.json"
private val CATALOG_CACHE = ComponentDownloads.catalogName("wincomponents-catalog.json")
fun fetch(context: Context): List<Component>? {
val cache = File(context.filesDir, CATALOG_CACHE)
Downloader.downloadString(CATALOG_URL)?.let { body ->
ComponentDownloads.downloadString(CATALOG_URL)?.let { body ->
parse(body)?.let { runCatching { write(cache, body) }; return it }
}
return runCatching { parse(cache.readText()) }.getOrNull()
@@ -258,7 +258,7 @@ object WinComponents {
val byRuntime = name.lowercase().let { it.endsWith(".exe") || it.endsWith(".cab") }
val file = if (byRuntime) File(root, "$MSI_CACHE/${archives.size}-$name").apply { parentFile?.mkdirs() }
else File(work, "${archives.size}-$name")
val ok = Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }
val ok = ComponentDownloads.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }
if (!ok) return "download failed: $name"
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return "checksum mismatch: $name"
@@ -385,7 +385,7 @@ object WinComponents {
val root = LinuxRuntime.rootDir(context)
val name = c.snapshot.substringAfterLast('/')
val json = File(work, "${c.name}.snapshot.json")
if (!Downloader.downloadFile(c.snapshot, json, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
if (!ComponentDownloads.downloadFile(c.snapshot, json, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $name"
}
val snapshot = runCatching { JSONObject(json.readText()) }.getOrNull() ?: return null to "the recording could not be read"
@@ -401,7 +401,7 @@ object WinComponents {
val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName
val file = File(work, "${c.name}.files.tar.xz")
val sha = archive.optString("sha256").takeIf { it.matches(Regex("(?i)[0-9a-f]{64}")) }
if (!Downloader.downloadFile(url, file, false, sha) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
if (!ComponentDownloads.downloadFile(url, file, false, sha) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $archiveName"
}
archive.optString("sha256").takeIf { it.length == 64 }?.let { sha ->
@@ -420,7 +420,7 @@ object WinComponents {
val installer = File(cache, "${c.name}-snapshot-src.bin")
var unpacked: File? = null
try {
if (!Downloader.downloadFile(url, installer, false) { f -> onProgress(Progress(c.name, installerName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
if (!ComponentDownloads.downloadFile(url, installer, false) { f -> onProgress(Progress(c.name, installerName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $installerName"
}
unpacked = unpackInRuntime(context, c, installer, "snapshot", onProgress) ?: return null to "could not open $installerName"
@@ -496,7 +496,7 @@ object WinComponents {
val ext = if (listOf(step.str("file_name"), name).any { it.endsWith(".exe", ignoreCase = true) }) "exe" else "msi"
val file = File(cache, "${c.name}-${c.steps.indexOf(step)}.$ext")
try {
if (!Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
if (!ComponentDownloads.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $name"
}
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
@@ -19,9 +19,10 @@ object SteamLiveShortcuts {
private const val EVAL_TIMEOUT_MS = 20_000L
private const val THAW_WAIT_MS = 15_000L
fun sync(context: Context, games: List<AddedGames.Game>, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) {
fun sync(context: Context, games: List<AddedGames.Game>, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }): Boolean {
val wanted = games.filter(include)
if (wanted.isEmpty() || !clientRunning()) return
if (wanted.isEmpty()) return true
if (!clientRunning()) return false
val list = JSONArray()
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir).put("appid", g.appId and 0xFFFFFFFFL).put("launch", g.launchOptions))
val js = """
@@ -35,9 +36,10 @@ object SteamLiveShortcuts {
return added;
})()
""".trimIndent()
val result = evaluate(context, js) ?: return
val result = evaluate(context, js) ?: return false
Log.i(TAG, "live shortcuts: $result added")
if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client")
return true
}
fun ensure(context: Context, name: String, exe: String, dir: String, compat: Boolean, appId: Long? = null): Long? {
@@ -102,6 +102,7 @@ class StoreLoginActivity : ComponentActivity() {
if (flow == null) { finish(); return }
val chrome = buildChrome()
setContentView(chrome)
com.droiddeck.launcher.core.SystemInsets.fit(chrome)
if (flooded) {
chrome.alpha = 0f
chrome.translationY = dp(14).toFloat()
@@ -39,7 +39,10 @@ class StoreDownloadService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
instance = this
startForegroundCompat(build())
if (!startForegroundCompat(build())) {
onTimeout(startId, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
return START_NOT_STICKY
}
val key = intent?.getStringExtra(EXTRA_KEY)
when (intent?.action) {
ACTION_PAUSE -> key?.let {
@@ -70,6 +73,15 @@ class StoreDownloadService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onTimeout(startId: Int, fgsType: Int) {
Log.w(TAG, "data sync time used up; pausing downloads")
StoresState.latestDownloads.filter { it.state == DownloadState.RUNNING }.forEach {
DownloadQueue.pause(it.key)
pausedNote(this, it.key)
}
stopNow()
}
private fun pending(action: String, key: String, code: Int): PendingIntent {
val i = Intent(this, StoreDownloadService::class.java).setAction(action).putExtra(EXTRA_KEY, key)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) PendingIntent.getForegroundService(this, code, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
@@ -146,10 +158,9 @@ class StoreDownloadService : Service() {
wakeAt = 0L
}
private fun startForegroundCompat(n: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(ID, n, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else startForeground(ID, n)
}
private fun startForegroundCompat(n: Notification): Boolean =
com.droiddeck.launcher.core.ForegroundServices.start(this, ID, n,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
private fun stopNow() {
releaseLocks()
@@ -39,7 +39,7 @@ object GogComet {
var status by mutableStateOf("off")
private set
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", true)
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", false)
fun setEnabled(context: Context, on: Boolean) {
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
@@ -33,7 +33,7 @@ class XboxCloudActivity : ComponentActivity() {
private var customCallback: WebChromeClient.CustomViewCallback? = null
private lateinit var root: FrameLayout
@SuppressLint("SetJavaScriptEnabled")
@SuppressLint("SetJavaScriptEnabled", "RestrictedApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -117,6 +117,7 @@ class XboxCloudActivity : ComponentActivity() {
if (hasFocus) hideBars()
}
@android.annotation.SuppressLint("RestrictedApi")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
val fromController = event.source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
@@ -252,6 +252,7 @@ class XboxCloudPlayerActivity : ComponentActivity() {
web?.evaluateJavascript("window.__ddButton && window.__ddButton('Nexus', $value)", null)
}
@android.annotation.SuppressLint("RestrictedApi")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (menu != null) {
if (isGuideKey(event)) {
@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@@ -45,7 +46,7 @@ class XboxCloudSettingsActivity : ComponentActivity() {
}.getOrDefault(emptyList())
}
}
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).verticalScroll(rememberScrollState())) {
Column(Modifier.fillMaxSize().systemBarsPadding().background(MaterialTheme.colorScheme.background).verticalScroll(rememberScrollState())) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = { finish() }, modifier = Modifier.focusGlide(CircleShape)) { Text("Back") }
Text("Xbox Cloud Gaming", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
@@ -42,6 +42,7 @@ class XboxLoginActivity : ComponentActivity() {
host.addView(status)
host.addView(Button(this).apply { text = getString(com.droiddeck.launcher.R.string.stores_login_close); setOnClickListener { finish() } })
setContentView(host)
com.droiddeck.launcher.core.SystemInsets.fit(host)
if (android.os.Build.VERSION.SDK_INT < 28 || !WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) ||
!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
status.text = "Update Android System WebView to sign in to Xbox."
@@ -418,6 +418,7 @@ class XboxNativePlayerActivity : ComponentActivity() {
root.postDelayed({ pad.overrideButtons = pad.overrideButtons and 2.inv() }, 200)
}
@android.annotation.SuppressLint("RestrictedApi")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (menu != null) {
if (isGuide(event)) { if (event.action == KeyEvent.ACTION_UP) { if (swallowGuideUp) swallowGuideUp = false else closeMenu() }; return true }
@@ -95,11 +95,13 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p
}
override fun onFrame(frame: VideoFrame) {
if (android.os.Build.VERSION.SDK_INT < 29) return
val w = frame.rotatedWidth; val h = frame.rotatedHeight
synchronized(lock) { if (w != readerW || h != readerH) startReader(w, h) }
egl.onFrame(frame)
}
@androidx.annotation.RequiresApi(29)
private fun startReader(w: Int, h: Int) {
val old = reader
if (old != null) egl.releaseEglSurface { }
@@ -114,6 +116,7 @@ internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, p
log("native video ${w}x$h")
}
@androidx.annotation.RequiresApi(29)
private fun onImage(r: ImageReader) {
val image = runCatching { r.acquireLatestImage() }.getOrNull() ?: return
synchronized(lock) {
@@ -72,6 +72,7 @@ object Syncthing {
@Volatile private var onWifi = true
private var visible = 0
private var foreground = false
private var backgroundBudgetAvailable = true
private val watchers = AtomicInteger()
private val pauseFailures = AtomicInteger()
private val pauseLock = Any()
@@ -102,7 +103,10 @@ object Syncthing {
override fun onActivityStarted(activity: Activity) {
visible++
main.removeCallbacks(leaveForeground)
if (!foreground) { foreground = true; evaluate() }
val resume = !foreground || !backgroundBudgetAvailable
backgroundBudgetAvailable = true
foreground = true
if (resume) evaluate()
}
override fun onActivityStopped(activity: Activity) {
visible = (visible - 1).coerceAtLeast(0)
@@ -137,9 +141,15 @@ object Syncthing {
fun evaluate() {
if (Looper.myLooper() != Looper.getMainLooper()) { main.post { evaluate() }; return }
val enabled = SyncPrefs.enabled(app)
val background = SyncPrefs.runMode(app) == SyncPrefs.RUN_BACKGROUND
val background = SyncPrefs.runMode(app) == SyncPrefs.RUN_BACKGROUND && backgroundBudgetAvailable
if (enabled && background) SyncthingService.start(app) else SyncthingService.stop(app)
if (enabled && (background || foreground)) start() else stop()
if (enabled && (foreground || background && backgroundBudgetAvailable)) start() else stop()
}
fun backgroundServiceUnavailable() {
if (Looper.myLooper() != Looper.getMainLooper()) { main.post { backgroundServiceUnavailable() }; return }
backgroundBudgetAvailable = false
evaluate()
}
fun setEnabled(context: Context, on: Boolean, forget: Boolean = false) {
@@ -16,6 +16,13 @@ import com.droiddeck.launcher.stores.download.StoreDownloadService
class SyncthingService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onTimeout(startId: Int, fgsType: Int) {
running = false
Syncthing.backgroundServiceUnavailable()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_PAUSE -> Syncthing.setPaused(this, !Syncthing.userPaused)
@@ -26,14 +33,20 @@ class SyncthingService : Service() {
return START_NOT_STICKY
}
val n = notification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(ID, n, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else startForeground(ID, n)
if (!com.droiddeck.launcher.core.ForegroundServices.start(this, ID, n,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)) {
running = false
Syncthing.backgroundServiceUnavailable()
stopSelf()
return START_NOT_STICKY
}
Syncthing.onChange = { getSystemService(NotificationManager::class.java)?.notify(ID, notification()) }
Syncthing.evaluate()
return START_STICKY
return START_NOT_STICKY
}
override fun onDestroy() {
running = false
Syncthing.onChange = null
super.onDestroy()
}
@@ -92,7 +105,7 @@ class SyncthingService : Service() {
fun start(context: Context) {
if (running) return
running = true
runCatching { context.startForegroundService(Intent(context, SyncthingService::class.java)) }.onFailure { running = false }
runCatching { context.startForegroundService(Intent(context, SyncthingService::class.java)) }.onFailure { running = false; Syncthing.backgroundServiceUnavailable() }
}
fun stop(context: Context) {
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -63,7 +64,7 @@ class SyncthingWebActivity : ComponentActivity() {
DisposableEffect(target) { onDispose { target?.close() } }
LaunchedEffect(Syncthing.phase) { if (Syncthing.phase == Syncthing.Phase.OFF) finish() }
BackHandler { web?.takeIf { it.canGoBack() }?.goBack() ?: finish() }
Column(Modifier.fillMaxSize().background(colors.background)) {
Column(Modifier.fillMaxSize().systemBarsPadding().background(colors.background)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().background(colors.surface).padding(horizontal = 16.dp, vertical = 8.dp),
@@ -49,9 +49,7 @@ 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
@@ -62,8 +60,6 @@ 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
@@ -196,7 +192,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)
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged, launchAppId = game.appId)
}
@Composable
@@ -212,17 +208,25 @@ 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,
onClose: () -> Unit, onChanged: () -> Unit, launchAppId: Int? = null,
) {
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) {
@@ -232,10 +236,19 @@ private fun ArtOnlyEditor(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 2.dp))
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))
for (slot in AddedGameArt.Slot.values()) key(slot) {
val state = arts.firstOrNull { it.slot == slot }
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER) first else null, onClick = { if (target != null) artSlot = slot }) {
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER && launchAppId == null) 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()
@@ -245,6 +258,27 @@ 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() }
@@ -253,72 +287,6 @@ 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
@@ -11,7 +11,6 @@ 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
@@ -111,16 +110,7 @@ internal class BottomScreenTextInput(private val fallback: TextInputService) : P
}
@Composable
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,
) {
internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
val colors = MaterialTheme.colorScheme
var field by remember(request) { mutableStateOf(request.value) }
LaunchedEffect(request.value) {
@@ -130,22 +120,19 @@ internal fun TypingPanel(
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.background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
Modifier.fillMaxSize().background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
leading?.invoke()
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.dual_typing_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
color = colors.onSurfaceVariant, modifier = Modifier.weight(1f))
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) }
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(stringResource(R.string.dual_typing_done)) }
}
OutlinedTextField(
value = field,
@@ -26,9 +26,11 @@ object BuildWatermark {
commit: String = BuildConfig.SOURCE_COMMIT,
title: String = BuildConfig.PR_TITLE,
branch: String = BuildConfig.BUILD_BRANCH,
play: Boolean = BuildConfig.PLAY_DISTRIBUTION,
): List<String>? {
if (ci && pr <= 0) return null
val hash = commit.take(7).ifEmpty { "unknown" }
if (play) return listOf(hash)
if (ci && pr <= 0) return null
val head = if (pr > 0) "PR-$pr | $hash" else "LOCAL | $hash"
val detail = (if (pr > 0) title else branch).trim()
return if (detail.isEmpty()) listOf(head) else listOf(head, truncate(detail))
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff. Show more