mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-09 10:09:45 -10:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4798ee84d5 | ||
|
|
95cb9159de | ||
|
|
6ea7b10d94 | ||
|
|
527fadd3b3 | ||
|
|
6affa87442 | ||
|
|
5d10c8e96d | ||
|
|
d25f6940ee | ||
|
|
d1707dc1b7 | ||
|
|
6588185e96 | ||
|
|
dcd955e175 | ||
|
|
77a332e615 | ||
|
|
c59884b42e | ||
|
|
d2298f4109 | ||
|
|
567ca34a48 | ||
|
|
2f6e43e3d1 | ||
|
|
773d37ff21 | ||
|
|
8a8bb2053c | ||
|
|
b14587baeb | ||
|
|
28a5423629 | ||
|
|
74977d1738 | ||
|
|
184409f33c | ||
|
|
42239e0097 | ||
|
|
c117f6f4c7 | ||
|
|
4a93663d14 | ||
|
|
1ca2d9bd49 | ||
|
|
c8baca10e6 | ||
|
|
9922823090 | ||
|
|
a760aea276 | ||
|
|
e079f9602f | ||
|
|
dfc0b95557 | ||
|
|
30270ca654 | ||
|
|
8c16a04d8d | ||
|
|
0fd50d76b2 |
No files matched your search
@@ -39,25 +39,11 @@ jobs:
|
||||
path: |
|
||||
gamescope.tzst
|
||||
gamescope.tzst.sha256
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: components-token
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-Components
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
GH_REPO: Droid-Deck/DroidDeck-Components
|
||||
TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" --notes "gamescope 3.16.29 with tools/gamescope/patches ($GITHUB_REPOSITORY $GITHUB_SHA, run $GITHUB_RUN_ID); staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
+104
-15
@@ -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
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Build PlayStore Bundle
|
||||
run-name: PlayStore ${{ inputs.version || 'branch build' }} from playstore
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
source_branch:
|
||||
type: string
|
||||
default: playstore
|
||||
push:
|
||||
branches: [playstore]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version for the file names, e.g. 0.1.7
|
||||
required: true
|
||||
source_branch:
|
||||
description: Branch to build
|
||||
type: choice
|
||||
options: [playstore]
|
||||
default: playstore
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: playstore-bundle
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Play distribution checks
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.sha || inputs.source_branch }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
python3 -m pip install PyYAML==6.0.3
|
||||
- name: Test Play distribution safeguards
|
||||
run: |
|
||||
python3 -m unittest \
|
||||
tools.tests.test_playstore_release_assets \
|
||||
tools.tests.test_playstore_split_archive \
|
||||
tools.tests.test_playstore_payloads \
|
||||
tools.tests.test_playstore_manifest \
|
||||
tools.tests.test_playstore_signing \
|
||||
tools.tests.test_playstore_workflow \
|
||||
tools.tests.test_playstore_archive_preparation \
|
||||
tools.tests.test_playstore_audio_bundle \
|
||||
tools.tests.test_playstore_emulators \
|
||||
tools.tests.test_android_page_alignment
|
||||
|
||||
source:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
version: ${{ steps.source.outputs.version }}
|
||||
version_code: ${{ steps.source.outputs.version_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.sha || inputs.source_branch }}
|
||||
persist-credentials: false
|
||||
- id: source
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
SOURCE_BRANCH: ${{ inputs.source_branch || 'playstore' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$SOURCE_BRANCH" = playstore
|
||||
test "$GITHUB_REF" = refs/heads/playstore
|
||||
sha=$(git rev-parse HEAD)
|
||||
name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle)
|
||||
[[ $name =~ ^[0-9A-Za-z._-]+$ ]]
|
||||
if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
|
||||
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::invalid version"; exit 1; }
|
||||
[[ $VERSION == "$name" || $VERSION == "$name"-* ]] || {
|
||||
echo "::error::version must match versionName in app/build.gradle; bump versionName and versionCode first"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
VERSION="$name"
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
source_code=$(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
|
||||
[[ $source_code =~ ^[1-9][0-9]*$ ]]
|
||||
[ "$source_code" -le 2100000000 ]
|
||||
echo "version_code=$source_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
bundle:
|
||||
needs: source
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
play_bundle: true
|
||||
source_sha: ${{ needs.source.outputs.sha }}
|
||||
play_version_code: ${{ needs.source.outputs.version_code }}
|
||||
secrets: inherit
|
||||
|
||||
sign:
|
||||
needs: [checks, source, bundle]
|
||||
runs-on: ubuntu-24.04
|
||||
environment: signing-playstore
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-play-bundle-review
|
||||
path: incoming
|
||||
- name: Sign the verified Play bundle
|
||||
env:
|
||||
RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }}
|
||||
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
VERSION: ${{ needs.source.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$RELEASE_KEYSTORE_B64"
|
||||
key="$RUNNER_TEMP/play-release.p12"
|
||||
(umask 077; printf '%s' "$RELEASE_KEYSTORE_B64" | base64 -d > "$key")
|
||||
mapfile -t bundles < <(find incoming -type f -name '*.aab')
|
||||
test "${#bundles[@]}" = 1
|
||||
mkdir -p signed
|
||||
python3 tools/playstore/sign_bundle.py "${bundles[0]}" "signed/DroidDeck-$VERSION-PlayStore.aab" \
|
||||
--keystore "$key"
|
||||
(cd signed && sha256sum *.aab > SHA256SUMS)
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: droiddeck-play-bundle-signed
|
||||
path: signed/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
|
||||
publish:
|
||||
needs: [source, sign]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-play-bundle-signed
|
||||
path: signed
|
||||
- name: Publish the PlayStore release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.source.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.source.outputs.sha }}
|
||||
VERSION_CODE: ${{ needs.source.outputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="playstore-$VERSION"
|
||||
if gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::error::PlayStore release $tag already exists"
|
||||
exit 1
|
||||
fi
|
||||
printf 'Signed Android App Bundle for Google Play upload.\n\nSource: `%s` on `playstore`.\nVersion code: `%s`.\n' "$SOURCE_SHA" "$VERSION_CODE" > "$RUNNER_TEMP/play-release.md"
|
||||
bundles=(signed/*.aab)
|
||||
[ "${#bundles[@]}" -eq 1 ] && [ -f "${bundles[0]}" ]
|
||||
python3 tools/playstore/release_assets.py --bundle "${bundles[0]}" --checksums signed/SHA256SUMS --output publish
|
||||
if compgen -G 'publish/*.part*' >/dev/null; then
|
||||
name=$(basename "${bundles[0]}")
|
||||
printf '\nGitHub release assets are split to stay below its per-file limit. Download every part and PART-SHA256SUMS, verify the parts, combine them in numeric order, and verify SHA256SUMS before uploading the resulting AAB to Google Play.\n\nThe complete signed AAB is also available in the droiddeck-play-bundle-signed artifact for this workflow run.\n\n```sh\nsha256sum -c PART-SHA256SUMS\nset -o noclobber\ncat "%s".part??? > "%s"\nsha256sum -c SHA256SUMS\n```\n' "$name" "$name" >> "$RUNNER_TEMP/play-release.md"
|
||||
fi
|
||||
gh release create "$tag" -R "$GITHUB_REPOSITORY" publish/* \
|
||||
--target "$SOURCE_SHA" --title "DroidDeck $VERSION · PlayStore" \
|
||||
--notes-file "$RUNNER_TEMP/play-release.md" --latest=false
|
||||
@@ -47,18 +47,10 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
retry() {
|
||||
for attempt in 1 2 3 4; do
|
||||
"$@" && return 0
|
||||
[ "$attempt" = 4 ] || sleep $((attempt * 20))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
existing=$(retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name')
|
||||
found=0
|
||||
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
|
||||
if grep -qxF "$tag" <<<"$existing"; then
|
||||
retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
|
||||
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
echo "Deleted $tag"
|
||||
found=1
|
||||
fi
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
name: PR Release Sweep
|
||||
run-name: sweep test builds of closed PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-release-sweep
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete test builds whose PR is no longer open
|
||||
env:
|
||||
CI_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
retry() {
|
||||
for attempt in 1 2 3 4; do
|
||||
"$@" && return 0
|
||||
[ "$attempt" = 4 ] || sleep $((attempt * 20))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
tags=$(GH_TOKEN="$CI_TOKEN" retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name' |
|
||||
grep -E '^pr-[0-9]+(\.0)?$' || true)
|
||||
failed=0
|
||||
for tag in $tags; do
|
||||
number=${tag#pr-}
|
||||
number=${number%.0}
|
||||
state=$(retry gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .state) || { failed=1; continue; }
|
||||
if [ "$state" != open ]; then
|
||||
if GH_TOKEN="$CI_TOKEN" retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag; then
|
||||
echo "Deleted $tag (PR #$number is $state)"
|
||||
else
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Release APKs
|
||||
run-name: release ${{ inputs.version }} from main ${{ github.sha }}
|
||||
run-name: ${{ github.ref == 'refs/heads/playstore' && format('PlayStore {0} from playstore {1}', inputs.version, github.sha) || format('release {0} from main {1}', inputs.version, github.sha) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -13,10 +13,23 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
group: ${{ github.ref == 'refs/heads/playstore' && 'release-playstore' || 'release' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
playstore:
|
||||
name: Build PlayStore Bundle
|
||||
if: github.ref == 'refs/heads/playstore'
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/playstore.yml
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
source_branch: playstore
|
||||
secrets: inherit
|
||||
|
||||
find:
|
||||
name: find the main build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
@@ -53,6 +66,7 @@ jobs:
|
||||
echo "Signing Build APK run $id (main ${GITHUB_SHA::7}) as $VERSION: $variants" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
sign:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: find
|
||||
uses: ./.github/workflows/sign.yml
|
||||
with:
|
||||
@@ -64,6 +78,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
packs:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: find
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -74,6 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
name: publish the release
|
||||
needs: [find, sign, packs]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Rules
|
||||
|
||||
- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants.
|
||||
|
||||
|
||||
## Google Play distribution
|
||||
|
||||
For `playstore` and all changes destined for Google Play, read and enforce [the mandatory distribution policy gate](tools/playstore/POLICY.md) before editing or integrating changes. Refuse known policy violations and hold uncertain changes for authoritative evidence. Review incoming main commits individually; never overwrite Play delivery, permission, updater, signing, versioning, catalog, or release protections. A successful build is not policy clearance.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# DroidDeck Privacy Policy
|
||||
|
||||
Effective date: 3 November 2026
|
||||
|
||||
DroidDeck is an open-source game launcher maintained by the Droid-Deck project. A separate DroidDeck online account is not required. DroidDeck does not include advertising analytics or automatically submit usage reports or crash reports to the project maintainers. The maintainers do not operate a central database of users' game libraries or sign-in credentials, and do not sell users' personal data.
|
||||
|
||||
## Information kept on your device
|
||||
|
||||
DroidDeck stores information needed to run its features locally. Depending on what you use, this includes settings, controller and display configurations, game-library metadata, artwork caches, installed components, game saves, download state, and diagnostic logs. Store integrations and the clients running inside DroidDeck may also retain account names, authentication tokens, cookies, and sign-in sessions. These are used to access the services you choose.
|
||||
|
||||
Game performance and racing telemetry can be processed on your device for HUD and secondary-screen displays. This is separate from sending analytics to the project maintainers.
|
||||
|
||||
## Permissions and optional features
|
||||
|
||||
DroidDeck accesses device information and resources to provide the features you enable:
|
||||
|
||||
- File access supports browsing and managing games, saves, libraries, and other files. Broad file access, when granted, allows access outside the app's private storage, including shared and removable storage.
|
||||
- The launcher reads launchable Android app names, icons, and package identifiers to display and launch those apps.
|
||||
- The accessibility service handles Home/Guide shortcuts and identifies apps occupying a secondary display. It reads app package names and window bounds locally. This service does not read text, capture screens, or transmit the information it reads.
|
||||
- Notification access lets the optional notification display read notification titles and content on the device.
|
||||
- Camera, microphone, motion, accessory, and input data support features such as tracking, glasses, capture, game audio, and controls. Screenshots or recordings you create are saved locally unless you choose to share or synchronize them.
|
||||
- Bluetooth, USB, Wi-Fi, and local-network access support accessories, network controls, synchronization, streaming, and connections you configure. Android can require location permission for Wi-Fi discovery.
|
||||
|
||||
You can manage permissions through Android settings. Turning off a permission or optional feature can limit the functionality that uses it.
|
||||
|
||||
## Connections to other services
|
||||
|
||||
DroidDeck connects to the stores, game services, cloud-streaming providers, artwork sources, and download services used by your selected features. Those services can receive information such as your IP address, requests, game identifiers, authentication information, or information you enter directly into their sign-in and purchase pages. Steam, games, and other software running through DroidDeck have their own network activity and privacy policies. Their providers determine how they handle that information.
|
||||
|
||||
The Google Play version downloads runtime modules and non-executable resource packs through Google Play. Google's Play Feature Delivery and Play Asset Delivery services process device metadata and the application version to deliver the correct files and preserve them across updates. Google documents encrypted processing and a fixed retention period. See [Play Feature Delivery data handling](https://developer.android.com/guide/playcore/feature-delivery#data-safety) and [Play Asset Delivery data handling](https://developer.android.com/guide/playcore/asset-delivery#terms-of-service).
|
||||
|
||||
The Google Play version contacts Google Play to check whether an app update is available. Selecting Update opens the app's Google Play listing. Google handles Play delivery and updates under its own [privacy policy](https://policies.google.com/privacy).
|
||||
|
||||
Optional Windows prerequisites can be downloaded from the sources identified in the app's pinned component inventory, including GitHub-hosted runtime packages. These providers receive the download request and IP address. DroidDeck verifies the package against the inventory before installing it into the guest runtime.
|
||||
|
||||
Optional Syncthing synchronization transfers selected files to the devices you configure and can use discovery and relay services. DroidDeck disables Syncthing usage reporting and crash reporting. Selected games or other guest software may use files and resources available within DroidDeck's runtime.
|
||||
|
||||
DroidDeck's privacy policy does not replace the policies of these separate providers. Review the relevant provider's policy when signing in, purchasing content, streaming, installing software, or enabling synchronization.
|
||||
|
||||
## Retention, removal, and reports
|
||||
|
||||
Local information remains until you clear it, sign out, remove the relevant files or feature data, or use Android's app-data controls. Removing DroidDeck or clearing its app storage removes Android-managed app data; libraries, saves, screenshots, and other files in shared storage or on other devices may remain. Signing out of a store integration does not delete the account held by that store. Manage or delete those accounts through the provider.
|
||||
|
||||
Diagnostic logs are kept locally. If you choose to send a report or export logs, the recipient receives what you submit. Public GitHub issues and discussions are public. Review reports and attachments before sharing them, and exclude passwords, authentication tokens, and other sensitive information.
|
||||
|
||||
## Contact and updates
|
||||
|
||||
For privacy questions, contact the Droid-Deck maintainers through the project's [GitHub issue form](https://github.com/Droid-Deck/DroidDeck/issues/new?title=Privacy%20question). Keep the initial public inquiry free of sensitive information; request a private contact method if necessary. GitHub handles information submitted through its service under its own [privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).
|
||||
|
||||
Updates to this policy will appear on this page with a revised effective date.
|
||||
@@ -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
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user