mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-22 07:04:40 -04:00
Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df88872c98 | ||
|
|
5109fa4839 | ||
|
|
3c64c6b3b8 | ||
|
|
f5c343bde0 | ||
|
|
6626cf99b7 | ||
|
|
1d18b43bc2 | ||
|
|
62502877fc | ||
|
|
d62b96ac15 | ||
|
|
fd11ed82c4 | ||
|
|
39ff0a653d | ||
|
|
6a175f1097 | ||
|
|
cc981aa69f | ||
|
|
41422407c4 | ||
|
|
b323a0b33e | ||
|
|
0849a7c18e | ||
|
|
889e783520 | ||
|
|
90a8a526d5 | ||
|
|
1580e3ee64 | ||
|
|
935b70c585 | ||
|
|
bf811040ae | ||
|
|
272cd60b19 | ||
|
|
a844483080 | ||
|
|
1d1de10b2d | ||
|
|
7ab0c12c97 | ||
|
|
cd85eb1624 | ||
|
|
dd2b2b08c3 | ||
|
|
97a0f1553f | ||
|
|
78e4cb8369 | ||
|
|
64d5f92391 | ||
|
|
3dbc4ecc6d | ||
|
|
2f1e0a1348 | ||
|
|
ff9807ad29 | ||
|
|
37b6f336a7 | ||
|
|
1a527e8ed1 | ||
|
|
1379396b99 | ||
|
|
e835f5c523 | ||
|
|
4748a217b3 | ||
|
|
96d8678dd3 | ||
|
|
e0b0af4290 | ||
|
|
8940b0ca32 | ||
|
|
cb2f7c0109 | ||
|
|
a5f608b9d7 | ||
|
|
236eeddeec | ||
|
|
3a90ac0665 | ||
|
|
dd4c4a294c | ||
|
|
a0bd480576 | ||
|
|
01c7248965 | ||
|
|
b6903db58c | ||
|
|
33caa8a57c | ||
|
|
fbd068c6b8 | ||
|
|
35dfd99013 | ||
|
|
6ca3052eb7 | ||
|
|
2d70341214 | ||
|
|
5a1e5deba9 | ||
|
|
a2433f1965 | ||
|
|
f9db495e3f | ||
|
|
fdb9cbb7a5 | ||
|
|
79e1d30d6c | ||
|
|
a4841dff9d | ||
|
|
21aeab0126 | ||
|
|
fd0c6f124f | ||
|
|
9f5a2f39cd | ||
|
|
61a9a48e70 | ||
|
|
9c0f87a4c8 | ||
|
|
b5ae6cc63f | ||
|
|
4bcfca58e2 | ||
|
|
0c59f551af | ||
|
|
0687e3477e | ||
|
|
f467bb22e8 | ||
|
|
dad1aa2dc7 | ||
|
|
9f71b427bf | ||
|
|
0091b751f4 | ||
|
|
05097b0eea | ||
|
|
401a650b5b | ||
|
|
134fa167fe | ||
|
|
da42a5d7a7 | ||
|
|
b6c1d3c09c | ||
|
|
a58b0ac3e0 | ||
|
|
e1700ba982 | ||
|
|
736ac7db74 | ||
|
|
6e2966be43 | ||
|
|
679948e792 | ||
|
|
e0fc8362b8 | ||
|
|
9d8ff9615f | ||
|
|
1304809240 | ||
|
|
343a516e1a | ||
|
|
7c08cb58ae | ||
|
|
7e81668de2 | ||
|
|
d231564f8e | ||
|
|
37e89c5b4d | ||
|
|
e070bd72e0 | ||
|
|
9310d19857 | ||
|
|
4454a6d39a | ||
|
|
453f6f5180 | ||
|
|
6d0484941e | ||
|
|
cf47910881 | ||
|
|
4f0f0cf5b1 | ||
|
|
a78d5cb600 | ||
|
|
206c8a8846 | ||
|
|
bf17ce1151 | ||
|
|
ef38c975b9 | ||
|
|
61953c4f7b | ||
|
|
1f13285ab8 | ||
|
|
1bc23ac169 | ||
|
|
50f50b16e0 | ||
|
|
adc86fc7df | ||
|
|
f3fd3a2307 |
No files matched your search
@@ -210,7 +210,7 @@ jobs:
|
||||
done
|
||||
if [ -f tools/gamescope/release.env ]; then
|
||||
. tools/gamescope/release.env
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
|
||||
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
@@ -331,6 +331,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
|
||||
# DirectAudio - the game driver (one set per Wine audio interface), the Android-side helper and
|
||||
# the Steam client's sinks - comes from a pinned release of The412Banner/directaudio, checksummed,
|
||||
# exactly like gamescope; nothing compiled for audio is kept in this repo.
|
||||
- name: Fetch DirectAudio (tools/directaudio/release.env)
|
||||
run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
|
||||
- name: Build the AAudio sink into the audio bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -340,12 +345,11 @@ jobs:
|
||||
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
|
||||
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
|
||||
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
|
||||
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
|
||||
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
rm -rf pa-bundle sink-out
|
||||
|
||||
- name: Cache proot
|
||||
id: proot
|
||||
uses: actions/cache@v4
|
||||
@@ -401,6 +405,39 @@ jobs:
|
||||
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
|
||||
done
|
||||
|
||||
# The store download engines (app/src/main/rust/stores) are Rust, built by Gradle's
|
||||
# buildRustStores task through cargo-ndk. rustup and cargo are on the runner image; the Android
|
||||
# target and cargo-ndk are added here. Cache = cargo registry + cargo-ndk + the crate's target
|
||||
# tree, keyed on the rustc version and Cargo.lock, so a source-only change rebuilds this crate
|
||||
# on top of the cached dependency objects, and a lockfile change still starts from the
|
||||
# previous objects (restore-keys).
|
||||
- name: Rust toolchain for the store engines (aarch64-linux-android)
|
||||
id: rust
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup target add aarch64-linux-android
|
||||
echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
|
||||
rustc -V; cargo -V
|
||||
|
||||
- name: Cache cargo registry and the store engines' target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-ndk
|
||||
app/src/main/rust/stores/target
|
||||
key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
stores-${{ steps.rust.outputs.rustc }}-
|
||||
|
||||
- name: cargo-ndk
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v cargo-ndk >/dev/null || cargo install cargo-ndk --locked
|
||||
cargo ndk --version
|
||||
|
||||
- name: Cache the CMake build tree
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -420,6 +457,52 @@ jobs:
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Check the store engines' library
|
||||
# The exports Kotlin binds must be in the apk's libdroiddeckstores.so, or the stores fail on
|
||||
# the device with an UnsatisfiedLinkError instead of here. The lockfile check is the
|
||||
# reproducibility guard: cargo must not have had to re-resolve anything.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
NM="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
|
||||
SO=app/build/rustJniLibs/arm64-v8a/libdroiddeckstores.so
|
||||
test -s "$SO"
|
||||
ls -la "$SO"
|
||||
unzip -l app/build/outputs/apk/release/app-release.apk | grep -q 'lib/arm64-v8a/libdroiddeckstores.so' \
|
||||
|| { echo "::error::libdroiddeckstores.so is not in the apk"; exit 1; }
|
||||
for sym in \
|
||||
Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion \
|
||||
Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart \
|
||||
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeStart \
|
||||
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeAssemble \
|
||||
Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart; do
|
||||
"$NM" -D --defined-only "$SO" | grep -q " ${sym}\$" \
|
||||
|| { echo "::error::libdroiddeckstores.so does not export $sym"; exit 1; }
|
||||
done
|
||||
git diff --stat -- app/src/main/rust/stores/Cargo.lock
|
||||
git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \
|
||||
|| { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; }
|
||||
|
||||
- name: Store engines host unit tests
|
||||
# The crate's #[test] functions are pure host-side logic (manifest parsing, chunk plans,
|
||||
# assembly from a cache, hashes), so a plain `cargo test` on the runner's own architecture
|
||||
# runs them; the Android build above compiles none of them. Informational on this first
|
||||
# run - the point is to learn whether they pass - and bounded so a hang can never hold
|
||||
# the apk. Make it blocking once it is known green.
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd app/src/main/rust/stores
|
||||
cargo test --locked 2>&1 | tail -60
|
||||
|
||||
- name: Upload the regenerated Cargo.lock
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: stores-cargo-lock
|
||||
path: app/src/main/rust/stores/Cargo.lock
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
|
||||
@@ -13,13 +13,28 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check the current PR state
|
||||
id: state
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .state)
|
||||
echo "closed=$([ "$state" = closed ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
@@ -27,6 +42,7 @@ jobs:
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete the test build
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -42,6 +58,19 @@ jobs:
|
||||
done
|
||||
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- name: Retire the Discord PR announcement
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: python3 tools/release/pr_discord.py retire
|
||||
|
||||
catalog:
|
||||
needs: cleanup
|
||||
if: ${{ always() }}
|
||||
|
||||
@@ -26,6 +26,7 @@ permissions:
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
@@ -178,6 +179,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-pr-signed-*
|
||||
@@ -307,39 +312,27 @@ jobs:
|
||||
$NEXT
|
||||
EOF
|
||||
)
|
||||
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
|
||||
comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --slurp | \
|
||||
jq "[.[][] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$mark\"))] | first // {}")
|
||||
id=$(jq -r '.id // empty' <<<"$comment")
|
||||
if [ -n "$id" ]; then
|
||||
state=$(jq -r '.body | split("\n")[] | select(startswith("<!-- droiddeck-discord: ") and endswith(" -->"))' <<<"$comment")
|
||||
if [ -n "$state" ]; then body=$(printf '%s\n%s' "$body" "$state"); fi
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
|
||||
fi
|
||||
|
||||
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
|
||||
# only a PR's first test build is, and later ones update its release quietly.
|
||||
- name: Post to Discord
|
||||
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
|
||||
- name: Update the Discord PR announcement
|
||||
if: ${{ always() && steps.publish.outcome == 'success' }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
||||
exit 0
|
||||
fi
|
||||
short=${HEAD_SHA:0:7}
|
||||
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
|
||||
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
run: python3 tools/release/pr_discord.py publish
|
||||
|
||||
catalog:
|
||||
needs: publish
|
||||
|
||||
@@ -13,6 +13,7 @@ permissions:
|
||||
concurrency:
|
||||
group: update-app-catalog
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
catalog:
|
||||
|
||||
@@ -14,6 +14,10 @@ app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
|
||||
# The store download engines (app/src/main/rust/stores) are cross-compiled by Gradle into
|
||||
# app/build/rustJniLibs; cargo's own build tree stays next to the crate. Neither is source.
|
||||
app/src/main/rust/stores/target/
|
||||
app/src/main/assets/droiddeck-esync/
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
@@ -34,3 +38,4 @@ __pycache__/
|
||||
|
||||
# Local signing with the release key (tools/build_local.sh); holds a password.
|
||||
.signing.env
|
||||
app/src/main/assets/directaudio/
|
||||
@@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
|
||||
|
||||
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
|
||||
+37
-2
@@ -79,8 +79,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 11
|
||||
versionName "0.3.1"
|
||||
versionCode 12
|
||||
versionName "0.3.2"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -165,6 +165,8 @@ android {
|
||||
testOptions {
|
||||
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
|
||||
unitTests.returnDefaultValues = true
|
||||
// Robolectric tests start activities whose text is now all string resources.
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -186,6 +188,39 @@ def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
|
||||
|
||||
// The GOG / Epic / Amazon download engines are Rust (app/src/main/rust/stores, ported from
|
||||
// Bannerlator's bl-steam-client crate). cargo-ndk cross-compiles them for arm64-v8a into the build
|
||||
// directory, which is one more jniLibs source below: nothing compiled is committed, the same rule
|
||||
// the preloads, proot and the DirectAudio relay follow. cargo-ndk exports the NDK clang/llvm-ar as
|
||||
// the C compiler for the one C-compiling crate (ring, under rustls) - the step Bannerlator's CI
|
||||
// does by hand - and reads the NDK from ANDROID_NDK_HOME, which is set to the NDK this build pins.
|
||||
// -PskipRust=true builds without the library, for a checkout with no Rust toolchain: the stores
|
||||
// then report themselves unavailable instead of the build failing.
|
||||
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
|
||||
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
|
||||
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
|
||||
def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
|
||||
onlyIf { !skipRust }
|
||||
inputs.dir(rustCrateDir.dir('src'))
|
||||
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
|
||||
outputs.dir(rustJniLibsDir)
|
||||
workingDir rustCrateDir
|
||||
// The configuration cache serialises the task, so everything it needs is resolved here, not
|
||||
// in doFirst. ~/.cargo/bin is where rustup puts cargo and cargo-ndk; an IDE-launched Gradle
|
||||
// does not always have it on PATH.
|
||||
def cargoBin = "${System.getProperty('user.home')}/.cargo/bin"
|
||||
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
|
||||
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
|
||||
environment 'CARGO_TERM_COLOR', 'never'
|
||||
// --platform 26 = minSdkVersion: the engines link against the API 26 libc, like the C++ code.
|
||||
commandLine 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
|
||||
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
|
||||
'build', '--release'
|
||||
}
|
||||
android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
dependencies {
|
||||
// The runtime tarball is a zstd-compressed tar.
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Store download progress and "installed" / "failed" notices (Android 13+). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- The session keeps running while the app is not on screen: a foreground service holds the
|
||||
process at perceptible priority, and a high-performance WiFi lock keeps a backgrounded
|
||||
download from being throttled to nothing by the radio's power-save. -->
|
||||
@@ -127,6 +129,13 @@
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<!-- A store's own sign-in page (GOG, Epic Games, Amazon Games) in a WebView. -->
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
@@ -163,6 +172,11 @@
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
|
||||
<service
|
||||
android:name=".stores.download.StoreDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# DirectAudio for the Linux Steam client (relay build)
|
||||
|
||||
This is the driver for games that run under **Valve's ARM64 Proton inside a Linux
|
||||
runtime on Android** - Bannerlator's linuxfs session with the native Steam client -
|
||||
where the game process is a glibc process and cannot touch Android's audio
|
||||
libraries itself.
|
||||
|
||||
Two pieces, both required:
|
||||
|
||||
| piece | what it is | where it runs |
|
||||
|---|---|---|
|
||||
| `aarch64-unix/winedirectaudio.so` + the two PE shells | the Wine `mmdevapi` driver, glibc build, **no AAudio inside** | in the game, inside the Linux rootfs |
|
||||
| `directaudio-relay` | a small bionic program that owns the AAudio output and microphone streams | on the Android side, under the app's uid |
|
||||
|
||||
They meet over a unix socket. The driver connects, sends its launch config, and
|
||||
gets back two shared-memory rings: one it fills with mixed game audio, one the
|
||||
helper fills with microphone audio. The helper plays and records; the driver
|
||||
keeps doing everything else it always did.
|
||||
|
||||
## Which Proton
|
||||
|
||||
Built inside `ValveSoftware/wine` at `proton_11.0`. The private `mmdevapi`
|
||||
interface it implements is byte-identical across **Proton 11.0 (ARM64)**,
|
||||
**Proton Experimental (ARM64)** and our own Wine-11 layers (checked 2026-09-19),
|
||||
so this one build serves any of them. A future Proton on a Wine 12 base will
|
||||
need a rebuild - the interface is not versioned, and a mismatch is silence, not
|
||||
an error.
|
||||
|
||||
## Installing the driver next to Proton (not into it)
|
||||
|
||||
Steam verifies and updates its Proton depots, so files added under
|
||||
`steamapps/common/Proton .../files/lib/wine/` will not survive. Put the driver
|
||||
in a directory of its own and point Wine at it:
|
||||
|
||||
```
|
||||
<somewhere>/directaudio/lib/wine/aarch64-unix/winedirectaudio.so
|
||||
<somewhere>/directaudio/lib/wine/aarch64-windows/winedirectaudio.drv
|
||||
<somewhere>/directaudio/lib/wine/i386-windows/winedirectaudio.drv
|
||||
```
|
||||
|
||||
and set, for the game process:
|
||||
|
||||
```
|
||||
WINEDLLPATH=<somewhere>/directaudio/lib/wine
|
||||
```
|
||||
|
||||
Wine searches `WINEDLLPATH` entries with the per-arch subdirectories appended,
|
||||
so both the PE shell and the unixlib are found there. In Bannerlator's runtime
|
||||
the natural place to export it is the `bannerlator-proton` compatibility-tool
|
||||
wrapper, which every Proton launch goes through.
|
||||
|
||||
## Selecting the driver
|
||||
|
||||
`mmdevapi` only tries the drivers named in the registry (or its built-in
|
||||
`pulse,alsa,oss,coreaudio` list, which does not know about this one). Set, in the
|
||||
game's prefix:
|
||||
|
||||
```
|
||||
wine reg add "HKCU\Software\Wine\Drivers" /v Audio /d directaudio /f
|
||||
```
|
||||
|
||||
Under Proton the prefix is `compatdata/<appid>/pfx`; run the command with that
|
||||
prefix as `WINEPREFIX` using Proton's own `bin-arm64/wine`, or write the key from
|
||||
the wrapper before it hands off to Proton.
|
||||
|
||||
## Running the helper
|
||||
|
||||
```
|
||||
directaudio-relay --socket <path> [--log]
|
||||
```
|
||||
|
||||
- Run it **under the app's uid**, the way the app already runs its PulseAudio
|
||||
daemon. Android checks `RECORD_AUDIO` against the calling uid, so a helper
|
||||
started any other way can play but cannot record.
|
||||
- Start it **before** the game: the driver asks "is a helper there?" once, when
|
||||
Wine picks an audio driver, and falls back to unavailable if not.
|
||||
- The socket path must be reachable from inside the Linux rootfs. Bannerlator
|
||||
binds the app's files directory and its runtime directory into the session at
|
||||
their own paths, so any socket under either works unchanged on both sides.
|
||||
- The helper serves any number of game processes; each gets its own streams.
|
||||
It exits on `SIGTERM`; a game exiting tears down that game's streams only.
|
||||
|
||||
## Telling the driver where the helper is
|
||||
|
||||
```
|
||||
BANNER_AUDIO_DIRECT_RELAY=<the same socket path>
|
||||
```
|
||||
|
||||
Unset, the driver looks for `$XDG_RUNTIME_DIR/directaudio-relay`.
|
||||
|
||||
## Everything else is unchanged
|
||||
|
||||
All `BANNER_AUDIO_DIRECT_*` knobs mean what they mean in the in-process build:
|
||||
`_PERF`, `_ADAPTIVE`, `_DECAY`, `_MS`, `_MAXMS`, `_BF`, `_MBF`, `_EXCLUSIVE`,
|
||||
`_WATCHDOG`, `_STALL_MS`, `_DECAY_*`, `_LOG`, `_PERIOD_MS`, `_MINPERIOD_MS`, and
|
||||
the live-config mailbox `_RUNTIME` (the helper watches the file; it must be a
|
||||
path the helper can read on the Android side). **`BANNER_AUDIO_DIRECT_MIC=1`**
|
||||
exposes the capture endpoint exactly as before: the helper opens the input
|
||||
stream (`VOICE_COMMUNICATION` preset) when the game connects and makes it hot on
|
||||
the game's first capture start.
|
||||
|
||||
One thing is different: there is one extra hand-off, the ring. The helper's
|
||||
callback pulls a burst from it, so the driver keeps two bursts queued (about 8 ms
|
||||
on a 192-frame device) on top of the AAudio buffer the presets control, and the
|
||||
helper raises that if a callback ever finds the ring short. `get_latency` reports
|
||||
the sum, so a game that sizes its own buffers from it still sees the truth.
|
||||
|
||||
## Proving it is running
|
||||
|
||||
- Helper side: `logcat -s DA-Relay:I` shows `hello from <exe>`, then `open: buffer
|
||||
... ring target ...`, then `capture open` / `capture start` for the mic.
|
||||
- Game side: the session log carries `DirectAudio: relay: connected - ...`.
|
||||
- The game process's `/proc/<pid>/maps` shows `winedirectaudio.so` and **no**
|
||||
`libaaudio.so` (it is in the helper's maps instead). The AudioFlinger track is
|
||||
owned by the helper's pid.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
||||
directaudio linux-wine11 relay build
|
||||
source 3548a4a730e8d01cb0cb0268d51a2fb8f23dcd33
|
||||
wine ValveSoftware/wine@proton_11.0
|
||||
unixlib.h sha256 fdbed263338d0719c4d12e02b045c23721de33219ec8878e4c2d7ebc9efcbe09
|
||||
@@ -11,6 +11,14 @@ import com.droiddeck.launcher.session.GpuClockPin
|
||||
* sandboxes are started through (BwrapSpawner).
|
||||
*/
|
||||
class App : Application() {
|
||||
override fun attachBaseContext(base: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
com.droiddeck.launcher.core.AppLanguage.systemChanged(newConfig)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
@@ -21,5 +29,10 @@ class App : Application() {
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
com.droiddeck.launcher.frontend.GameFileSync.start(this)
|
||||
// The game stores the Stores section can talk to; the native engine loads only when one needs it.
|
||||
com.droiddeck.launcher.stores.StoresState.init(this)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
if (compBusy != null) return
|
||||
compBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
val message = runCatching(work).getOrElse { e -> activity.getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post {
|
||||
compBusy = null
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -79,7 +79,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
|
||||
ui.post {
|
||||
compChecking = false
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
|
||||
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
}
|
||||
}, "components-catalog").start()
|
||||
@@ -91,8 +91,8 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
|
||||
"Stored ${pkg.version}"
|
||||
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
activity.getString(R.string.comp_stored, pkg.version)
|
||||
}.getOrElse { e -> activity.getString(R.string.comp_download_failed, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post {
|
||||
compDownloads = compDownloads - item.file
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
|
||||
@@ -19,6 +19,8 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
var deckyChecking by mutableStateOf(false)
|
||||
var deckyStage by mutableStateOf<String?>(null)
|
||||
var deckyPercent by mutableIntStateOf(-1)
|
||||
/** True while the stage is the plugin's binary download (DeckyManager.downloadingBinary). */
|
||||
var deckyDownloadingBinary by mutableStateOf(false)
|
||||
var deckySupervisor by mutableStateOf(false)
|
||||
var deckyReleaseRequest = 0
|
||||
|
||||
@@ -41,11 +43,11 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
|
||||
fun installDecky(release: DeckyManager.Release) {
|
||||
if (deckyStage != null || SessionState.running) return
|
||||
deckyStage = "Starting…"; deckyPercent = -1
|
||||
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
|
||||
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
}.getOrElse { error -> activity.getString(R.string.decky_install_failed, error.message ?: error.javaClass.simpleName) }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -55,16 +57,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
|
||||
fun importPluginZip(archive: File) {
|
||||
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
|
||||
deckyStage = "Starting plugin import…"; deckyPercent = -1
|
||||
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.installPluginZip(activity, archive) { label, value ->
|
||||
ui.post { deckyStage = label; deckyPercent = value }
|
||||
val binary = DeckyManager.downloadingBinary
|
||||
ui.post { deckyStage = label; deckyPercent = value; deckyDownloadingBinary = binary }
|
||||
}
|
||||
}.getOrElse { error -> "Plugin import failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
}.getOrElse { error -> activity.getString(R.string.decky_plugin_failed, error.message ?: error.javaClass.simpleName) }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1
|
||||
val message = problem ?: "Plugin installed. Restart Steam to load it."
|
||||
deckyStage = null; deckyPercent = -1; deckyDownloadingBinary = false
|
||||
val message = problem ?: activity.getString(R.string.decky_plugin_installed)
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "import-decky-plugin").start()
|
||||
|
||||
@@ -37,7 +37,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
|
||||
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
|
||||
// Empty until the first refresh: the activity has no context yet while its fields are made.
|
||||
var releaseStatus by mutableStateOf("")
|
||||
var releaseChecking by mutableStateOf(false)
|
||||
var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
@@ -57,10 +58,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
|
||||
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
|
||||
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
|
||||
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus.ifEmpty { activity.getString(R.string.drivers_not_checked) }, checking = releaseChecking,
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
@@ -73,10 +74,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
val bundles = DriverBundle.all(activity)
|
||||
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
|
||||
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
|
||||
DriverRow(id, it.label, activity.getString(if (linux) R.string.drivers_bundle_sets_display else R.string.drivers_bundle_sets_runtime), true, DriverRow.BUNDLE)
|
||||
}
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
if (id.isEmpty()) DriverRow("", activity.getString(R.string.gpu_runtime_default), activity.getString(R.string.drivers_runtime_builtin), false)
|
||||
else bundleRow(id, linux = true) ?: DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
@@ -92,8 +93,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
add(DriverRow(
|
||||
TurnipDriver.AUTO, "Auto - picked by GPU",
|
||||
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
|
||||
TurnipDriver.AUTO, activity.getString(R.string.gpu_auto_by_gpu),
|
||||
if (auto == "system") activity.getString(R.string.drivers_system_vulkan) else activity.getString(R.string.drivers_bundled, td.displayName(auto)),
|
||||
false,
|
||||
))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
@@ -109,7 +110,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
|
||||
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
|
||||
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
|
||||
}
|
||||
|
||||
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
|
||||
@@ -125,7 +127,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val mb = p.assets.sumOf { it.size } / 1_048_576.0
|
||||
PairRow(
|
||||
key = p.key, name = p.name, version = p.version,
|
||||
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
|
||||
detail = if (!p.complete) activity.getString(R.string.drivers_half_published) else activity.getString(R.string.drivers_pair_size, mb),
|
||||
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
|
||||
installed = displayId != null && linuxId != null,
|
||||
active = displayId != null && linuxId != null &&
|
||||
@@ -153,11 +155,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
|
||||
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
|
||||
return
|
||||
}
|
||||
releaseChecking = true
|
||||
autoStatus = "Checking for the latest drivers…"
|
||||
autoStatus = activity.getString(R.string.drivers_checking_latest)
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
@@ -172,9 +174,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val pair = pairs.firstOrNull { it.key == key }
|
||||
val row = pairRows.firstOrNull { it.key == key }
|
||||
when {
|
||||
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
|
||||
row?.active == true -> autoStatus = problem?.let { activity.getString(R.string.drivers_up_to_date_check_failed, it) }
|
||||
?: activity.getString(R.string.upd_current_status)
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
|
||||
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +200,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (!pair.complete) return
|
||||
pairBusy = pair.key
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
|
||||
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
@@ -217,7 +220,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
val restart = displayChanged && CompositorHost.isStarted
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
@@ -230,14 +233,18 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
|
||||
autoStatus = activity.getString(
|
||||
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
|
||||
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
|
||||
pair.name, pair.version,
|
||||
)
|
||||
} else {
|
||||
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(activity, activity.getString(if (restart) R.string.drivers_using_restart else R.string.drivers_using, pair.name, pair.version), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "driver pair ${pair.key}", e)
|
||||
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
|
||||
if (auto) autoStatus = why ?: "Download failed"
|
||||
val why = if (e is IllegalArgumentException) e.message else activity.getString(R.string.comp_download_failed, e.message)
|
||||
if (auto) autoStatus = why ?: activity.getString(R.string.user_apps_download_failed)
|
||||
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshDrivers()
|
||||
@@ -298,7 +305,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
try {
|
||||
LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
} catch (runtime: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
|
||||
throw IllegalArgumentException(activity.getString(R.string.drivers_not_a_driver))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,10 +314,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "driver import", e)
|
||||
"Import failed: ${e.message}"
|
||||
activity.getString(R.string.drivers_import_failed, e.message)
|
||||
}
|
||||
ui.post {
|
||||
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
|
||||
val done = bundle?.let { useBundle(it) }
|
||||
?: name?.let { activity.getString(R.string.comp_imported, it) } ?: activity.getString(R.string.drivers_imported_unnamed)
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: done,
|
||||
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
@@ -340,8 +348,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return "Using ${bundle.label} as both the runtime and display driver." +
|
||||
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
return activity.getString(
|
||||
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
|
||||
bundle.label,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +364,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (bundle != null) {
|
||||
DriverBundle.remove(activity, bundle)
|
||||
TurnipReleases.forget(activity, bundle.id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted_bundle, bundle.label), android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
@@ -367,7 +377,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted, id), android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
@@ -378,26 +388,28 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
.filter { it.bundle || it.linux == linux }
|
||||
.filter { a -> installedIdOf(a) == null }
|
||||
.map { a ->
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
val mb = activity.getString(R.string.common_size_mb, a.size / 1_048_576.0)
|
||||
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
}
|
||||
linuxDownloads = rows(linux = true)
|
||||
androidDownloads = rows(linux = false)
|
||||
if (!releaseChecking) releaseStatus = when (check) {
|
||||
null -> "Not checked yet - tap refresh to look for new drivers"
|
||||
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
|
||||
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
|
||||
" · checked ${ago(check.checkedAt)}"
|
||||
null -> activity.getString(R.string.drivers_not_checked)
|
||||
else -> {
|
||||
val latest = check.latest.joinToString(" · ") { "${it.first} ${it.second}" }
|
||||
if (check.failed.isEmpty()) activity.getString(R.string.drivers_release_status, latest, ago(check.checkedAt))
|
||||
else activity.getString(R.string.drivers_release_status_failed, latest, check.failed.joinToString(), ago(check.checkedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ago(t: Long): String {
|
||||
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
|
||||
return when {
|
||||
m < 1 -> "just now"
|
||||
m < 60 -> "$m min ago"
|
||||
m < 48 * 60 -> "${m / 60} h ago"
|
||||
else -> "${m / (24 * 60)} days ago"
|
||||
m < 1 -> activity.getString(R.string.upd_just_now)
|
||||
m < 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_minutes, m.toInt(), m)
|
||||
m < 48 * 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_hours, (m / 60).toInt(), m / 60)
|
||||
else -> activity.resources.getQuantityString(R.plurals.drivers_ago_days, (m / (24 * 60)).toInt(), m / (24 * 60))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,16 +417,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
|
||||
releaseStatus = activity.getString(R.string.drivers_checking_sources)
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: activity.getString(R.string.drivers_check_failed)
|
||||
}
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
if (problem != null) releaseStatus = activity.getString(R.string.upd_couldnt_check, problem)
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
}
|
||||
@@ -433,13 +445,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
"Download failed: ${e.message}"
|
||||
activity.getString(R.string.comp_download_failed, e.message)
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
|
||||
val installed = activity.getString(
|
||||
if (asset.bundle) R.string.drivers_installed_pick_either else R.string.drivers_installed_pick,
|
||||
asset.name.removeSuffix(".zip"),
|
||||
)
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
|
||||
activity, problem ?: installed,
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
|
||||
@@ -7,6 +7,11 @@ import com.droiddeck.launcher.session.SessionService
|
||||
|
||||
/** Receives the system Home launch and routes it without consuming KEYCODE_HOME. */
|
||||
class HomeActivity : Activity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
routeHome()
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
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.ComponentsManager
|
||||
@@ -84,6 +85,11 @@ import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
* the store, downloads, settings - is the client's own job once [SessionActivity] has it on screen.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
private val ui = Handler(Looper.getMainLooper())
|
||||
private val drivers = DriverMenus(this, ui)
|
||||
private val components = ComponentsMenu(this, ui)
|
||||
@@ -107,7 +113,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var stage by mutableStateOf("")
|
||||
private var percent by mutableIntStateOf(-1)
|
||||
private var failed by mutableStateOf(false)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var frameGenLabel by mutableStateOf("")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
@@ -154,11 +160,16 @@ class MainActivity : ComponentActivity() {
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
private var gameStoresEnabled by mutableStateOf(false)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var mic by mutableStateOf(false)
|
||||
private var wifiDiscovery by mutableStateOf(false)
|
||||
private var wifiDiscoveryPermission by mutableStateOf(false)
|
||||
@@ -170,6 +181,16 @@ class MainActivity : ComponentActivity() {
|
||||
refreshWifiDiscovery()
|
||||
}
|
||||
}
|
||||
private val mediaRefresh = Runnable {
|
||||
refresh()
|
||||
refreshAddedGames()
|
||||
}
|
||||
private val mediaReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: Intent?) {
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
|
||||
refreshWifiDiscovery()
|
||||
@@ -253,29 +274,32 @@ class MainActivity : ComponentActivity() {
|
||||
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
|
||||
private fun importSaves(name: String, game: () -> GameSaves.Game) {
|
||||
if (SessionState.running) {
|
||||
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_saves_close_session, android.widget.Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
onSavePicked = { zip ->
|
||||
saveAction("Importing into $name") {
|
||||
val (written, backup) = GameSaves.import(game(), zip)
|
||||
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
|
||||
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
|
||||
saveAction(getString(R.string.main_saves_importing, name)) {
|
||||
val (written, backup) = GameSaves.import(this, game(), zip)
|
||||
val kind = GameSaves.layoutOf(zip)?.label(this) ?: getString(R.string.saves_zip)
|
||||
resources.getQuantityString(
|
||||
if (backup != null) R.plurals.main_saves_imported_backup else R.plurals.main_saves_imported,
|
||||
written, written, kind, name,
|
||||
)
|
||||
}
|
||||
}
|
||||
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
|
||||
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), getString(R.string.main_saves_pick_zip, name), GameSaves.savesDir().parentFile?.parentFile?.path))
|
||||
}
|
||||
|
||||
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
|
||||
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
|
||||
onSavePicked = { dir ->
|
||||
saveAction("Exporting $name") {
|
||||
val (zip, count) = GameSaves.export(game(), layout, dir)
|
||||
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
|
||||
saveAction(getString(R.string.main_saves_exporting, name)) {
|
||||
val (zip, count) = GameSaves.export(this, game(), layout, dir)
|
||||
resources.getQuantityString(R.plurals.main_saves_exported, count, count, layout.label(this), zip.path.removePrefix("/storage/emulated/0/"))
|
||||
}
|
||||
}
|
||||
GameSaves.savesDir().mkdirs()
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
|
||||
}
|
||||
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
@@ -322,12 +346,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var renderer by mutableStateOf("vulkan")
|
||||
private var gameStorage by mutableStateOf("")
|
||||
private var storageDiagnostics by mutableStateOf(false)
|
||||
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
|
||||
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
|
||||
}
|
||||
@@ -336,6 +361,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
private var agentCommands by mutableStateOf(false)
|
||||
private var showRoms by mutableStateOf(false)
|
||||
private var homeAppSelected by mutableStateOf(false)
|
||||
private var homeScreenEnabled by mutableStateOf(false)
|
||||
@@ -358,15 +384,15 @@ class MainActivity : ComponentActivity() {
|
||||
if (intent?.component?.className == SessionActivity::class.java.name) {
|
||||
when {
|
||||
busy || LinuxRuntimeInstaller.isBusy() -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the runtime operation to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_runtime, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_proton_install, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
pkgStage != null -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the desktop app install to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -402,7 +428,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun pickGameExport(game: Library.SteamGame?) {
|
||||
onSavePicked = { folder ->
|
||||
saveAction(getString(R.string.game_frontend_files)) {
|
||||
val saved = if (game != null) GameFiles.export(folder, game)
|
||||
val saved = if (game != null) GameFiles.export(this, folder, game)
|
||||
else {
|
||||
GameFileSync.enable(this, folder)
|
||||
folder
|
||||
@@ -417,6 +443,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun readGameIntent(request: Intent) {
|
||||
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
|
||||
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
|
||||
if (shortcutPicker) {
|
||||
// The shortcut picker must only offer games from the scan started for this request.
|
||||
@@ -442,7 +469,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
|
||||
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
|
||||
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
if (SessionState.running) {
|
||||
@@ -495,6 +522,7 @@ class MainActivity : ComponentActivity() {
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
theme = SessionPrefs.theme(this)
|
||||
frameGenLabel = getString(R.string.frame_gen_off)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
@@ -502,11 +530,15 @@ class MainActivity : ComponentActivity() {
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
storesShowMature = SessionPrefs.storesShowMature(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
@@ -526,12 +558,13 @@ class MainActivity : ComponentActivity() {
|
||||
busy = busy, stage = stage, percent = percent,
|
||||
desktopInstalled = desktopInstalled,
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
|
||||
appScale = appScale,
|
||||
isHomeApp = homeAppSelected,
|
||||
homeScreenEnabled = homeScreenEnabled,
|
||||
@@ -554,6 +587,12 @@ class MainActivity : ComponentActivity() {
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
navigate = navRequest,
|
||||
storesShowMature = storesShowMature,
|
||||
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSteamSession() },
|
||||
@@ -564,7 +603,8 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
@@ -637,9 +677,13 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
},
|
||||
onAgentCommands = {
|
||||
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
|
||||
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
},
|
||||
@@ -648,7 +692,7 @@ class MainActivity : ComponentActivity() {
|
||||
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
|
||||
ui.post {
|
||||
android.widget.Toast.makeText(this,
|
||||
if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.",
|
||||
if (cleared == 0) getString(R.string.main_no_logs_to_clear) else resources.getQuantityString(R.plurals.main_logs_cleared, cleared, cleared),
|
||||
android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "clear-logs").start()
|
||||
@@ -659,6 +703,13 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLanguage = { tag ->
|
||||
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
|
||||
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
|
||||
// Redrawn in the new language; Setup's page and tab are saved state.
|
||||
recreate()
|
||||
}
|
||||
},
|
||||
onAppScale = { percent ->
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
@@ -669,6 +720,12 @@ class MainActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
|
||||
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onNavigated = { navRequest = null },
|
||||
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
|
||||
onLibraryChanged = { refreshAddedGames(); refresh() },
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
@@ -691,7 +748,7 @@ class MainActivity : ComponentActivity() {
|
||||
val result = runCatching {
|
||||
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
|
||||
}
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging pairing failed" }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_pair_failed) }
|
||||
ui.post { complete(error) }
|
||||
}, "wireless-adb-pair").start()
|
||||
},
|
||||
@@ -704,7 +761,7 @@ class MainActivity : ComponentActivity() {
|
||||
onWirelessAdbApply = { host, port, enabled, complete ->
|
||||
Thread({
|
||||
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging command failed" }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_command_failed) }
|
||||
ui.post {
|
||||
if (error == null) refreshPhantomStatus()
|
||||
complete(error)
|
||||
@@ -714,7 +771,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSetPhantomProcessLimit = { enabled, complete ->
|
||||
Thread({
|
||||
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging is unavailable" }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_unavailable) }
|
||||
ui.post {
|
||||
if (error == null) refreshPhantomStatus()
|
||||
complete(error)
|
||||
@@ -723,9 +780,9 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onCopyPhantomCommand = { enabled ->
|
||||
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||
ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.adbCommand(enabled)),
|
||||
ClipData.newPlainText(getString(R.string.main_adb_clip_label), PhantomProcessLimit.adbCommand(enabled)),
|
||||
)
|
||||
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_adb_copied, android.widget.Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onDismissPhantomGate = { showPhantomGate = false },
|
||||
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
|
||||
@@ -759,24 +816,24 @@ class MainActivity : ComponentActivity() {
|
||||
path = romsDir,
|
||||
onChoose = {
|
||||
showRoms = false
|
||||
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, "Choose the ROMs folder", romsDir))
|
||||
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_roms), romsDir))
|
||||
},
|
||||
onClear = { SessionPrefs.setRomsDir(this, ""); romsDir = null; showRoms = false },
|
||||
onDismiss = { showRoms = false },
|
||||
)
|
||||
showNonAdreno?.let { release ->
|
||||
ConfirmDialog(
|
||||
title = "Not an Adreno GPU",
|
||||
text = "Turnip supports Adreno GPUs. On ${com.droiddeck.launcher.core.DeviceSupport.gpuName()}, Steam may show a black screen. Download: ${"%.0f".format(release.size / 1e6)} MB.",
|
||||
confirm = "Install anyway",
|
||||
title = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_title),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@MainActivity), release.size / 1e6),
|
||||
confirm = androidx.compose.ui.res.stringResource(R.string.main_install_anyway),
|
||||
onConfirm = { showNonAdreno = null; install(release) },
|
||||
onDismiss = { showNonAdreno = null },
|
||||
)
|
||||
}
|
||||
if (showRemove) ConfirmDialog(
|
||||
title = "Remove Linux runtime",
|
||||
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
|
||||
confirm = "Remove",
|
||||
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
|
||||
confirm = androidx.compose.ui.res.stringResource(R.string.store_remove),
|
||||
onConfirm = { showRemove = false; removeRuntime() },
|
||||
onDismiss = { showRemove = false },
|
||||
)
|
||||
@@ -870,6 +927,10 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshWifiDiscovery()
|
||||
// Back from a store's sign-in page: the Stores chips show the account at once. A store
|
||||
// install finishing while this screen is up rebuilds the Games list through the listener.
|
||||
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
// Back from a session stopped behind a flood: open on its blue, before the first frame.
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
|
||||
@@ -886,7 +947,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (!SessionState.running) Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
if (applied.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
ui.post { if (showComponents) components.refreshComponents() }
|
||||
}, "components-queue").start()
|
||||
@@ -909,6 +970,11 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
|
||||
registerReceiver(mediaReceiver, android.content.IntentFilter().apply {
|
||||
for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED,
|
||||
Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action)
|
||||
addDataScheme("file")
|
||||
})
|
||||
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
@@ -920,6 +986,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
unregisterReceiver(wifiLocationReceiver)
|
||||
unregisterReceiver(mediaReceiver)
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
// An install finishing while this screen is away is picked up by onResume's refresh.
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = null
|
||||
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
|
||||
// The session covers the page by now; coming back finds it as it was.
|
||||
flood = null
|
||||
@@ -945,9 +1015,13 @@ class MainActivity : ComponentActivity() {
|
||||
try {
|
||||
HomeApp.launch(this, app, displayId)
|
||||
} catch (_: Exception) {
|
||||
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
|
||||
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
|
||||
android.widget.Toast.makeText(this, "Could not open ${app.label} on $target", android.widget.Toast.LENGTH_SHORT).show()
|
||||
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
|
||||
val message = when {
|
||||
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
|
||||
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
|
||||
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
|
||||
}
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +1052,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
|
||||
saveBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
val message = runCatching(work).getOrElse { e -> getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post {
|
||||
saveBusy = null
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -1024,10 +1098,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
val name = displayNameOf(uri) ?: "imported.wcp"
|
||||
components.componentAction("Importing") {
|
||||
components.componentAction(getString(R.string.comp_busy_importing)) {
|
||||
val tmp = File(cacheDir, "component-import.wcp")
|
||||
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
|
||||
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,14 +1119,14 @@ class MainActivity : ComponentActivity() {
|
||||
requestInitialFocus = focusComponentsContent,
|
||||
onProton = { components.chooseProton(it) },
|
||||
onComp = { components.compComp = it },
|
||||
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
|
||||
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
|
||||
onCancelQueued = { components.compProton?.let { pid -> components.componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, components.compComp); "The waiting swap was cancelled." } } },
|
||||
onDeletePackage = { file -> components.componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
|
||||
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
|
||||
onSwap = { file -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_swapping)) { ComponentsManager.swap(this, pid, file) } } },
|
||||
onRestore = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_restoring)) { ComponentsManager.restore(this, pid, components.compComp, version) } } },
|
||||
onCancelQueued = { components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_cancelling)) { ComponentsManager.cancelQueued(this, pid, components.compComp); getString(R.string.comp_swap_cancelled) } } },
|
||||
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() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
|
||||
onBack = { showComponents = false },
|
||||
gpu = drivers.state(),
|
||||
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
|
||||
@@ -1065,9 +1139,9 @@ class MainActivity : ComponentActivity() {
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_linux))) },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
)
|
||||
@@ -1103,14 +1177,14 @@ class MainActivity : ComponentActivity() {
|
||||
private fun installPackage(id: String) {
|
||||
val entry = catalog?.firstOrNull { it.id == id } ?: return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
|
||||
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
|
||||
pkgId = id; pkgStage = getString(R.string.store_starting); pkgPercent = -1
|
||||
Thread({
|
||||
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
|
||||
ui.post { pkgStage = stage; pkgPercent = percent }
|
||||
}
|
||||
ui.post {
|
||||
pkgStage = null; pkgId = null
|
||||
if (problem != null) android.widget.Toast.makeText(this, "${entry.name}: $problem", android.widget.Toast.LENGTH_LONG).show()
|
||||
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, entry.name, problem), android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshPackages()
|
||||
refresh()
|
||||
}
|
||||
@@ -1120,7 +1194,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun removePackage(id: String) {
|
||||
val entry = catalog?.firstOrNull { it.id == id } ?: return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
|
||||
pkgId = id; pkgStage = if (entry.kind == "appimage") "Removing ${entry.name}…" else "Forgetting ${entry.name}…"; pkgPercent = -1
|
||||
pkgId = id; pkgStage = getString(if (entry.kind == "appimage") R.string.main_removing_named else R.string.main_forgetting_named, entry.name); pkgPercent = -1
|
||||
Thread({
|
||||
DesktopCatalog.remove(this, entry)
|
||||
ui.post {
|
||||
@@ -1142,12 +1216,12 @@ class MainActivity : ComponentActivity() {
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
clientDirectAudio = clientDirectAudio,
|
||||
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
|
||||
mic = if (mode == SessionService.MODE_STEAM) mic else null,
|
||||
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
|
||||
@@ -1174,6 +1248,7 @@ class MainActivity : ComponentActivity() {
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
deckyDownloadingBinary = decky.deckyDownloadingBinary,
|
||||
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
|
||||
),
|
||||
ModeSettingsActions(
|
||||
@@ -1200,6 +1275,10 @@ class MainActivity : ComponentActivity() {
|
||||
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onSteamDownloadsInBackground = { enabled ->
|
||||
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
|
||||
steamDownloadsInBackground = enabled
|
||||
},
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackActionsInverted = { inverted ->
|
||||
@@ -1207,7 +1286,6 @@ class MainActivity : ComponentActivity() {
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
|
||||
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
|
||||
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
|
||||
onMic = { on ->
|
||||
SessionPrefs.setMicEnabled(this, on)
|
||||
@@ -1221,7 +1299,7 @@ class MainActivity : ComponentActivity() {
|
||||
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
|
||||
onGameStorage = { path, label -> setGameStorage(path, label) },
|
||||
onPickGameStorageFolder = {
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
|
||||
},
|
||||
onStorageDiagnostics = { on ->
|
||||
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
|
||||
@@ -1248,13 +1326,13 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose a folder of your own games", addedGamesDirs.lastOrNull())) },
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
|
||||
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
|
||||
onPickAddedGameExe = { folder ->
|
||||
pendingAddedGame = folder
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder))
|
||||
},
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
@@ -1265,7 +1343,7 @@ class MainActivity : ComponentActivity() {
|
||||
decky.deckySupervisor = false
|
||||
},
|
||||
onPickDeckyPluginZip = {
|
||||
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Decky plugin ZIP"))
|
||||
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
|
||||
},
|
||||
onDismiss = { settingsMode = null },
|
||||
),
|
||||
@@ -1321,7 +1399,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
|
||||
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
|
||||
private fun fetchAddedGameArt() {
|
||||
@@ -1368,10 +1446,10 @@ class MainActivity : ComponentActivity() {
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
@@ -1384,7 +1462,7 @@ class MainActivity : ComponentActivity() {
|
||||
Thread({
|
||||
drivers.refreshDrivers()
|
||||
val games = scanAddedGames()
|
||||
val storage = GameStorage.options(this).map { it.label to it.path }
|
||||
val storage = GameStorage.options(this)
|
||||
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
|
||||
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
|
||||
ui.post {
|
||||
@@ -1403,9 +1481,9 @@ class MainActivity : ComponentActivity() {
|
||||
/** A second Steam library, proven writable first; "" = internal only. */
|
||||
private fun setGameStorage(path: String, label: String) {
|
||||
if (path.isNotEmpty() && path != SessionPrefs.GAME_STORAGE_OFF) {
|
||||
val problem = GameStorage.prepare(path)
|
||||
val problem = GameStorage.prepare(this, path)
|
||||
if (problem != null) {
|
||||
android.widget.Toast.makeText(this, "Not usable: $problem", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, getString(R.string.main_storage_not_usable, problem), android.widget.Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1446,10 +1524,11 @@ class MainActivity : ComponentActivity() {
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
runningLabel = if (SessionState.running) when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> "Desktop"
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
|
||||
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
|
||||
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
|
||||
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: getString(R.string.main_running_program)
|
||||
else -> "Steam"
|
||||
} else null
|
||||
// The libraries, off the main thread: manifests and a folder scan.
|
||||
@@ -1516,7 +1595,10 @@ class MainActivity : ComponentActivity() {
|
||||
private fun refreshPhantomStatus() {
|
||||
phantomProcessStatus = PhantomProcessLimit.read(this)
|
||||
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
|
||||
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
|
||||
getString(
|
||||
R.string.phantom_warning, PhantomProcessLimit.title(this, phantomProcessStatus),
|
||||
PhantomProcessLimit.instructions(this, phantomProcessStatus), PhantomProcessLimit.adbCommand(),
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
@@ -1532,10 +1614,10 @@ class MainActivity : ComponentActivity() {
|
||||
} catch (error: Exception) {
|
||||
if (displayId != null) {
|
||||
runCatching { startActivity(intent) }
|
||||
android.widget.Toast.makeText(this, "Could not open Settings on the bottom screen; opened it on the main screen.", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_settings_main_screen, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
android.widget.Toast.makeText(this, "Open Developer options in Settings", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, R.string.main_open_dev_options, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1568,7 +1650,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
|
||||
busy = true
|
||||
failed = false
|
||||
stage = if (LinuxRuntimeInstaller.isRemoving()) "Removing Linux runtime" else "Starting…"
|
||||
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
|
||||
percent = -1
|
||||
Thread({
|
||||
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
|
||||
@@ -1593,6 +1675,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val MEDIA_SETTLE_MS = 1500L
|
||||
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
|
||||
private val ZIP_EXT = listOf("zip")
|
||||
private val WCP_EXT = listOf("wcp")
|
||||
|
||||
@@ -24,7 +24,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
ProtonExtras.unqueue(activity, tool)
|
||||
protonBusyId = id
|
||||
protonStage = "Starting…"
|
||||
protonStage = activity.getString(R.string.store_starting)
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
val problem = ProtonExtras.install(activity, tool) { label, value ->
|
||||
@@ -44,7 +44,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
protonBusyId = id
|
||||
protonStage = "Removing ${tool.name}…"
|
||||
protonStage = activity.getString(R.string.main_removing_named, tool.name)
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
ProtonExtras.remove(activity, tool)
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.InputSpace
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.SessionClipboard
|
||||
@@ -100,6 +101,11 @@ import kotlin.math.abs
|
||||
* the HUD line, the loading overlay, the drawer and its dialogs.
|
||||
*/
|
||||
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
|
||||
private var pipUi by mutableStateOf(false)
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
@@ -241,6 +247,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var onScreenButtonsVisible by mutableStateOf(false)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
@@ -300,7 +307,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
|
||||
runtimeRemovalBlocked = true
|
||||
android.widget.Toast.makeText(this, "Wait for runtime removal to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@@ -356,6 +363,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
@@ -461,6 +469,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
if (loading.visible) LoadingOverlay(
|
||||
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
|
||||
topic = loading.topic, readable = loading.readable,
|
||||
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
|
||||
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
|
||||
onCancel = { SessionService.stop(this@SessionActivity); finish() },
|
||||
@@ -496,7 +505,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
|
||||
steam = SessionState.mode == SessionService.MODE_STEAM,
|
||||
title = if (SessionState.mode == SessionService.MODE_RUN)
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null,
|
||||
isHomeApp = isHomeApp,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
@@ -504,8 +513,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
|
||||
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
|
||||
fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
@@ -556,6 +566,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
suspendPolicy = policy
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onSteamDownloadsInBackground = { enabled ->
|
||||
SessionPrefs.setSteamDownloadsInBackground(this@SessionActivity, enabled)
|
||||
steamDownloadsInBackground = enabled
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
@@ -565,9 +580,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
try {
|
||||
HomeApp.launch(this@SessionActivity, app, displayId)
|
||||
} catch (_: Exception) {
|
||||
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
|
||||
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
|
||||
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
|
||||
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
|
||||
val message = when {
|
||||
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
|
||||
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
|
||||
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
|
||||
}
|
||||
Toast.makeText(this@SessionActivity, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity),
|
||||
@@ -685,7 +704,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
|
||||
uiHandler.post {
|
||||
drawerComponents = snap
|
||||
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
|
||||
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "drawer-components").start()
|
||||
}
|
||||
@@ -700,10 +719,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val current = active ?: "orig:$build"
|
||||
if (queued != null && value == current) {
|
||||
// Picking what is already in place again cancels the swap waiting for the game.
|
||||
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
|
||||
ComponentsManager.cancelQueued(this, protonId, comp); getString(R.string.comp_swap_cancelled)
|
||||
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
|
||||
else ComponentsManager.swap(this, protonId, value)
|
||||
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
}.getOrElse { e -> getString(R.string.session_swap_failed, e.message ?: e.javaClass.simpleName) }
|
||||
uiHandler.post {
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshDrawerComponents()
|
||||
@@ -717,20 +736,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
else intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
|
||||
|
||||
private fun pausedTitle(): String = when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> "The desktop is paused"
|
||||
SessionService.MODE_RUN -> (com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program") + " is paused"
|
||||
else -> "Steam is paused"
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.session_desktop_paused)
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
|
||||
?.let { getString(R.string.session_named_paused, it) } ?: getString(R.string.session_program_paused)
|
||||
else -> getString(R.string.load_steam_paused)
|
||||
}
|
||||
|
||||
private fun loadingTitle(): String = when (loadingMode()) {
|
||||
SessionService.MODE_DESKTOP ->
|
||||
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) "Starting Steam on the desktop"
|
||||
else "Starting the desktop"
|
||||
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) getString(R.string.session_starting_steam_desktop)
|
||||
else getString(R.string.session_starting_desktop)
|
||||
SessionService.MODE_RUN -> {
|
||||
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
"Starting " + (com.droiddeck.launcher.frontend.Library.nameForProgram(program) ?: "the program")
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
|
||||
?: getString(R.string.session_starting_program)
|
||||
}
|
||||
else -> "Starting Steam"
|
||||
else -> getString(R.string.load_starting_steam)
|
||||
}
|
||||
|
||||
private fun shareCurrentSessionLogs() {
|
||||
@@ -738,17 +759,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// ended screen's Share logs is for that one.
|
||||
val folder = SessionPaths.current() ?: SessionPaths.lastEnded()
|
||||
if (folder == null || !folder.isDirectory) {
|
||||
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, R.string.session_no_logs, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, R.string.session_logs_archive_failed, Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not share current session logs", it)
|
||||
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -777,6 +798,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
}
|
||||
@@ -805,8 +827,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
"${SessionState.installing}.installing",
|
||||
mapOf("component" to SessionState.installing),
|
||||
)
|
||||
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
|
||||
else "downloading Proton Experimental (ARM64)"
|
||||
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
|
||||
loading.say(getString(first.downloading), first.topic, readable = true)
|
||||
loading.percent = -1
|
||||
Thread({
|
||||
var failedComponent: String? = null
|
||||
@@ -847,7 +869,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return@post
|
||||
}
|
||||
loading.percent = -1
|
||||
loading.step = "Starting the session…"
|
||||
loading.say(getString(R.string.session_starting_session), LoadingState.Topic.SESSION, readable = false)
|
||||
// The surface may have come and gone while the download ran; start on the live one.
|
||||
if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder)
|
||||
}
|
||||
@@ -863,14 +885,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "session-failure-artifacts").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading screen's lines for one package: downloading it (with and without its size), checking it,
|
||||
* unpacking it; the checklist [topic] they belong to, and whether the checking line is shown as it is.
|
||||
*/
|
||||
private class PackageLines(
|
||||
val downloading: Int, val downloadingMb: Int, val checking: Int, val unpacking: Int,
|
||||
val topic: LoadingState.Topic, val checkingReadable: Boolean,
|
||||
)
|
||||
|
||||
/** Reports one package's download on the loading screen: "<what> · 332 of 791 MB", checking, unpacking. */
|
||||
private fun progressFor(what: String, mb: Long) = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p ->
|
||||
uiHandler.post {
|
||||
loading.percent = p
|
||||
loading.step = when {
|
||||
stage.startsWith("Downloading") -> if (p >= 0 && mb > 0) "downloading $what · ${p * mb / 100} of $mb MB" else "downloading $what"
|
||||
stage.startsWith("Verifying") -> "checking $what"
|
||||
else -> "unpacking $what"
|
||||
private fun progressFor(what: PackageLines, mb: Long) = object : com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener {
|
||||
override fun onProgress(stage: String, percent: Int) =
|
||||
onProgress(com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.OTHER, stage, percent)
|
||||
|
||||
override fun onProgress(step: com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
|
||||
uiHandler.post {
|
||||
loading.percent = percent
|
||||
when (step) {
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.DOWNLOADING -> loading.say(
|
||||
if (percent >= 0 && mb > 0) getString(what.downloadingMb, percent * mb / 100, mb) else getString(what.downloading),
|
||||
what.topic, readable = true,
|
||||
)
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.VERIFYING ->
|
||||
loading.say(getString(what.checking), what.topic, what.checkingReadable)
|
||||
else -> loading.say(getString(what.unpacking), what.topic, readable = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -878,29 +918,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Null when the runtime is in, else the loading screen's closing line. */
|
||||
private fun installRuntime(): String? {
|
||||
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
|
||||
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
|
||||
?: return getString(R.string.session_runtime_catalog_unreachable)
|
||||
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
|
||||
progressFor("the Linux runtime", release.size / 1_000_000))
|
||||
return if (ok) null else "The Linux runtime did not install. Check the runtime in Setup and press Play again."
|
||||
progressFor(RUNTIME_LINES, release.size / 1_000_000))
|
||||
return if (ok) null else getString(R.string.session_runtime_install_failed)
|
||||
}
|
||||
|
||||
/** Null when the desktop package is in, else the loading screen's closing line. */
|
||||
private fun installDesktop(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.step = "downloading the desktop" }
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
|
||||
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
|
||||
?: return "Could not reach the desktop catalog. Check the connection and press Desktop again."
|
||||
?: return getString(R.string.session_desktop_catalog_unreachable)
|
||||
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry,
|
||||
progressFor("the desktop", entry.size / 1_000_000))
|
||||
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
|
||||
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
|
||||
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
|
||||
}
|
||||
|
||||
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
|
||||
private fun installProton(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) }
|
||||
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
|
||||
?: return "catalog unreachable"
|
||||
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
|
||||
return catalog.install(this, entry, progressFor(PROTON_LINES, entry.size / 1_000_000))
|
||||
}
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
@@ -1161,14 +1201,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
// It stays until the user leaves: a failure read against a timer is a failure not read.
|
||||
val what = when (SessionState.mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
|
||||
SessionService.MODE_DESKTOP -> "The desktop"
|
||||
else -> "Steam"
|
||||
val stopped = when (SessionState.mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
|
||||
?.let { getString(R.string.session_ended_named, it) } ?: getString(R.string.session_ended_program)
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
|
||||
else -> getString(R.string.session_ended_named, "Steam")
|
||||
}
|
||||
loading.showEnded(
|
||||
hint?.text ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
|
||||
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
|
||||
hint?.text ?: stopped,
|
||||
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
|
||||
)
|
||||
focusEndedScreen()
|
||||
}
|
||||
@@ -1218,8 +1259,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
|
||||
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
|
||||
} else if (enosys >= 8) {
|
||||
EndHint("The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
|
||||
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again.")
|
||||
EndHint(resources.getQuantityString(R.plurals.session_hint_enosys, enosys, enosys,
|
||||
getString(R.string.perf_title), getString(R.string.perf_seccomp), getString(R.string.perf_xalia)))
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
@@ -1446,18 +1487,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun movePointer(x: Float, y: Float) {
|
||||
val width = surfaceView.width.takeIf { it > 0 } ?: return
|
||||
val height = surfaceView.height.takeIf { it > 0 } ?: return
|
||||
val out = SessionState.outputSize
|
||||
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
|
||||
val drawnW = out.first * scale
|
||||
val drawnH = out.second * scale
|
||||
val left = (width - drawnW) / 2f
|
||||
val top = (height - drawnH) / 2f
|
||||
val px = ((x - left) / drawnW * 1920f).toInt().coerceIn(0, 1919)
|
||||
val py = ((y - top) / drawnH * 1080f).toInt().coerceIn(0, 1079)
|
||||
WaylandCompositor.nativeSendPointer(1, px, py)
|
||||
showCursor(x.coerceIn(left, left + drawnW), y.coerceIn(top, top + drawnH))
|
||||
val rect = drawnRect() ?: return
|
||||
val cx = x.coerceIn(rect.left, rect.right)
|
||||
val cy = y.coerceIn(rect.top, rect.bottom)
|
||||
WaylandCompositor.nativeSendPointer(1, InputSpace.x(cx, surfaceView.width), InputSpace.y(cy, surfaceView.height))
|
||||
showCursor(cx, cy)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1604,10 +1638,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
val nx = (x / width).coerceIn(0f, 1f)
|
||||
val ny = (y / height).coerceIn(0f, 1f)
|
||||
WaylandCompositor.nativeSendPointer(1, (nx * 1919f).toInt(), (ny * 1079f).toInt())
|
||||
drawnRect()?.let { rect ->
|
||||
showCursor(rect.left + nx * rect.width(), rect.top + ny * rect.height())
|
||||
}
|
||||
val rect = drawnRect() ?: return
|
||||
val px = rect.left + nx * rect.width()
|
||||
val py = rect.top + ny * rect.height()
|
||||
WaylandCompositor.nativeSendPointer(1, InputSpace.x(px, surfaceView.width), InputSpace.y(py, surfaceView.height))
|
||||
showCursor(px, py)
|
||||
}
|
||||
|
||||
private fun closeSecondScreen(reset: Boolean) {
|
||||
@@ -1654,9 +1689,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
val rect = drawnRect() ?: return false
|
||||
fun sendTouch(action: Int, index: Int) {
|
||||
val x = ((event.getX(index) - rect.left) / rect.width()).coerceIn(0f, 1f)
|
||||
val y = ((event.getY(index) - rect.top) / rect.height()).coerceIn(0f, 1f)
|
||||
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index), (x * 1919f).toInt(), (y * 1079f).toInt())
|
||||
val x = event.getX(index).coerceIn(rect.left, rect.right)
|
||||
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
|
||||
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index),
|
||||
InputSpace.x(x, surfaceView.width), InputSpace.y(y, surfaceView.height))
|
||||
}
|
||||
when (event.actionMasked) {
|
||||
// A first finger starts a new gesture, and Android has ended every earlier one: whatever
|
||||
@@ -2013,6 +2049,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = false)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
@@ -2090,5 +2127,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Compositor scale modes (Container.FULLSCREEN_* values): 1 = fit with bars, centred. */
|
||||
private const val SCALE_FIT = 1
|
||||
private const val ALIGN_CENTER = 0
|
||||
private val RUNTIME_LINES = PackageLines(
|
||||
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
|
||||
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
|
||||
LoadingState.Topic.RUNTIME, checkingReadable = true,
|
||||
)
|
||||
private val DESKTOP_LINES = PackageLines(
|
||||
R.string.session_desktop_downloading, R.string.session_desktop_downloading_mb,
|
||||
R.string.session_desktop_checking, R.string.session_desktop_unpacking,
|
||||
LoadingState.Topic.DESKTOP, checkingReadable = false,
|
||||
)
|
||||
private val PROTON_LINES = PackageLines(
|
||||
R.string.session_proton_downloading, R.string.session_proton_downloading_mb,
|
||||
R.string.session_proton_checking, R.string.session_proton_unpacking,
|
||||
LoadingState.Topic.STEAM, checkingReadable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
onAllowInstalls = {
|
||||
askPermission = false
|
||||
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
|
||||
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
|
||||
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
|
||||
},
|
||||
onDismissPermission = { askPermission = false; pending = null },
|
||||
)
|
||||
@@ -77,7 +77,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
catalog = it
|
||||
follow = AppUpdates.follow(activity, it)
|
||||
error = null
|
||||
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}.onFailure { if (force) error = activity.getString(R.string.upd_couldnt_check, it.message ?: it.javaClass.simpleName) }
|
||||
}
|
||||
}, "app-updates").start()
|
||||
}
|
||||
@@ -90,20 +90,20 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
|
||||
private fun install(r: AppUpdates.Release) {
|
||||
if (stage != null) return
|
||||
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
|
||||
AppUpdates.installBlock(r)?.let { error = it; return }
|
||||
if (SessionState.running) { error = "Stop the running session first, then update"; return }
|
||||
val apk = r.apk ?: run { error = activity.getString(R.string.upd_no_download); return }
|
||||
AppUpdates.installBlock(r)?.let { error = it.message(activity); return }
|
||||
if (SessionState.running) { error = activity.getString(R.string.upd_stop_first); return }
|
||||
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
|
||||
error = null
|
||||
stage = "Downloading"
|
||||
stage = activity.getString(R.string.upd_stage_downloading)
|
||||
percent = 0
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
|
||||
ui.post { stage = "Installing"; percent = -1 }
|
||||
ui.post { stage = activity.getString(R.string.upd_stage_installing); percent = -1 }
|
||||
SelfInstaller.install(activity, file)
|
||||
}
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: activity.getString(R.string.upd_failed) } }
|
||||
}, "app-update-install").start()
|
||||
}
|
||||
|
||||
@@ -114,14 +114,14 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
|
||||
stage = "Waiting for you to confirm"
|
||||
if (confirm == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
|
||||
stage = activity.getString(R.string.upd_stage_confirm)
|
||||
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> stage = null
|
||||
else -> {
|
||||
stage = null
|
||||
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
error = SelfInstaller.describeFailure(activity, status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Who may run code through the agent bridge. Reading state, starting and stopping sessions,
|
||||
* launching and quitting games and sending input only need the shell (the provider's DUMP
|
||||
* permission). Running commands in the guest, evaluating JavaScript in the Steam client, session
|
||||
* environment, binary overrides and preference writes reach inside the app's sandbox - the guest
|
||||
* holds the Steam login - which the shell of a stock device otherwise cannot. They require
|
||||
* Setup > Session > Debugging tools to be on, in every build. The bridge itself cannot turn it on.
|
||||
*/
|
||||
object AgentAccess {
|
||||
const val MAX_REQUEST_CHARS = 256 * 1024
|
||||
private const val PREFS = "agent"
|
||||
private const val KEY_COMMANDS = "commands"
|
||||
|
||||
/** The preferences file the bridge must never write: set-pref refuses it. */
|
||||
const val PROTECTED_PREFS = PREFS
|
||||
|
||||
fun commandsAllowed(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
/** The Setup toggle's own state. */
|
||||
fun commandsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
fun setCommandsEnabled(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_COMMANDS, enabled).apply()
|
||||
if (!enabled) runCatching { AgentEnv.revoke(context) }
|
||||
.onFailure { android.util.Log.w("AgentAccess", "Could not finish clearing debugging experiments") }
|
||||
}
|
||||
|
||||
fun requireCommands(context: Context) {
|
||||
if (!commandsAllowed(context)) throw AgentException(
|
||||
"AGENT_COMMANDS_DISABLED",
|
||||
"Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
|
||||
)
|
||||
}
|
||||
|
||||
fun requireStart(context: Context, request: org.json.JSONObject) {
|
||||
if (request.optString("mode") == "run") requireCommands(context)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** A refusal with a stable code, answered as {"ok": false, "error": {code, message}}. */
|
||||
class AgentException(val code: String, message: String) : RuntimeException(message)
|
||||
@@ -10,13 +10,14 @@ import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.util.Base64
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
@@ -25,7 +26,14 @@ import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** A shell-only control surface, installed only by debug builds. */
|
||||
/**
|
||||
* The shell's control surface (docs/agent-control.md), in every build: the provider and its start
|
||||
* Activity need android.permission.DUMP, held by the shell and privileged or explicitly granted callers. Commands that
|
||||
* reach into the app's sandbox are further gated by [AgentAccess].
|
||||
*
|
||||
* A method's request is JSON, base64-encoded in the "request" extra; every answer is JSON in the
|
||||
* "json" key of the returned Bundle, {"ok": true, ...} or {"ok": false, "error": {code, message}}.
|
||||
*/
|
||||
class AgentBridgeProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
val appContext = context?.applicationContext ?: return false
|
||||
@@ -48,17 +56,53 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
}
|
||||
|
||||
val response = try {
|
||||
when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
val request = decode(extras?.getString(EXTRA_REQUEST))
|
||||
// Binder identity is the shell's; everything below acts as the app.
|
||||
val token = Binder.clearCallingIdentity()
|
||||
try {
|
||||
dispatch(context, method, request)
|
||||
} finally {
|
||||
Binder.restoreCallingIdentity(token)
|
||||
}
|
||||
} catch (e: AgentException) {
|
||||
error(e.code, e.message ?: e.code)
|
||||
} catch (e: Exception) {
|
||||
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, response.toString()) }
|
||||
val json = response.toString().let {
|
||||
if (it.length <= 384 * 1024) it else error("RESPONSE_TOO_LARGE", "Reduce the command output or select fewer fields").toString()
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, json) }
|
||||
}
|
||||
|
||||
private fun dispatch(context: Context, method: String, request: JSONObject): JSONObject = when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
"launch" -> launch(context, request)
|
||||
"focus" -> guest(context, JSONObject().put("kind", "focus"), 15_000)
|
||||
"quit" -> guest(context, JSONObject().put("kind", "quit").put("appId", request.opt("appId"))
|
||||
.put("timeout", request.optDouble("timeout", 15.0)), timeoutMs(request, 15.0, 30_000))
|
||||
"input" -> AgentInput.handle(request)
|
||||
"ui" -> AgentUi.handle(request)
|
||||
"guest" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "exec"), timeoutMs(request, 30.0, 10_000))
|
||||
}
|
||||
"cdp" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "cdp"), timeoutMs(request, 15.0, 5_000))
|
||||
}
|
||||
"env" -> env(context, request)
|
||||
"override" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
|
||||
}
|
||||
"prefs" -> prefs(context, request)
|
||||
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
|
||||
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
@@ -91,13 +135,31 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
|
||||
session.put("failure", failure)
|
||||
|
||||
// gamescope's focus as last published by the guest agent, without the window lists
|
||||
// (the focus command returns those, fresh).
|
||||
val focus = AgentGuest.focus(context)?.let { snap ->
|
||||
JSONObject()
|
||||
.put("focusedApp", snap.opt("focusedApp") ?: JSONObject.NULL)
|
||||
.put("focusedWindow", snap.opt("focusedWindow") ?: JSONObject.NULL)
|
||||
.put("focusableApps", snap.optJSONArray("focusableApps") ?: JSONArray())
|
||||
.put("baselayerAppIds", snap.optJSONArray("baselayerAppIds") ?: JSONArray())
|
||||
.put("seq", snap.optInt("seq"))
|
||||
.put("t", snap.optLong("t"))
|
||||
}
|
||||
session.put("focus", focus ?: JSONObject.NULL)
|
||||
|
||||
val hello = AgentGuest.hello(context)
|
||||
return JSONObject()
|
||||
.put("schema", 1)
|
||||
.put("schema", SCHEMA)
|
||||
.put("build", BuildConfig.BUILD_LABEL)
|
||||
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
|
||||
.put("runtime", JSONObject()
|
||||
.put("installed", LinuxRuntime.isInstalled(context))
|
||||
.put("version", runtimeVersion ?: JSONObject.NULL))
|
||||
.put("agent", JSONObject()
|
||||
.put("guest", hello != null)
|
||||
.put("guestFocus", hello?.optBoolean("focus") ?: false)
|
||||
.put("commands", AgentAccess.commandsAllowed(context)))
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
@@ -131,6 +193,67 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
return JSONObject().put("ok", true).put("command", "resume")
|
||||
}
|
||||
|
||||
private fun launch(context: Context, request: JSONObject): JSONObject {
|
||||
val appId = request.optString("appId")
|
||||
if (!GameLaunchLink.validId(appId)) throw AgentException("INVALID_APP_ID", "appId must be a decimal Steam app id")
|
||||
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) {
|
||||
throw AgentException("NO_STEAM_SESSION", "Games launch inside a running Steam session")
|
||||
}
|
||||
if (!SessionService.launchGame(context, appId)) throw AgentException("LAUNCH_REJECTED", "The session did not accept the launch")
|
||||
SessionEvents.record("agent.launch_requested", mapOf("appId" to appId))
|
||||
return JSONObject().put("ok", true).put("command", "launch").put("appId", appId)
|
||||
}
|
||||
|
||||
private fun env(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val result = when (val op = request.optString("op", "list")) {
|
||||
"list" -> AgentEnv.list(context)
|
||||
"set" -> {
|
||||
val lines = request.optJSONArray("lines") ?: JSONArray()
|
||||
AgentEnv.set(context, List(lines.length()) { lines.optString(it) }, request.optBoolean("persistent"))
|
||||
}
|
||||
"clear" -> AgentEnv.clear(context, request.optString("scope", "next"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "env op '$op' is not list, set or clear")
|
||||
}
|
||||
return result.put("ok", true).put("command", "env")
|
||||
}
|
||||
|
||||
private fun prefs(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val file = request.optString("file")
|
||||
val result = when (val op = request.optString("op", "get")) {
|
||||
"get" -> AgentPrefs.get(context, file)
|
||||
"set" -> AgentPrefs.set(context, file, request.optString("key"), request.optString("type", "string"),
|
||||
if (request.isNull("value")) null else request.optString("value"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "prefs op '$op' is not get or set")
|
||||
}
|
||||
return result.put("ok", true).put("command", "prefs")
|
||||
}
|
||||
|
||||
/** A guest agent request; its own answer already carries ok or error. */
|
||||
private fun guest(context: Context, request: JSONObject, timeoutMs: Long): JSONObject =
|
||||
AgentGuest.call(context, request, timeoutMs)
|
||||
|
||||
/** The guest's own timeout plus slack for the round trip. */
|
||||
private fun timeoutMs(request: JSONObject, defaultSeconds: Double, slackMs: Long): Long {
|
||||
val seconds = if (request.has("timeout")) request.opt("timeout")?.toString()?.toDoubleOrNull()
|
||||
?: throw AgentException("INVALID_REQUEST", "timeout must be a number") else defaultSeconds
|
||||
if (!seconds.isFinite() || seconds <= 0 || seconds > 600) {
|
||||
throw AgentException("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
|
||||
}
|
||||
return (seconds * 1000).toLong() + slackMs
|
||||
}
|
||||
|
||||
private fun decode(encoded: String?): JSONObject {
|
||||
if (encoded.isNullOrBlank()) return JSONObject()
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) throw AgentException("INVALID_REQUEST", "The encoded request exceeds 256 KiB")
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
throw AgentException("INVALID_REQUEST", "The request extra is not base64 JSON")
|
||||
}
|
||||
}
|
||||
|
||||
private fun error(code: String, message: String) = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("error", JSONObject().put("code", code).put("message", message))
|
||||
@@ -143,6 +266,9 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
|
||||
companion object {
|
||||
const val RESULT_JSON = "json"
|
||||
const val EXTRA_REQUEST = "request"
|
||||
/** 2 added agent and session.focus; everything in 1 is unchanged. */
|
||||
const val SCHEMA = 2
|
||||
private val recoveryComplete = CountDownLatch(1)
|
||||
|
||||
fun awaitRecovery() {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
|
||||
/**
|
||||
* Experiment switches an agent sets without touching Download/droiddeck-env or rebuilding the apk:
|
||||
*
|
||||
* - environment lines (KEY=VALUE) for the session, applied after the user's own droiddeck-env, so
|
||||
* they win. "next" lines are taken by the next session that starts and dropped when it ends;
|
||||
* "persistent" ones stay until cleared.
|
||||
* - binary overrides: a file pushed to the app's external files (agent-inbox/<name>) is installed
|
||||
* as /opt/droiddeck-agent/bin/<name> in the rootfs and put first on the next session's PATH -
|
||||
* a gamescope build under test, say. Removed when that session ends.
|
||||
*
|
||||
* Kept in the app's own files (agent-env.json), never in a place the shell can write.
|
||||
*/
|
||||
object AgentEnv {
|
||||
private const val TAG = "AgentEnv"
|
||||
private const val FILE = "agent-env.json"
|
||||
private const val GUEST_BIN = "/opt/droiddeck-agent/bin"
|
||||
private const val BASE_PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||
private val NAME = Regex("[A-Za-z0-9._-]{1,64}")
|
||||
private val KEY = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val lock = Any()
|
||||
|
||||
fun inbox(context: Context): File = File(context.getExternalFilesDir(null), "agent-inbox")
|
||||
|
||||
fun list(context: Context): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
load(context)
|
||||
}
|
||||
|
||||
/** Add [lines] for the next session ([persistent] = every session until cleared). */
|
||||
fun set(context: Context, lines: List<String>, persistent: Boolean): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (lines.any { line -> KEY.matchEntire(line.substringBefore('=', "")) == null || !line.contains('=') || line.any { it == '\n' || it == '\r' || it == '\u0000' } }) {
|
||||
throw AgentException("INVALID_ENV", "Environment entries must be single KEY=VALUE lines without NUL bytes")
|
||||
}
|
||||
val state = load(context)
|
||||
val key = if (persistent) "persistent" else "pending"
|
||||
val merged = merge(strings(state.optJSONArray(key)), lines)
|
||||
state.put(key, JSONArray(merged))
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Drop lines: [scope] is "next", "persistent" or "all". */
|
||||
fun clear(context: Context, scope: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
val state = load(context)
|
||||
when (scope) {
|
||||
"next" -> state.put("pending", JSONArray())
|
||||
"persistent" -> state.put("persistent", JSONArray())
|
||||
"all" -> { state.put("pending", JSONArray()); state.put("persistent", JSONArray()); state.put("pendingOverrides", JSONArray()) }
|
||||
else -> throw AgentException("INVALID_SCOPE", "Scope must be next, persistent or all")
|
||||
}
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Install agent-inbox/[name] as an override for the next session. */
|
||||
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
|
||||
val source = File(inbox(context), name)
|
||||
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
|
||||
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
|
||||
val target = File(bin(context), name)
|
||||
target.parentFile?.mkdirs()
|
||||
val staged = File.createTempFile("override-", ".tmp", target.parentFile)
|
||||
try {
|
||||
Files.newInputStream(source.toPath(), READ, NOFOLLOW_LINKS).use { input -> staged.outputStream().use { input.copyTo(it) } }
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
|
||||
}
|
||||
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
|
||||
}
|
||||
source.delete()
|
||||
val state = load(context)
|
||||
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
|
||||
save(context, state)
|
||||
state.put("installed", "$GUEST_BIN/$name")
|
||||
}
|
||||
|
||||
/**
|
||||
* At session start: the lines this session gets, after the user's droiddeck-env. Pending lines
|
||||
* and overrides become this session's own, to be dropped by [endSession].
|
||||
*/
|
||||
fun beginSession(context: Context): List<String> = synchronized(lock) {
|
||||
if (!AgentAccess.commandsAllowed(context)) {
|
||||
revoke(context)
|
||||
return@synchronized emptyList()
|
||||
}
|
||||
val state = load(context)
|
||||
val active = strings(state.optJSONArray("pending"))
|
||||
val overrides = strings(state.optJSONArray("pendingOverrides"))
|
||||
state.put("active", JSONArray(active)).put("pending", JSONArray())
|
||||
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
|
||||
save(context, state)
|
||||
// Anything else there was left by a session that never reached endSession (the app died).
|
||||
bin(context).listFiles()
|
||||
?.filter { it.name !in overrides }?.forEach { it.delete() }
|
||||
val lines = strings(state.optJSONArray("persistent")) + active
|
||||
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
|
||||
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
|
||||
withPath
|
||||
}
|
||||
|
||||
fun revoke(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) state.put(key, JSONArray())
|
||||
save(context, state)
|
||||
bin(context).listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** At session end: this session's lines and override binaries go. */
|
||||
fun endSession(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
val overrides = strings(state.optJSONArray("activeOverrides"))
|
||||
val pending = strings(state.optJSONArray("pendingOverrides"))
|
||||
val bin = bin(context)
|
||||
overrides.filter { it !in pending }.forEach { File(bin, it).delete() }
|
||||
state.put("active", JSONArray()).put("activeOverrides", JSONArray())
|
||||
save(context, state)
|
||||
}
|
||||
|
||||
/** A PATH= line among [lines] (the user's or an agent's) is extended rather than replaced. */
|
||||
private fun pathOf(lines: List<String>) =
|
||||
lines.lastOrNull { it.startsWith("PATH=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } ?: BASE_PATH
|
||||
|
||||
private fun bin(context: Context): File {
|
||||
val root = LinuxRuntime.rootDir(context).canonicalFile.toPath()
|
||||
val directory = root.resolve(GUEST_BIN.removePrefix("/"))
|
||||
var path = directory
|
||||
while (path != root) {
|
||||
if (Files.isSymbolicLink(path)) throw AgentException("OVERRIDE_FAILED", "The override directory must not contain symbolic links")
|
||||
path = path.parent
|
||||
}
|
||||
return directory.toFile()
|
||||
}
|
||||
|
||||
private fun merge(existing: List<String>, added: List<String>): List<String> {
|
||||
val keys = added.map { it.substringBefore('=') }.toSet()
|
||||
return existing.filter { it.substringBefore('=') !in keys } + added
|
||||
}
|
||||
|
||||
private fun strings(array: JSONArray?): List<String> =
|
||||
if (array == null) emptyList() else List(array.length()) { array.optString(it) }.filter { it.isNotEmpty() }
|
||||
|
||||
private fun load(context: Context): JSONObject {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val state = try { if (file.isFile) JSONObject(file.readText()) else JSONObject() } catch (e: Exception) { JSONObject() }
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) {
|
||||
if (!state.has(key)) state.put(key, JSONArray())
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun save(context: Context, state: JSONObject) {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val staged = File(context.filesDir, "$FILE.tmp")
|
||||
staged.writeText(state.toString())
|
||||
if (!staged.renameTo(file)) {
|
||||
staged.delete()
|
||||
throw AgentException("ENV_WRITE_FAILED", "Could not save the debugging environment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed writes to the app's SharedPreferences, for driving a settings matrix without the UI. */
|
||||
object AgentPrefs {
|
||||
fun set(context: Context, file: String, key: String, type: String, value: String?): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
if (file == AgentAccess.PROTECTED_PREFS) throw AgentException("PROTECTED_PREFS", "The agent preferences are set on the device only")
|
||||
if (file.isBlank() || key.isBlank()) throw AgentException("INVALID_PREF", "file and key are required")
|
||||
val prefs = context.getSharedPreferences(file, Context.MODE_PRIVATE)
|
||||
val previous = prefs.all[key]
|
||||
val editor = prefs.edit()
|
||||
try {
|
||||
when (type) {
|
||||
"remove" -> editor.remove(key)
|
||||
"bool" -> editor.putBoolean(key, value?.toBooleanStrictOrNull() ?: throw NumberFormatException("not true/false"))
|
||||
"int" -> editor.putInt(key, value!!.toInt())
|
||||
"long" -> editor.putLong(key, value!!.toLong())
|
||||
"float" -> editor.putFloat(key, value!!.toFloat())
|
||||
"string" -> editor.putString(key, value ?: "")
|
||||
else -> throw AgentException("INVALID_PREF", "type must be bool, int, long, float, string or remove")
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
if (e is AgentException) throw e
|
||||
throw AgentException("INVALID_PREF", "The value is not a $type")
|
||||
}
|
||||
if (!editor.commit()) throw AgentException("PREF_WRITE_FAILED", "Could not write $file/$key")
|
||||
return JSONObject().put("file", file).put("key", key).put("type", type)
|
||||
.put("previous", previous ?: JSONObject.NULL).put("value", prefs.all[key] ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
fun get(context: Context, file: String): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
val all = context.getSharedPreferences(file, Context.MODE_PRIVATE).all
|
||||
return JSONObject().put("file", file).put("values", JSONObject(all.mapValues { it.value ?: JSONObject.NULL }))
|
||||
}
|
||||
|
||||
private fun validateFile(file: String) {
|
||||
if (!Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}").matches(file)) {
|
||||
throw AgentException("INVALID_PREF", "Preference files must be simple names")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.FileObserver
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The app's side of the guest agent (tools/linuxfs/overlay/usr/local/bin/droiddeck-agent): requests
|
||||
* go in as files under the session root's agent directory and answers come back the same way, so
|
||||
* whatever runs does so inside the live session with its own environment.
|
||||
*/
|
||||
object AgentGuest {
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
private val lock = Any()
|
||||
private var observer: FileObserver? = null
|
||||
private var lastFocusKey: String? = null
|
||||
|
||||
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "agent")
|
||||
|
||||
/**
|
||||
* Before each session: nothing of the last one's daemon, answers or focus may be read as
|
||||
* current. Then watch for the new daemon's hello and focus snapshots, which go into the
|
||||
* session's events.jsonl.
|
||||
*/
|
||||
fun reset(context: Context) {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@Suppress("DEPRECATION")
|
||||
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
when (path) {
|
||||
"hello.json" -> readJson(File(dir, path))?.let {
|
||||
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
|
||||
}
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
}
|
||||
}
|
||||
}.also { it.startWatching() }
|
||||
}
|
||||
}
|
||||
|
||||
/** At the end of a session: no more events for a folder that is being closed. */
|
||||
fun stop() {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordFocus(snapshot: JSONObject) {
|
||||
val fields = linkedMapOf<String, Any?>(
|
||||
"focusedApp" to snapshot.opt("focusedApp"),
|
||||
"focusableApps" to snapshot.optJSONArray("focusableApps")?.toString(),
|
||||
"baselayerAppIds" to snapshot.optJSONArray("baselayerAppIds")?.toString(),
|
||||
)
|
||||
val key = fields.values.joinToString("|")
|
||||
synchronized(lock) {
|
||||
if (key == lastFocusKey) return
|
||||
lastFocusKey = key
|
||||
}
|
||||
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
/** The daemon's hello, when it is running in the current session. */
|
||||
fun hello(context: Context): JSONObject? {
|
||||
if (!SessionState.running) return null
|
||||
val hello = readJson(File(dir(context), "hello.json")) ?: return null
|
||||
val pid = hello.optInt("pid", -1)
|
||||
return hello.takeIf { pid > 1 && File("/proc/$pid").exists() }
|
||||
}
|
||||
|
||||
/** The newest focus snapshot the daemon published, when a session is running. */
|
||||
fun focus(context: Context): JSONObject? =
|
||||
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
|
||||
|
||||
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
|
||||
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
|
||||
val dir = dir(context)
|
||||
val requests = File(dir, "req").apply { mkdirs() }
|
||||
val answers = File(dir, "resp").apply { mkdirs() }
|
||||
val id = UUID.randomUUID().toString()
|
||||
val staged = File(requests, "$id.json.tmp")
|
||||
val published = File(requests, "$id.json")
|
||||
val answer = File(answers, "$id.json")
|
||||
staged.writeText(request.toString())
|
||||
if (!staged.renameTo(published)) {
|
||||
staged.delete()
|
||||
throw AgentException("AGENT_UNAVAILABLE", "Could not hand the request to the guest agent")
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (answer.isFile) {
|
||||
if (answer.length() > 1024 * 1024) {
|
||||
answer.delete()
|
||||
throw AgentException("RESPONSE_TOO_LARGE", "The guest response exceeds 1 MiB")
|
||||
}
|
||||
val result = readJson(answer)
|
||||
answer.delete()
|
||||
return result ?: throw AgentException("INVALID_AGENT_RESPONSE", "The guest agent's answer was not JSON")
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
published.delete()
|
||||
Log.w(TAG, "no answer to ${request.optString("kind")} in ${timeoutMs}ms")
|
||||
throw AgentException("TIMEOUT", "The guest agent did not answer within ${timeoutMs / 1000}s")
|
||||
}
|
||||
|
||||
private fun readJson(file: File): JSONObject? = try {
|
||||
if (file.isFile && file.length() <= 1024 * 1024) JSONObject(file.readText()) else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Input into the running session the way a player gives it: keys and touches through the
|
||||
* compositor (what the session screen sends), and the virtual pad through the same PadBridge the
|
||||
* on-screen controls drive, so games that ignore touch still see a controller.
|
||||
*/
|
||||
object AgentInput {
|
||||
/** Out of the way of real fingers, which Android numbers from 0. */
|
||||
private const val TOUCH_ID = 9
|
||||
private const val TOUCH_SCALE_X = 1919f
|
||||
private const val TOUCH_SCALE_Y = 1079f
|
||||
|
||||
fun handle(request: JSONObject): JSONObject {
|
||||
if (!SessionState.running || !SessionState.firstFrameSeen) {
|
||||
throw AgentException("SESSION_NOT_READY", "Input needs a running session that has drawn its first frame")
|
||||
}
|
||||
return when (val type = request.optString("type")) {
|
||||
"key" -> key(request)
|
||||
"text" -> text(request)
|
||||
"tap" -> tap(request)
|
||||
"swipe" -> swipe(request)
|
||||
"pad" -> pad(request)
|
||||
"stick" -> stick(request)
|
||||
"trigger" -> trigger(request)
|
||||
else -> throw AgentException("INVALID_INPUT", "Unknown input type '$type'")
|
||||
}.put("ok", true).put("command", "input").put("type", request.optString("type"))
|
||||
}
|
||||
|
||||
private fun key(request: JSONObject): JSONObject {
|
||||
val name = request.optString("key")
|
||||
val code = KEYS[name.uppercase()] ?: name.toIntOrNull()
|
||||
?: throw AgentException("INVALID_INPUT", "Unknown key '$name' (a name such as ENTER or an evdev code)")
|
||||
val modifiers = request.optJSONArray("modifiers")?.let { array ->
|
||||
List(array.length()) { i ->
|
||||
KEYS[array.optString(i).uppercase()] ?: throw AgentException("INVALID_INPUT", "Unknown modifier '${array.optString(i)}'")
|
||||
}
|
||||
}.orEmpty()
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> WaylandCompositor.nativeSendKey(code, 1)
|
||||
"up" -> WaylandCompositor.nativeSendKey(code, 0)
|
||||
"tap" -> {
|
||||
modifiers.forEach { WaylandCompositor.nativeSendKey(it, 1) }
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
hold(request, 40)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
modifiers.asReversed().forEach { WaylandCompositor.nativeSendKey(it, 0) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Key action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("code", code)
|
||||
}
|
||||
|
||||
/** Typed as key presses (US layout), which reaches X programs that have no text-input support. */
|
||||
private fun text(request: JSONObject): JSONObject {
|
||||
val text = request.optString("text")
|
||||
if (request.optBoolean("commit")) {
|
||||
WaylandCompositor.textInputCommit(text)
|
||||
return JSONObject().put("length", text.length).put("via", "text-input")
|
||||
}
|
||||
val missing = text.filter { TYPED[it] == null }
|
||||
if (missing.isNotEmpty()) throw AgentException("INVALID_INPUT", "Cannot type '${missing.toSet().joinToString("")}' as keys; use commit")
|
||||
val delay = request.optLong("delayMs", 15)
|
||||
for (ch in text) {
|
||||
val (code, shift) = TYPED.getValue(ch)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 1)
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 0)
|
||||
Thread.sleep(delay)
|
||||
}
|
||||
return JSONObject().put("length", text.length).put("via", "keys")
|
||||
}
|
||||
|
||||
private fun tap(request: JSONObject): JSONObject {
|
||||
val (x, y) = point(request, "x", "y")
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x, y)
|
||||
hold(request, 60)
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x, y)
|
||||
return JSONObject().put("touch", org.json.JSONArray().put(x).put(y))
|
||||
}
|
||||
|
||||
private fun swipe(request: JSONObject): JSONObject {
|
||||
val (x1, y1) = point(request, "x", "y")
|
||||
val (x2, y2) = point(request, "x2", "y2")
|
||||
val duration = request.optLong("durationMs", 300).coerceIn(16, 10_000)
|
||||
val steps = (duration / 16).toInt().coerceAtLeast(2)
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x1, y1)
|
||||
for (i in 1..steps) {
|
||||
Thread.sleep(duration / steps)
|
||||
WaylandCompositor.nativeSendTouch(1, TOUCH_ID, x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
|
||||
}
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x2, y2)
|
||||
return JSONObject().put("from", org.json.JSONArray().put(x1).put(y1)).put("to", org.json.JSONArray().put(x2).put(y2))
|
||||
}
|
||||
|
||||
/**
|
||||
* A point in the compositor's touch space (0..1919 x 0..1079 whatever the session's size), from
|
||||
* the session's output pixels (space "output", the default: what the guest draws at) or 0..1
|
||||
* fractions (space "fraction").
|
||||
*/
|
||||
private fun point(request: JSONObject, xKey: String, yKey: String): Pair<Int, Int> {
|
||||
if (!request.has(xKey) || !request.has(yKey)) throw AgentException("INVALID_INPUT", "$xKey and $yKey are required")
|
||||
val x = request.getDouble(xKey)
|
||||
val y = request.getDouble(yKey)
|
||||
val (fx, fy) = when (val space = request.optString("space", "output")) {
|
||||
"fraction" -> x to y
|
||||
"output" -> {
|
||||
val (w, h) = SessionState.outputSize
|
||||
x / (w - 1).coerceAtLeast(1) to y / (h - 1).coerceAtLeast(1)
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "space '$space' is not output or fraction")
|
||||
}
|
||||
return (fx.coerceIn(0.0, 1.0) * TOUCH_SCALE_X).toInt() to (fy.coerceIn(0.0, 1.0) * TOUCH_SCALE_Y).toInt()
|
||||
}
|
||||
|
||||
private fun pad(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val name = request.optString("button").lowercase()
|
||||
if (name == "qam") {
|
||||
bridge.triggerQam()
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
val button = BUTTONS[name]
|
||||
if (button == null && name !in DPAD) throw AgentException("INVALID_INPUT", "Unknown pad button '$name'")
|
||||
val apply = { s: PadState, down: Boolean ->
|
||||
when (name) {
|
||||
"up" -> s.up = down
|
||||
"down" -> s.down = down
|
||||
"left" -> s.left = down
|
||||
"right" -> s.right = down
|
||||
else -> s.press(button!!, down)
|
||||
}
|
||||
}
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> bridge.applyTouch { apply(it, true) }
|
||||
"up" -> bridge.applyTouch { apply(it, false) }
|
||||
"tap" -> {
|
||||
bridge.applyTouch { apply(it, true) }
|
||||
hold(request, 120)
|
||||
bridge.applyTouch { apply(it, false) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Pad action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
|
||||
private fun stick(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("stick", "left") == "right"
|
||||
val x = request.optDouble("x", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
val y = request.optDouble("y", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
bridge.applyTouch { if (right) { it.rightX = x; it.rightY = y } else { it.leftX = x; it.leftY = y } }
|
||||
hold(request, 300)
|
||||
bridge.applyTouch { if (right) { it.rightX = 0f; it.rightY = 0f } else { it.leftX = 0f; it.leftY = 0f } }
|
||||
return JSONObject().put("stick", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun trigger(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("trigger", "right") == "right"
|
||||
val value = request.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = value else it.leftTrigger = value }
|
||||
hold(request, 200)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = 0f else it.leftTrigger = 0f }
|
||||
return JSONObject().put("trigger", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun hold(request: JSONObject, defaultMs: Long) =
|
||||
Thread.sleep(request.optLong("holdMs", defaultMs).coerceIn(0, 10_000))
|
||||
|
||||
private val DPAD = setOf("up", "down", "left", "right")
|
||||
|
||||
private val BUTTONS = mapOf(
|
||||
"a" to PadState.A, "b" to PadState.B, "x" to PadState.X, "y" to PadState.Y,
|
||||
"lb" to PadState.LB, "rb" to PadState.RB, "select" to PadState.SELECT, "back" to PadState.SELECT,
|
||||
"start" to PadState.START, "menu" to PadState.START, "l3" to PadState.L3, "r3" to PadState.R3,
|
||||
"guide" to PadState.GUIDE, "steam" to PadState.GUIDE,
|
||||
)
|
||||
|
||||
/** Linux evdev codes (input-event-codes.h) by name. */
|
||||
private val KEYS: Map<String, Int> = buildMap {
|
||||
"1234567890".forEachIndexed { i, c -> put(c.toString(), 2 + i) }
|
||||
"QWERTYUIOP".forEachIndexed { i, c -> put(c.toString(), 16 + i) }
|
||||
"ASDFGHJKL".forEachIndexed { i, c -> put(c.toString(), 30 + i) }
|
||||
"ZXCVBNM".forEachIndexed { i, c -> put(c.toString(), 44 + i) }
|
||||
(1..10).forEach { put("F$it", 58 + it) }
|
||||
put("F11", 87); put("F12", 88)
|
||||
putAll(mapOf(
|
||||
"ESC" to 1, "MINUS" to 12, "EQUAL" to 13, "BACKSPACE" to 14, "TAB" to 15, "LEFTBRACE" to 26,
|
||||
"RIGHTBRACE" to 27, "ENTER" to 28, "LEFTCTRL" to 29, "SEMICOLON" to 39, "APOSTROPHE" to 40,
|
||||
"GRAVE" to 41, "LEFTSHIFT" to 42, "BACKSLASH" to 43, "COMMA" to 51, "DOT" to 52, "SLASH" to 53,
|
||||
"RIGHTSHIFT" to 54, "LEFTALT" to 56, "SPACE" to 57, "CAPSLOCK" to 58, "RIGHTCTRL" to 97,
|
||||
"RIGHTALT" to 100, "HOME" to 102, "UP" to 103, "PAGEUP" to 104, "LEFT" to 105, "RIGHT" to 106,
|
||||
"END" to 107, "DOWN" to 108, "PAGEDOWN" to 109, "INSERT" to 110, "DELETE" to 111, "LEFTMETA" to 125,
|
||||
"CTRL" to 29, "SHIFT" to 42, "ALT" to 56, "META" to 125, "SUPER" to 125, "RETURN" to 28, "ESCAPE" to 1,
|
||||
))
|
||||
}
|
||||
|
||||
/** US-layout characters as (evdev code, shift). */
|
||||
private val TYPED: Map<Char, Pair<Int, Boolean>> = buildMap {
|
||||
for (c in 'a'..'z') put(c, KEYS.getValue(c.uppercase()) to false)
|
||||
for (c in 'A'..'Z') put(c, KEYS.getValue(c.toString()) to true)
|
||||
for (c in '0'..'9') put(c, KEYS.getValue(c.toString()) to false)
|
||||
"!@#$%^&*()".forEachIndexed { i, c -> put(c, KEYS.getValue(((i + 1) % 10).toString()) to true) }
|
||||
putAll(mapOf(
|
||||
' ' to (57 to false), '\n' to (28 to false), '\t' to (15 to false),
|
||||
'-' to (12 to false), '_' to (12 to true), '=' to (13 to false), '+' to (13 to true),
|
||||
'[' to (26 to false), '{' to (26 to true), ']' to (27 to false), '}' to (27 to true),
|
||||
';' to (39 to false), ':' to (39 to true), '\'' to (40 to false), '"' to (40 to true),
|
||||
'`' to (41 to false), '~' to (41 to true), '\\' to (43 to false), '|' to (43 to true),
|
||||
',' to (51 to false), '<' to (51 to true), '.' to (52 to false), '>' to (52 to true),
|
||||
'/' to (53 to false), '?' to (53 to true),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ class AgentStartActivity : Activity() {
|
||||
Log.e(TAG, "missing agent start request")
|
||||
return null
|
||||
}
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) {
|
||||
Log.w(TAG, "agent start request is too large")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
@@ -47,6 +51,13 @@ class AgentStartActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun startSession(request: JSONObject) {
|
||||
try {
|
||||
AgentAccess.requireStart(this, request)
|
||||
} catch (e: AgentException) {
|
||||
Log.w(TAG, e.code)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val requestedMode = request.optString("mode")
|
||||
val sessionMode = when (requestedMode) {
|
||||
"steam" -> SessionService.MODE_STEAM
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inspector.WindowInspector
|
||||
import androidx.compose.ui.platform.ViewRootForTest
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsConfiguration
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The app's own UI as an agent sees it: every Compose node in every window of the process (popups
|
||||
* and menus included), read from Compose's semantics rather than through UI Automator, which on a
|
||||
* dual-screen device dumps whichever window has focus - often not this app's. Nodes carry their
|
||||
* test tag (ui/TestTags.kt), text, content description, role, state and on-screen bounds; click
|
||||
* and set-text act on a node by tag or text, so a test needs no coordinates.
|
||||
*/
|
||||
object AgentUi {
|
||||
private const val TIMEOUT_MS = 5_000L
|
||||
/** Setup's Debugging tools toggle: the bridge never acts on its own gate (AgentAccess). */
|
||||
private const val GATE_TAG = "setting-agent-commands"
|
||||
|
||||
fun handle(request: JSONObject): JSONObject = when (val op = request.optString("op", "dump")) {
|
||||
"dump" -> onMain { dump(request.optBoolean("all")) }
|
||||
"click" -> onMain { act(request) { node -> perform(node, SemanticsActions.OnClick) } }
|
||||
"set-text" -> onMain {
|
||||
val text = request.optString("text")
|
||||
act(request) { node ->
|
||||
node.config.getOrNull(SemanticsActions.SetText)?.action?.invoke(AnnotatedString(text))
|
||||
?: throw AgentException("NOT_EDITABLE", "That node takes no text")
|
||||
}
|
||||
}
|
||||
else -> throw AgentException("INVALID_REQUEST", "ui op '$op' is not dump, click or set-text")
|
||||
}.put("ok", true).put("command", "ui")
|
||||
|
||||
private fun roots(): List<Pair<View, ViewRootForTest>> {
|
||||
if (Build.VERSION.SDK_INT < 29) throw AgentException("UNSUPPORTED", "The UI dump needs Android 10 or later")
|
||||
val found = mutableListOf<Pair<View, ViewRootForTest>>()
|
||||
fun walk(view: View) {
|
||||
if (view is ViewRootForTest) found += view to view
|
||||
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||
}
|
||||
WindowInspector.getGlobalWindowViews().filter { it.isAttachedToWindow && it.isShown }.forEach(::walk)
|
||||
return found
|
||||
}
|
||||
|
||||
private fun dump(all: Boolean): JSONObject {
|
||||
val windows = JSONArray()
|
||||
for ((view, root) in roots()) {
|
||||
val nodes = JSONArray()
|
||||
val location = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val json = describe(node, location)
|
||||
if (all || json.has("tag") || json.has("text") || json.has("description") || json.optBoolean("clickable")) nodes.put(json)
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
windows.put(JSONObject()
|
||||
.put("display", view.display?.displayId ?: JSONObject.NULL)
|
||||
.put("window", view.rootView.javaClass.simpleName)
|
||||
.put("nodes", nodes))
|
||||
}
|
||||
return JSONObject().put("windows", windows)
|
||||
}
|
||||
|
||||
private fun describe(node: SemanticsNode, origin: IntArray): JSONObject {
|
||||
val config = node.config
|
||||
val json = JSONObject().put("id", node.id)
|
||||
config.getOrNull(SemanticsProperties.TestTag)?.let { json.put("tag", it) }
|
||||
val text = textFields(config)
|
||||
text.keys().forEach { json.put(it, text.get(it)) }
|
||||
config.getOrNull(SemanticsProperties.Role)?.let { json.put("role", it.toString()) }
|
||||
config.getOrNull(SemanticsProperties.ToggleableState)?.let { json.put("toggle", it.name) }
|
||||
config.getOrNull(SemanticsProperties.Selected)?.let { json.put("selected", it) }
|
||||
config.getOrNull(SemanticsProperties.Focused)?.let { json.put("focused", it) }
|
||||
if (config.contains(SemanticsProperties.Disabled)) json.put("disabled", true)
|
||||
if (config.contains(SemanticsActions.OnClick)) json.put("clickable", true)
|
||||
if (config.contains(SemanticsActions.SetText)) json.put("editable", true)
|
||||
val b = node.boundsInWindow
|
||||
json.put("bounds", JSONArray()
|
||||
.put(origin[0] + b.left.toInt()).put(origin[1] + b.top.toInt())
|
||||
.put(origin[0] + b.right.toInt()).put(origin[1] + b.bottom.toInt()))
|
||||
return json
|
||||
}
|
||||
|
||||
internal fun textFields(config: SemanticsConfiguration): JSONObject {
|
||||
val password = config.contains(SemanticsProperties.Password)
|
||||
val json = JSONObject()
|
||||
for ((key, value) in listOf(
|
||||
"text" to config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text },
|
||||
"editableText" to config.getOrNull(SemanticsProperties.EditableText)?.text,
|
||||
"description" to config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" "),
|
||||
"state" to config.getOrNull(SemanticsProperties.StateDescription),
|
||||
)) value?.let { json.put(key, if (password) "<redacted>" else it) }
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a request names by "tag" (exact) or "match" (text or description containing it,
|
||||
* ignoring case); "index" picks among several, in tree order.
|
||||
*/
|
||||
private fun act(request: JSONObject, action: (SemanticsNode) -> Unit): JSONObject {
|
||||
val tag = request.optString("tag").takeIf { it.isNotEmpty() }
|
||||
val text = if (tag == null) request.optString("match").takeIf { it.isNotEmpty() } else null
|
||||
if (tag == null && text == null) throw AgentException("INVALID_REQUEST", "Name the node by tag or match")
|
||||
val matches = mutableListOf<Pair<SemanticsNode, IntArray>>()
|
||||
for ((view, root) in roots()) {
|
||||
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val config = node.config
|
||||
val hit = if (tag != null) config.getOrNull(SemanticsProperties.TestTag) == tag
|
||||
else (config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
|
||||
?: config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ") ?: "")
|
||||
.contains(text!!, ignoreCase = true)
|
||||
if (hit) matches += node to origin
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
}
|
||||
val index = request.optInt("index", 0)
|
||||
val (node, origin) = matches.getOrNull(index)
|
||||
?: throw AgentException("NO_SUCH_NODE", "No node with ${if (tag != null) "tag '$tag'" else "text '$text'"} (${matches.size} found)")
|
||||
if (generateSequence(node) { it.parent }.any { it.config.getOrNull(SemanticsProperties.TestTag) == GATE_TAG }) {
|
||||
throw AgentException("PROTECTED_CONTROL", "Debugging tools are switched on the device only")
|
||||
}
|
||||
action(node)
|
||||
return JSONObject().put("matches", matches.size).put("node", describe(node, origin))
|
||||
}
|
||||
|
||||
private fun perform(node: SemanticsNode, key: androidx.compose.ui.semantics.SemanticsPropertyKey<androidx.compose.ui.semantics.AccessibilityAction<() -> Boolean>>) {
|
||||
val action = node.config.getOrNull(key)?.action ?: throw AgentException("NOT_CLICKABLE", "That node has no click action")
|
||||
action()
|
||||
}
|
||||
|
||||
private fun <T> onMain(block: () -> T): T {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) return block()
|
||||
val result = AtomicReference<Result<T>>()
|
||||
val done = CountDownLatch(1)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
result.set(runCatching(block))
|
||||
done.countDown()
|
||||
}
|
||||
if (!done.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) throw AgentException("TIMEOUT", "The UI thread did not answer")
|
||||
return result.get().getOrThrow()
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ public class PulseAudioComponent extends SessionPart {
|
||||
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
|
||||
public static final String SOCKET_NAME = "PS0";
|
||||
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
|
||||
private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r5";
|
||||
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
|
||||
|
||||
|
||||
|
||||
private final File workingDir;
|
||||
/** Where the daemon's own output is kept for this session, or null for logcat only. */
|
||||
@@ -40,8 +42,6 @@ public class PulseAudioComponent extends SessionPart {
|
||||
* turns that one stream into a source the client can see, named DirectAudioMic.
|
||||
*/
|
||||
private final String micFifoPath;
|
||||
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
|
||||
private String relaySocketPath;
|
||||
private volatile int pid = -1;
|
||||
|
||||
public PulseAudioComponent(Context context) {
|
||||
@@ -54,15 +54,6 @@ public class PulseAudioComponent extends SessionPart {
|
||||
this.micFifoPath = micFifoPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the daemon's output through the DirectAudio relay at this socket instead of an AAudio
|
||||
* stream of its own. The relay owns the stream outside proot, with its adaptive buffer; the
|
||||
* daemon only fills a shared ring. Set before {@link #start()}; the relay may start later.
|
||||
*/
|
||||
public void setRelaySocket(String path) {
|
||||
this.relaySocketPath = path;
|
||||
}
|
||||
|
||||
/** CRC-32 of the bundled pulseaudio.tzst, or "?" if it cannot be read. About 600 KB; cheap. */
|
||||
private String bundleChecksum() {
|
||||
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
|
||||
@@ -106,6 +97,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
String have = FileUtils.readString(stamp);
|
||||
String want = BUNDLE_STAMP + "-" + bundleChecksum();
|
||||
if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile()
|
||||
|| !new File(modulesDir, "arm64/module-directaudio-native-sink.so").isFile()
|
||||
|| !new File(workingDir, "pactl").isFile()
|
||||
|| have == null || !want.equals(have.trim())) {
|
||||
Log.i(TAG, "unpacking pulseaudio.tzst (" + want + "; had " + have + ")");
|
||||
@@ -139,14 +131,15 @@ public class PulseAudioComponent extends SessionPart {
|
||||
// the sink to 0% and the session plays silence.
|
||||
// The sink's name is what the client's Audio settings show as the output device, so it
|
||||
// says which road the sound takes.
|
||||
if (relaySocketPath != null && !relaySocketPath.isEmpty()) {
|
||||
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
} else {
|
||||
// The classic sink: the adaptive AAudio module 0.1.5 shipped, unchanged.
|
||||
config.add("load-module module-aaudio-classic-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink AAudioSink");
|
||||
}
|
||||
// The client's sound: DirectAudio's own engine, in this process, one step from Android.
|
||||
// decay=0: shrinking the device buffer after a quiet spell probes below what works and that
|
||||
// probe is a click; the client plays menus. Between .nofail and .fail a module that cannot
|
||||
// open its stream does not take the daemon down; ensureClientSink() then loads the plain
|
||||
// AAudio sink in its place.
|
||||
config.add(".nofail");
|
||||
config.add("load-module module-directaudio-native-sink sink_name=DirectAudio performance_mode=1 adaptive=1 decay=0 limiter=1 watchdog=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
config.add(".fail");
|
||||
if (micFifoPath != null && !micFifoPath.isEmpty()) {
|
||||
// The format is the helper's, fixed at s16le/48000/mono: it resamples when the device
|
||||
// grants another input rate, so the daemon is never told a rate the bytes are not.
|
||||
@@ -178,6 +171,74 @@ public class PulseAudioComponent extends SessionPart {
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
});
|
||||
Thread guard = new Thread(() -> ensureClientSink(out), "pulse-sink-guard");
|
||||
guard.setDaemon(true);
|
||||
guard.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback: once the daemon answers, if the DirectAudio sink is not
|
||||
* there (the module failed to load or to open its stream), load the plain AAudio sink so the
|
||||
* client is never left without an output. Says so in the audio log either way.
|
||||
*/
|
||||
private void ensureClientSink(java.io.PrintWriter out) {
|
||||
long deadline = System.currentTimeMillis() + 15000L;
|
||||
String sinks = null;
|
||||
while (System.currentTimeMillis() < deadline && pid > 1) {
|
||||
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
|
||||
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
|
||||
}
|
||||
String line;
|
||||
if (sinks == null) {
|
||||
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
|
||||
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
|
||||
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
|
||||
} else {
|
||||
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
|
||||
&& pactl("set-default-sink", "AAudioSink") != null;
|
||||
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
|
||||
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
|
||||
Log.w(TAG, line);
|
||||
}
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
}
|
||||
|
||||
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
|
||||
private String pactl(String... args) {
|
||||
File pactl = new File(workingDir, "pactl");
|
||||
if (!pactl.isFile() || pid <= 1) return null;
|
||||
java.lang.Process process = null;
|
||||
try {
|
||||
File modules = new File(workingDir, "modules/arm64");
|
||||
ArrayList<String> cmd = new ArrayList<>();
|
||||
cmd.add(pactl.getAbsolutePath());
|
||||
java.util.Collections.addAll(cmd, args);
|
||||
ProcessBuilder builder = new ProcessBuilder(cmd);
|
||||
builder.directory(workingDir);
|
||||
builder.redirectErrorStream(true);
|
||||
builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir);
|
||||
builder.environment().put("HOME", workingDir.getAbsolutePath());
|
||||
builder.environment().put("TMPDIR", workingDir.getAbsolutePath());
|
||||
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
|
||||
process = builder.start();
|
||||
String output;
|
||||
try (InputStream in = process.getInputStream()) {
|
||||
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
return null;
|
||||
}
|
||||
return process.exitValue() == 0 ? output : null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The language the app shows itself in.
|
||||
*
|
||||
* Nothing chosen (the default) means the system's: Android's own language, or the per-app language
|
||||
* Android 13+ lets a user set for DroidDeck in its settings, exactly as Android resolves it. Picking
|
||||
* one in Setup overrides that until the user picks "System default" again. Each activity and service
|
||||
* applies the choice to its own context ([wrap], [applyTo]), so it works on every Android version
|
||||
* the app runs on rather than only where the platform's per-app language API exists.
|
||||
*
|
||||
* Steam follows the same choice: [SteamLanguage] turns [effective] into the client's language.
|
||||
*/
|
||||
object AppLanguage {
|
||||
/** The stored value meaning "follow the system". */
|
||||
const val SYSTEM = ""
|
||||
|
||||
/** The languages the app ships, as BCP 47 tags, in the order the picker lists them. */
|
||||
val supported: List<String> = listOf("en", "es", "fr", "ru", "ja", "ko", "zh-CN", "zh-TW", "zh-HK")
|
||||
|
||||
private const val PREFS = "language"
|
||||
private const val KEY = "app_language"
|
||||
|
||||
/** The locale Android gave the process before any choice here was applied. */
|
||||
@Volatile private var systemLocale: Locale? = null
|
||||
|
||||
/** The stored choice: one of [supported], or [SYSTEM]. */
|
||||
fun chosen(context: Context): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, SYSTEM)
|
||||
?.takeIf { it in supported } ?: SYSTEM
|
||||
|
||||
fun setChosen(context: Context, tag: String) {
|
||||
val value = if (tag in supported) tag else SYSTEM
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, value).commit()
|
||||
val locale = value.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
|
||||
Locale.setDefault(locale)
|
||||
// Code that reads strings through the application context sees the new language too.
|
||||
val app = context.applicationContext
|
||||
@Suppress("DEPRECATION")
|
||||
app.resources.updateConfiguration(
|
||||
Configuration(app.resources.configuration).apply { setLocales(LocaleList(locale)) },
|
||||
app.resources.displayMetrics,
|
||||
)
|
||||
}
|
||||
|
||||
/** The locale Android chose for the app, ignoring the choice made here. */
|
||||
fun system(context: Context): Locale =
|
||||
systemLocale ?: context.resources.configuration.locales[0] ?: Locale.getDefault()
|
||||
|
||||
/** The locale the app is shown in: the chosen language, or else the system's. */
|
||||
fun effective(context: Context): Locale =
|
||||
chosen(context).takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
|
||||
|
||||
/** Remembers the system's locale; call with the untouched base context, before [wrap]. */
|
||||
fun noteSystem(base: Context) {
|
||||
if (systemLocale == null) systemLocale = base.resources.configuration.locales[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* [base], showing the chosen language: for an Application's or a Service's attachBaseContext.
|
||||
* Only the locale is overridden, so screen size, density and night mode keep following the
|
||||
* device as they change.
|
||||
*/
|
||||
fun wrap(base: Context): Context {
|
||||
noteSystem(base)
|
||||
val tag = chosen(base).ifEmpty { return base }
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
Locale.setDefault(locale)
|
||||
return base.createConfigurationContext(override(locale))
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the chosen language to an activity; call from its attachBaseContext, after super.
|
||||
* applyOverrideConfiguration keeps the locale through rotation and window resizes, which an
|
||||
* activity here handles itself rather than being recreated.
|
||||
*/
|
||||
fun applyTo(activity: Activity, base: Context) {
|
||||
noteSystem(base)
|
||||
val tag = chosen(base).ifEmpty { return }
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
Locale.setDefault(locale)
|
||||
activity.applyOverrideConfiguration(override(locale))
|
||||
}
|
||||
|
||||
/** Follows a change of the system's language while the process lives (Application.onConfigurationChanged). */
|
||||
fun systemChanged(config: Configuration) {
|
||||
config.locales[0]?.let { systemLocale = it }
|
||||
}
|
||||
|
||||
// A new Configuration's fontScale is 1, which as an override would undo the user's font size;
|
||||
// 0 leaves it undefined, so only the locale is changed.
|
||||
private fun override(locale: Locale) = Configuration().apply { fontScale = 0f; setLocales(LocaleList(locale)) }
|
||||
|
||||
/** Which of the app's languages [locale] reads as, or null when the app does not ship it. */
|
||||
fun supportedTag(locale: Locale): String? = when (locale.language) {
|
||||
"en", "es", "fr", "ru", "ja", "ko" -> locale.language
|
||||
"zh" -> when {
|
||||
locale.script == "Hans" -> "zh-CN"
|
||||
locale.country == "HK" || locale.country == "MO" -> "zh-HK"
|
||||
locale.script == "Hant" || locale.country == "TW" -> "zh-TW"
|
||||
else -> "zh-CN"
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** [locale]'s language named in itself, without its region unless the app tells them apart. */
|
||||
fun displayName(locale: Locale): String =
|
||||
supportedTag(locale)?.let(::nativeName) ?: locale.getDisplayLanguage(locale).replaceFirstChar { it.titlecase(locale) }
|
||||
|
||||
/** A language's name in that language, for the picker: what a reader of it looks for. */
|
||||
fun nativeName(tag: String): String = when (tag) {
|
||||
"en" -> "English"
|
||||
"es" -> "Español"
|
||||
"fr" -> "Français"
|
||||
"ru" -> "Русский"
|
||||
"ja" -> "日本語"
|
||||
"ko" -> "한국어"
|
||||
"zh-CN" -> "简体中文"
|
||||
"zh-TW" -> "繁體中文(台灣)"
|
||||
"zh-HK" -> "繁體中文(香港)"
|
||||
else -> Locale.forLanguageTag(tag).let { it.getDisplayName(it) }
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,6 @@ object DeviceReport {
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
k("Microphone", SessionPrefs.micEnabled(context))
|
||||
k("On-screen controls", SessionPrefs.oscMode(context))
|
||||
k("Touch mode", SessionPrefs.touchMode(context))
|
||||
@@ -181,8 +180,14 @@ object DeviceReport {
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
h("Device switch files")
|
||||
SessionService.envSwitchFile(context)?.takeIf { it.isFile }?.let { f ->
|
||||
k("droiddeck-env", FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
}
|
||||
if (File(Environment.getExternalStorageDirectory(), "Download/droiddeck-env").isFile) {
|
||||
k("Download/droiddeck-env", "(ignored - it moved to Android/data/${context.packageName}/files)")
|
||||
}
|
||||
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
|
||||
"droiddeck-osc", "droiddeck-no-pad",
|
||||
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
|
||||
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -17,8 +19,8 @@ object DeviceSupport {
|
||||
File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
|
||||
/** The chip as the device names it, for the card that explains the refusal. */
|
||||
fun gpuName(): String {
|
||||
fun gpuName(context: Context): String {
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { "this GPU" }
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ object HostProcess {
|
||||
if (onLine == null) builder.redirectOutput(File("/dev/null"))
|
||||
val process = builder.start()
|
||||
val pid = pidOf(process)
|
||||
Log.i(TAG, "started pid $pid: $command")
|
||||
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
|
||||
if (onLine != null) {
|
||||
Thread({
|
||||
try {
|
||||
@@ -59,7 +59,7 @@ object HostProcess {
|
||||
}
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "could not start: $command", e)
|
||||
Log.e(TAG, "could not start: ${argv.first()}", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,15 @@ object LogRedactor {
|
||||
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
|
||||
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
|
||||
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
|
||||
private val MAC = Regex("(?i)(?<![0-9a-z:-])[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\\1){4}[0-9a-f]{2}(?![0-9a-z:-])")
|
||||
private val MAC_FIELD = Regex(
|
||||
"(?i)(\\bmac(?:[_ ]?address)?[\"']?\\s*[:=]\\s*[\"']?)[0-9a-f]{2}([:-])" +
|
||||
"(?:[0-9a-f]{2}\\2){4}[0-9a-f]{2}(?![0-9a-z:-])"
|
||||
)
|
||||
private val DEVICE_IDENTIFIER = Regex(
|
||||
"(?i)(\\b(?:android[_ ]?id|(?:device|controller)[_ ]?serial|serial(?:[_ ]?number)?|build\\.serial)" +
|
||||
"[\"']?\\s*[:=]\\s*)(\"[^\"\\r\\n<>]+\"|'[^'\\r\\n<>]+'|[^\\s\"'<>&;,\\[\\]{}]+)"
|
||||
)
|
||||
|
||||
/**
|
||||
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
|
||||
@@ -196,7 +205,7 @@ object LogRedactor {
|
||||
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
|
||||
* out.
|
||||
*/
|
||||
const val RULES_VERSION = 1
|
||||
const val RULES_VERSION = 2
|
||||
|
||||
/** [src]'s lines, scrubbed, to [out]. */
|
||||
fun scrubTo(src: java.io.File, out: java.io.Writer) {
|
||||
@@ -208,6 +217,13 @@ object LogRedactor {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
var out = line
|
||||
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
|
||||
out = MAC.replace(out, "<redacted:mac>")
|
||||
out = DEVICE_IDENTIFIER.replace(out) {
|
||||
val value = it.groupValues[2]
|
||||
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
|
||||
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
|
||||
}
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
|
||||
enum class PhantomProcessStatus {
|
||||
@@ -98,29 +99,30 @@ object PhantomProcessLimit {
|
||||
fun blocksSteam(status: PhantomProcessStatus): Boolean =
|
||||
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
|
||||
|
||||
fun title(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
|
||||
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
|
||||
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
|
||||
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
|
||||
fun title(context: Context, status: PhantomProcessStatus): String = context.getString(when (status) {
|
||||
PhantomProcessStatus.ENABLED -> R.string.phantom_title_on
|
||||
PhantomProcessStatus.UNSET -> R.string.phantom_title_unset
|
||||
PhantomProcessStatus.UNREADABLE -> R.string.phantom_title_unreadable
|
||||
PhantomProcessStatus.DISABLED -> R.string.phantom_title_off
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> R.string.phantom_title_not_required
|
||||
})
|
||||
|
||||
fun instructions(context: Context, status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE ->
|
||||
context.getString(R.string.phantom_instructions_fix, fixSentence(context))
|
||||
PhantomProcessStatus.DISABLED -> context.getString(R.string.phantom_instructions_off)
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> context.getString(R.string.phantom_instructions_not_applicable)
|
||||
}
|
||||
|
||||
fun instructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
|
||||
fun gateInstructions(context: Context, status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence(context)
|
||||
else -> instructions(context, status)
|
||||
}
|
||||
|
||||
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
|
||||
else -> instructions(status)
|
||||
}
|
||||
|
||||
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
|
||||
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
|
||||
fun fixSentence(context: Context, sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
context.getString(if (hasDeveloperToggle(sdk)) R.string.phantom_fix_toggle else R.string.phantom_fix_no_toggle)
|
||||
|
||||
/** English: for the device report. */
|
||||
fun reportValue(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
|
||||
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import com.droiddeck.launcher.R
|
||||
import com.flyfishxu.kadb.Kadb
|
||||
import com.flyfishxu.kadb.cert.KadbCert
|
||||
import java.io.File
|
||||
@@ -37,21 +38,21 @@ object WirelessAdbFix {
|
||||
|
||||
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
|
||||
KadbCert.set(identity.first, identity.second)
|
||||
writeAtomically(cert, identity.first)
|
||||
writeAtomically(key, identity.second)
|
||||
writeAtomically(context, cert, identity.first)
|
||||
writeAtomically(context, key, identity.second)
|
||||
}
|
||||
|
||||
private fun writeAtomically(destination: File, contents: ByteArray) {
|
||||
private fun writeAtomically(context: Context, destination: File, contents: ByteArray) {
|
||||
val temporary = File(destination.parentFile, "${destination.name}.tmp")
|
||||
temporary.writeBytes(contents)
|
||||
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
|
||||
check(temporary.renameTo(destination)) { context.getString(R.string.adbfix_save_failed) }
|
||||
}
|
||||
|
||||
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.pair(host, port, pairingCode, "DroidDeck")
|
||||
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
writeAtomically(context, File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(context, File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
}
|
||||
|
||||
private fun savedConnection(context: Context): SavedConnection? {
|
||||
@@ -73,6 +74,7 @@ object WirelessAdbFix {
|
||||
|
||||
private fun saveConnection(context: Context, host: String, port: Int) {
|
||||
writeAtomically(
|
||||
context,
|
||||
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
|
||||
"$host\n$port".toByteArray(),
|
||||
)
|
||||
@@ -171,11 +173,11 @@ object WirelessAdbFix {
|
||||
Kadb.create(host, port).use { adb ->
|
||||
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
|
||||
val change = adb.shell(command)
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { context.getString(R.string.adbfix_command_failed, change.exitCode) } }
|
||||
}
|
||||
val result = adb.shell(PhantomProcessLimit.verifyCommand())
|
||||
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
|
||||
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
|
||||
context.getString(R.string.adbfix_not_confirmed, result.allOutput.trim())
|
||||
}
|
||||
}
|
||||
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
|
||||
@@ -186,7 +188,7 @@ object WirelessAdbFix {
|
||||
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
|
||||
val appContext = context.applicationContext
|
||||
val saved = savedConnection(appContext)
|
||||
?: error("No Wireless debugging pairing is saved")
|
||||
?: error(appContext.getString(R.string.adbfix_no_pairing))
|
||||
val cachedPort = saved.port
|
||||
if (cachedPort != null) {
|
||||
val cachedResult = runCatching {
|
||||
@@ -201,7 +203,7 @@ object WirelessAdbFix {
|
||||
}
|
||||
} else {
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
?: error("Enter the current Wireless debugging IP address & Port")
|
||||
?: error(appContext.getString(R.string.adbfix_enter_address))
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class WirelessAdbPairingService : Service() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
|
||||
|
||||
sealed interface Stage {
|
||||
data object Idle : Stage
|
||||
data object Waiting : Stage
|
||||
@@ -35,7 +38,7 @@ class WirelessAdbPairingService : Service() {
|
||||
private var pairingHost: String? = null
|
||||
private var pairingPort: Int? = null
|
||||
@Volatile private var working = false
|
||||
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
|
||||
private val timeout = Runnable { finish(Stage.Failed(getString(R.string.adbpair_timed_out))) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -75,13 +78,13 @@ class WirelessAdbPairingService : Service() {
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) = Unit
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
|
||||
main.post { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed_code, errorCode))) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
|
||||
}
|
||||
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
|
||||
.onSuccess { nsd = manager; discovery = listener }
|
||||
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
|
||||
.onFailure { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed, it.localizedMessage.toString()))) }
|
||||
}
|
||||
|
||||
private fun resolveListener() = object : NsdManager.ResolveListener {
|
||||
@@ -107,34 +110,34 @@ class WirelessAdbPairingService : Service() {
|
||||
return
|
||||
}
|
||||
if (code.length != 6) {
|
||||
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
|
||||
show(Stage.CodeNeeded(getString(R.string.adbpair_six_digits)))
|
||||
return
|
||||
}
|
||||
working = true
|
||||
show(Stage.Working("Pairing…"))
|
||||
show(Stage.Working(getString(R.string.adbpair_pairing)))
|
||||
Thread({
|
||||
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
|
||||
if (paired.isFailure) {
|
||||
main.post {
|
||||
working = false
|
||||
pairingPort = null
|
||||
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
|
||||
show(Stage.CodeNeeded(getString(R.string.adbpair_code_rejected)))
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
main.post {
|
||||
stopDiscovery()
|
||||
show(Stage.Working("Paired. Connecting…"))
|
||||
show(Stage.Working(getString(R.string.adbpair_connecting)))
|
||||
}
|
||||
val result = runCatching {
|
||||
val connectPort = WirelessAdbFix.localConnectPort(this)
|
||||
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
|
||||
main.post { show(Stage.Working("Applying the setting…")) }
|
||||
?: error(getString(R.string.adbpair_port_not_found))
|
||||
main.post { show(Stage.Working(getString(R.string.adbpair_applying))) }
|
||||
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
|
||||
}
|
||||
main.post {
|
||||
working = false
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: getString(R.string.adbpair_command_failed)) } ?: Stage.Done)
|
||||
}
|
||||
}, "wireless-adb-pairing").start()
|
||||
}
|
||||
@@ -174,9 +177,9 @@ class WirelessAdbPairingService : Service() {
|
||||
|
||||
private fun notification(stage: Stage): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.adbpair_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Asks for the Wireless debugging pairing code while Settings is open"
|
||||
description = getString(R.string.adbpair_channel_description)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
@@ -193,41 +196,40 @@ class WirelessAdbPairingService : Service() {
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
when (stage) {
|
||||
Stage.Waiting, Stage.Idle -> builder
|
||||
.setContentTitle("Pair Wireless debugging")
|
||||
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
|
||||
.setStyle(Notification.BigTextStyle().bigText(
|
||||
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
|
||||
.setContentTitle(getString(R.string.adbpair_title_pair))
|
||||
.setContentText(getString(R.string.adbpair_waiting_text))
|
||||
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_waiting_long)))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
|
||||
is Stage.CodeNeeded -> {
|
||||
val reply = PendingIntent.getService(this, 2,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
|
||||
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
|
||||
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel(getString(R.string.adbpair_code_label)).build()
|
||||
val text = stage.error ?: getString(R.string.adbpair_code_hint)
|
||||
builder
|
||||
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
|
||||
.setContentTitle(getString(if (stage.error == null) R.string.adbpair_title_code else R.string.adbpair_title_retry))
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setOngoing(true)
|
||||
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.adbpair_enter_code), reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
|
||||
}
|
||||
is Stage.Working -> builder
|
||||
.setContentTitle("Setting up Steam")
|
||||
.setContentTitle(getString(R.string.adbpair_title_working))
|
||||
.setContentText(stage.step)
|
||||
.setProgress(0, 0, true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
Stage.Done -> builder
|
||||
.setContentTitle("Steam is ready")
|
||||
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
|
||||
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
|
||||
.setContentTitle(getString(R.string.adbpair_title_done))
|
||||
.setContentText(getString(R.string.adbpair_done_text))
|
||||
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_done_text)))
|
||||
.setAutoCancel(true)
|
||||
is Stage.Failed -> builder
|
||||
.setContentTitle("Wireless debugging setup stopped")
|
||||
.setContentTitle(getString(R.string.adbpair_title_failed))
|
||||
.setContentText(stage.error)
|
||||
.setStyle(Notification.BigTextStyle().bigText(stage.error))
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -56,6 +56,8 @@ data class RailItem(
|
||||
val selected: Boolean,
|
||||
/** >0 shows a small accent count badge on the item's icon (expanded and collapsed). */
|
||||
val badge: Int = 0,
|
||||
/** The under-icon label when the rail is collapsed, for names too long to fit; null derives one. */
|
||||
val shortLabel: String? = null,
|
||||
// onClick stays LAST so existing call sites can pass it as a trailing lambda; badge is an
|
||||
// optional named param before it (default 0).
|
||||
val onClick: () -> Unit,
|
||||
@@ -73,7 +75,6 @@ private fun collapsedLabel(label: String): String {
|
||||
when (label) {
|
||||
"ENVIROMENT" -> return "ENVIRON" // the app's existing (mis)spelling, kept uppercase
|
||||
"WIN COMPONENTS" -> return "WIN COMP"
|
||||
"Downloads" -> return "Downlds"
|
||||
}
|
||||
if (label.contains(' ')) return label // multi-word → wraps to 2 lines
|
||||
return if (label.length > 9) label.take(8) + "…" else label
|
||||
@@ -317,7 +318,7 @@ fun CollapsibleRail(
|
||||
RailItemIcon(item, iconTint)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
collapsedLabel(item.label),
|
||||
item.shortLabel ?: collapsedLabel(item.label),
|
||||
color = labelColor,
|
||||
fontSize = 7.5.sp,
|
||||
lineHeight = 8.5.sp,
|
||||
|
||||
@@ -15,6 +15,11 @@ import java.io.File
|
||||
|
||||
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
|
||||
class FileManagerActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
@@ -61,6 +62,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -107,7 +109,7 @@ private fun FileContextMenuItems(
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select") },
|
||||
text = { Text(stringResource(R.string.fm_select)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onSelect() },
|
||||
)
|
||||
@@ -115,7 +117,7 @@ private fun FileContextMenuItems(
|
||||
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
|
||||
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Properties") },
|
||||
text = { Text(stringResource(R.string.fm_properties)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
@@ -123,7 +125,7 @@ private fun FileContextMenuItems(
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
text = { Text(if (isFavorite) stringResource(R.string.fm_favorite_remove) else stringResource(R.string.fm_favorite_add)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
@@ -136,25 +138,25 @@ private fun FileContextMenuItems(
|
||||
MenuItemDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
text = { Text(stringResource(R.string.fm_rename)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onRename() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
text = { Text(stringResource(R.string.fm_copy)) },
|
||||
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCopy() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cut") },
|
||||
text = { Text(stringResource(R.string.fm_cut)) },
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCut() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
text = { Text(stringResource(R.string.common_delete)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onDelete() },
|
||||
)
|
||||
@@ -184,6 +186,7 @@ internal fun FileItemRow(
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val context = LocalContext.current
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
@@ -276,7 +279,7 @@ internal fun FileItemRow(
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
|
||||
if (!isDir) append(FileOps.formatBytes(context, file.length())).append(" \u2022 ")
|
||||
append(dateFormat.format(Date(file.lastModified())))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -285,7 +288,7 @@ internal fun FileItemRow(
|
||||
}
|
||||
if (showActions) Box {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
Icon(Icons.Filled.MoreVert, stringResource(R.string.fm_actions), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
@@ -310,6 +313,47 @@ internal fun FileItemRow(
|
||||
}
|
||||
}
|
||||
|
||||
// The ".." row at the top of a folder: one level up, styled like a folder row so it reads as part
|
||||
// of the list and takes controller focus the same way.
|
||||
@Composable
|
||||
internal fun ParentFolderRow(compact: Boolean = false, onTap: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onTap),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "..",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fm_up_one_level),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
@@ -338,7 +382,7 @@ internal fun FavoritesList(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
text = stringResource(R.string.fm_favorites),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -351,7 +395,7 @@ internal fun FavoritesList(
|
||||
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Pin current folder",
|
||||
text = stringResource(R.string.fm_pin_current),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
@@ -366,7 +410,7 @@ internal fun FavoritesList(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
text = stringResource(R.string.fm_no_favorites),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
@@ -431,7 +475,7 @@ private fun FavoriteCard(
|
||||
// Origin line: coloured drive badge + source description.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = loc.driveLabel,
|
||||
text = stringResource(loc.driveLabel),
|
||||
color = badgeFg,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -443,9 +487,9 @@ private fun FavoriteCard(
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (loc.storage) {
|
||||
FavStorage.INTERNAL -> "Internal storage"
|
||||
FavStorage.SD -> "SD card"
|
||||
FavStorage.OTHER -> "Storage"
|
||||
FavStorage.INTERNAL -> stringResource(R.string.games_internal)
|
||||
FavStorage.SD -> stringResource(R.string.fm_drive_sd)
|
||||
FavStorage.OTHER -> stringResource(R.string.fm_drive_storage)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
@@ -464,7 +508,7 @@ private fun FavoriteCard(
|
||||
IconButton(onClick = onUnpin) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = "Remove from favorites",
|
||||
contentDescription = stringResource(R.string.fm_unpin_favorite),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
|
||||
@@ -77,7 +79,7 @@ enum class FavStorage { INTERNAL, SD, OTHER }
|
||||
|
||||
data class FavLocation(
|
||||
val storage: FavStorage,
|
||||
val driveLabel: String, // "Internal", "SD card", or "Storage"
|
||||
@StringRes val driveLabel: Int, // Internal, SD card, or Storage
|
||||
val displayPath: String // the unix absolute path
|
||||
)
|
||||
|
||||
@@ -87,15 +89,20 @@ fun describeLocation(file: File): FavLocation {
|
||||
|
||||
val internal = "/storage/emulated/0"
|
||||
if (abs == internal || abs.startsWith("$internal/")) {
|
||||
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
|
||||
return FavLocation(FavStorage.INTERNAL, R.string.fm_drive_internal, abs)
|
||||
}
|
||||
|
||||
if (abs.startsWith("/storage/")) {
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") {
|
||||
return FavLocation(FavStorage.SD, "SD card", abs)
|
||||
return FavLocation(FavStorage.SD, R.string.fm_drive_sd, abs)
|
||||
}
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, "Storage", abs)
|
||||
// The Linux system's root (the app's files/linuxfs, the sessions' /) and anything under it.
|
||||
if (Regex("/files/linuxfs(/|$)").containsMatchIn(abs)) {
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_linux, abs)
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_storage, abs)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -41,6 +42,7 @@ import androidx.compose.material.icons.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Smartphone
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
@@ -76,6 +78,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -140,7 +145,14 @@ fun FileManagerScreen(
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") File("/storage/$name") else rootDir
|
||||
}
|
||||
else -> rootDir
|
||||
// A game's own folder or Proton prefix lives in the app's private storage, which has no
|
||||
// volume above it: floor at the app's files dir so up/back still reach the library roots.
|
||||
else -> {
|
||||
val appFiles = runCatching { context.filesDir.canonicalPath }.getOrNull()
|
||||
generateSequence(rootDir) { it.parentFile }
|
||||
.firstOrNull { appFiles != null && runCatching { it.canonicalPath }.getOrNull() == appFiles }
|
||||
?: rootDir
|
||||
}
|
||||
}
|
||||
mutableStateOf(vol)
|
||||
}
|
||||
@@ -229,7 +241,9 @@ fun FileManagerScreen(
|
||||
loadDirectory(dir)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { openDrive(rootDir) }
|
||||
// Open the start dir without openDrive(): that pins the floor to the start dir itself, which
|
||||
// greyed out up/back whenever the manager was opened at a game's folder.
|
||||
LaunchedEffect(Unit) { loadDirectory(rootDir) }
|
||||
|
||||
// System/gesture Back: while the Favorites view is open it closes that first; otherwise
|
||||
// it goes up one directory. Only at the current drive's root with Favorites closed is it
|
||||
@@ -243,6 +257,14 @@ fun FileManagerScreen(
|
||||
if (parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// One level up, never above the current drive's root: shared by the toolbar arrow and the
|
||||
// ".." row at the top of the list.
|
||||
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
|
||||
fun goUp() {
|
||||
val parent = currentDir.parentFile
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// Resolve a non-colliding destination in [dir] for [name] (foo.txt -> "foo (1).txt").
|
||||
fun uniqueDestination(dir: File, name: String): File {
|
||||
var candidate = File(dir, name)
|
||||
@@ -261,11 +283,11 @@ fun FileManagerScreen(
|
||||
fun performDelete(file: File) {
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = "Deleting..."
|
||||
operationLabel = context.getString(R.string.fm_op_deleting)
|
||||
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, "Delete failed", Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +310,7 @@ fun FileManagerScreen(
|
||||
operationJob = scope.launch {
|
||||
operationProgress = 0f
|
||||
operationDeterminate = true
|
||||
operationLabel = if (cut) "Moving..." else "Copying..."
|
||||
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
|
||||
isOperationRunning = true
|
||||
|
||||
var applyToAll: ConflictChoice? = null
|
||||
@@ -324,10 +346,10 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
// Progress is per item; with a batch the label carries the overall position.
|
||||
operationLabel = buildString {
|
||||
append(if (cut) "Moving" else "Copying")
|
||||
if (sources.size > 1) append(" ${done + 1}/${sources.size}")
|
||||
append(" - ").append(src.name)
|
||||
operationLabel = if (sources.size > 1) {
|
||||
context.getString(if (cut) R.string.fm_op_moving_batch else R.string.fm_op_copying_batch, done + 1, sources.size, src.name)
|
||||
} else {
|
||||
context.getString(if (cut) R.string.fm_op_moving_item else R.string.fm_op_copying_item, src.name)
|
||||
}
|
||||
var lastPct = -1
|
||||
val onProgress = FileOps.ProgressCallback { copied, total ->
|
||||
@@ -355,9 +377,9 @@ fun FileManagerScreen(
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
|
||||
val message = when {
|
||||
failed > 0 -> "$done done, $failed failed"
|
||||
skipped > 0 -> "$done done, $skipped skipped"
|
||||
sources.size > 1 -> "$done items ${if (cut) "moved" else "copied"}"
|
||||
failed > 0 -> context.getString(R.string.fm_paste_failed, done, failed)
|
||||
skipped > 0 -> context.getString(R.string.fm_paste_skipped, done, skipped)
|
||||
sources.size > 1 -> context.resources.getQuantityString(if (cut) R.plurals.fm_paste_moved else R.plurals.fm_paste_copied, done, done)
|
||||
else -> null
|
||||
}
|
||||
if (message != null) Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
@@ -367,32 +389,32 @@ fun FileManagerScreen(
|
||||
fun performRename(file: File, newName: String) {
|
||||
val target = File(file.parentFile, newName)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, "\"$newName\" already exists", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = "Renaming..."
|
||||
operationLabel = context.getString(R.string.fm_op_renaming)
|
||||
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, "Rename failed", Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun createFolder(parent: File, name: String) {
|
||||
val target = File(parent, name)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, "\"$name\" already exists", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = "Creating folder..."
|
||||
operationLabel = context.getString(R.string.fm_op_creating_folder)
|
||||
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, "Could not create folder", Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +431,9 @@ fun FileManagerScreen(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val drives = remember(storageTick) { StorageRoots.list(context) }
|
||||
val linuxRoot = remember(storageTick) {
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
|
||||
}
|
||||
|
||||
// ── Dialogs ──
|
||||
|
||||
@@ -416,12 +441,12 @@ fun FileManagerScreen(
|
||||
var folderName by remember { mutableStateOf("") }
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { showNewFolderDialog = false },
|
||||
title = { Text("New Folder") },
|
||||
title = { Text(stringResource(R.string.fm_new_folder)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = folderName,
|
||||
onValueChange = { folderName = it },
|
||||
label = { Text("Folder name") },
|
||||
label = { Text(stringResource(R.string.fm_folder_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
@@ -429,9 +454,9 @@ fun FileManagerScreen(
|
||||
TextButton(onClick = {
|
||||
showNewFolderDialog = false
|
||||
if (folderName.isNotBlank()) createFolder(currentDir, folderName)
|
||||
}) { Text("Create") }
|
||||
}) { Text(stringResource(R.string.fm_create)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,12 +464,12 @@ fun FileManagerScreen(
|
||||
var newName by remember(renameTarget) { mutableStateOf(renameTarget?.name ?: "") }
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { renameTarget = null },
|
||||
title = { Text("Rename") },
|
||||
title = { Text(stringResource(R.string.fm_rename)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("New name") },
|
||||
label = { Text(stringResource(R.string.fm_new_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
@@ -453,9 +478,9 @@ fun FileManagerScreen(
|
||||
val file = renameTarget
|
||||
renameTarget = null
|
||||
if (file != null && newName.isNotBlank()) performRename(file, newName)
|
||||
}) { Text("Rename") }
|
||||
}) { Text(stringResource(R.string.fm_rename)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,15 +498,15 @@ fun FileManagerScreen(
|
||||
val file = selectedEntry ?: return
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { selectedEntry = null },
|
||||
title = { Text("Delete?") },
|
||||
text = { Text("Delete \"${file.name}\" permanently?") },
|
||||
title = { Text(stringResource(R.string.fm_delete_title)) },
|
||||
text = { Text(stringResource(R.string.fm_delete_text, file.name)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
selectedEntry = null
|
||||
performDelete(file)
|
||||
}) { Text("Delete") }
|
||||
}) { Text(stringResource(R.string.common_delete)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -489,10 +514,10 @@ fun FileManagerScreen(
|
||||
val victims = pendingBulkDelete
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { pendingBulkDelete = emptyList() },
|
||||
title = { Text("Delete ${victims.size} item${if (victims.size == 1) "" else "s"}?") },
|
||||
title = { Text(pluralStringResource(R.plurals.fm_delete_items_title, victims.size, victims.size)) },
|
||||
text = {
|
||||
Column {
|
||||
Text("This can't be undone.")
|
||||
Text(stringResource(R.string.fm_cannot_undo))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Name a few so an accidental Select-All is obvious before it's too late.
|
||||
victims.take(5).forEach {
|
||||
@@ -500,7 +525,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
if (victims.size > 5) {
|
||||
Text(
|
||||
"…and ${victims.size - 5} more",
|
||||
pluralStringResource(R.plurals.fm_and_more, victims.size - 5, victims.size - 5),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -516,19 +541,19 @@ fun FileManagerScreen(
|
||||
isOperationRunning = true
|
||||
var failed = 0
|
||||
victims.forEachIndexed { i, f ->
|
||||
operationLabel = "Deleting ${i + 1}/${victims.size} - ${f.name}"
|
||||
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
|
||||
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
|
||||
}
|
||||
isOperationRunning = false
|
||||
operationJob = null
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (failed > 0) {
|
||||
Toast.makeText(context, "$failed couldn't be deleted", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
||||
}) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -537,12 +562,12 @@ fun FileManagerScreen(
|
||||
val isDir = conflict.isDirectory
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { pendingConflict = null },
|
||||
title = { Text("\"${conflict.name}\" already exists") },
|
||||
title = { Text(stringResource(R.string.fm_already_exists, conflict.name)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (isDir) "Merge adds and replaces files inside the existing folder."
|
||||
else "Overwrite replaces the existing file.",
|
||||
if (isDir) stringResource(R.string.fm_conflict_merge_hint)
|
||||
else stringResource(R.string.fm_conflict_overwrite_hint),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -553,15 +578,15 @@ fun FileManagerScreen(
|
||||
checked = conflictApplyToAll,
|
||||
onCheckedChange = { conflictApplyToAll = it },
|
||||
)
|
||||
Text("Apply to all conflicts", fontSize = 12.sp)
|
||||
Text(stringResource(R.string.fm_conflict_apply_all), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
listOf(
|
||||
(if (isDir) ConflictChoice.MERGE else ConflictChoice.OVERWRITE) to
|
||||
(if (isDir) "Merge" else "Overwrite"),
|
||||
ConflictChoice.KEEP_BOTH to "Keep both",
|
||||
ConflictChoice.SKIP to "Skip",
|
||||
(if (isDir) stringResource(R.string.fm_conflict_merge) else stringResource(R.string.fm_conflict_overwrite)),
|
||||
ConflictChoice.KEEP_BOTH to stringResource(R.string.fm_conflict_keep_both),
|
||||
ConflictChoice.SKIP to stringResource(R.string.fm_conflict_skip),
|
||||
).forEach { (choice, label) ->
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -573,7 +598,7 @@ fun FileManagerScreen(
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { conflictChoice = ConflictChoice.SKIP; pendingConflict = null }) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -603,7 +628,7 @@ fun FileManagerScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.Folder, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Select this folder")
|
||||
Text(stringResource(R.string.fm_select_this_folder))
|
||||
}
|
||||
}
|
||||
// ── Path bar ──
|
||||
@@ -614,15 +639,11 @@ fun FileManagerScreen(
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val parent = currentDir.parentFile
|
||||
// Don't climb above the current drive's root.
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}, enabled = currentDir != currentRoot) {
|
||||
Icon(Icons.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.primary)
|
||||
IconButton(onClick = { goUp() }, enabled = canGoUp) {
|
||||
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
val currentDriveLabel = describeLocation(currentDir).driveLabel
|
||||
val currentDriveLabel = stringResource(describeLocation(currentDir).driveLabel)
|
||||
// Dim the drive chip while the Favorites list is open (it's not the active context).
|
||||
val driveChipAlpha = if (showFavorites) 0.45f else 1f
|
||||
Box {
|
||||
@@ -666,13 +687,23 @@ fun FileManagerScreen(
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"${drive.label} is mounted but not readable right now",
|
||||
context.getString(R.string.fm_drive_not_readable, drive.label),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
linuxRoot?.let { root ->
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_drive_linux)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
onClick = { showDriveMenu = false; openDrive(root) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,7 +711,7 @@ fun FileManagerScreen(
|
||||
|
||||
if (showFavorites) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
text = stringResource(R.string.fm_favorites),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -718,7 +749,7 @@ fun FileManagerScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.CreateNewFolder, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("New Folder", color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
|
||||
Text(stringResource(R.string.fm_new_folder), color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
@@ -727,19 +758,19 @@ fun FileManagerScreen(
|
||||
}) {
|
||||
Icon(
|
||||
if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView,
|
||||
if (gridView) "List view" else "Grid view",
|
||||
if (gridView) stringResource(R.string.fm_list_view) else stringResource(R.string.fm_grid_view),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showSearch = !showSearch; if (!showSearch) searchQuery = "" }) {
|
||||
Icon(Icons.Filled.Search, "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.Search, stringResource(R.string.fm_search), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showSortMenu = true }) {
|
||||
Icon(Icons.Filled.Sort, "Sort", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.Sort, stringResource(R.string.fm_sort), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
|
||||
listOf("name" to "Name", "date" to "Date modified", "size" to "Size")
|
||||
listOf("name" to stringResource(R.string.fm_sort_name), "date" to stringResource(R.string.fm_sort_date), "size" to stringResource(R.string.fm_prop_size))
|
||||
.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
@@ -758,7 +789,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (compactRows) "Comfortable rows" else "Compact rows") },
|
||||
text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) },
|
||||
onClick = {
|
||||
compactRows = !compactRows
|
||||
browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply()
|
||||
@@ -766,7 +797,7 @@ fun FileManagerScreen(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (showHidden) "Hide hidden files" else "Show hidden files") },
|
||||
text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) },
|
||||
onClick = {
|
||||
showHidden = !showHidden
|
||||
browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply()
|
||||
@@ -781,9 +812,9 @@ fun FileManagerScreen(
|
||||
// Star toggle: open/close the dedicated Favorites list.
|
||||
IconButton(onClick = { showFavorites = !showFavorites }) {
|
||||
if (showFavorites) {
|
||||
Icon(Icons.Filled.Star, "Hide favorites", tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Filled.Star, stringResource(R.string.fm_hide_favorites), tint = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
Icon(Icons.Filled.StarBorder, "Show favorites", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -794,7 +825,7 @@ fun FileManagerScreen(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text("Filter this folder", fontSize = 13.sp) },
|
||||
placeholder = { Text(stringResource(R.string.fm_filter_folder), fontSize = 13.sp) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
@@ -821,7 +852,7 @@ fun FileManagerScreen(
|
||||
)
|
||||
if (freeSpace > 0) {
|
||||
Text(
|
||||
"${FileOps.formatBytes(freeSpace)} free",
|
||||
stringResource(R.string.fm_free_space, FileOps.formatBytes(context, freeSpace)),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
@@ -842,7 +873,7 @@ fun FileManagerScreen(
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
"${selectedPaths.size} selected",
|
||||
stringResource(R.string.fm_selected_count, selectedPaths.size),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -855,7 +886,7 @@ fun FileManagerScreen(
|
||||
else entries.map { it.absolutePath }.toSet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text(if (selectedPaths.size == entries.size) "None" else "All", fontSize = 12.sp) }
|
||||
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
@@ -866,7 +897,7 @@ fun FileManagerScreen(
|
||||
selectedPaths = emptySet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text("Copy", fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
@@ -877,19 +908,19 @@ fun FileManagerScreen(
|
||||
selectedPaths = emptySet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text("Cut", fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
|
||||
contentPadding = selBarPadding,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
) { Text("Delete", color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { selectionMode = false; selectedPaths = emptySet() },
|
||||
contentPadding = selBarPadding,
|
||||
) { Text("Done", fontSize = 12.sp) }
|
||||
) { Text(stringResource(R.string.fm_done), fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,16 +934,16 @@ fun FileManagerScreen(
|
||||
.clickable { performPaste() }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentPaste, "Paste", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val what = if (clipboardFiles.size == 1) clipboardFiles.first().name
|
||||
else "${clipboardFiles.size} items"
|
||||
else pluralStringResource(R.plurals.fm_items, clipboardFiles.size, clipboardFiles.size)
|
||||
Text(
|
||||
"Paste $what${if (isCutOperation) " (move)" else ""} here",
|
||||
if (isCutOperation) stringResource(R.string.fm_paste_move_here, what) else stringResource(R.string.fm_paste_here, what),
|
||||
color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { clipboardFiles = emptyList(); isCutOperation = false }) {
|
||||
Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
Text(stringResource(R.string.common_cancel), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -944,7 +975,7 @@ fun FileManagerScreen(
|
||||
isOperationRunning = false
|
||||
operationDeterminate = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
}) { Text("Cancel", fontSize = 12.sp) }
|
||||
}) { Text(stringResource(R.string.common_cancel), fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -970,38 +1001,40 @@ fun FileManagerScreen(
|
||||
// shown in pick mode (the themed picker keeps its slim layout). Built each recompose (cheap)
|
||||
// so it tracks the current drive/favourites without stale click lambdas.
|
||||
val fmRailState = rememberRailState("filemanager")
|
||||
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File) =
|
||||
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath) {
|
||||
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File, shortLabel: String? = null) =
|
||||
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath, shortLabel = shortLabel) {
|
||||
showFavorites = false; openDrive(dir)
|
||||
}
|
||||
val storageItems = buildList {
|
||||
add(locItem("Internal", Icons.Filled.Smartphone, File("/storage/emulated/0")))
|
||||
add(locItem(stringResource(R.string.fm_drive_internal), Icons.Filled.Smartphone, File("/storage/emulated/0")))
|
||||
drives.filter { it.removable }.forEach { d ->
|
||||
add(RailItem(d.label, Icons.Filled.SdStorage, !showFavorites && currentRoot.absolutePath == d.dir.absolutePath) {
|
||||
showFavorites = false; if (d.readable) openDrive(d.dir)
|
||||
})
|
||||
}
|
||||
// The Linux system the sessions run in: its own / (the desktop's root), once installed.
|
||||
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
|
||||
}
|
||||
val quickItems = buildList {
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem("Downloads", Icons.Filled.Download, it)) }
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
|
||||
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
?.let { add(locItem("ROMs", Icons.Filled.SportsEsports, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem("Session logs", Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem("Pictures", Icons.Filled.Image, it)) }
|
||||
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
}
|
||||
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
.map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } }
|
||||
val locationSections = buildList {
|
||||
add(RailSection("STORAGE", storageItems))
|
||||
if (quickItems.isNotEmpty()) add(RailSection("QUICK", quickItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection("FAVORITES", favItems))
|
||||
add(RailSection(stringResource(R.string.fm_rail_storage), storageItems))
|
||||
if (quickItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_quick), quickItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_favorites), favItems))
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
// The slim picker keeps its layout: no rail.
|
||||
if (!pickMode) {
|
||||
CollapsibleRail(state = fmRailState, title = "Files", sections = locationSections, outlinedItems = true)
|
||||
CollapsibleRail(state = fmRailState, title = stringResource(R.string.fm_rail_title), sections = locationSections, outlinedItems = true)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||
// ── Favorites list OR file list ──
|
||||
@@ -1012,7 +1045,7 @@ fun FileManagerScreen(
|
||||
onPinCurrent = {
|
||||
FavoritesStore.add(context, currentDir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, "Added \"${currentDir.name}\" to Favorites", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onJump = { dir ->
|
||||
showFavorites = false
|
||||
@@ -1021,7 +1054,7 @@ fun FileManagerScreen(
|
||||
onUnpin = { dir ->
|
||||
FavoritesStore.remove(context, dir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, "Removed \"${dir.name}\" from Favorites", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -1041,6 +1074,9 @@ fun FileManagerScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
items(shownEntries, key = { it.absolutePath }) { file ->
|
||||
val isFav = remember(file.absolutePath, favTick) {
|
||||
FavoritesStore.isFavorite(context, file.absolutePath)
|
||||
@@ -1095,8 +1131,8 @@ fun FileManagerScreen(
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (nowFav) "Added \"${file.name}\" to Favorites"
|
||||
else "Removed \"${file.name}\" from Favorites",
|
||||
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
|
||||
else context.getString(R.string.fm_favorite_removed, file.name),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
@@ -1106,13 +1142,16 @@ fun FileManagerScreen(
|
||||
}
|
||||
} else
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
if (canGoUp) item(key = "..") {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("Empty directory", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.fm_empty_directory), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1173,8 +1212,8 @@ fun FileManagerScreen(
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (nowFav) "Added \"${file.name}\" to Favorites"
|
||||
else "Removed \"${file.name}\" from Favorites",
|
||||
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
|
||||
else context.getString(R.string.fm_favorite_removed, file.name),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
@@ -19,9 +21,9 @@ object FileOps {
|
||||
/** Byte-accurate copy progress (copied/total bytes), reported as the copy proceeds. */
|
||||
fun interface ProgressCallback { fun onProgress(copiedBytes: Long, totalBytes: Long) }
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes <= 0) return "0 bytes"
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB")
|
||||
fun formatBytes(context: Context, bytes: Long): String {
|
||||
if (bytes <= 0) return context.getString(R.string.fm_size_zero)
|
||||
val units = arrayOf(context.getString(R.string.fm_unit_bytes), "KB", "MB", "GB", "TB")
|
||||
val group = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt().coerceIn(0, units.lastIndex)
|
||||
return String.format(Locale.ENGLISH, "%.2f", bytes / Math.pow(1024.0, group.toDouble())) + " " + units[group]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import java.io.File
|
||||
|
||||
@@ -26,6 +27,11 @@ import java.io.File
|
||||
* - [EXTRA_PICKER_TITLE] the header
|
||||
*/
|
||||
class FilePickerActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -50,7 +56,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
pickDirMode = pickDir,
|
||||
pickExtensions = extensions,
|
||||
initialDir = initialDir,
|
||||
pickerTitle = title ?: if (pickDir) "Choose a folder" else "Choose a file",
|
||||
pickerTitle = title ?: getString(if (pickDir) R.string.fm_choose_folder else R.string.fm_choose_file),
|
||||
onPick = { file ->
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
|
||||
finish()
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -66,7 +68,7 @@ internal fun FilePropertiesDialog(
|
||||
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Properties") },
|
||||
title = { Text(stringResource(R.string.fm_properties)) },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// ── Basic info ──
|
||||
@@ -79,14 +81,14 @@ internal fun FilePropertiesDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine(stringResource(R.string.fm_prop_location), file.parent ?: "-")
|
||||
PropertyLine(
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
|
||||
stringResource(R.string.fm_prop_type),
|
||||
if (file.isDirectory) stringResource(R.string.fm_type_folder)
|
||||
else file.extension.uppercase().let { if (it.isBlank()) stringResource(R.string.fm_type_file) else stringResource(R.string.fm_type_ext_file, it) },
|
||||
)
|
||||
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
|
||||
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
|
||||
if (!file.isDirectory) PropertyLine(stringResource(R.string.fm_prop_size), FileOps.formatBytes(context, file.length()))
|
||||
PropertyLine(stringResource(R.string.fm_prop_modified), dateFormat.format(Date(file.lastModified())))
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
@@ -95,8 +97,8 @@ internal fun FilePropertiesDialog(
|
||||
val state = attrs
|
||||
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
|
||||
AttributeToggleRow(
|
||||
label = "Read-only",
|
||||
description = "Stops games (and Wine) from overwriting or deleting this file.",
|
||||
label = stringResource(R.string.fm_read_only),
|
||||
description = stringResource(R.string.fm_read_only_hint),
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
@@ -108,7 +110,7 @@ internal fun FilePropertiesDialog(
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
onChanged()
|
||||
} else {
|
||||
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, R.string.fm_read_only_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -117,10 +119,10 @@ internal fun FilePropertiesDialog(
|
||||
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
|
||||
val hiddenSupported = state?.hiddenSupported == true
|
||||
AttributeToggleRow(
|
||||
label = "Hidden",
|
||||
label = stringResource(R.string.fm_hidden),
|
||||
description = if (state != null && !hiddenSupported)
|
||||
"This storage can't store the hidden flag."
|
||||
else "Marks the file hidden in Windows (Wine's hidden attribute).",
|
||||
stringResource(R.string.fm_hidden_unsupported)
|
||||
else stringResource(R.string.fm_hidden_hint),
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
@@ -134,14 +136,14 @@ internal fun FilePropertiesDialog(
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, R.string.fm_hidden_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.fm_done)) } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -63,7 +64,7 @@ object StorageRoots {
|
||||
|
||||
// Internal storage is never removable and never absent; seed it first so it heads the menu.
|
||||
volumeOf(PRIMARY_KEY).apply {
|
||||
label = "Internal"
|
||||
label = context.getString(R.string.fm_drive_internal)
|
||||
removable = false
|
||||
candidates += File(INTERNAL_PATH)
|
||||
}
|
||||
@@ -109,13 +110,13 @@ object StorageRoots {
|
||||
entry.seeds += dir
|
||||
}
|
||||
|
||||
val roots = volumes.values.map { it.toRoot() }
|
||||
val roots = volumes.values.map { it.toRoot(context) }
|
||||
val internal = roots.filter { !it.removable }
|
||||
val removable = roots.filter { it.removable }.sortedBy { it.label }
|
||||
return disambiguate(internal + removable)
|
||||
}
|
||||
|
||||
private fun Volume.toRoot(): StorageRoot {
|
||||
private fun Volume.toRoot(context: Context): StorageRoot {
|
||||
// Prefer a path we can actually list; a readable child beats an unreadable root.
|
||||
val dir = candidates.firstOrNull(::canBrowse)
|
||||
?: candidates.firstNotNullOfOrNull { root -> seeds.firstNotNullOfOrNull { deepestReadable(root, it) } }
|
||||
@@ -125,14 +126,14 @@ object StorageRoots {
|
||||
?: File("/storage/$key")
|
||||
|
||||
return StorageRoot(
|
||||
label = label ?: defaultLabel(),
|
||||
label = label ?: defaultLabel(context),
|
||||
dir = dir,
|
||||
removable = removable,
|
||||
readable = canBrowse(dir),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Volume.defaultLabel(): String = if (removable) "SD card" else key
|
||||
private fun Volume.defaultLabel(context: Context): String = if (removable) context.getString(R.string.fm_drive_sd) else key
|
||||
|
||||
/** Appends the volume id to any label used by more than one volume, so entries stay tellable apart. */
|
||||
private fun disambiguate(roots: List<StorageRoot>): List<StorageRoot> {
|
||||
@@ -183,12 +184,12 @@ object StorageRoots {
|
||||
* name like "android", which tells the user nothing about which card they are looking at.
|
||||
*/
|
||||
private fun labelFor(context: Context, volume: StorageVolume, uuid: String?): String {
|
||||
if (volume.isPrimary) return "Internal"
|
||||
if (volume.isRemovable) return "SD card"
|
||||
if (volume.isPrimary) return context.getString(R.string.fm_drive_internal)
|
||||
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
|
||||
val description = volume.getDescription(context)?.trim()
|
||||
return description?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?: uuid
|
||||
?: "Storage"
|
||||
?: context.getString(R.string.fm_drive_storage)
|
||||
}
|
||||
|
||||
private fun isEmulated(dir: File): Boolean = dir.absolutePath.startsWith(EMULATED_PREFIX)
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
@@ -16,13 +18,21 @@ object AddedGames {
|
||||
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
val folder: File, val name: String,
|
||||
/** The game's own .exe: what the icon is read from and what the settings page lists. */
|
||||
val exe: File,
|
||||
/** The guest path the shortcut runs: [exe], or a store install's launcher .bat in front of it. */
|
||||
val guestExe: String, val guestDir: String,
|
||||
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
|
||||
val appId: Long,
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
val steamAppId: Int? = null,
|
||||
/** Where the game came from: a store's id, or [Library.ADDED] for a folder the user added. */
|
||||
val source: String = Library.ADDED,
|
||||
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
|
||||
val storeId: String? = null,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
@@ -66,9 +76,16 @@ object AddedGames {
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms ->
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
// A store root on a card has a bind of its own, before the library rule: the same folder
|
||||
// keeps the same guest path (and appid) whether or not that card is also the Steam library.
|
||||
StoreInstallRoot.externalGuestPath(context, host)?.let { return it }
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
// Inside the runtime's own tree (store installs on internal storage live at
|
||||
// root/Games/Stores): the tree is the session's / and needs no bind.
|
||||
val runtime = LinuxRuntime.rootDir(context).absolutePath
|
||||
if (path.startsWith("$runtime/")) return "/" + path.removePrefix("$runtime/")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -98,10 +115,15 @@ object AddedGames {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
if (folder.name.lowercase() in steamInstalls) continue
|
||||
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
// The store install root sits inside the Games storage; its games are scanned below, per store.
|
||||
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
}
|
||||
// Games the Stores section installed: one folder each under <root>/<Store>/, with a sidecar.
|
||||
// An unfinished one (sidecar still `installing`, or none at all) is neither a game nor Custom.
|
||||
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
@@ -125,33 +147,67 @@ object AddedGames {
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
// A store install carries a sidecar; every one of them is a shortcut.
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
val candidates = candidates(folder)
|
||||
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = chosen ?: candidates.firstOrNull() ?: return
|
||||
val guestExe = guestPath(context, exe)
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val name = folder.name
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
// A store install says which exe it is; the folder's guess only when that file is gone.
|
||||
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
|
||||
// The store's launcher .bat stands in front of the exe when the game needs arguments or
|
||||
// environment; it is what the shortcut runs. Dropped when the user picked another exe.
|
||||
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val foundGuest = guestPath(context, launch)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
||||
val name = sidecar?.title ?: folder.name
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
|
||||
val record = config?.let { shortcutRecord(it, appId) }
|
||||
val rev = record?.optInt("rev", 0) ?: 0
|
||||
val adopted = guestPath(context, folder)
|
||||
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, appId) }
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
|
||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id))
|
||||
}
|
||||
}
|
||||
|
||||
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
|
||||
val users = File(root, "config/loginusers.vdf").readText()
|
||||
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
|
||||
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
?: return@runCatching null
|
||||
val account = recent.groupValues[1].toLong() - 76561197960265728L
|
||||
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
|
||||
.optInt(appId.toString()).takeIf { it > 0 }
|
||||
private fun accountConfig(root: File): File? {
|
||||
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
|
||||
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
|
||||
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
|
||||
return account?.let { File(root, "userdata/$it/config") }
|
||||
}
|
||||
|
||||
private fun steamRoute(config: File, appId: Long): Int? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
||||
}.getOrNull()
|
||||
|
||||
internal fun steamTarget(folder: File, folderGuest: String, record: JSONObject?, guestExe: String, seen: Int): File? {
|
||||
if (record == null || record.optInt("rev", 0) == seen) return null
|
||||
val asked = unquote(record.optJSONObject("app")?.optString("Exe"))
|
||||
val steam = unquote(record.optJSONObject("steam")?.optString("Exe"))
|
||||
if (asked != guestExe || steam.isEmpty() || steam == asked || !steam.startsWith("$folderGuest/")) return null
|
||||
val file = File(folder, steam.removePrefix("$folderGuest/"))
|
||||
val inside = runCatching { file.canonicalPath.startsWith(folder.canonicalPath + File.separator) }.getOrDefault(false)
|
||||
return file.takeIf { inside && it.isFile }
|
||||
}
|
||||
|
||||
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -161,6 +217,7 @@ object AddedGames {
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
val art = AddedGameArt.resolve(context, g)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -37,18 +38,18 @@ object GameFileSync {
|
||||
}
|
||||
|
||||
@Synchronized private fun sync(context: Context, folder: File) {
|
||||
check(LinuxRuntime.isInstalled(context)) { "Install the runtime first" }
|
||||
check(LinuxRuntime.isInstalled(context)) { context.getString(R.string.game_sync_err_runtime) }
|
||||
// A disconnected library must not be interpreted as a mass uninstall.
|
||||
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { "Steam library is unavailable" }
|
||||
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
|
||||
val storage = GameStorage.effective(context)
|
||||
val storageSetting = SessionPrefs.gameStorage(context)
|
||||
val previousStorage = prefs(context).getString("storagePath", null)
|
||||
if (storageSetting.isEmpty() && prefs(context).getString("storageSetting", null) == storageSetting && previousStorage != null) {
|
||||
check(File(previousStorage).isDirectory) { "Previous SD library is unavailable" }
|
||||
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
|
||||
}
|
||||
storage?.let { check(File(it.path).listFiles() != null) { "Game storage is unavailable" } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { "Added games folder is unavailable" } }
|
||||
GameFiles.sync(folder, Library.launchableGames(context, strictRead = true))
|
||||
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
|
||||
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AtomicFile
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.core.util.readText
|
||||
import androidx.core.util.writeBytes
|
||||
import androidx.core.util.writeText
|
||||
@@ -63,28 +64,28 @@ object GameFiles {
|
||||
return id
|
||||
}
|
||||
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(folder, game, filename(game))
|
||||
@Synchronized fun export(context: Context, folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(context, folder, game, filename(game))
|
||||
|
||||
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
private fun exportNamed(context: Context, folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
|
||||
val text = contents(game.gameIdString)
|
||||
val target = exportTarget(folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
|
||||
val target = exportTarget(context, folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { context.getString(R.string.gfiles_name_taken, target.name) }
|
||||
if (!target.exists()) write(target, text)
|
||||
exportArt(folder, game)
|
||||
exportArt(context, folder, game)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(folder, name)
|
||||
private fun exportTarget(context: Context, folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(context, folder, name)
|
||||
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
|
||||
child(folder, filename(game, disambiguate = true))
|
||||
child(context, folder, filename(game, disambiguate = true))
|
||||
} else target
|
||||
}
|
||||
|
||||
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
|
||||
private fun exportArt(folder: File, game: Library.SteamGame) {
|
||||
private fun exportArt(context: Context, folder: File, game: Library.SteamGame) {
|
||||
val art = listOfNotNull(game.icon, game.art).firstOrNull {
|
||||
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
|
||||
} ?: return
|
||||
@@ -93,19 +94,19 @@ object GameFiles {
|
||||
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
|
||||
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
|
||||
if (title.isEmpty()) return
|
||||
val images = child(folder, "images")
|
||||
check(images.isDirectory || images.mkdir()) { "Artwork folder is unavailable" }
|
||||
val target = child(images, "$title-icon.${art.extension.lowercase()}")
|
||||
val images = child(context, folder, "images")
|
||||
check(images.isDirectory || images.mkdir()) { context.getString(R.string.gfiles_art_unavailable) }
|
||||
val target = child(context, images, "$title-icon.${art.extension.lowercase()}")
|
||||
if (!target.exists()) AtomicFile(target).writeBytes(art.readBytes())
|
||||
}
|
||||
|
||||
/** Only remove files in our inventory whose content is still exactly what we wrote. */
|
||||
@Synchronized fun sync(folder: File, games: List<Library.SteamGame>) {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
val inventory = child(folder, INVENTORY)
|
||||
@Synchronized fun sync(context: Context, folder: File, games: List<Library.SteamGame>) {
|
||||
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
|
||||
val inventory = child(context, folder, INVENTORY)
|
||||
val previous = if (inventory.exists()) {
|
||||
val record = JSONObject(AtomicFile(inventory).readText())
|
||||
require(record.optString("format") == FORMAT) { "Folder has an unrelated sync inventory" }
|
||||
require(record.optString("format") == FORMAT) { context.getString(R.string.gfiles_unrelated_inventory) }
|
||||
record.getJSONObject("games")
|
||||
} else JSONObject()
|
||||
val next = JSONObject()
|
||||
@@ -113,15 +114,15 @@ object GameFiles {
|
||||
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
|
||||
val exports = unique.map { game ->
|
||||
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
|
||||
val preferred = child(folder, name)
|
||||
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(folder, game, name)
|
||||
val preferred = child(context, folder, name)
|
||||
val target = if (inventoryOwns(context, folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(context, folder, game, name)
|
||||
val text = contents(game.gameIdString)
|
||||
val existed = target.exists()
|
||||
// Keep an edited export without letting it block the rest of the library sync.
|
||||
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
|
||||
val owned = inventoryOwns(context, folder, previous, target, game.gameIdString)
|
||||
val file = if (owned && target.exists() &&
|
||||
!matches(target, text)) target else exportNamed(folder, game, target.name)
|
||||
!matches(target, text)) target else exportNamed(context, folder, game, target.name)
|
||||
if (!existed || owned) next.put(file.name, game.gameIdString)
|
||||
file
|
||||
}
|
||||
@@ -129,32 +130,32 @@ object GameFiles {
|
||||
for (name in previous.keys()) {
|
||||
val id = previous.getString(name)
|
||||
if (!next.has(name) && name.endsWith(".$EXTENSION") && GameLaunchLink.validId(id)) {
|
||||
val file = child(folder, name)
|
||||
val file = child(context, folder, name)
|
||||
if (matches(file, contents(id))) {
|
||||
// Android shared storage is often case insensitive. A case-only rename may
|
||||
// still resolve to the live export, which must not be pruned as an old file.
|
||||
val retained = exports.any { java.nio.file.Files.isSameFile(it.toPath(), file.toPath()) }
|
||||
if (!retained) check(file.delete()) { "Could not remove $name" }
|
||||
if (!retained) check(file.delete()) { context.getString(R.string.gfiles_remove_failed, name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
|
||||
}
|
||||
|
||||
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
|
||||
private fun inventoryOwns(context: Context, folder: File, record: JSONObject, file: File, id: String): Boolean {
|
||||
if (record.optString(file.name) == id) return true
|
||||
if (!file.exists()) return false
|
||||
return record.keys().asSequence().any { name ->
|
||||
record.optString(name) == id && child(folder, name).let { old ->
|
||||
record.optString(name) == id && child(context, folder, name).let { old ->
|
||||
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun child(folder: File, name: String): File {
|
||||
private fun child(context: Context, folder: File, name: String): File {
|
||||
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
|
||||
val file = File(folder, name)
|
||||
require(file.canonicalFile.parentFile == folder.canonicalFile) { "File leaves the export folder" }
|
||||
require(file.canonicalFile.parentFile == folder.canonicalFile) { context.getString(R.string.gfiles_outside_folder) }
|
||||
return file
|
||||
}
|
||||
|
||||
|
||||
@@ -24,15 +24,23 @@ object Library {
|
||||
val hero: File? = null, val lastPlayed: Long = 0L,
|
||||
val gameFiles: File? = null, val protonPrefix: File? = null,
|
||||
val icon: File? = null,
|
||||
/** Where the game came from: [SOURCE_STEAM], a store's id (gog, epic, amazon) or [ADDED] for a folder the user added. */
|
||||
val source: String = SOURCE_STEAM,
|
||||
/** The store's own id for a store install; null otherwise. */
|
||||
val storeId: String? = null,
|
||||
) {
|
||||
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
}
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
|
||||
const val ADDED = "added"
|
||||
/** The [SteamGame.source] of a title Steam itself installed. */
|
||||
const val SOURCE_STEAM = "steam"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
/** The shown name of [system] when it is words rather than a platform name, else 0. */
|
||||
val systemRes: Int get() = if (id == "retroarch") R.string.content_system_many else 0
|
||||
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
|
||||
val iconRes: Int get() = when (id) {
|
||||
"rpcs3" -> R.drawable.emu_rpcs3; "armsx2" -> R.drawable.emu_pcsx2; "dolphin" -> R.drawable.emu_dolphin
|
||||
@@ -84,17 +92,20 @@ object Library {
|
||||
return listOfNotNull(
|
||||
root to "internal",
|
||||
GameStorage.effective(context)?.let { File(it.path) to it.label },
|
||||
)
|
||||
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
|
||||
}
|
||||
|
||||
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
|
||||
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
|
||||
fun protonPrefix(
|
||||
context: Context, appId: Long, preferredLibrary: File? = null,
|
||||
libraries: List<File> = steamLibraries(context).map { it.first },
|
||||
): File? {
|
||||
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
|
||||
val secondary = GameStorage.effective(context)?.let { File(it.path) }
|
||||
fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath)
|
||||
val secondary = libraries.drop(1).mapNotNull { runCatching { it.canonicalPath }.getOrNull() }.toSet()
|
||||
fun prefixRoot(root: File): File = if (runCatching { root.canonicalPath }.getOrNull() in secondary)
|
||||
SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root
|
||||
else root
|
||||
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot)
|
||||
val roots = (listOfNotNull(preferredLibrary) + libraries).map(::prefixRoot)
|
||||
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
|
||||
return roots.asSequence()
|
||||
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
|
||||
@@ -105,11 +116,12 @@ object Library {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val libraries = steamLibraries(context)
|
||||
val roots = libraries.map { it.first }
|
||||
val out = LinkedHashMap<Int, SteamGame>()
|
||||
for ((library, label) in libraries) {
|
||||
val steamapps = File(library, "steamapps")
|
||||
val manifests = steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
|
||||
if (strictRead && steamapps.isDirectory) check(manifests != null) { "Steam manifests are unavailable" }
|
||||
if (strictRead && steamapps.isDirectory) check(manifests != null) { context.getString(R.string.game_sync_err_manifests) }
|
||||
manifests?.sortedBy { it.name }?.forEach { manifest ->
|
||||
val appId = manifest.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return@forEach
|
||||
if (appId in NOT_GAMES || out.containsKey(appId)) return@forEach
|
||||
@@ -130,7 +142,7 @@ object Library {
|
||||
out[appId] = SteamGame(
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library),
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
|
||||
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
|
||||
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
|
||||
)
|
||||
@@ -140,16 +152,20 @@ object Library {
|
||||
}
|
||||
|
||||
/** The same installed-game inventory used for links, shortcuts and file exports. */
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
|
||||
val roots = steamLibraries(context).map { it.first }
|
||||
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
|
||||
@@ -26,6 +26,8 @@ object LibraryCache {
|
||||
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
|
||||
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
|
||||
icon = o.file("icon"),
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
|
||||
@@ -36,7 +38,8 @@ object LibraryCache {
|
||||
for (g in games) array.put(
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.gpu
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
@@ -84,7 +85,7 @@ object DriverBundle {
|
||||
unzip(context, uri, work)
|
||||
val manifest = File(work, MANIFEST).takeIf { it.isFile }
|
||||
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
|
||||
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
|
||||
?: throw IllegalArgumentException(context.getString(R.string.bundle_no_manifest))
|
||||
val androidDir = File(work, manifest.androidPath)
|
||||
val linuxDir = File(work, manifest.linuxPath)
|
||||
|
||||
@@ -135,7 +136,7 @@ object DriverBundle {
|
||||
for (entry in generateSequence { zip.nextEntry }) {
|
||||
if (entry.isDirectory) continue
|
||||
val out = File(root, entry.name).canonicalFile
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException(context.getString(R.string.bundle_entry_outside, entry.name))
|
||||
out.parentFile?.mkdirs()
|
||||
out.outputStream().use { sink ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
@@ -143,7 +144,7 @@ object DriverBundle {
|
||||
val r = zip.read(buf)
|
||||
if (r <= 0) break
|
||||
total += r
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException(context.getString(R.string.bundle_too_large, MAX_BYTES shr 20))
|
||||
sink.write(buf, 0, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
|
||||
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
|
||||
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
|
||||
* may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
* support level is what the app has actually been tested on (Adreno 650, 725 and newer) - an
|
||||
* Adreno 610 may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
*/
|
||||
data class GpuInfo(
|
||||
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
|
||||
@@ -33,7 +35,10 @@ data class GpuInfo(
|
||||
A6XX("Adreno 6xx"),
|
||||
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
|
||||
ADRENO_UNKNOWN("Adreno"),
|
||||
NOT_ADRENO("Not an Adreno GPU"),
|
||||
NOT_ADRENO("Not an Adreno GPU");
|
||||
|
||||
/** [label] in the app's language: the Adreno families are names, the rest is words. */
|
||||
fun label(context: Context): String = if (this == NOT_ADRENO) context.getString(R.string.gpuinfo_not_adreno) else label
|
||||
}
|
||||
|
||||
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
|
||||
@@ -41,21 +46,35 @@ data class GpuInfo(
|
||||
val support: Support
|
||||
get() = when {
|
||||
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
|
||||
family == Family.A6XX && model == 650 -> Support.TESTED
|
||||
family == Family.A8XX -> Support.TESTED
|
||||
family == Family.A7XX && model >= 725 -> Support.TESTED
|
||||
else -> Support.UNTESTED
|
||||
}
|
||||
|
||||
/** One line for the device card and the system check. */
|
||||
/** One line for the device card and the system check, in English for the device report. */
|
||||
val supportText: String
|
||||
get() = when (support) {
|
||||
Support.TESTED -> "Supported"
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
|
||||
else "Below tested hardware (Adreno 725 and newer): it may not run"
|
||||
else "Outside tested hardware (Adreno 650, 725 and newer): it may not run"
|
||||
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
|
||||
}
|
||||
|
||||
/** [supportText] in the app's language. */
|
||||
fun supportText(context: Context): String = context.getString(when (support) {
|
||||
Support.TESTED -> R.string.gpuinfo_supported
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) R.string.gpuinfo_experimental else R.string.gpuinfo_below_tested
|
||||
Support.UNSUPPORTED -> R.string.gpuinfo_unsupported
|
||||
})
|
||||
|
||||
/** [name] for the screen: the stand-in for a GPU the device does not name is in the app's language. */
|
||||
fun displayName(context: Context): String = if (name == UNNAMED) context.getString(R.string.gpu_this_gpu) else name
|
||||
|
||||
companion object {
|
||||
/** [name] when the device names no GPU; English, as the device report shows it. */
|
||||
private const val UNNAMED = "this GPU"
|
||||
|
||||
fun detect(): GpuInfo {
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
@@ -78,7 +97,7 @@ data class GpuInfo(
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { UNNAMED } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
kgslName = raw.orEmpty(), modelSource = source,
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -181,20 +182,17 @@ public class LinuxVulkanDriverManager {
|
||||
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
|
||||
try {
|
||||
if (soName == null) {
|
||||
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
|
||||
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
|
||||
throw new IllegalArgumentException(context.getString(R.string.lvd_no_library));
|
||||
}
|
||||
File so = new File(tmpDir, LIB_NAME);
|
||||
if (!isAarch64Elf(so)) {
|
||||
throw new IllegalArgumentException(soName + " is not a 64-bit AArch64 ELF shared library.");
|
||||
throw new IllegalArgumentException(context.getString(R.string.lvd_not_aarch64, soName));
|
||||
}
|
||||
// The one check that actually separates this from the bionic builds. Both sonames
|
||||
// are in the driver's .dynstr, so a byte scan is enough and needs no ELF parsing:
|
||||
// glibc's is versioned ("libc.so.6"), bionic's is not ("libc.so").
|
||||
if (!containsAscii(so, "libc.so.6")) {
|
||||
throw new IllegalArgumentException(soName + " is not a glibc driver - it links Android's libc. "
|
||||
+ "The Linux runtime needs a \"-Linux\" zip; a plain or \"-Wayland\" Turnip cannot be "
|
||||
+ "loaded by the Steam client at all.");
|
||||
throw new IllegalArgumentException(context.getString(R.string.lvd_not_glibc, soName));
|
||||
}
|
||||
String kind = zipMeta != null ? zipMeta.optString("kind", "") : "";
|
||||
if (!kind.isEmpty() && !"linux-vulkan-icd".equals(kind)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
@@ -82,11 +84,15 @@ data class ScreenEffects(
|
||||
* SessionPrefs.upscalerChoices mode the Look switches to.
|
||||
*/
|
||||
data class Look(
|
||||
/** The Look's id: what the row picks by and the compositor logs. */
|
||||
val name: String,
|
||||
val effects: ScreenEffects,
|
||||
val scalingMode: Int? = null,
|
||||
/** One line under the row while this Look is picked. */
|
||||
val desc: String,
|
||||
/** [name] and [desc] in the app's language, for the menu. */
|
||||
@StringRes val label: Int,
|
||||
@StringRes val descRes: Int,
|
||||
)
|
||||
|
||||
object ScreenEffectLooks {
|
||||
@@ -96,29 +102,29 @@ object ScreenEffectLooks {
|
||||
|
||||
/** Index 0 is the neutral state a fresh session matches, so the row always names something. */
|
||||
val LOOKS: List<Look> = listOf(
|
||||
Look("Off", ScreenEffects.OFF, desc = "Nothing applied."),
|
||||
Look("Off", ScreenEffects.OFF, desc = "Nothing applied.", label = R.string.frame_gen_off, descRes = R.string.look_off_desc),
|
||||
Look("Game Clarity", ScreenEffects(brightness = 2, contrast = 12, saturation = 108, cas = true, casLevel = 55),
|
||||
desc = "Sharpening with a touch of contrast - the everyday one."),
|
||||
desc = "Sharpening with a touch of contrast - the everyday one.", label = R.string.look_game_clarity, descRes = R.string.look_game_clarity_desc),
|
||||
Look("Vivid", ScreenEffects(brightness = 3, contrast = 10, gamma = 0.98f, saturation = 145, cas = true, casLevel = 30, deband = true),
|
||||
desc = "Colour pushed hard. Deband on so skies don't stripe."),
|
||||
desc = "Colour pushed hard. Deband on so skies don't stripe.", label = R.string.look_vivid, descRes = R.string.look_vivid_desc),
|
||||
Look("Cinematic", ScreenEffects(brightness = -4, contrast = 22, gamma = 1.10f, saturation = 88, cas = true, casLevel = 20, fxaa = true, deband = true),
|
||||
desc = "Deeper contrast, cooler colour, edges smoothed."),
|
||||
desc = "Deeper contrast, cooler colour, edges smoothed.", label = R.string.look_cinematic, descRes = R.string.look_cinematic_desc),
|
||||
Look("Competitive", ScreenEffects(brightness = 16, contrast = 18, gamma = 0.92f, saturation = 82, cas = true, casLevel = 75),
|
||||
desc = "Shadows lifted and heavy sharpening - spot people first."),
|
||||
desc = "Shadows lifted and heavy sharpening - spot people first.", label = R.string.look_competitive, descRes = R.string.look_competitive_desc),
|
||||
Look("Adaptive Sharpen", ScreenEffects(cas = true, casLevel = 85),
|
||||
desc = "Sharpening only, colour untouched."),
|
||||
desc = "Sharpening only, colour untouched.", label = R.string.look_adaptive_sharpen, descRes = R.string.look_adaptive_sharpen_desc),
|
||||
Look("Filmic", ScreenEffects(brightness = -2, contrast = 16, gamma = 1.14f, saturation = 92, cas = true, casLevel = 15, fxaa = true, deband = true),
|
||||
desc = "Film-like curve, slightly muted."),
|
||||
desc = "Film-like curve, slightly muted.", label = R.string.look_filmic, descRes = R.string.look_filmic_desc),
|
||||
Look("Arcade", ScreenEffects(brightness = 6, contrast = 20, gamma = 0.95f, saturation = 170, cas = true, casLevel = 45, deband = true),
|
||||
desc = "Loud and punchy."),
|
||||
desc = "Loud and punchy.", label = R.string.look_arcade, descRes = R.string.look_arcade_desc),
|
||||
Look("Retro CRT", ScreenEffects(brightness = 6, contrast = 14, gamma = 1.05f, saturation = 115, crt = true, ntsc = true),
|
||||
desc = "The CRT tube and analogue signal passes together."),
|
||||
desc = "The CRT tube and analogue signal passes together.", label = R.string.look_retro_crt, descRes = R.string.look_retro_crt_desc),
|
||||
Look("Upscale Sharp", ScreenEffects(contrast = 6, saturation = 104, cas = true, casLevel = 70), scalingMode = SCALING_FSR,
|
||||
desc = "For running below panel resolution."),
|
||||
desc = "For running below panel resolution.", label = R.string.look_upscale_sharp, descRes = R.string.look_upscale_sharp_desc),
|
||||
Look("Pixel Clean", ScreenEffects(contrast = 8, deband = true), scalingMode = SCALING_NEAREST,
|
||||
desc = "Crisp pixels for 2D and old titles."),
|
||||
desc = "Crisp pixels for 2D and old titles.", label = R.string.look_pixel_clean, descRes = R.string.look_pixel_clean_desc),
|
||||
Look("Anime Edge", ScreenEffects(brightness = 2, contrast = 10, saturation = 125, cas = true, casLevel = 50, toon = true, deband = true),
|
||||
desc = "Toon outlines plus sharpening."),
|
||||
desc = "Toon outlines plus sharpening.", label = R.string.look_anime_edge, descRes = R.string.look_anime_edge_desc),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri;
|
||||
import android.os.Environment;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
import com.droiddeck.launcher.core.TarZst;
|
||||
import com.droiddeck.launcher.session.SessionPrefs;
|
||||
@@ -218,7 +219,7 @@ public final class TurnipDriver {
|
||||
*/
|
||||
String adopt(File tmpDir, String displayName) throws IOException {
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
if (rejected != null) throw new IllegalArgumentException(context.getString(R.string.turnip_not_adrenotools, rejected));
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
@@ -245,31 +246,31 @@ public final class TurnipDriver {
|
||||
* Why a just-extracted zip is not something adrenotools can be pointed at, or null when it is.
|
||||
* The kinds refused by name are the other driver lists' zips, which carry a meta.json too.
|
||||
*/
|
||||
private static String rejectionReason(File dir) {
|
||||
private String rejectionReason(File dir) {
|
||||
File metaFile = new File(dir, "meta.json");
|
||||
if (!metaFile.isFile()) return "no meta.json";
|
||||
if (!metaFile.isFile()) return context.getString(R.string.turnip_reject_no_meta);
|
||||
String libraryName, kind;
|
||||
try {
|
||||
JSONObject meta = new JSONObject(FileUtils.readString(metaFile));
|
||||
libraryName = meta.optString("libraryName", "");
|
||||
kind = meta.optString("kind", "");
|
||||
} catch (Exception e) {
|
||||
return "meta.json is unreadable (" + e.getMessage() + ")";
|
||||
return context.getString(R.string.turnip_reject_meta_unreadable, e.getMessage());
|
||||
}
|
||||
if ("linux-vulkan-icd".equals(kind))
|
||||
return "this is a Linux runtime driver (import it under \"Linux runtime driver\")";
|
||||
return context.getString(R.string.turnip_reject_linux_driver);
|
||||
if ("wayland-game-driver".equals(kind))
|
||||
return "this is a Wayland game driver, which this app has no use for";
|
||||
return context.getString(R.string.turnip_reject_wayland);
|
||||
if (libraryName.isEmpty())
|
||||
return "meta.json names no libraryName, so nothing could be handed to AdrenoTools";
|
||||
return context.getString(R.string.turnip_reject_no_library_name);
|
||||
File library = new File(dir, libraryName);
|
||||
if (!library.isFile())
|
||||
return "meta.json names " + libraryName + ", which is not in the zip";
|
||||
return context.getString(R.string.turnip_reject_library_missing, libraryName);
|
||||
if (!LinuxVulkanDriverManager.isAarch64Elf(library))
|
||||
return libraryName + " is not a 64-bit AArch64 ELF shared library";
|
||||
return context.getString(R.string.turnip_reject_not_aarch64, libraryName);
|
||||
// A glibc build that happens to carry a libraryName would load nothing in this process.
|
||||
if (LinuxVulkanDriverManager.containsAscii(library, "libc.so.6"))
|
||||
return libraryName + " links glibc - a \"-Linux\" Turnip, which belongs under \"Linux runtime driver\"";
|
||||
return context.getString(R.string.turnip_reject_glibc, libraryName);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONArray
|
||||
@@ -95,7 +96,7 @@ object TurnipReleases {
|
||||
var lastError: Exception? = null
|
||||
for (src in SOURCES) {
|
||||
try {
|
||||
val releases = JSONArray(get("https://api.github.com/repos/${src.repo}/releases?per_page=15"))
|
||||
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=15"))
|
||||
val seen = HashSet<String>()
|
||||
var newest: String? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
@@ -135,7 +136,7 @@ object TurnipReleases {
|
||||
return parse(stored)
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
private fun get(context: Context, url: String): String {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 20_000
|
||||
@@ -143,8 +144,8 @@ object TurnipReleases {
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-app")
|
||||
try {
|
||||
val code = c.responseCode
|
||||
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
|
||||
if (code != 200) throw IOException("GitHub answered HTTP $code")
|
||||
if (code == 403 || code == 429) throw IOException(context.getString(R.string.turnip_rate_limited))
|
||||
if (code != 200) throw IOException(context.getString(R.string.turnip_github_http, code))
|
||||
return c.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
c.disconnect()
|
||||
@@ -194,7 +195,7 @@ object TurnipReleases {
|
||||
|
||||
/** Download an asset into the cache; the caller imports it and deletes the file. */
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
|
||||
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val c = URL(asset.url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
@@ -202,7 +203,7 @@ object TurnipReleases {
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-app")
|
||||
c.instanceFollowRedirects = true
|
||||
try {
|
||||
if (c.responseCode != 200) throw IOException("download answered HTTP ${c.responseCode}")
|
||||
if (c.responseCode != 200) throw IOException(context.getString(R.string.turnip_download_http, c.responseCode))
|
||||
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
|
||||
c.inputStream.use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
@@ -220,7 +221,7 @@ object TurnipReleases {
|
||||
}
|
||||
}
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
throw IOException("Checksum mismatch - the download was discarded")
|
||||
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
|
||||
}
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -24,11 +24,18 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.onSignal
|
||||
import com.droiddeck.launcher.ui.LocalPalette
|
||||
|
||||
class ControllerEditorActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
private lateinit var controls: OnScreenControls
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -48,10 +55,10 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
|
||||
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
EditorButton("Reset", false) { controls.resetLayout() }
|
||||
EditorButton("Cancel", false) { finish() }
|
||||
EditorButton("Save", true) {
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
|
||||
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
|
||||
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
|
||||
controls.saveLayout()
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object ControllerPrefs {
|
||||
const val STEAM_BLUE = 0xFF1A9FFF.toInt()
|
||||
const val OFF = "off"
|
||||
|
||||
val tints = listOf(
|
||||
STEAM_BLUE to "Steam blue",
|
||||
0xFF66C0F4.toInt() to "Sky",
|
||||
0xFFE8EEF4.toInt() to "White",
|
||||
0xFFC77DFF.toInt() to "Violet",
|
||||
0xFF3DDC84.toInt() to "Green",
|
||||
0xFFFFA726.toInt() to "Amber",
|
||||
0xFFFF5252.toInt() to "Red",
|
||||
0xFFFF6FB5.toInt() to "Pink",
|
||||
STEAM_BLUE to R.string.ctrl_tint_steam_blue,
|
||||
0xFF66C0F4.toInt() to R.string.ctrl_tint_sky,
|
||||
0xFFE8EEF4.toInt() to R.string.ctrl_tint_white,
|
||||
0xFFC77DFF.toInt() to R.string.ctrl_tint_violet,
|
||||
0xFF3DDC84.toInt() to R.string.ctrl_tint_green,
|
||||
0xFFFFA726.toInt() to R.string.ctrl_tint_amber,
|
||||
0xFFFF5252.toInt() to R.string.ctrl_tint_red,
|
||||
0xFFFF6FB5.toInt() to R.string.ctrl_tint_pink,
|
||||
)
|
||||
|
||||
val opacities = listOf(40, 60, 80, 100)
|
||||
@@ -22,17 +23,17 @@ object ControllerPrefs {
|
||||
val sizes = listOf(80, 90, 100, 110, 125)
|
||||
|
||||
val mappable = listOf(
|
||||
"a" to "A button", "b" to "B button", "x" to "X button", "y" to "Y button",
|
||||
"lb" to "Left bumper", "rb" to "Right bumper", "lt" to "Left trigger", "rt" to "Right trigger",
|
||||
"select" to "View button", "start" to "Menu button",
|
||||
"a" to R.string.ctrl_button_a, "b" to R.string.ctrl_button_b, "x" to R.string.ctrl_button_x, "y" to R.string.ctrl_button_y,
|
||||
"lb" to R.string.ctrl_button_lb, "rb" to R.string.ctrl_button_rb, "lt" to R.string.ctrl_button_lt, "rt" to R.string.ctrl_button_rt,
|
||||
"select" to R.string.ctrl_button_view, "start" to R.string.ctrl_button_menu,
|
||||
)
|
||||
|
||||
val targets = listOf(
|
||||
"a" to "A", "b" to "B", "x" to "X", "y" to "Y",
|
||||
"lb" to "LB", "rb" to "RB", "lt" to "LT", "rt" to "RT",
|
||||
"l3" to "L3", "r3" to "R3", "select" to "View", "start" to "Menu", "guide" to "Steam",
|
||||
"up" to "D-pad up", "down" to "D-pad down", "left" to "D-pad left", "right" to "D-pad right",
|
||||
OFF to "Hidden",
|
||||
"a" to R.string.ctrl_target_a, "b" to R.string.ctrl_target_b, "x" to R.string.ctrl_target_x, "y" to R.string.ctrl_target_y,
|
||||
"lb" to R.string.ctrl_target_lb, "rb" to R.string.ctrl_target_rb, "lt" to R.string.ctrl_target_lt, "rt" to R.string.ctrl_target_rt,
|
||||
"l3" to R.string.ctrl_target_l3, "r3" to R.string.ctrl_target_r3, "select" to R.string.ctrl_target_view, "start" to R.string.ctrl_target_menu, "guide" to R.string.ctrl_target_steam,
|
||||
"up" to R.string.ctrl_target_up, "down" to R.string.ctrl_target_down, "left" to R.string.ctrl_target_left, "right" to R.string.ctrl_target_right,
|
||||
OFF to R.string.ctrl_target_hidden,
|
||||
)
|
||||
|
||||
class Settings(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
object InputSpace {
|
||||
const val WIDTH = 1920
|
||||
const val HEIGHT = 1080
|
||||
|
||||
fun x(position: Float, viewWidth: Int): Int = scale(position, viewWidth, WIDTH)
|
||||
|
||||
fun y(position: Float, viewHeight: Int): Int = scale(position, viewHeight, HEIGHT)
|
||||
|
||||
private fun scale(position: Float, extent: Int, size: Int): Int =
|
||||
if (extent <= 0) 0 else (position / extent * size).toInt().coerceIn(0, size - 1)
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package com.droiddeck.launcher.input
|
||||
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/** The control surface shown on a secondary Android display during a Linux session. */
|
||||
enum class SecondScreenMode(val id: String, val label: String) {
|
||||
NONE("none", "None"),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
|
||||
TERMINAL("terminal", "Terminal"),
|
||||
enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
|
||||
NONE("none", R.string.second_mode_none),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
|
||||
TERMINAL("terminal", R.string.second_mode_terminal),
|
||||
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
|
||||
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
|
||||
}
|
||||
|
||||
data class SecondScreenDisplay(val id: Int, val label: String)
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.graphics.BitmapFactory
|
||||
import android.os.StatFs
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
@@ -27,6 +29,11 @@ object AppImageManager {
|
||||
val guestDir: String get() = "$GUEST_DIR/$id"
|
||||
}
|
||||
|
||||
/** Why a file cannot be imported: [text] with its [args]. */
|
||||
internal class Problem(@StringRes val text: Int, vararg val args: Any) {
|
||||
fun describe(context: Context): String = context.getString(text, *args)
|
||||
}
|
||||
|
||||
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
|
||||
|
||||
fun list(context: Context): List<Item> =
|
||||
@@ -44,22 +51,22 @@ object AppImageManager {
|
||||
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
|
||||
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
|
||||
*/
|
||||
internal fun problem(file: File): String? {
|
||||
if (!file.isFile) return "The file is gone"
|
||||
internal fun problem(file: File): Problem? {
|
||||
if (!file.isFile) return Problem(R.string.user_apps_file_gone)
|
||||
val head = ByteArray(20)
|
||||
try {
|
||||
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
|
||||
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return Problem(R.string.appimg_too_small) }
|
||||
} catch (e: Exception) {
|
||||
return "The file cannot be read: ${e.message}"
|
||||
return Problem(R.string.appimg_unreadable, e.message.toString())
|
||||
}
|
||||
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
|
||||
return "This is not an AppImage (it is not a Linux program)"
|
||||
return Problem(R.string.appimg_not_linux)
|
||||
}
|
||||
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
|
||||
return when {
|
||||
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs"
|
||||
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage"
|
||||
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
|
||||
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> Problem(R.string.appimg_wrong_arch)
|
||||
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> Problem(R.string.appimg_not_appimage)
|
||||
head[10].toInt() != 2 -> Problem(R.string.appimg_wrong_type, head[10].toInt())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -83,12 +90,12 @@ object AppImageManager {
|
||||
fun import(
|
||||
context: Context, file: File, name: String?, icon: File?, meta: Map<String, String> = emptyMap(), onProgress: (String) -> Unit,
|
||||
): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
problem(file)?.let { return it }
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
problem(file)?.let { return it.describe(context) }
|
||||
val base = root(context).apply { mkdirs() }
|
||||
// Room for the image while it is extracted, and the extracted tree (about the same again).
|
||||
if (StatFs(base.path).availableBytes < file.length() * 3) {
|
||||
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
|
||||
return context.getString(R.string.appimg_no_space_import, FileUtils.sizeToString(file.length() * 3))
|
||||
}
|
||||
val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
|
||||
try {
|
||||
@@ -103,17 +110,17 @@ object AppImageManager {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "import ${file.name}", e)
|
||||
FileUtils.delete(dir)
|
||||
return e.message ?: "Import failed"
|
||||
return e.message ?: context.getString(R.string.appimg_import_failed)
|
||||
}
|
||||
}
|
||||
|
||||
/** Swaps the program of imported [id] for [file], keeping its name and icon; the old one stays if this fails. */
|
||||
fun replace(context: Context, id: String, file: File, meta: Map<String, String>, onProgress: (String) -> Unit): String? {
|
||||
problem(file)?.let { return it }
|
||||
problem(file)?.let { return it.describe(context) }
|
||||
val dir = dir(context, id)
|
||||
if (!File(dir, "app").isDirectory) return "That AppImage is gone"
|
||||
if (!File(dir, "app").isDirectory) return context.getString(R.string.appimg_gone)
|
||||
if (StatFs(dir.path).availableBytes < file.length() * 3) {
|
||||
return "Not enough free space: updating needs about ${FileUtils.sizeToString(file.length() * 3)}"
|
||||
return context.getString(R.string.appimg_no_space_update, FileUtils.sizeToString(file.length() * 3))
|
||||
}
|
||||
return try {
|
||||
extract(context, dir, file, onProgress) ?: run {
|
||||
@@ -123,7 +130,7 @@ object AppImageManager {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "replace $id", e)
|
||||
e.message ?: "Update failed"
|
||||
e.message ?: context.getString(R.string.appimg_update_failed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +142,10 @@ object AppImageManager {
|
||||
dir.mkdirs()
|
||||
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
|
||||
// maps a program executable.
|
||||
onProgress("Copying ${file.name}")
|
||||
onProgress(context.getString(R.string.user_apps_copying, file.name))
|
||||
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
|
||||
image.setExecutable(true, false)
|
||||
onProgress("Extracting ${file.name}")
|
||||
onProgress(context.getString(R.string.appimg_extracting, file.name))
|
||||
val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage"
|
||||
else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }"
|
||||
val out = StringBuilder()
|
||||
@@ -152,7 +159,8 @@ object AppImageManager {
|
||||
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
|
||||
if (status != 0 || !File(dir, "app").isDirectory) {
|
||||
listOf("squashfs-root", "AppDir").forEach { FileUtils.delete(File(dir, it)) }
|
||||
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
|
||||
return out.lines().lastOrNull { it.isNotBlank() }?.let { context.getString(R.string.appimg_extract_failed_detail, it) }
|
||||
?: context.getString(R.string.appimg_extract_failed)
|
||||
}
|
||||
copyLinkedFiles(File(dir, "app"), links)
|
||||
return null
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
|
||||
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.session.SessionState
|
||||
import com.droiddeck.launcher.core.ArchivePaths
|
||||
@@ -24,13 +25,17 @@ object DeckyManager {
|
||||
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
|
||||
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
|
||||
|
||||
/** True while a plugin's remote binary downloads: what the UI branches on, not the stage's words. */
|
||||
@Volatile var downloadingBinary = false
|
||||
private set
|
||||
|
||||
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
|
||||
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
|
||||
|
||||
fun installed(context: Context): String? {
|
||||
val loader = loader(context)
|
||||
if (!loader.isFile || !loader.canExecute()) return null
|
||||
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: "Installed"
|
||||
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: context.getString(R.string.setup_installed)
|
||||
}
|
||||
|
||||
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
|
||||
@@ -102,39 +107,40 @@ object DeckyManager {
|
||||
|
||||
/** Downloads to a sibling temp file and atomically renames only after size, digest and ELF checks. */
|
||||
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing Decky Loader"
|
||||
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_loader)
|
||||
val target = loader(context)
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "PluginLoader.download")
|
||||
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress("Downloading ${release.tag}", if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok) { temp.delete(); return "Decky Loader download failed" }
|
||||
if (temp.length() != release.size) { temp.delete(); return "Decky Loader size did not match the release metadata" }
|
||||
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
|
||||
val ok = Downloader.downloadFile(release.url, temp, false) { 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.downloadString(checksumUrl) ?: run { temp.delete(); return "Could not download the release checksum" }
|
||||
val checksumBody = Downloader.downloadString(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 "Release checksum is missing" }
|
||||
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
|
||||
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
|
||||
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
|
||||
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
|
||||
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
|
||||
} ?: 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) }
|
||||
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return context.getString(R.string.deckymgr_checksum_mismatch) }
|
||||
if (!validElf(temp, release.machine)) { temp.delete(); return context.getString(R.string.deckymgr_not_elf) }
|
||||
if (SessionState.running) { temp.delete(); return context.getString(R.string.deckymgr_session_started_loader) }
|
||||
if (!temp.setExecutable(true, false)) { temp.delete(); return context.getString(R.string.deckymgr_loader_not_executable) }
|
||||
// Decky needs Steam's local CEF debugger only when its session supervisor is enabled.
|
||||
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
|
||||
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
|
||||
cefMarker.parentFile?.mkdirs()
|
||||
cefMarker.createNewFile() || cefMarker.isFile
|
||||
}.getOrDefault(false)
|
||||
if (!cefReady) { temp.delete(); return "Could not enable Steam CEF remote debugging" }
|
||||
if (!cefReady) { temp.delete(); return context.getString(R.string.deckymgr_cef_failed) }
|
||||
val staged = File(target.parentFile, "PluginLoader.new")
|
||||
staged.delete()
|
||||
if (!temp.renameTo(staged)) { temp.delete(); return "Could not stage PluginLoader" }
|
||||
if (!temp.renameTo(staged)) { temp.delete(); return context.getString(R.string.deckymgr_stage_failed) }
|
||||
val old = File(target.parentFile, "PluginLoader.old")
|
||||
old.delete()
|
||||
if (target.exists() && !target.renameTo(old)) { staged.delete(); return "Could not preserve the existing PluginLoader" }
|
||||
if (target.exists() && !target.renameTo(old)) { staged.delete(); return context.getString(R.string.deckymgr_loader_preserve_failed) }
|
||||
if (!staged.renameTo(target)) {
|
||||
if (old.exists()) old.renameTo(target)
|
||||
staged.delete()
|
||||
return "Could not activate the new PluginLoader"
|
||||
return context.getString(R.string.deckymgr_loader_activate_failed)
|
||||
}
|
||||
old.delete()
|
||||
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
|
||||
@@ -145,43 +151,44 @@ object DeckyManager {
|
||||
|
||||
/** Imports a Decky distribution ZIP into homebrew/plugins for the next Steam session. */
|
||||
fun installPluginZip(context: Context, archive: File, progress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing a plugin"
|
||||
if (installed(context) == null) return "Install Decky Loader before importing a plugin"
|
||||
if (!archive.isFile || !archive.canRead()) return "Could not read the selected ZIP file"
|
||||
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_plugin)
|
||||
if (installed(context) == null) return context.getString(R.string.deckymgr_loader_required)
|
||||
if (!archive.isFile || !archive.canRead()) return context.getString(R.string.deckymgr_zip_unreadable)
|
||||
|
||||
val plugins = File(LinuxRuntime.rootDir(context), "root/homebrew/plugins")
|
||||
if (!plugins.exists() && !plugins.mkdirs()) return "Could not create the Decky plugins directory"
|
||||
if (!plugins.exists() && !plugins.mkdirs()) return context.getString(R.string.deckymgr_plugins_dir_failed)
|
||||
val staging = File(plugins, ".droiddeck-import-${UUID.randomUUID()}")
|
||||
val backupSuffix = ".droiddeck-backup-${UUID.randomUUID()}"
|
||||
try {
|
||||
staging.mkdirs()
|
||||
progress("Extracting plugin ZIP", -1)
|
||||
extractPluginZip(archive, staging)
|
||||
progress(context.getString(R.string.deckymgr_extracting_zip), -1)
|
||||
extractPluginZip(context, archive, staging)
|
||||
val roots = staging.listFiles()?.filter { it.isDirectory } ?: emptyList()
|
||||
if (roots.size != 1 || staging.listFiles()?.size != 1) return "Plugin ZIP must contain one top-level plugin folder"
|
||||
if (roots.size != 1 || staging.listFiles()?.size != 1) return context.getString(R.string.deckymgr_zip_one_folder)
|
||||
val plugin = roots.single()
|
||||
if (!File(plugin, "plugin.json").isFile || !File(plugin, "package.json").isFile || !File(plugin, "dist/index.js").isFile) {
|
||||
return "Plugin ZIP is missing plugin.json, package.json, or dist/index.js"
|
||||
return context.getString(R.string.deckymgr_zip_missing_files)
|
||||
}
|
||||
val name = JSONObject(File(plugin, "plugin.json").readText()).getString("name")
|
||||
require(name.isNotBlank()) { "Plugin name is missing" }
|
||||
require(name.isNotBlank()) { context.getString(R.string.deckymgr_plugin_name_missing) }
|
||||
val metadata = JSONObject(File(plugin, "package.json").readText())
|
||||
downloadRemoteBinaries(metadata, plugin, progress)
|
||||
if (SessionState.running) return "A session started during installation; stop it before installing a plugin"
|
||||
downloadRemoteBinaries(context, metadata, plugin, progress)
|
||||
if (SessionState.running) return context.getString(R.string.deckymgr_session_started_plugin)
|
||||
|
||||
DeckyPluginInstall.activate(plugin, plugins, name,
|
||||
File(plugins.parentFile, "settings/loader.json"), backupSuffix)
|
||||
progress("Plugin installed · restart Steam to load it", 100)
|
||||
progress(context.getString(R.string.deckymgr_plugin_installed), 100)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "plugin ZIP import", e)
|
||||
return e.message?.takeIf { it.isNotBlank() } ?: "Plugin ZIP installation failed"
|
||||
if (e is DeckyPluginInstall.Failure) return context.getString(e.text)
|
||||
return e.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.deckymgr_plugin_install_failed)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractPluginZip(archive: File, destination: File) {
|
||||
private fun extractPluginZip(context: Context, archive: File, destination: File) {
|
||||
var totalBytes = 0L
|
||||
var entries = 0
|
||||
val seen = HashSet<String>()
|
||||
@@ -189,30 +196,30 @@ object DeckyManager {
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
entries++
|
||||
require(entries <= 10_000) { "Plugin ZIP contains too many entries" }
|
||||
require(entries <= 10_000) { context.getString(R.string.deckymgr_zip_too_many) }
|
||||
val relative = entry.name.replace('\\', '/')
|
||||
require(relative.isNotBlank() && !relative.startsWith('/')) { "Plugin ZIP contains an invalid path" }
|
||||
require(seen.add(relative)) { "Plugin ZIP contains duplicate paths" }
|
||||
require(relative.isNotBlank() && !relative.startsWith('/')) { context.getString(R.string.deckymgr_zip_invalid_path) }
|
||||
require(seen.add(relative)) { context.getString(R.string.deckymgr_zip_duplicate_paths) }
|
||||
val output = ArchivePaths.inside(destination, relative)
|
||||
?: throw IllegalArgumentException("Plugin ZIP contains an unsafe path")
|
||||
?: throw IllegalArgumentException(context.getString(R.string.deckymgr_zip_unsafe_path))
|
||||
if (entry.isDirectory) {
|
||||
require(output.mkdirs() || output.isDirectory) { "Could not create plugin folder" }
|
||||
require(output.mkdirs() || output.isDirectory) { context.getString(R.string.deckymgr_folder_failed) }
|
||||
} else {
|
||||
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { "Could not create plugin folder" }
|
||||
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { context.getString(R.string.deckymgr_folder_failed) }
|
||||
FileOutputStream(output).use { out ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
totalBytes += read
|
||||
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { "Plugin ZIP expands beyond the 1 GiB limit" }
|
||||
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { context.getString(R.string.deckymgr_zip_too_large) }
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
// Decky distributions place plugin executables under bin/. Java's ZIP reader
|
||||
// does not expose Unix mode bits, so restore execution on those payload files.
|
||||
if (relative.split('/').dropLast(1).any { it == "bin" } && !output.setExecutable(true, false)) {
|
||||
throw IllegalStateException("Could not mark bundled plugin binary executable")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_bundled_not_executable))
|
||||
}
|
||||
}
|
||||
zip.closeEntry()
|
||||
@@ -220,42 +227,49 @@ object DeckyManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadRemoteBinaries(metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
|
||||
private fun downloadRemoteBinaries(context: Context, metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
|
||||
val binaries = metadata.optJSONArray("remote_binary") ?: return
|
||||
val bin = File(plugin, "bin")
|
||||
for (i in 0 until binaries.length()) {
|
||||
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException("Invalid remote_binary entry")
|
||||
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException(context.getString(R.string.deckymgr_remote_entry_invalid))
|
||||
val name = binary.optString("name")
|
||||
val url = binary.optString("url")
|
||||
val hash = binary.optString("sha256hash")
|
||||
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { "Invalid remote binary name" }
|
||||
require(url.startsWith("https://")) { "Remote binary URL must use HTTPS" }
|
||||
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { "Remote binary $name has an invalid SHA-256 hash" }
|
||||
require(bin.mkdirs() || bin.isDirectory) { "Could not create plugin bin directory" }
|
||||
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { context.getString(R.string.deckymgr_remote_name_invalid) }
|
||||
require(url.startsWith("https://")) { context.getString(R.string.deckymgr_remote_url_https) }
|
||||
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { context.getString(R.string.deckymgr_remote_hash_invalid, name) }
|
||||
require(bin.mkdirs() || bin.isDirectory) { context.getString(R.string.deckymgr_bin_dir_failed) }
|
||||
val temp = File(bin, ".$name.download")
|
||||
progress("Downloading plugin binary · $name", -1)
|
||||
if (!Downloader.downloadFile(url, temp, false) { fraction ->
|
||||
progress("Downloading plugin binary · $name", if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
|
||||
}) {
|
||||
val downloading = context.getString(R.string.deckymgr_downloading_binary, name)
|
||||
downloadingBinary = true
|
||||
progress(downloading, -1)
|
||||
val downloaded = try {
|
||||
Downloader.downloadFile(url, temp, false) { fraction ->
|
||||
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
|
||||
}
|
||||
} finally {
|
||||
downloadingBinary = false
|
||||
}
|
||||
if (!downloaded) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not download plugin binary $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_download_failed, name))
|
||||
}
|
||||
if (!hash.equals(Hashes.sha256(temp), ignoreCase = true)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Plugin binary checksum mismatch · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_checksum_mismatch, name))
|
||||
}
|
||||
if (!temp.setExecutable(true, false)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not mark plugin binary executable · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_not_executable, name))
|
||||
}
|
||||
val target = File(bin, name)
|
||||
if (target.exists() && !target.delete()) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not replace plugin binary · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_replace_failed, name))
|
||||
}
|
||||
if (!temp.renameTo(target)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not install plugin binary · $name")
|
||||
throw IllegalStateException(context.getString(R.string.deckymgr_binary_install_failed, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Offline equivalent of Decky's identity-based replacement and loader settings update. */
|
||||
internal object DeckyPluginInstall {
|
||||
/** A failure the user is told about: [text] is its string resource. */
|
||||
class Failure(@StringRes val text: Int) : IllegalStateException()
|
||||
|
||||
fun activate(staged: File, plugins: File, name: String, settingsFile: File, backupSuffix: String) {
|
||||
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
|
||||
val existing = plugins.listFiles().orEmpty().filter { folder ->
|
||||
@@ -14,7 +19,7 @@ internal object DeckyPluginInstall {
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
val target = File(plugins, staged.name)
|
||||
require(!target.exists() || target in existing) { "Plugin folder belongs to another plugin" }
|
||||
if (target.exists() && target !in existing) throw Failure(R.string.deckymgr_folder_taken)
|
||||
val order = settings.optJSONArray("pluginOrder") ?: JSONArray(plugins.listFiles().orEmpty()
|
||||
.filter { it.isDirectory && !it.name.startsWith(".droiddeck-") }
|
||||
.mapNotNull { runCatching { JSONObject(File(it, "plugin.json").readText()).getString("name") }.getOrNull() }
|
||||
@@ -34,15 +39,15 @@ internal object DeckyPluginInstall {
|
||||
pending.writeText(settings.toString(4))
|
||||
for (old in existing) {
|
||||
val backup = File(plugins, ".droiddeck-backup-${old.name}$backupSuffix")
|
||||
check(old.renameTo(backup)) { "Could not preserve the existing plugin" }
|
||||
if (!old.renameTo(backup)) throw Failure(R.string.deckymgr_preserve_failed)
|
||||
backups.add(old to backup)
|
||||
}
|
||||
check(staged.renameTo(target)) { "Could not activate the imported plugin" }
|
||||
if (!staged.renameTo(target)) throw Failure(R.string.deckymgr_activate_failed)
|
||||
activated = true
|
||||
check(pending.renameTo(settingsFile)) { "Could not update Decky plugin settings" }
|
||||
if (!pending.renameTo(settingsFile)) throw Failure(R.string.deckymgr_settings_failed)
|
||||
} catch (error: Exception) {
|
||||
if (activated) target.deleteRecursively()
|
||||
for ((old, backup) in backups.asReversed()) check(backup.renameTo(old)) { "Could not restore the existing plugin" }
|
||||
for ((old, backup) in backups.asReversed()) if (!backup.renameTo(old)) throw Failure(R.string.deckymgr_restore_failed)
|
||||
throw error
|
||||
} finally {
|
||||
pending.delete()
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
|
||||
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.FileUtils
|
||||
import org.json.JSONObject
|
||||
@@ -81,20 +82,21 @@ object DesktopCatalog {
|
||||
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
|
||||
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (!root.isDirectory) return "The Linux runtime is not installed"
|
||||
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
|
||||
// Every catalog row carries a sha256; one without is refused rather than trusted.
|
||||
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
|
||||
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
|
||||
val download = File(context.cacheDir, "pkg-${entry.id}.download")
|
||||
try {
|
||||
listener?.onProgress("Downloading ${entry.name}", 0)
|
||||
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
|
||||
val ok = Downloader.downloadFile(entry.url, download, true) { f ->
|
||||
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
|
||||
}
|
||||
if (!ok) return "Download failed"
|
||||
listener?.onProgress("Verifying", -1)
|
||||
if (!ok) return context.getString(R.string.user_apps_download_failed)
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
|
||||
val actual = Hashes.sha256(download)
|
||||
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
|
||||
listener?.onProgress("Installing ${entry.name}", -1)
|
||||
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
|
||||
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
|
||||
when (entry.kind) {
|
||||
"appimage" -> {
|
||||
val dir = File(root, "opt/appimages").apply { mkdirs() }
|
||||
@@ -102,20 +104,20 @@ object DesktopCatalog {
|
||||
// Some projects zip the AppImage (melonDS); the one file inside is what we want.
|
||||
val placed = if (entry.url.endsWith(".zip", ignoreCase = true)) unzipAppImage(download, target)
|
||||
else download.renameTo(target)
|
||||
if (!placed) return "Could not place the AppImage"
|
||||
if (!placed) return context.getString(R.string.deskpkg_place_failed)
|
||||
target.setExecutable(true, false)
|
||||
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
|
||||
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> if (!LinuxRuntimeInstaller.extract(download, root, listener)) return "Extraction failed"
|
||||
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
}
|
||||
FileUtils.writeString(marker(context, entry.id), entry.version)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${entry.id}", e)
|
||||
return e.message ?: "Install failed"
|
||||
return e.message ?: context.getString(R.string.deskpkg_install_failed)
|
||||
} finally {
|
||||
download.delete()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -92,9 +93,10 @@ object FlatpakManager {
|
||||
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
|
||||
}
|
||||
|
||||
private inline fun <T> exclusive(what: String, block: () -> T): T? {
|
||||
/** [block]'s result, or [ifBusy]'s while another operation has the installation. */
|
||||
private inline fun <T> exclusive(what: String, ifBusy: () -> T, block: () -> T): T {
|
||||
synchronized(this) {
|
||||
if (busy != null) return null
|
||||
if (busy != null) return ifBusy()
|
||||
busy = what
|
||||
}
|
||||
try { return block() } finally { busy = null }
|
||||
@@ -102,8 +104,8 @@ object FlatpakManager {
|
||||
|
||||
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
|
||||
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
return exclusive("setup") {
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
return exclusive("setup", { context.getString(R.string.flatpakmgr_busy) }) {
|
||||
var failure: String? = null
|
||||
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
|
||||
Log.i(TAG, "setup: $line")
|
||||
@@ -114,63 +116,76 @@ object FlatpakManager {
|
||||
}
|
||||
when {
|
||||
failure != null -> failure
|
||||
status != 0 -> "Flatpak setup failed (exit $status)"
|
||||
!ready(context) -> "Flatpak setup did not finish"
|
||||
status != 0 -> context.getString(R.string.flatpakmgr_setup_failed, status)
|
||||
!ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished)
|
||||
else -> null
|
||||
}
|
||||
} ?: "Another store task is running"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One store operation - install, uninstall or update - with its progress as a stage line and
|
||||
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
|
||||
*/
|
||||
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!ready(context)) return "Set up Flatpak first"
|
||||
return exclusive("$verb:${id ?: "all"}") {
|
||||
private fun transaction(
|
||||
context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit, onDone: (JSONObject) -> Unit = {},
|
||||
): String? {
|
||||
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
|
||||
return exclusive("$verb:${id ?: "all"}", { context.getString(R.string.flatpakmgr_busy) }) {
|
||||
var error: String? = null
|
||||
// Progress lines carry no ref; they belong to the step the last "op" line started.
|
||||
var step = "Starting"
|
||||
var step = context.getString(R.string.flatpakmgr_starting)
|
||||
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
|
||||
val status = runGuest(context, argv, fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull()
|
||||
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
|
||||
when (o.optString("e")) {
|
||||
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
|
||||
"op" -> { step = stage(context, o); onProgress(step, overall(o, 0)) }
|
||||
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
|
||||
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
|
||||
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
|
||||
"done" -> onDone(o)
|
||||
}
|
||||
}
|
||||
error ?: if (status != 0) "Flatpak exited with status $status" else null
|
||||
} ?: "Another store task is running"
|
||||
error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stage(o: JSONObject): String {
|
||||
private fun stage(context: Context, o: JSONObject): String {
|
||||
val ref = o.optString("ref")
|
||||
val name = refLabel(ref)
|
||||
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
|
||||
return when (o.optString("kind")) {
|
||||
"uninstall" -> "Removing $name$step"
|
||||
"update" -> "Updating $name$step"
|
||||
else -> "Installing $name$step"
|
||||
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
|
||||
if (ref.isEmpty()) return o.optString("status").ifEmpty { context.getString(R.string.setup_check_busy) }
|
||||
val name = refLabel(ref) { id, args -> context.getString(id, *args) }
|
||||
val n = o.optInt("n")
|
||||
val kind = o.optString("kind")
|
||||
return if (n > 1) context.getString(when (kind) {
|
||||
"uninstall" -> R.string.flatpakmgr_removing_step
|
||||
"update" -> R.string.flatpakmgr_updating_step
|
||||
else -> R.string.flatpakmgr_installing_step
|
||||
}, name, o.optInt("i"), n)
|
||||
else context.getString(when (kind) {
|
||||
"uninstall" -> R.string.flatpakmgr_removing
|
||||
"update" -> R.string.flatpakmgr_updating
|
||||
else -> R.string.flatpakmgr_installing
|
||||
}, name)
|
||||
}
|
||||
|
||||
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
|
||||
internal fun refLabel(ref: String): String {
|
||||
/**
|
||||
* A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". [text]
|
||||
* resolves a string resource with its arguments.
|
||||
*/
|
||||
internal fun refLabel(ref: String, text: (Int, Array<out Any>) -> String): String {
|
||||
val parts = ref.split('/')
|
||||
val id = parts.getOrNull(1) ?: return ref
|
||||
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
|
||||
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
|
||||
val branch = parts.getOrNull(3)
|
||||
return when {
|
||||
id.endsWith(".Locale") -> "translations"
|
||||
".GL." in id || ".GL32." in id -> "graphics drivers"
|
||||
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
|
||||
id.endsWith(".Locale") -> text(R.string.flatpakmgr_ref_translations, emptyArray())
|
||||
".GL." in id || ".GL32." in id -> text(R.string.flatpakmgr_ref_graphics_drivers, emptyArray())
|
||||
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> text(R.string.flatpakmgr_ref_media_codecs, emptyArray())
|
||||
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
|
||||
"gnome", "kde" -> vendor.uppercase()
|
||||
else -> vendor.replaceFirstChar { it.uppercase() }
|
||||
} + " runtime$branch"
|
||||
}.let { if (branch != null) text(R.string.flatpakmgr_ref_runtime_branch, arrayOf(it, branch)) else text(R.string.flatpakmgr_ref_runtime, arrayOf(it)) }
|
||||
else -> id.substringAfterLast('.')
|
||||
}
|
||||
}
|
||||
@@ -184,13 +199,20 @@ object FlatpakManager {
|
||||
|
||||
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
|
||||
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
|
||||
|
||||
/** Installs the .flatpak bundle at [guestPath], its runtimes from Flathub: the app's ID, and what went wrong if anything. */
|
||||
fun installBundle(context: Context, guestPath: String, onProgress: (String, Int) -> Unit): Pair<String?, String?> {
|
||||
var id: String? = null
|
||||
val problem = transaction(context, "install-bundle", guestPath, onProgress) { id = it.optString("id").ifEmpty { null } }
|
||||
return id to problem
|
||||
}
|
||||
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
|
||||
|
||||
/** Installed apps with an update on Flathub, or null when that could not be checked. */
|
||||
fun updates(context: Context): Set<String>? {
|
||||
if (!ready(context)) return emptySet()
|
||||
var apps: Set<String>? = null
|
||||
exclusive("updates") {
|
||||
exclusive("updates", {}) {
|
||||
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
|
||||
if (o.optString("e") == "updates") {
|
||||
|
||||
@@ -42,6 +42,8 @@ public final class LinuxRuntime {
|
||||
* session ended at once. A long host path behind it is proot's to shorten.
|
||||
*/
|
||||
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
|
||||
private static final String SYSTEM_FONTS = "/system/fonts";
|
||||
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
@@ -207,6 +209,7 @@ public final class LinuxRuntime {
|
||||
File shm = new File(context.getCacheDir(), "shm");
|
||||
shm.mkdirs();
|
||||
bind(cmd, shm.getPath() + ":/dev/shm");
|
||||
bindSystemFonts(cmd, root);
|
||||
|
||||
// Android denies apps these; glibc, Steam and libcap read them at startup.
|
||||
File fakeProc = new File(root, "etc/droiddeck/proc");
|
||||
@@ -465,6 +468,15 @@ public final class LinuxRuntime {
|
||||
return byType;
|
||||
}
|
||||
|
||||
private static void bindSystemFonts(List<String> cmd, File root) {
|
||||
File fonts = new File(SYSTEM_FONTS);
|
||||
File target = new File(root, GUEST_SYSTEM_FONTS.substring(1));
|
||||
if (!fonts.isDirectory() || !fonts.canRead() || !new File(root, "usr/local").isDirectory()) return;
|
||||
if (target.isDirectory() || target.mkdirs()) {
|
||||
bind(cmd, SYSTEM_FONTS + ":" + GUEST_SYSTEM_FONTS);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bind(List<String> cmd, String spec) {
|
||||
cmd.add("-b");
|
||||
cmd.add(spec);
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.ArchivePaths;
|
||||
import com.droiddeck.launcher.core.Downloader;
|
||||
import com.droiddeck.launcher.core.Hashes;
|
||||
@@ -44,9 +45,17 @@ public final class LinuxRuntimeInstaller {
|
||||
|
||||
private static final String VERSION_FILE = ".version";
|
||||
|
||||
/** What a stage is doing, for code that branches on it rather than on its (translated) words. */
|
||||
public enum Step { DOWNLOADING, VERIFYING, OTHER }
|
||||
|
||||
public interface ProgressListener {
|
||||
/** {@code percent} is -1 while the size is unknown. */
|
||||
void onProgress(String stage, int percent);
|
||||
|
||||
/** The same with its {@link Step}; a listener that branches on the step overrides this one. */
|
||||
default void onProgress(Step step, String stage, int percent) {
|
||||
onProgress(stage, percent);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Release {
|
||||
@@ -71,11 +80,12 @@ public final class LinuxRuntimeInstaller {
|
||||
private static final class Job {
|
||||
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
|
||||
final CountDownLatch done = new CountDownLatch(1);
|
||||
volatile String stage = "Starting\u2026";
|
||||
volatile String stage;
|
||||
volatile Step step = Step.OTHER;
|
||||
volatile int percent = -1;
|
||||
volatile boolean ok;
|
||||
final boolean removal;
|
||||
Job(boolean removal) { this.removal = removal; }
|
||||
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
|
||||
}
|
||||
|
||||
private static final Object JOB_LOCK = new Object();
|
||||
@@ -131,16 +141,23 @@ public final class LinuxRuntimeInstaller {
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null && running.removal) return false;
|
||||
owner = running == null;
|
||||
if (owner) { running = new Job(false); removalError = null; }
|
||||
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
|
||||
job = running;
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
}
|
||||
if (!owner) return join(job, listener);
|
||||
try {
|
||||
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
|
||||
job.ok = installOnce(context.getApplicationContext(), release, new ProgressListener() {
|
||||
@Override public void onProgress(String stage, int percent) {
|
||||
onProgress(Step.OTHER, stage, percent);
|
||||
}
|
||||
|
||||
@Override public void onProgress(Step step, String stage, int percent) {
|
||||
job.step = step;
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener l : job.listeners) l.onProgress(step, stage, percent);
|
||||
}
|
||||
});
|
||||
return job.ok;
|
||||
} finally {
|
||||
@@ -192,7 +209,7 @@ public final class LinuxRuntimeInstaller {
|
||||
|
||||
private static boolean join(Job job, ProgressListener listener) {
|
||||
try {
|
||||
if (listener != null) listener.onProgress(job.stage, job.percent);
|
||||
if (listener != null) listener.onProgress(job.step, job.stage, job.percent);
|
||||
job.done.await();
|
||||
return job.ok;
|
||||
} catch (InterruptedException e) {
|
||||
@@ -209,13 +226,14 @@ public final class LinuxRuntimeInstaller {
|
||||
// A failed/interrupted explicit removal must finish before a fresh installation.
|
||||
// Its quarantined home is not user data to carry into an update.
|
||||
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
|
||||
if (pendingRemoval.exists() && listener != null) listener.onProgress("Removing runtime leftovers", -1);
|
||||
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
|
||||
RuntimeFileTree.delete(pendingRemoval, null);
|
||||
if (listener != null) listener.onProgress("Downloading", 0);
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
|
||||
if (listener != null) {
|
||||
listener.onProgress("Downloading",
|
||||
listener.onProgress(Step.DOWNLOADING, downloading,
|
||||
fraction < 0 ? -1 : Math.round(fraction * 100f));
|
||||
}
|
||||
});
|
||||
@@ -224,7 +242,7 @@ public final class LinuxRuntimeInstaller {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listener != null) listener.onProgress("Verifying", -1);
|
||||
if (listener != null) listener.onProgress(Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1);
|
||||
String actual = Hashes.sha256(archive);
|
||||
if (!release.sha256.equalsIgnoreCase(actual)) {
|
||||
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
|
||||
@@ -239,8 +257,8 @@ public final class LinuxRuntimeInstaller {
|
||||
recoverInterruptedSwap(root, staging, old);
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
if (!staging.mkdirs()) return false;
|
||||
if (listener != null) listener.onProgress("Extracting", -1);
|
||||
if (!extract(archive, staging, listener)) {
|
||||
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
|
||||
if (!extract(context, archive, staging, listener)) {
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
return false;
|
||||
}
|
||||
@@ -307,30 +325,35 @@ public final class LinuxRuntimeInstaller {
|
||||
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
|
||||
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
|
||||
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
|
||||
return beginUninstall(LinuxRuntime.rootDir(context));
|
||||
return beginUninstall(context.getApplicationContext(), LinuxRuntime.rootDir(context));
|
||||
}
|
||||
|
||||
static Removal beginUninstall(File root) {
|
||||
/** {@code app} words the progress; unit tests pass null and get none. */
|
||||
static Removal beginUninstall(Context app, File root) {
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null) return null;
|
||||
removalError = null;
|
||||
running = new Job(true);
|
||||
running.stage = "Removing Linux runtime";
|
||||
return new Removal(root, running);
|
||||
running = new Job(true, text(app, R.string.rtinst_removing));
|
||||
return new Removal(app, root, running);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(Context app, int id) {
|
||||
return app == null ? "" : app.getString(id);
|
||||
}
|
||||
|
||||
public static final class Removal {
|
||||
private final Context app;
|
||||
private final File root;
|
||||
private final Job job;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private Removal(File root, Job job) { this.root = root; this.job = job; }
|
||||
private Removal(Context app, File root, Job job) { this.app = app; this.root = root; this.job = job; }
|
||||
|
||||
public boolean run(ProgressListener listener) {
|
||||
if (!started.compareAndSet(false, true)) return join(job, listener);
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
try {
|
||||
report("Removing Linux runtime", -1);
|
||||
report(text(app, R.string.rtinst_removing), -1);
|
||||
final long[] removed = {0};
|
||||
final long[] lastShown = {0};
|
||||
java.util.function.LongConsumer progress = ignored -> {
|
||||
@@ -338,7 +361,8 @@ public final class LinuxRuntimeInstaller {
|
||||
long now = System.nanoTime();
|
||||
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
|
||||
lastShown[0] = now;
|
||||
report("Removing Linux runtime · " + removed[0] + " entries", -1);
|
||||
report(app == null ? "" : app.getResources().getQuantityString(
|
||||
R.plurals.rtinst_removing_entries, (int) Math.min(removed[0], Integer.MAX_VALUE), removed[0]), -1);
|
||||
}
|
||||
};
|
||||
File pending = removalDirectory(root);
|
||||
@@ -355,10 +379,10 @@ public final class LinuxRuntimeInstaller {
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
job.ok = true;
|
||||
report("Linux runtime removed", 100);
|
||||
report(text(app, R.string.rtinst_removed), 100);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
|
||||
removalError = text(app, R.string.rtinst_removal_failed);
|
||||
Log.w(TAG, "runtime removal incomplete", e);
|
||||
report(removalError, -1);
|
||||
return false;
|
||||
@@ -380,8 +404,9 @@ public final class LinuxRuntimeInstaller {
|
||||
* empty file instead of its link target is a rootfs that boots to nothing. Symlinks, hard
|
||||
* links and the executable bit are all carried over here.
|
||||
*/
|
||||
public static boolean extract(File archive, File destination, ProgressListener listener) {
|
||||
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
|
||||
long entries = 0;
|
||||
String extracting = context.getString(R.string.rtinst_extracting);
|
||||
try (InputStream in = new ZstdCompressorInputStream(
|
||||
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
|
||||
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
|
||||
@@ -432,7 +457,7 @@ public final class LinuxRuntimeInstaller {
|
||||
continue; // device nodes and fifos: the runtime binds the real ones
|
||||
}
|
||||
if (++entries % 2000 == 0 && listener != null) {
|
||||
listener.onProgress("Extracting", -1);
|
||||
listener.onProgress(extracting, -1);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -22,12 +22,15 @@ import com.droiddeck.launcher.R
|
||||
* two gets there first.
|
||||
*/
|
||||
class RuntimeInstallService : Service() {
|
||||
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 onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val removal = intent?.action == ACTION_REMOVE
|
||||
val release = intent?.let(::releaseFrom)
|
||||
startForeground(NOTIFICATION_ID, notification(if (removal) "Removing Linux runtime" else "Starting…", -1))
|
||||
startForeground(NOTIFICATION_ID, notification(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
|
||||
if (release == null && !removal) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
@@ -53,9 +56,9 @@ class RuntimeInstallService : Service() {
|
||||
|
||||
private fun notification(stage: String, percent: Int): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Linux runtime",
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
|
||||
NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Shows while the Linux runtime is installed or removed"
|
||||
description = getString(R.string.rtinst_channel_description)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
@@ -65,7 +68,7 @@ class RuntimeInstallService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(if (LinuxRuntimeInstaller.isRemoving()) "Removing the Linux runtime" else "Installing the Linux runtime")
|
||||
.setContentTitle(getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentText(stage)
|
||||
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
|
||||
.setContentIntent(open)
|
||||
|
||||
@@ -69,6 +69,7 @@ object UserApps {
|
||||
class AppImage(val file: File) : Source()
|
||||
class GitHub(val repo: String) : Source()
|
||||
class Flatpak(val id: String) : Source()
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. */
|
||||
@@ -124,6 +125,7 @@ object UserApps {
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,9 +424,36 @@ object UserApps {
|
||||
private fun addFlatpak(context: Context, id: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
|
||||
FlatpakManager.install(context, id, onProgress)?.let { return it }
|
||||
val custom = overrides(context, id)
|
||||
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
|
||||
if (icon != null && !saveIcon(icon, File(custom, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
customiseFlatpak(context, id, name, icon)
|
||||
return null
|
||||
}
|
||||
|
||||
private fun addFlatpakBundle(context: Context, file: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!file.isFile) return context.getString(R.string.user_apps_file_gone)
|
||||
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
|
||||
// Shared storage is bound into the runtime at its own path; a file anywhere else is copied in.
|
||||
val shared = Environment.getExternalStorageDirectory().canonicalPath
|
||||
val copy = File(LinuxRuntime.rootDir(context), "tmp/droiddeck-bundle.flatpak").takeIf { !file.canonicalPath.startsWith("$shared/") }
|
||||
try {
|
||||
if (copy != null) {
|
||||
onProgress(context.getString(R.string.user_apps_copying, file.name), -1)
|
||||
file.copyTo(copy.apply { parentFile?.mkdirs() }, overwrite = true)
|
||||
}
|
||||
val (id, problem) = FlatpakManager.installBundle(context, copy?.let { "/tmp/${it.name}" } ?: file.canonicalPath, onProgress)
|
||||
problem?.let { return it }
|
||||
if (id != null) customiseFlatpak(context, id, name, icon)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "bundle ${file.path}", e)
|
||||
return e.message ?: context.getString(R.string.user_apps_failed)
|
||||
} finally {
|
||||
copy?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun customiseFlatpak(context: Context, id: String, name: String?, icon: File?) {
|
||||
val custom = overrides(context, id)
|
||||
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
|
||||
if (icon != null && !saveIcon(icon, File(custom.apply { mkdirs() }, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
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
|
||||
@@ -248,12 +249,13 @@ object ComponentsManager {
|
||||
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
|
||||
}.getOrDefault("")
|
||||
|
||||
private fun fexVersion(dir: File): String {
|
||||
/** The FEX build's version name; [unnamed] when its DLL carries none, "" when there is no FEX. */
|
||||
private fun fexVersion(dir: File, unnamed: String = "FEX (build without a version name)"): String {
|
||||
val dll = File(dir, FEX_FILES[0])
|
||||
if (!dll.isFile) return ""
|
||||
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
|
||||
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
|
||||
?: "FEX (build without a version name)"
|
||||
?: unnamed
|
||||
}
|
||||
|
||||
private fun compFiles(dir: File, comp: String): List<String> {
|
||||
@@ -274,23 +276,24 @@ object ComponentsManager {
|
||||
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
|
||||
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
|
||||
|
||||
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
|
||||
private fun readProfile(context: Context, f: File): JSONObject = readWcp(f) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
|
||||
}
|
||||
throw IllegalArgumentException("no profile.json in ${f.name}")
|
||||
throw IllegalArgumentException(context.getString(R.string.cmgr_no_profile, f.name))
|
||||
}
|
||||
|
||||
private fun packageInfo(f: File): Package {
|
||||
val prof = readProfile(f)
|
||||
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
|
||||
private fun packageInfo(context: Context, f: File): Package {
|
||||
val prof = readProfile(context, f)
|
||||
val comp = TYPES[prof.optString("type")]
|
||||
?: throw IllegalArgumentException(context.getString(R.string.cmgr_not_component_package, f.name, prof.optString("type")))
|
||||
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
|
||||
}
|
||||
|
||||
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
|
||||
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
|
||||
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
|
||||
?.mapNotNull { f -> runCatching { packageInfo(context, f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
|
||||
?: emptyList()
|
||||
|
||||
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
|
||||
@@ -363,7 +366,7 @@ object ComponentsManager {
|
||||
return versions.flatMap { v ->
|
||||
COMPONENTS.mapNotNull { comp ->
|
||||
val f = File(v, "$comp.wcp")
|
||||
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
|
||||
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(context, f).optString("description") }.getOrDefault(""), f.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,24 +418,24 @@ object ComponentsManager {
|
||||
val syncPacks = EsyncPacks.status(root(context))
|
||||
val views = protons(context).map { p ->
|
||||
val comps = COMPONENTS.associateWith { comp ->
|
||||
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
|
||||
val detected = if (comp == "fex") fexVersion(p.dir, context.getString(R.string.cmgr_fex_unnamed)) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val detail = if (comp == "fex") context.getString(if (File(p.dir, FEX_FILES[2]).isFile) R.string.cmgr_fex_linux_helpers else R.string.cmgr_fex_dlls_only) else ""
|
||||
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
val inUseLabel = when {
|
||||
active == null -> "Original"
|
||||
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
|
||||
active == null -> context.getString(R.string.comp_tag_original)
|
||||
active.optString("protonVersion") != p.version -> context.getString(R.string.cmgr_original_proton_updated)
|
||||
else -> {
|
||||
val want = active.optJSONObject("files") ?: JSONObject()
|
||||
val fp = fingerprint(p.dir, comp)
|
||||
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
|
||||
else "Changed outside Components"
|
||||
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) originalLabel(context, active) ?: active.optString("label", active.optString("file"))
|
||||
else context.getString(R.string.cmgr_changed_outside)
|
||||
}
|
||||
}
|
||||
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
Component(
|
||||
detected.ifEmpty { "not present" }, detail, inUseLabel,
|
||||
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
|
||||
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
|
||||
q?.optString("label"),
|
||||
q?.let { originalLabel(context, it) ?: it.optString("label") },
|
||||
)
|
||||
}
|
||||
ProtonView(p, comps, originals(context, p.id), gameRunning(context, p), reapplied[p.dir.name] ?: 0, syncPacks.tool(p.guestPath))
|
||||
@@ -440,6 +443,16 @@ object ComponentsManager {
|
||||
Snapshot(views, packages(context))
|
||||
}
|
||||
|
||||
/**
|
||||
* The label of a chosen or queued original, in the app's language: the stored "label" was written
|
||||
* in English when it was chosen. Null for a package, whose label is its version.
|
||||
*/
|
||||
private fun originalLabel(context: Context, entry: JSONObject): String? = when {
|
||||
entry.optString("kind") == "original" -> context.getString(R.string.cmgr_original_version, entry.optString("protonVersion"))
|
||||
entry.optString("file").startsWith("original ") -> context.getString(R.string.cmgr_original_from, entry.optString("file").removePrefix("original "))
|
||||
else -> null
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ launch-time enforcement
|
||||
|
||||
/**
|
||||
@@ -508,17 +521,17 @@ object ComponentsManager {
|
||||
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
val wcp = File(packagesDir(context), safeName(file))
|
||||
val info = packageInfo(wcp)
|
||||
val info = packageInfo(context, wcp)
|
||||
val state = loadState(context)
|
||||
if (inUse(context, p)) {
|
||||
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
|
||||
saveState(context, state)
|
||||
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
|
||||
return context.getString(R.string.cmgr_swap_queued, LABEL[info.comp], info.version, p.name)
|
||||
}
|
||||
applyPackage(context, p, info, wcp, state)
|
||||
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
|
||||
saveState(context, state)
|
||||
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
|
||||
context.getString(R.string.cmgr_swap_done, LABEL[info.comp], info.version, p.name)
|
||||
}
|
||||
|
||||
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
|
||||
@@ -534,17 +547,17 @@ object ComponentsManager {
|
||||
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
|
||||
check(wcp.isFile) { "That original bundle is not stored" }
|
||||
check(wcp.isFile) { context.getString(R.string.cmgr_original_missing) }
|
||||
val state = loadState(context)
|
||||
if (inUse(context, p)) {
|
||||
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
|
||||
saveState(context, state)
|
||||
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
|
||||
return context.getString(R.string.cmgr_restore_queued, LABEL[comp], p.name)
|
||||
}
|
||||
applyOriginal(context, p, comp, protonVersion, wcp, state)
|
||||
state.sub("queued").optJSONObject(p.id)?.remove(comp)
|
||||
saveState(context, state)
|
||||
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
|
||||
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
|
||||
}
|
||||
|
||||
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
|
||||
@@ -576,7 +589,7 @@ object ComponentsManager {
|
||||
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
|
||||
} else {
|
||||
val wcp = File(packagesDir(context), q.optString("file"))
|
||||
applyPackage(context, p, packageInfo(wcp), wcp, state)
|
||||
applyPackage(context, p, packageInfo(context, wcp), wcp, state)
|
||||
}
|
||||
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
|
||||
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
|
||||
@@ -600,27 +613,27 @@ object ComponentsManager {
|
||||
val active = state.sub("active")
|
||||
for (pid in active.keys()) {
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
|
||||
}
|
||||
val f = File(packagesDir(context), name)
|
||||
check(f.isFile) { "$name is not stored" }
|
||||
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
|
||||
f.delete()
|
||||
"Deleted $name."
|
||||
context.getString(R.string.cmgr_deleted, name)
|
||||
}
|
||||
|
||||
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
|
||||
check(safeName(p.version) != protonVersion) { context.getString(R.string.cmgr_original_kept) }
|
||||
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
|
||||
check(f.isFile) { "Not stored" }
|
||||
check(f.isFile) { context.getString(R.string.cmgr_not_stored) }
|
||||
f.delete()
|
||||
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
|
||||
"Deleted the $protonVersion original of ${LABEL[comp]}."
|
||||
context.getString(R.string.cmgr_deleted_original, protonVersion, LABEL[comp])
|
||||
}
|
||||
|
||||
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
|
||||
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
|
||||
val info = packageInfo(source)
|
||||
val info = packageInfo(context, source)
|
||||
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
|
||||
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
|
||||
info.copy(file = dest.name)
|
||||
@@ -742,7 +755,7 @@ object ComponentsManager {
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
): String? {
|
||||
val wcp = File(packagesDir(context), safeName(rule.file))
|
||||
if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null
|
||||
if (wcp.isFile && runCatching { packageInfo(context, wcp) }.isSuccess) return null
|
||||
wcp.delete()
|
||||
val item = lookup(rule.file, rule.release)
|
||||
?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy"
|
||||
@@ -764,14 +777,14 @@ object ComponentsManager {
|
||||
|
||||
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
|
||||
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
|
||||
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { context.getString(R.string.cmgr_nightlies_only) }
|
||||
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")
|
||||
part.delete()
|
||||
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
|
||||
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
|
||||
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
|
||||
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(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)) }
|
||||
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
|
||||
synchronized(lock) { part.renameTo(dest) }
|
||||
return info.copy(file = dest.name)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ object EsyncPacks {
|
||||
private const val TOOLS = "tools.tsv"
|
||||
private const val FLOOR = "floor"
|
||||
private const val PACK_FAILURES = "packFailures"
|
||||
private const val PROGRESS_LABEL = "Fetching droiddeck-esync pack"
|
||||
private const val INDEX_LIMIT = 4L shl 20
|
||||
private const val SIG_LIMIT = 4096L
|
||||
private const val ASSET_LIMIT = 256L shl 20
|
||||
@@ -618,14 +617,15 @@ object EsyncPacks {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
onProgress?.invoke(PROGRESS_LABEL, -1)
|
||||
val label = context.getString(com.droiddeck.launcher.R.string.esync_fetching)
|
||||
onProgress?.invoke(label, -1)
|
||||
var reported = -1
|
||||
val fetched = FileOutputStream(part).use { output ->
|
||||
fetch(entry.asset.url, entry.asset.size, output) { done ->
|
||||
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
|
||||
if (percent != reported) {
|
||||
reported = percent
|
||||
onProgress?.invoke(PROGRESS_LABEL, percent)
|
||||
onProgress?.invoke(label, percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
@@ -25,9 +27,12 @@ import java.util.zip.ZipOutputStream
|
||||
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
|
||||
*/
|
||||
object GameSaves {
|
||||
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
|
||||
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
|
||||
WINLATOR("Winlator zip", "drive_c/", "xuser"),
|
||||
enum class Layout(val app: String, val rootPrefix: String, val user: String) {
|
||||
GAMEHUB("GameHub", "/drive_c/", "steamuser"),
|
||||
WINLATOR("Winlator", "drive_c/", "xuser");
|
||||
|
||||
/** "GameHub zip", in the app's language. */
|
||||
fun label(context: Context): String = context.getString(R.string.saves_layout_zip, app)
|
||||
}
|
||||
|
||||
/** A game, its prefix, and the save folders found in it. */
|
||||
@@ -127,12 +132,12 @@ object GameSaves {
|
||||
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
|
||||
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
|
||||
*/
|
||||
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
fun export(context: Context, game: Game, layout: Layout, dir: File): Pair<File, Int> {
|
||||
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
|
||||
dir.mkdirs()
|
||||
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
|
||||
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
|
||||
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
|
||||
if (count == 0) { out.delete(); error(context.getString(R.string.saves_none_found, game.game.name)) }
|
||||
return out to count
|
||||
}
|
||||
|
||||
@@ -162,11 +167,11 @@ object GameSaves {
|
||||
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
|
||||
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
|
||||
*/
|
||||
fun import(game: Game, zip: File): Pair<Int, File?> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
fun import(context: Context, game: Game, zip: File): Pair<Int, File?> {
|
||||
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
|
||||
val driveC = File(prefix, "drive_c")
|
||||
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
|
||||
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
|
||||
if (!driveC.isDirectory) error(context.getString(R.string.saves_not_launched, game.game.name))
|
||||
if (!looksLikeSaveZip(zip)) error(context.getString(R.string.saves_not_save_zip, zip.name))
|
||||
|
||||
backupsDir().mkdirs()
|
||||
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.os.storage.StorageManager
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -17,7 +19,8 @@ import java.io.File
|
||||
* slowly (intro movies, big asset loads); internal stays the default and the dialog says so.
|
||||
*/
|
||||
object GameStorage {
|
||||
class Option(val label: String, val path: String)
|
||||
/** A library root: [label] as the menu lists it (a volume with its free space), [name] alone. */
|
||||
class Option(val label: String, val path: String, val name: String = label)
|
||||
|
||||
/** Every removable volume, as its app folder, with the free space it has. */
|
||||
fun options(context: Context): List<Option> {
|
||||
@@ -26,8 +29,8 @@ object GameStorage {
|
||||
val removable = try { Environment.isExternalStorageRemovable(dir) } catch (e: Exception) { false }
|
||||
if (!removable) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
|
||||
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: "SD card"
|
||||
Option("$name · ${free(dir)} free", File(dir, "steam").absolutePath)
|
||||
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: context.getString(R.string.gstore_sd_card)
|
||||
Option(context.getString(R.string.gstore_volume_free, name, free(dir)), File(dir, "steam").absolutePath, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,15 +44,15 @@ object GameStorage {
|
||||
* Makes [path] a library root the session can bind - the folder and its steamapps/ - and proves
|
||||
* it writable. Returns why it cannot be used, or null when it can.
|
||||
*/
|
||||
fun prepare(path: String): String? {
|
||||
fun prepare(context: Context, path: String): String? {
|
||||
val root = File(path)
|
||||
val steamapps = File(root, "steamapps")
|
||||
if (!steamapps.isDirectory && !steamapps.mkdirs()) return "cannot create folders in $path"
|
||||
if (!steamapps.isDirectory && !steamapps.mkdirs()) return context.getString(R.string.gstore_cannot_create, path)
|
||||
val probe = File(steamapps, ".writable")
|
||||
return try {
|
||||
if (!probe.createNewFile() && !probe.isFile) "cannot write in $path" else { probe.delete(); null }
|
||||
if (!probe.createNewFile() && !probe.isFile) context.getString(R.string.gstore_cannot_write, path) else { probe.delete(); null }
|
||||
} catch (e: Exception) {
|
||||
"cannot write in $path (${e.message})"
|
||||
context.getString(R.string.gstore_cannot_write_detail, path, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +61,32 @@ object GameStorage {
|
||||
val pref = SessionPrefs.gameStorage(context)
|
||||
return when {
|
||||
pref == SessionPrefs.GAME_STORAGE_OFF -> null
|
||||
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.label.substringBefore(" ·"), it.path) }
|
||||
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.name, it.path) }
|
||||
else -> Option(SessionPrefs.gameStorageLabel(context), pref)
|
||||
}
|
||||
}
|
||||
|
||||
class Library(val host: File, val guest: String, val label: String)
|
||||
|
||||
fun gamesFolderLibraries(context: Context): List<Library> {
|
||||
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
|
||||
val card = effective(context)?.let { canonical(File(it.path)) }
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
return AddedGames.roots(context).mapNotNull { root ->
|
||||
val host = canonical(root.host)
|
||||
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
|
||||
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
|
||||
val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) }
|
||||
val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume)
|
||||
Library(root.host, root.guest, label.replace('"', ' ').replace('\\', ' '))
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
|
||||
|
||||
/** The label the client shows for a chosen folder: its last name, or the volume's. */
|
||||
fun labelFor(context: Context, path: String): String =
|
||||
options(context).firstOrNull { it.path == path }?.label?.substringBefore(" ·")
|
||||
?: File(path).name.ifEmpty { "Folder" }
|
||||
options(context).firstOrNull { it.path == path }?.name
|
||||
?: File(path).name.ifEmpty { context.getString(R.string.gstore_folder) }
|
||||
}
|
||||
@@ -17,9 +17,16 @@ import java.nio.charset.StandardCharsets
|
||||
* progress, and lifting that out is the difference between "starting the Steam client" for three
|
||||
* minutes and a percentage that moves.
|
||||
*/
|
||||
class LoadingState(context: Context, steam: Boolean = true) {
|
||||
class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
var visible by mutableStateOf(true)
|
||||
var step by mutableStateOf("Starting the session…")
|
||||
var step by mutableStateOf(context.getString(com.droiddeck.launcher.R.string.loadstate_starting))
|
||||
private set
|
||||
/** What [step] is about, so the screen picks its checklist stage without reading the (translated) words. */
|
||||
var topic by mutableStateOf(Topic.SESSION)
|
||||
private set
|
||||
/** [step] is a download or install line worth showing as it is, not one of the script's own notes. */
|
||||
var readable by mutableStateOf(false)
|
||||
private set
|
||||
var percent by mutableIntStateOf(-1)
|
||||
var elapsed by mutableStateOf("")
|
||||
var hint by mutableStateOf("")
|
||||
@@ -31,15 +38,23 @@ class LoadingState(context: Context, steam: Boolean = true) {
|
||||
private val hints = context.resources.getStringArray(
|
||||
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
|
||||
)
|
||||
private val res = context.resources
|
||||
private val startedAt = SystemClock.elapsedRealtime()
|
||||
|
||||
/** Once a second: the clock and the hint. */
|
||||
fun tick() {
|
||||
val seconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
|
||||
elapsed = String.format(java.util.Locale.US, "%d:%02d elapsed · still working", seconds / 60, seconds % 60)
|
||||
elapsed = res.getString(com.droiddeck.launcher.R.string.loadstate_elapsed, seconds / 60, seconds % 60)
|
||||
hint = hints[((seconds / 8) % hints.size).toInt()]
|
||||
}
|
||||
|
||||
/** A line the app itself writes (an install's progress, the session starting). */
|
||||
fun say(line: String, topic: Topic, readable: Boolean) {
|
||||
step = line
|
||||
this.topic = topic
|
||||
this.readable = readable
|
||||
}
|
||||
|
||||
fun showEnded(message: String, detail: String? = null) {
|
||||
visible = true
|
||||
ended = true
|
||||
@@ -50,12 +65,16 @@ class LoadingState(context: Context, steam: Boolean = true) {
|
||||
/** Re-reads the end of the log and updates the line and the bar. */
|
||||
fun update(context: Context, log: File?) {
|
||||
val state = read(context, log) ?: return
|
||||
step = state.first
|
||||
percent = state.second
|
||||
step = state.line
|
||||
topic = state.topic
|
||||
readable = state.readable
|
||||
percent = state.percent
|
||||
}
|
||||
|
||||
private class Line(val line: String, val percent: Int, val topic: Topic, val readable: Boolean)
|
||||
|
||||
/** Only the tail is read: the client alone writes megabytes an hour. */
|
||||
private fun read(context: Context, log: File?): Pair<String, Int>? {
|
||||
private fun read(context: Context, log: File?): Line? {
|
||||
if (log == null || !log.isFile) return null
|
||||
val text = try {
|
||||
RandomAccessFile(log, "r").use { file ->
|
||||
@@ -100,16 +119,45 @@ class LoadingState(context: Context, steam: Boolean = true) {
|
||||
}
|
||||
}
|
||||
return when {
|
||||
downloadAt > stepAt && downloadPercent >= 0 ->
|
||||
Pair("Downloading the Steam client update · $downloadPercent%", downloadPercent)
|
||||
clientDownloadAt == stepAt && clientDownload != null ->
|
||||
Pair("Downloading the Steam client · $clientDownload", clientPercent)
|
||||
stepText != null -> Pair(stepText, -1)
|
||||
downloadAt > stepAt && downloadPercent >= 0 -> Line(
|
||||
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_update, downloadPercent), downloadPercent,
|
||||
Topic.STEAM, readable = true,
|
||||
)
|
||||
clientDownloadAt == stepAt && clientDownload != null -> Line(
|
||||
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_client, clientDownload), clientPercent,
|
||||
Topic.STEAM, readable = true,
|
||||
)
|
||||
stepText != null -> Line(stepText, -1, topicOf(stepText, steam), readableScriptStep(stepText))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The checklist stages a loading line can move the screen to. */
|
||||
enum class Topic { RUNTIME, SESSION, DESKTOP, STEAM, STEAM_STARTING, OTHER }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A session script "== STEP" line's topic in a Steam ([steam]) or desktop session; those
|
||||
* lines are English whatever the app's language.
|
||||
*/
|
||||
internal fun topicOf(scriptStep: String, steam: Boolean): Topic {
|
||||
val t = scriptStep.lowercase()
|
||||
return when {
|
||||
"linux runtime" in t -> Topic.RUNTIME
|
||||
"starting the session" in t -> Topic.SESSION
|
||||
!steam && "desktop" in t -> Topic.DESKTOP
|
||||
steam && "starting the steam client" in t -> Topic.STEAM_STARTING
|
||||
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> Topic.STEAM
|
||||
else -> Topic.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/** The script's install lines are worth reading as they are; its other notes are not. */
|
||||
internal fun readableScriptStep(scriptStep: String): Boolean {
|
||||
val t = scriptStep.lowercase()
|
||||
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
|
||||
}
|
||||
|
||||
private const val TAIL_BYTES = 48L * 1024
|
||||
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
|
||||
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
|
||||
|
||||
@@ -30,7 +30,7 @@ object OfflineMode {
|
||||
|
||||
private fun loginUsers(root: File) = File(root, "root/.local/share/Steam/config/loginusers.vdf")
|
||||
|
||||
/** Whether the client has an account it could sign in as offline. */
|
||||
/** The account the client could sign in as offline: its name, "" when it recorded none, null when there is none. */
|
||||
fun account(context: Context): String? {
|
||||
val file = loginUsers(LinuxRuntime.rootDir(context))
|
||||
val text = try {
|
||||
@@ -39,7 +39,7 @@ object OfflineMode {
|
||||
Log.w(TAG, "could not read loginusers.vdf", e); return null
|
||||
}
|
||||
if (!REMEMBER.containsMatchIn(text)) return null
|
||||
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() } ?: "signed in"
|
||||
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
fun enabled(context: Context): Boolean = prefs(context).getBoolean(KEY, false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
@@ -59,70 +60,72 @@ object ProtonExtras {
|
||||
/** Downloads, verifies and registers the latest build without waiting for another session. */
|
||||
@Synchronized
|
||||
fun install(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
|
||||
if (installInProgress) return "A compatibility tool install is already running"
|
||||
if (installInProgress) return context.getString(R.string.pextra_busy)
|
||||
installInProgress = true
|
||||
return try { installNow(context, tool, onProgress) } finally { installInProgress = false }
|
||||
}
|
||||
|
||||
private fun installNow(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing compatibility tools"
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
if (SessionState.running) return context.getString(R.string.pextra_stop_session)
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
|
||||
val asset = findLatestAsset(tool) ?: return "Could not find an ARM64 release for ${tool.name}"
|
||||
val asset = findLatestAsset(tool) ?: return context.getString(R.string.pextra_no_release, tool.name)
|
||||
val downloads = File(context.filesDir, "proton-downloads").apply { mkdirs() }
|
||||
val archive = File(downloads, "${tool.id}-${asset.name}")
|
||||
if (asset.size > 0 && archive.length() > asset.size) archive.delete()
|
||||
val missing = (asset.size - archive.length()).coerceAtLeast(0L)
|
||||
if (StatFs(LinuxRuntime.rootDir(context).path).availableBytes < NEED_BYTES + missing) {
|
||||
return "At least 4 GB free plus the download size is required to install ${tool.name}"
|
||||
return context.getString(R.string.pextra_no_space, tool.name)
|
||||
}
|
||||
|
||||
onProgress("Downloading ${tool.name} ${asset.tag}", 0)
|
||||
val downloading = context.getString(R.string.pextra_downloading, tool.name, asset.tag)
|
||||
onProgress(downloading, 0)
|
||||
var downloaded = false
|
||||
for (attempt in 0 until 5) {
|
||||
downloaded = Downloader.downloadFile(asset.url, archive, true) { fraction ->
|
||||
onProgress("Downloading ${tool.name} ${asset.tag}", if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
|
||||
onProgress(downloading, if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
|
||||
}
|
||||
if (downloaded) break
|
||||
if (attempt < 4) {
|
||||
onProgress("Retrying download · attempt ${attempt + 2} of 5", -1)
|
||||
onProgress(context.getString(R.string.pextra_retrying, attempt + 2, 5), -1)
|
||||
try { Thread.sleep(3_000) } catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
return "Download interrupted"
|
||||
return context.getString(R.string.pextra_interrupted)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!downloaded) return "Download failed; the partial file is kept so a retry can resume"
|
||||
if (!downloaded) return context.getString(R.string.pextra_download_failed)
|
||||
if (asset.size > 0 && archive.length() != asset.size) {
|
||||
if (archive.length() > asset.size) {
|
||||
archive.delete()
|
||||
return "Download exceeded the release size and was deleted"
|
||||
return context.getString(R.string.pextra_too_big)
|
||||
}
|
||||
return "Download was incomplete; the partial file is kept for a resumable retry"
|
||||
return context.getString(R.string.pextra_incomplete)
|
||||
}
|
||||
|
||||
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
|
||||
// release's own sha512 file. A download neither vouches for is not installed.
|
||||
onProgress("Verifying download", -1)
|
||||
onProgress(context.getString(R.string.pextra_verifying), -1)
|
||||
val verified = when {
|
||||
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
|
||||
asset.sha512 != null -> {
|
||||
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
|
||||
?: return "Could not read the release checksum; try again"
|
||||
?: return context.getString(R.string.pextra_checksum_unreadable)
|
||||
expected.equals(Hashes.sha512(archive), ignoreCase = true)
|
||||
}
|
||||
else -> {
|
||||
archive.delete()
|
||||
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
|
||||
return context.getString(R.string.pextra_no_checksum, tool.name, asset.tag)
|
||||
}
|
||||
}
|
||||
if (!verified) {
|
||||
archive.delete()
|
||||
return "Checksum mismatch; the download was deleted"
|
||||
return context.getString(R.string.pextra_checksum_mismatch)
|
||||
}
|
||||
|
||||
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
|
||||
onProgress("Installing ${tool.name}", -1)
|
||||
if (SessionState.running) return context.getString(R.string.pextra_session_started)
|
||||
val installing = context.getString(R.string.pextra_installing, tool.name)
|
||||
onProgress(installing, -1)
|
||||
return try {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
@@ -146,17 +149,17 @@ object ProtonExtras {
|
||||
lines.forEach { line ->
|
||||
Log.i(TAG, line)
|
||||
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
|
||||
onProgress("Installing ${tool.name}", -1)
|
||||
onProgress(installing, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
val status = child.waitFor()
|
||||
if (status != 0) "${tool.name} installation failed (exit $status)"
|
||||
if (status != 0) context.getString(R.string.pextra_install_failed, tool.name, status)
|
||||
else {
|
||||
archive.delete()
|
||||
unqueue(context, tool)
|
||||
if (EsyncPacks.enabled(context)) {
|
||||
onProgress("Fetching droiddeck-esync pack", -1)
|
||||
onProgress(context.getString(R.string.esync_fetching), -1)
|
||||
try {
|
||||
EsyncPacks.fetchWanted(context, root, onProgress)
|
||||
} catch (t: Throwable) {
|
||||
@@ -167,7 +170,7 @@ object ProtonExtras {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${tool.id}", e)
|
||||
e.message ?: "Could not install ${tool.name}"
|
||||
e.message ?: context.getString(R.string.pextra_could_not_install, tool.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ object SecondaryLibrary {
|
||||
return File(files, "steam-libraries/$digest")
|
||||
}
|
||||
|
||||
val CARD_GUESTS = listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd")
|
||||
|
||||
/** Parent binds come first; the more specific private directories override them in PRoot. */
|
||||
fun binds(files: File, library: File): List<String> {
|
||||
fun binds(files: File, library: File, guests: List<String> = CARD_GUESTS): List<String> {
|
||||
val private = privateRoot(files, library)
|
||||
val apps = File(library, "steamapps")
|
||||
val tools = LinkedHashMap(bootstrapTools)
|
||||
@@ -57,7 +59,7 @@ object SecondaryLibrary {
|
||||
if (lower != relative) overrides.add(target to lower)
|
||||
}
|
||||
}
|
||||
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
|
||||
return guests.flatMap { guest ->
|
||||
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ object SessionFiles {
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
/** Where the DirectAudio driver lives inside the runtime; the wrappers get it as BL_DIRECTAUDIO. */
|
||||
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
|
||||
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
|
||||
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
|
||||
|
||||
/**
|
||||
* The libraries and scripts the session runs, refreshed from the apk at every launch.
|
||||
@@ -41,10 +43,13 @@ object SessionFiles {
|
||||
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
|
||||
"libssbs.so" to "usr/local/lib/libssbs.so",
|
||||
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
|
||||
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
|
||||
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
|
||||
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
|
||||
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
|
||||
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
|
||||
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
|
||||
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
|
||||
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
|
||||
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
@@ -55,6 +60,10 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
|
||||
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
|
||||
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
|
||||
// Asks the app for an Epic game's sign-in code at launch, from the compat tool (StoreLaunchRequests).
|
||||
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
|
||||
// A game's web links to Android's browser (Wine's winebrowser starts it).
|
||||
"usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url",
|
||||
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
|
||||
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
|
||||
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
|
||||
@@ -149,30 +158,37 @@ object SessionFiles {
|
||||
ComponentsManager.migrateLaunchDir(context)
|
||||
EsyncPacks.stageBundled(context, root)
|
||||
// The DirectAudio driver for games under Proton: the glibc build of winedirectaudio, which
|
||||
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO).
|
||||
// Staged like the scripts, so a driver fix reaches an installed runtime without re-hosting.
|
||||
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO). Two
|
||||
// sets, one per Wine audio interface - the wrapper picks by what the Proton's own winepulse.so
|
||||
// imports (see steam-compatibility), because both Wines call themselves 11.0 and the wrong set
|
||||
// plays silence. Staged like the scripts, so a driver fix reaches an installed runtime without
|
||||
// re-hosting. The sets come from the pinned DirectAudio release (tools/directaudio/release.env).
|
||||
val directAudio = arrayOf(
|
||||
"aarch64-unix/winedirectaudio.so",
|
||||
"aarch64-windows/winedirectaudio.drv",
|
||||
"i386-windows/winedirectaudio.drv",
|
||||
"version.txt",
|
||||
)
|
||||
for (relative in directAudio) {
|
||||
val target = File(root, "$DIRECTAUDIO_DIR/lib/wine/$relative")
|
||||
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
|
||||
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
|
||||
val staged = File(target.parentFile, target.name + ".staged")
|
||||
var installed = false
|
||||
try {
|
||||
target.parentFile?.mkdirs()
|
||||
context.assets.open("directaudio/linux-wine11/$relative").use { input ->
|
||||
context.assets.open("directaudio/$set/$relative").use { input ->
|
||||
staged.outputStream().use { output -> FileUtils.copy(input, output) }
|
||||
}
|
||||
installed = staged.setReadable(true, false) && staged.renameTo(target)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not stage DirectAudio $relative", e)
|
||||
Log.w(TAG, "could not stage DirectAudio $set/$relative", e)
|
||||
} finally {
|
||||
if (!installed) staged.delete()
|
||||
}
|
||||
if (!installed) Log.e(TAG, "DirectAudio $relative NOT staged")
|
||||
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
|
||||
}
|
||||
// A runtime from before the sets had names kept the driver at the directory's root; it would
|
||||
// never be picked again, so it goes.
|
||||
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
|
||||
// What every process in the session preloads. LD_PRELOAD in the environment would not
|
||||
// survive: the Steam client rebuilds it for each process it starts and appends its own
|
||||
// overlay entry without a separator, which silently drops whatever was there.
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.FileProvider
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
@@ -72,7 +73,9 @@ object SessionLogShare {
|
||||
// every share kept the share sheet from appearing for ten seconds or more.
|
||||
val scrubbed = SessionArtifacts.scrubbedFiles(folder)
|
||||
val live = liveSteamLogs(context, folder)
|
||||
val total = (files + live).sumOf { it.length() }.coerceAtLeast(1L)
|
||||
// The stores' own log (last 7 days, app-private, already redacted), under stores/.
|
||||
val storeLogs = com.droiddeck.launcher.stores.StoreLogFiles.files(context)
|
||||
val total = (files + live + storeLogs).sumOf { it.length() }.coerceAtLeast(1L)
|
||||
var done = 0L
|
||||
var shown = -1
|
||||
fun advance(f: File) {
|
||||
@@ -83,6 +86,7 @@ object SessionLogShare {
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
|
||||
storeLogs.forEach { f -> addEntry(z, folder.name + "/stores/" + f.name, f, false); advance(f) }
|
||||
}
|
||||
return zip
|
||||
}
|
||||
@@ -123,6 +127,6 @@ object SessionLogShare {
|
||||
.putExtra(Intent.EXTRA_SUBJECT, zip.nameWithoutExtension)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
send.clipData = ClipData.newRawUri(zip.name, uri)
|
||||
return Intent.createChooser(send, "Share session logs").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
return Intent.createChooser(send, context.getString(R.string.logshare_chooser)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import org.json.JSONObject
|
||||
@@ -11,6 +13,8 @@ object SessionPrefs {
|
||||
const val SUSPEND_NATIVE = "native"
|
||||
const val SUSPEND_MANUAL = "manual"
|
||||
const val SUSPEND_NEVER = "never"
|
||||
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
|
||||
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
@@ -19,10 +23,12 @@ object SessionPrefs {
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
const val OSC_NEVER = "never"
|
||||
|
||||
const val BACK_MENU_THEN_QAM = "1: menu 2: QAM"
|
||||
const val BACK_QAM_THEN_MENU = "1: QAM 2: menu"
|
||||
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
|
||||
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
|
||||
val BACK_QAM_THEN_MENU = R.string.back_qam_then_menu
|
||||
|
||||
fun backActionsOrder(inverted: Boolean): String =
|
||||
@StringRes
|
||||
fun backActionsOrder(inverted: Boolean): Int =
|
||||
if (inverted) BACK_QAM_THEN_MENU else BACK_MENU_THEN_QAM
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
@@ -47,6 +53,52 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("storeEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
|
||||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
|
||||
|
||||
fun setGameStoresEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
|
||||
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
|
||||
|
||||
fun setGameStoresSpeedTier(context: Context, tier: String) {
|
||||
prefs(context).edit().putString("gameStoresSpeedTier", tier).apply()
|
||||
}
|
||||
|
||||
/** Which tab a signed-in store opens on when its chip is picked: [STORES_OPEN_LIBRARY] or [STORES_OPEN_STORE]. */
|
||||
fun storesOpenTab(context: Context): String =
|
||||
prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY
|
||||
|
||||
fun setStoresOpenTab(context: Context, tab: String) {
|
||||
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
|
||||
}
|
||||
|
||||
/** Mature titles (the stores' own 17+/18 ratings or adult tags) in the storefront's shelves and search; off by default. Owned games always show. */
|
||||
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
|
||||
|
||||
fun setStoresShowMature(context: Context, show: Boolean) {
|
||||
prefs(context).edit().putBoolean("storesShowMature", show).apply()
|
||||
}
|
||||
|
||||
const val STORES_OPEN_LIBRARY = "library"
|
||||
const val STORES_OPEN_STORE = "store"
|
||||
|
||||
/** The install root picked last time the Stores asked where (a StoreInstallRoot target's path); only the dialog's default, never a silent choice. */
|
||||
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
|
||||
|
||||
fun setStoresInstallTarget(context: Context, path: String) {
|
||||
prefs(context).edit().putString("storesInstallTarget", path).apply()
|
||||
}
|
||||
|
||||
/** How many store downloads run at once, 1..3; one by default (a single download already fills the link). */
|
||||
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
|
||||
|
||||
fun setGameStoresParallel(context: Context, count: Int) {
|
||||
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
|
||||
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
|
||||
@@ -154,16 +206,9 @@ object SessionPrefs {
|
||||
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam client's own sound through the DirectAudio relay instead of the classic AAudio
|
||||
* sink. Off by default: on an AYN Thor (Android 13, 20 ms bursts) the relay path stayed choppy
|
||||
* where the classic sink - the one 0.1.5 shipped - was fine.
|
||||
*/
|
||||
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", false)
|
||||
|
||||
fun setClientDirectAudio(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
|
||||
}
|
||||
// The Steam client's own sound has one route now: DirectAudio's engine inside the PulseAudio
|
||||
// daemon (PulseAudioComponent), one step from Android. The old "clientDirectAudio" boolean
|
||||
// (which meant the relay route, one hop more) is no longer read.
|
||||
|
||||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||||
|
||||
@@ -588,8 +633,25 @@ object SessionPrefs {
|
||||
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
|
||||
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
|
||||
|
||||
private val addedGameLock = Any()
|
||||
|
||||
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
|
||||
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
|
||||
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
|
||||
}
|
||||
|
||||
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
|
||||
|
||||
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
|
||||
synchronized(addedGameLock) {
|
||||
val unchanged = addedGameExe(context, folderPath) == expected
|
||||
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
|
||||
unchanged
|
||||
}
|
||||
|
||||
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||||
val p = prefs(context)
|
||||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
|
||||
}
|
||||
|
||||
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
|
||||
@@ -658,6 +720,10 @@ object SessionPrefs {
|
||||
|
||||
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
|
||||
|
||||
/** [fpsLimitChoices] labelled in the app's language. */
|
||||
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
|
||||
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
|
||||
|
||||
/**
|
||||
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
|
||||
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
|
||||
@@ -669,6 +735,16 @@ object SessionPrefs {
|
||||
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
|
||||
)
|
||||
|
||||
/** The [upscalerChoices] labels that are words rather than product names. */
|
||||
private val upscalerLabels = mapOf(
|
||||
0 to R.string.sprefs_upscaler_linear, 2 to R.string.sprefs_upscaler_nearest,
|
||||
8 to R.string.sprefs_upscaler_gsr_quality, 6 to R.string.sprefs_upscaler_sharpen,
|
||||
)
|
||||
|
||||
/** [upscalerChoices] labelled in the app's language; the English list stays for the device report. */
|
||||
fun upscalerChoices(context: Context): List<Pair<Int, String>> =
|
||||
upscalerChoices.map { (mode, label) -> mode to (upscalerLabels[mode]?.let(context::getString) ?: label) }
|
||||
|
||||
fun canonicalUpscaler(mode: Int): Int = when (mode) {
|
||||
1 -> 0
|
||||
5 -> 4
|
||||
@@ -703,6 +779,10 @@ object SessionPrefs {
|
||||
|
||||
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
|
||||
|
||||
/** [textureAnisotropyChoices] labelled in the app's language. */
|
||||
fun textureAnisotropyChoices(context: Context): List<Pair<Int, String>> =
|
||||
textureAnisotropyChoices.map { (value, label) -> value to if (value == 0) context.getString(R.string.frame_gen_off) else label }
|
||||
|
||||
val textureLodBiasChoices = TextureFiltering.LOD_BIAS.map {
|
||||
it to when (it) {
|
||||
TextureFiltering.LOD_BIAS_OFF -> "Off"
|
||||
@@ -711,6 +791,15 @@ object SessionPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/** [textureLodBiasChoices] labelled in the app's language. */
|
||||
fun textureLodBiasChoices(context: Context): List<Pair<String, String>> = textureLodBiasChoices.map { (value, label) ->
|
||||
value to when (value) {
|
||||
TextureFiltering.LOD_BIAS_OFF -> context.getString(R.string.frame_gen_off)
|
||||
TextureFiltering.LOD_BIAS_AUTO -> context.getString(R.string.sprefs_lod_bias_auto)
|
||||
else -> label
|
||||
}
|
||||
}
|
||||
|
||||
/** Anisotropic filtering forced on DirectX 9-11 games (core/TextureFiltering); 0 = the game's own. */
|
||||
fun textureAnisotropy(context: Context): Int =
|
||||
prefs(context).getInt("textureAnisotropy", 0).takeIf { it in TextureFiltering.ANISOTROPY } ?: 0
|
||||
@@ -749,16 +838,41 @@ object SessionPrefs {
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||||
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
|
||||
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
|
||||
// Direct games share Steam's settings but have no Steam client to prepare.
|
||||
?.let { if (it == SUSPEND_NATIVE && mode != SessionService.MODE_STEAM) SUSPEND_AUTO else it }
|
||||
?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it }
|
||||
?: SUSPEND_MANUAL
|
||||
|
||||
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
|
||||
val normalized = policy.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_MANUAL, SUSPEND_NEVER) ||
|
||||
(it == SUSPEND_NATIVE && mode == SessionService.MODE_STEAM) }
|
||||
?: SUSPEND_MANUAL
|
||||
prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply()
|
||||
val prefs = prefs(context)
|
||||
val key = "suspendPolicy.${prefMode(mode)}"
|
||||
prefs.edit().apply {
|
||||
if (mode == SessionService.MODE_STEAM && prefs.getString(key, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, true)
|
||||
}
|
||||
putString(key, normalized)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun steamDownloadsInBackground(context: Context): Boolean {
|
||||
val prefs = prefs(context)
|
||||
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
|
||||
prefs.getString("suspendPolicy.${SessionService.MODE_STEAM}", null) == LEGACY_SUSPEND_DOWNLOADS
|
||||
}
|
||||
|
||||
fun setSteamDownloadsInBackground(context: Context, enabled: Boolean) {
|
||||
val prefs = prefs(context)
|
||||
val policyKey = "suspendPolicy.${SessionService.MODE_STEAM}"
|
||||
prefs.edit().apply {
|
||||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, enabled)
|
||||
if (prefs.getString(policyKey, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||||
putString(policyKey, SUSPEND_AUTO)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
|
||||
// ── Game storage ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.TrafficStats
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
@@ -60,6 +61,9 @@ import java.util.UUID
|
||||
* The activity comes and goes on top of this; see [com.droiddeck.launcher.wayland.CompositorHost].
|
||||
*/
|
||||
class SessionService : Service() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
|
||||
|
||||
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
|
||||
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
|
||||
private var deckBinds: List<String> = emptyList()
|
||||
@@ -72,7 +76,12 @@ class SessionService : Service() {
|
||||
private val auxiliaryProcessesLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var suspendController: SessionSuspendController? = null
|
||||
private val steamDownloadMonitor = SteamDownloadMonitor()
|
||||
private var downloadMonitorTask: Runnable? = null
|
||||
private var downloadActive = false
|
||||
private var suspendDownloadNow = false
|
||||
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
|
||||
private var steamDownloadsInBackground = false
|
||||
private var activityVisible = true
|
||||
private var screenOn = true
|
||||
private var manualPauseRequested = false
|
||||
@@ -164,6 +173,7 @@ class SessionService : Service() {
|
||||
ACTION_ACTIVITY_VISIBLE -> {
|
||||
if (!SessionState.pipActive) pipTask = false
|
||||
activityVisible = true
|
||||
suspendDownloadNow = false
|
||||
resumeSteamSleepOnReturn()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
@@ -178,10 +188,18 @@ class SessionService : Service() {
|
||||
ACTION_SUSPEND_POLICY_CHANGED -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
updateDownloadMonitoring()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_SUSPEND_NOW -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendDownloadNow = true
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_RESUME -> {
|
||||
resumeSession()
|
||||
return START_NOT_STICKY
|
||||
@@ -196,9 +214,13 @@ class SessionService : Service() {
|
||||
}
|
||||
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
steamSleepToken = null
|
||||
completeNativeSleep()
|
||||
suspendOperationPending = false
|
||||
@@ -263,11 +285,15 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun extraEnv(): List<String> {
|
||||
val file = File(Environment.getExternalStorageDirectory(), ENV_SWITCH).takeIf { it.isFile } ?: return emptyList()
|
||||
if (File(Environment.getExternalStorageDirectory(), LEGACY_ENV_SWITCH).isFile) {
|
||||
Log.w(TAG, "ignoring $LEGACY_ENV_SWITCH: any app with storage access can write there; " +
|
||||
"the file now lives at ${envSwitchFile(this)?.path ?: "(not available before Android 11)"}")
|
||||
}
|
||||
val file = envSwitchFile(this)?.takeIf { it.isFile } ?: return emptyList()
|
||||
val lines = FileUtils.readString(file)?.lines().orEmpty()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') && !it.startsWith("=") }
|
||||
if (lines.isNotEmpty()) Log.i(TAG, "extra environment from $ENV_SWITCH: $lines")
|
||||
if (lines.isNotEmpty()) Log.i(TAG, "extra environment keys from ${file.path}: ${lines.map { it.substringBefore('=') }}")
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -298,6 +324,9 @@ class SessionService : Service() {
|
||||
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
|
||||
killStragglers()
|
||||
SessionFiles.stage(this, root)
|
||||
com.droiddeck.launcher.agent.AgentGuest.reset(this)
|
||||
// The compat tool asks for an Epic game's sign-in code at every launch, the client's Play button included.
|
||||
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
|
||||
|
||||
val sessionDir = openSessionFolder()
|
||||
val sessionLog = File(sessionDir, "session.log")
|
||||
@@ -397,14 +426,14 @@ class SessionService : Service() {
|
||||
}
|
||||
guest.add(program)
|
||||
guest.addAll(SessionState.programArgs)
|
||||
Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope")
|
||||
Log.i(TAG, "run: $program (${SessionState.programArgs.size} arguments) under gamescope")
|
||||
}
|
||||
|
||||
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
|
||||
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
|
||||
FileUtils.clear(File(cacheDir, "shm"))
|
||||
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest)
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest, sessionRoot, runtimeDir)
|
||||
// The fast path is told exactly the rootfs and binds proot is given (ProotFastPath).
|
||||
val fastPathKey = if (ProotFastPath.enabled(this)) {
|
||||
val prootBinds = LinuxRuntime.binds(
|
||||
@@ -522,6 +551,7 @@ class SessionService : Service() {
|
||||
},
|
||||
)
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
updateDownloadMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +584,12 @@ class SessionService : Service() {
|
||||
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
|
||||
guest.add("TERM=xterm-256color")
|
||||
guest.add("LANG=C.UTF-8")
|
||||
// Steam's interface language: the app's (Setup's choice, or the system's). The system's
|
||||
// region decides between Spain's and Latin American Spanish, which the app has one of.
|
||||
guest.add("BL_STEAM_LANGUAGE=" + SteamLanguage.forLocale(
|
||||
com.droiddeck.launcher.core.AppLanguage.effective(this),
|
||||
com.droiddeck.launcher.core.AppLanguage.system(this).country,
|
||||
))
|
||||
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
|
||||
// session bundle sit hours off the device's. Bannerlator carries the same line.
|
||||
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
|
||||
@@ -619,11 +655,15 @@ class SessionService : Service() {
|
||||
// gamescope's realtime Vulkan queues (the session script turns this into
|
||||
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
|
||||
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
|
||||
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
|
||||
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
|
||||
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
|
||||
// the client's - whatever the experiment needs, without a build per attempt.
|
||||
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
|
||||
// Anything else, for a device that cannot be reached with a debugger: droiddeck-env in the
|
||||
// app's own external files (envSwitchFile) holds KEY=VALUE lines that go into the session's
|
||||
// environment as written, after ours, so a line here wins. Zink and Turnip tunables
|
||||
// (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, the client's - whatever the experiment
|
||||
// needs, without a build per attempt.
|
||||
extraEnv().forEach { guest.add(it) }
|
||||
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
|
||||
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
|
||||
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
|
||||
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
|
||||
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
|
||||
@@ -656,16 +696,13 @@ class SessionService : Service() {
|
||||
|
||||
val audioLog = File(sessionDir, "audio.log")
|
||||
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
|
||||
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
|
||||
// fills the relay's ring and the relay, outside proot, drives the device.
|
||||
// The client's own sound: the classic AAudio sink unless the user chose the relay.
|
||||
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
|
||||
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
|
||||
// The client's own sound: DirectAudio's engine inside the daemon, one step from Android (the
|
||||
// daemon runs on the Android side). The relay below is for games and the microphone only.
|
||||
pulse.setLogFile(audioLog)
|
||||
pulse.attach(this)
|
||||
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
|
||||
components.add(pulse)
|
||||
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
|
||||
if (wantsDirectAudio || wantsMic) {
|
||||
// After the daemon in the list, so it can wait for the pipe the daemon makes.
|
||||
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
|
||||
relay.setLogFile(audioLog)
|
||||
@@ -679,7 +716,7 @@ class SessionService : Service() {
|
||||
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
|
||||
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
|
||||
}
|
||||
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
|
||||
Log.i(TAG, "audio: client DirectAudio (in-process sink)" + (if (wantsDirectAudio) " + DirectAudio for games" else "")
|
||||
+ (if (wantsMic) " + microphone" else "") +
|
||||
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
|
||||
return pulse
|
||||
@@ -747,6 +784,8 @@ class SessionService : Service() {
|
||||
fakeInputDir: File,
|
||||
sessionDir: File,
|
||||
guest: MutableList<String>,
|
||||
sessionRoot: File,
|
||||
runtimeDir: File,
|
||||
): ArrayList<String> {
|
||||
val binds = ArrayList<String>()
|
||||
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
|
||||
@@ -808,7 +847,7 @@ class SessionService : Service() {
|
||||
val library = GameStorage.effective(this)
|
||||
var storageDiagnosticLibrary: File? = null
|
||||
if (library != null) {
|
||||
val problem = GameStorage.prepare(library.path)
|
||||
val problem = GameStorage.prepare(this, library.path)
|
||||
if (problem == null) {
|
||||
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
|
||||
// Links, prefixes and Steam entries made before the rename still name the old path.
|
||||
@@ -857,6 +896,15 @@ class SessionService : Service() {
|
||||
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
|
||||
}
|
||||
}
|
||||
// Store games installed on a card: each card's Games root at its own fixed place, so the
|
||||
// shortcuts point somewhere whether or not the card is also the Steam library.
|
||||
for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRoots(this)) {
|
||||
if (host.isDirectory && host.canRead()) {
|
||||
File(LinuxRuntime.rootDir(this), guest.removePrefix("/")).mkdirs()
|
||||
binds.add(host.path + ":" + guest)
|
||||
Log.i(TAG, "store games: $host -> $guest")
|
||||
}
|
||||
}
|
||||
// Folders of added scripts outside internal storage, where their links point.
|
||||
binds.addAll(com.droiddeck.launcher.runtime.UserApps.binds(this))
|
||||
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
|
||||
@@ -867,6 +915,45 @@ class SessionService : Service() {
|
||||
} else if (roms != null) {
|
||||
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
|
||||
}
|
||||
val libraries = ArrayList<Pair<String, String>>()
|
||||
if (storageDiagnosticLibrary != null && library != null) libraries.add(SecondaryLibrary.CARD_GUESTS[0] to library.label)
|
||||
val gamesLibraries = GameStorage.gamesFolderLibraries(this)
|
||||
if (gamesLibraries.isNotEmpty()) {
|
||||
var room = if (ProotFastPath.enabled(this)) {
|
||||
ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size
|
||||
} else Int.MAX_VALUE
|
||||
for (games in gamesLibraries) {
|
||||
if (games.host.path + ":" + games.guest !in binds) continue
|
||||
val problem = GameStorage.prepare(this, games.host.path)
|
||||
if (problem != null) {
|
||||
Log.w(TAG, "steam library: $problem")
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val extra = SecondaryLibrary.binds(filesDir, games.host, listOf(games.guest)).drop(1)
|
||||
if (extra.size > room) {
|
||||
Log.w(TAG, "steam library: ${games.host} needs ${extra.size} binds, $room left for the fast path; not a library this session")
|
||||
continue
|
||||
}
|
||||
binds.addAll(extra)
|
||||
room -= extra.size
|
||||
libraries.add(games.guest to games.label)
|
||||
Log.i(TAG, "steam library: ${games.host} -> ${games.guest} (\"${games.label}\")")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam library: ${games.host} private directories could not be prepared", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() }
|
||||
val json = org.json.JSONArray()
|
||||
for ((path, label) in libraries) json.put(org.json.JSONObject().put("path", path).put("label", label.replace('"', ' ').replace('\\', ' ')))
|
||||
listing.writeText(json.toString())
|
||||
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
|
||||
guest.add(envAt, "BL_STEAM_LIBRARIES=" + listing.path)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam library: the list could not be written; the card alone this session", e)
|
||||
}
|
||||
return binds
|
||||
}
|
||||
|
||||
@@ -1007,6 +1094,7 @@ class SessionService : Service() {
|
||||
activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
@@ -1178,16 +1266,19 @@ class SessionService : Service() {
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !activityVisible || !screenOn
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden) {
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
|
||||
completeNativeSleep()
|
||||
} else if (nativeSleepToken == null && steamSleepToken == null &&
|
||||
!SessionState.suspended && !suspendAttemptFailed) {
|
||||
prepareNativeSleep()
|
||||
}
|
||||
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
|
||||
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
|
||||
else -> false
|
||||
}
|
||||
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
|
||||
@@ -1324,6 +1415,13 @@ class SessionService : Service() {
|
||||
SessionState.pipActive = false
|
||||
pipTask = false
|
||||
SessionState.guestPid = -1
|
||||
com.droiddeck.launcher.agent.AgentGuest.stop()
|
||||
com.droiddeck.launcher.stores.StoreLaunchRequests.stop()
|
||||
// Cloud saves of a store game played this session, if the compat tool's exit request did
|
||||
// not upload them (a session closed from the drawer never reaches it).
|
||||
Thread({ com.droiddeck.launcher.stores.CloudSaves.uploadAllDirty(applicationContext, "session-end", running = false) }, "cloud-session-end").start()
|
||||
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
|
||||
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
|
||||
releaseLocks()
|
||||
if (status == 0) {
|
||||
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
|
||||
@@ -1371,6 +1469,7 @@ class SessionService : Service() {
|
||||
if (!SessionState.running) return
|
||||
SessionState.running = false
|
||||
}
|
||||
updateDownloadMonitoring()
|
||||
val stoppedGen = sessionGen
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
@@ -1539,11 +1638,26 @@ class SessionService : Service() {
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification))
|
||||
.setContentText(getString(
|
||||
when {
|
||||
SessionState.suspended -> R.string.session_notification_paused
|
||||
downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
|
||||
else -> R.string.session_notification
|
||||
}
|
||||
))
|
||||
.setContentIntent(open)
|
||||
.apply {
|
||||
if (SessionState.suspended) {
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
|
||||
} else if (downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
|
||||
val suspend = PendingIntent.getService(
|
||||
this@SessionService, 3,
|
||||
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.suspend_now), suspend).build())
|
||||
}
|
||||
}
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build())
|
||||
@@ -1557,6 +1671,40 @@ class SessionService : Service() {
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
private fun updateDownloadMonitoring() {
|
||||
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
|
||||
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
|
||||
if (!enabled) {
|
||||
val wasActive = downloadActive
|
||||
downloadMonitorTask?.let(mainHandler::removeCallbacks)
|
||||
downloadMonitorTask = null
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
if (wasActive) refreshNotification()
|
||||
return
|
||||
}
|
||||
if (downloadMonitorTask != null) return
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (downloadMonitorTask !== this || !SessionState.running ||
|
||||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
|
||||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
|
||||
return
|
||||
}
|
||||
val wasActive = downloadActive
|
||||
downloadActive = steamDownloadMonitor.poll(
|
||||
LinuxRuntime.rootDir(this@SessionService),
|
||||
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
|
||||
)
|
||||
if (wasActive != downloadActive) refreshNotification()
|
||||
updateSuspendPolicy()
|
||||
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
|
||||
}
|
||||
}
|
||||
downloadMonitorTask = poll
|
||||
mainHandler.post(poll)
|
||||
}
|
||||
|
||||
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
|
||||
private fun logControllersAtStart() {
|
||||
val pads = ArrayList<String>()
|
||||
@@ -1575,12 +1723,30 @@ class SessionService : Service() {
|
||||
private const val TRACER_NICE = -6
|
||||
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
|
||||
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
|
||||
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
|
||||
private const val ENV_SWITCH = "Download/droiddeck-env"
|
||||
/**
|
||||
* Where droiddeck-env (KEY=VALUE lines added to the session environment verbatim) used to
|
||||
* be. Ignored now: a line there reaches the Steam client's environment as written
|
||||
* (LD_PRELOAD, VK_ICD_FILENAMES, PATH...), and any app with storage access can write to
|
||||
* Download - code of its choosing inside our sandbox, next to the client's saved login.
|
||||
*/
|
||||
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
|
||||
private const val ENV_SWITCH_NAME = "droiddeck-env"
|
||||
|
||||
/**
|
||||
* The session's extra-environment file: Android/data/<package>/files/droiddeck-env. Only
|
||||
* this app, adb and the user (through the app's own file manager) can write there - from
|
||||
* Android 11 on. Before that other apps with storage access can reach Android/data too, so
|
||||
* there is no such file at all.
|
||||
*/
|
||||
fun envSwitchFile(context: Context): File? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
context.getExternalFilesDir(null)?.let { File(it, ENV_SWITCH_NAME) }
|
||||
else null
|
||||
private const val CHANNEL_ID = "session"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
|
||||
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
|
||||
const val ACTION_SUSPEND_NOW = "com.droiddeck.launcher.SUSPEND_NOW"
|
||||
private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN"
|
||||
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
|
||||
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
|
||||
@@ -1600,6 +1766,7 @@ class SessionService : Service() {
|
||||
private const val STEAM_PICKUP_MS = 1500L
|
||||
private const val STEAM_EXIT_MS = 10_000L
|
||||
private const val GAME_LAUNCH_RETRY_MS = 250L
|
||||
private const val DOWNLOAD_POLL_MS = 2_000L
|
||||
private const val STEAM_GAME_REQUEST = "steam-game"
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
|
||||
@@ -80,6 +80,9 @@ object SessionState {
|
||||
/** Told (on the thread that changed it) whenever [deckPad] changes. */
|
||||
@Volatile var deckPadListener: (() -> Unit)? = null
|
||||
|
||||
/** The session screen's virtual pad, for the agent bridge's pad input (AgentInput). */
|
||||
@Volatile var padBridge: com.droiddeck.launcher.input.PadBridge? = null
|
||||
|
||||
/** What the second screen shows, and on which display: kept for the session, so a screen
|
||||
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
|
||||
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
internal class SteamDownloadMonitor {
|
||||
private data class ManifestState(
|
||||
val bytesDownloaded: Long,
|
||||
val incomplete: Boolean,
|
||||
)
|
||||
|
||||
private var previous = emptyMap<String, ManifestState>()
|
||||
private var lastProgressAt = 0L
|
||||
private var contentLogOffset = 0L
|
||||
private val activeContentUpdates = mutableSetOf<String>()
|
||||
private var lastContentActivityAt = 0L
|
||||
private var lastReceivedBytes = -1L
|
||||
private var lastReceivedAt = 0L
|
||||
|
||||
/**
|
||||
* [receivedBytes] is the app uid's received-byte counter (the guest runs as that uid), or a
|
||||
* negative value when Android does not report one. Steam writes its rate line about once a
|
||||
* minute and flushes manifest counters even less often, so live traffic is what bridges them.
|
||||
*/
|
||||
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L): Boolean {
|
||||
val current = manifests(runtimeRoot).associate { it.path to read(it) }
|
||||
val progressed = current.any { (path, state) ->
|
||||
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
|
||||
}
|
||||
if (progressed) lastProgressAt = now
|
||||
previous = current
|
||||
val manifestActive = progressed || lastProgressAt > 0L && now - lastProgressAt <= MANIFEST_PROGRESS_GRACE_MS &&
|
||||
current.values.any { it.incomplete }
|
||||
if (receiving(receivedBytes, now)) lastContentActivityAt = now
|
||||
return manifestActive || readContentActivity(runtimeRoot, now)
|
||||
}
|
||||
|
||||
private fun receiving(receivedBytes: Long, now: Long): Boolean {
|
||||
val previousBytes = lastReceivedBytes
|
||||
val previousAt = lastReceivedAt
|
||||
lastReceivedBytes = receivedBytes
|
||||
lastReceivedAt = now
|
||||
if (receivedBytes < 0L || previousBytes < 0L || receivedBytes < previousBytes || now <= previousAt) return false
|
||||
return (receivedBytes - previousBytes) * 1000L / (now - previousAt) >= MIN_RECEIVE_BYTES_PER_SEC
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
previous = emptyMap()
|
||||
lastProgressAt = 0L
|
||||
contentLogOffset = 0L
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
lastReceivedBytes = -1L
|
||||
lastReceivedAt = 0L
|
||||
}
|
||||
|
||||
private fun readContentActivity(runtimeRoot: File, now: Long): Boolean {
|
||||
val log = File(runtimeRoot, "root/.local/share/Steam/logs/content_log.txt")
|
||||
if (!log.isFile) return false
|
||||
if (log.length() < contentLogOffset) {
|
||||
contentLogOffset = 0L
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
runCatching {
|
||||
RandomAccessFile(log, "r").use { input ->
|
||||
input.seek(contentLogOffset)
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
when {
|
||||
"Client version:" in line -> {
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
CONTENT_UPDATE.find(line)?.let { match ->
|
||||
val appId = match.groupValues[1]
|
||||
val updateType = match.groupValues[2]
|
||||
val state = match.groupValues[3]
|
||||
val updateId = "$updateType:$appId"
|
||||
if ("Running Update" in state && "Stopping" !in state) {
|
||||
activeContentUpdates += updateId
|
||||
lastContentActivityAt = now
|
||||
} else {
|
||||
activeContentUpdates -= updateId
|
||||
}
|
||||
true
|
||||
} == true -> Unit
|
||||
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
|
||||
lastContentActivityAt = now
|
||||
"Downloading " in line && " chunks for depot " in line ->
|
||||
lastContentActivityAt = now
|
||||
}
|
||||
if (activeContentUpdates.isNotEmpty()) lastContentActivityAt = now
|
||||
}
|
||||
contentLogOffset = input.filePointer
|
||||
}
|
||||
}
|
||||
return activeContentUpdates.isNotEmpty() && lastContentActivityAt > 0L &&
|
||||
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
|
||||
}
|
||||
|
||||
private fun manifests(runtimeRoot: File): List<File> {
|
||||
val steamRoots = linkedSetOf(File(runtimeRoot, "root/.local/share/Steam"))
|
||||
val primary = steamRoots.first()
|
||||
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
|
||||
runCatching {
|
||||
PATH.findAll(libraryFolders.readText()).forEach { match ->
|
||||
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
|
||||
}
|
||||
}
|
||||
return steamRoots.flatMap { root ->
|
||||
File(root, "steamapps").listFiles { file ->
|
||||
file.isFile && file.name.startsWith("appmanifest_") && file.name.endsWith(".acf")
|
||||
}.orEmpty().toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun read(file: File): ManifestState {
|
||||
val text = runCatching { file.readText() }.getOrDefault("")
|
||||
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
|
||||
val downloaded = value(text, "BytesDownloaded")?.toLongOrNull() ?: 0L
|
||||
val total = value(text, "BytesToDownload")?.toLongOrNull() ?: 0L
|
||||
return ManifestState(downloaded, flags != 4L && (total <= 0L || downloaded < total))
|
||||
}
|
||||
|
||||
private fun value(text: String, key: String): String? =
|
||||
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
|
||||
|
||||
private companion object {
|
||||
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
|
||||
// Longer than Steam's ~60 s between rate lines, for when no traffic counter is available.
|
||||
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
|
||||
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
|
||||
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
|
||||
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
|
||||
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Steam's name for a language: the API language codes the client and the Steamworks API use
|
||||
* ("english", "schinese", "koreana"...), for every language Steam's interface comes in.
|
||||
*/
|
||||
object SteamLanguage {
|
||||
const val DEFAULT = "english"
|
||||
|
||||
/** Every language the Steam client's interface is offered in. */
|
||||
val all: Set<String> = setOf(
|
||||
"arabic", "bulgarian", "schinese", "tchinese", "czech", "danish", "dutch", "english",
|
||||
"finnish", "french", "german", "greek", "hungarian", "indonesian", "italian", "japanese",
|
||||
"koreana", "norwegian", "polish", "portuguese", "brazilian", "romanian", "russian",
|
||||
"spanish", "latam", "swedish", "thai", "turkish", "ukrainian", "vietnamese", "malay",
|
||||
)
|
||||
|
||||
/** Spanish-speaking regions Steam serves with Latin American Spanish rather than Spain's. */
|
||||
private val latinAmerica = setOf(
|
||||
"419", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "MX", "NI", "PA", "PE",
|
||||
"PR", "PY", "SV", "US", "UY", "VE",
|
||||
)
|
||||
|
||||
/**
|
||||
* Steam's language for [locale]. [region] stands in for the locale's own when it has none: the
|
||||
* app's Spanish carries no region, and a player in Mexico should get Latin American Spanish.
|
||||
*/
|
||||
fun forLocale(locale: Locale, region: String? = null): String {
|
||||
val country = locale.country.ifEmpty { region.orEmpty() }
|
||||
return when (locale.language) {
|
||||
"ar" -> "arabic"
|
||||
"bg" -> "bulgarian"
|
||||
"cs" -> "czech"
|
||||
"da" -> "danish"
|
||||
"nl" -> "dutch"
|
||||
"en" -> "english"
|
||||
"fi" -> "finnish"
|
||||
"fr" -> "french"
|
||||
"de" -> "german"
|
||||
"el" -> "greek"
|
||||
"hu" -> "hungarian"
|
||||
"id", "in" -> "indonesian"
|
||||
"it" -> "italian"
|
||||
"ms" -> "malay"
|
||||
"ja" -> "japanese"
|
||||
"ko" -> "koreana"
|
||||
"nb", "no", "nn" -> "norwegian"
|
||||
"pl" -> "polish"
|
||||
"pt" -> if (country == "BR") "brazilian" else "portuguese"
|
||||
"ro" -> "romanian"
|
||||
"ru" -> "russian"
|
||||
"es" -> if (country in latinAmerica) "latam" else "spanish"
|
||||
"sv" -> "swedish"
|
||||
"th" -> "thai"
|
||||
"tr" -> "turkish"
|
||||
"uk" -> "ukrainian"
|
||||
"vi" -> "vietnamese"
|
||||
"zh" -> when {
|
||||
locale.script == "Hant" -> "tchinese"
|
||||
locale.script == "Hans" -> "schinese"
|
||||
country == "TW" || country == "HK" || country == "MO" -> "tchinese"
|
||||
else -> "schinese"
|
||||
}
|
||||
else -> DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
@@ -32,13 +34,13 @@ object FlathubApi {
|
||||
)
|
||||
|
||||
/** A category as Flathub's search filters it, with the name shown for it. */
|
||||
class Category(val id: String, val label: String)
|
||||
class Category(val id: String, @StringRes val label: Int)
|
||||
|
||||
val categories = listOf(
|
||||
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
|
||||
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
|
||||
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
|
||||
Category("Utility", "Utilities"),
|
||||
Category("Game", R.string.store_cat_games), Category("AudioVideo", R.string.store_cat_audio_video), Category("Graphics", R.string.store_cat_graphics),
|
||||
Category("Network", R.string.store_cat_internet), Category("Office", R.string.store_cat_office), Category("Development", R.string.store_cat_developer),
|
||||
Category("Education", R.string.store_cat_education), Category("Science", R.string.store_cat_science), Category("System", R.string.store_cat_system),
|
||||
Category("Utility", R.string.store_cat_utilities),
|
||||
)
|
||||
|
||||
/** Front-page collections: popular, trending, recently-updated, recently-added. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.FlatpakManager
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,7 @@ object StoreState {
|
||||
private set
|
||||
private var searchSeq = 0
|
||||
|
||||
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
|
||||
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
@@ -97,7 +98,7 @@ object StoreState {
|
||||
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
|
||||
if (busy != null) return
|
||||
val app = context.applicationContext
|
||||
busy = what; stage = "Starting…"; percent = -1
|
||||
busy = what; stage = app.getString(R.string.store_starting); percent = -1
|
||||
Thread({
|
||||
val problem = try {
|
||||
work(app) { s, p -> main.post { stage = s; percent = p } }
|
||||
@@ -109,12 +110,12 @@ object StoreState {
|
||||
main.post {
|
||||
busy = null; stage = null; percent = -1
|
||||
ready = r; installed = list
|
||||
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
|
||||
if (problem != null) Toast.makeText(app, app.getString(R.string.store_problem, label, problem), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "store-$what").start()
|
||||
}
|
||||
|
||||
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
|
||||
fun setup(context: Context) = run(context, "setup", context.getString(R.string.store_flatpak_setup)) { c, p -> FlatpakManager.setup(c, p) }
|
||||
|
||||
fun install(context: Context, id: String, name: String) =
|
||||
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
|
||||
@@ -135,7 +136,7 @@ object StoreState {
|
||||
val u = FlatpakManager.updates(app)
|
||||
main.post {
|
||||
checkingUpdates = false
|
||||
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
|
||||
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, R.string.store_updates_check_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "store-updates").start()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a cloud sync may do, decided from three views of each save file: the local copy, the cloud
|
||||
* copy, and the baseline - both as they were when this device last synced the game. Pure, so the
|
||||
* rules are tested on their own:
|
||||
*
|
||||
* - No upload before a download has completed once on this device (no baseline): a first launch
|
||||
* writes new profile files that must never replace the real saves in the cloud.
|
||||
* - The first download (no baseline) lets the cloud win where the two differ - the local folder is
|
||||
* backed up first.
|
||||
* - A file changed on both sides since the baseline is a conflict: neither side is overwritten; the
|
||||
* user picks Keep cloud or Keep local in Manage saves.
|
||||
* - Otherwise the side that changed since the baseline wins; one that changed on neither is left.
|
||||
*/
|
||||
object CloudPlan {
|
||||
/** One file as this device last saw it in sync: the local MD5, and the cloud's MD5 (when known) and time. */
|
||||
data class Entry(val localMd5: String, val cloudMd5: String?, val cloudModified: Long)
|
||||
|
||||
class Local(val md5: String)
|
||||
class Remote(val md5: String?, val modified: Long)
|
||||
|
||||
class Plan(val transfer: List<String>, val conflicts: List<String>, val same: List<String>, val refused: String? = null)
|
||||
|
||||
/** The cloud copy differs from what the baseline recorded of it. */
|
||||
private fun cloudChanged(r: Remote, b: Entry?): Boolean {
|
||||
if (b == null) return true
|
||||
if (r.md5 != null && b.cloudMd5 != null) return !r.md5.equals(b.cloudMd5, ignoreCase = true)
|
||||
// Times only (Epic's listing): the service's own clock, so a couple of seconds of slack.
|
||||
return r.modified > 0 && b.cloudModified > 0 && kotlin.math.abs(r.modified - b.cloudModified) > 2000
|
||||
}
|
||||
|
||||
private fun localChanged(l: Local, b: Entry?): Boolean = b == null || !l.md5.equals(b.localMd5, ignoreCase = true)
|
||||
|
||||
private fun same(l: Local, r: Remote): Boolean = r.md5 != null && r.md5.equals(l.md5, ignoreCase = true)
|
||||
|
||||
/** Cloud → device. [baseline] null = this device never synced the game. */
|
||||
fun down(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
|
||||
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
|
||||
for ((name, r) in remote) {
|
||||
val l = local[name]
|
||||
val b = baseline?.get(name)
|
||||
when {
|
||||
l == null -> transfer += name
|
||||
same(l, r) -> same += name
|
||||
baseline == null -> transfer += name
|
||||
cloudChanged(r, b) && localChanged(l, b) -> conflicts += name
|
||||
cloudChanged(r, b) -> transfer += name
|
||||
}
|
||||
}
|
||||
return Plan(transfer, conflicts, same)
|
||||
}
|
||||
|
||||
/** Device → cloud. Refused outright without a baseline. */
|
||||
fun up(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
|
||||
if (baseline == null) return Plan(emptyList(), emptyList(), emptyList(), refused = "no-baseline")
|
||||
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
|
||||
for ((name, l) in local) {
|
||||
val r = remote[name]
|
||||
val b = baseline[name]
|
||||
when {
|
||||
r == null -> transfer += name
|
||||
same(l, r) -> same += name
|
||||
localChanged(l, b) && cloudChanged(r, b) -> conflicts += name
|
||||
localChanged(l, b) -> transfer += name
|
||||
}
|
||||
}
|
||||
return Plan(transfer, conflicts, same)
|
||||
}
|
||||
|
||||
fun toJson(baseline: Map<String, Entry>): String = JSONObject().apply {
|
||||
for ((k, e) in baseline) put(k, JSONObject().put("l", e.localMd5).put("c", e.cloudMd5 ?: "").put("t", e.cloudModified))
|
||||
}.toString()
|
||||
|
||||
fun fromJson(text: String?): Map<String, Entry>? {
|
||||
if (text == null) return null
|
||||
return runCatching {
|
||||
val o = JSONObject(text)
|
||||
o.keys().asSequence().associateWith { k ->
|
||||
val e = o.getJSONObject(k)
|
||||
Entry(e.optString("l"), e.optString("c").ifEmpty { null }, e.optLong("t", -1L))
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Cloud-save folder resolution for GOG and Epic games, after Bannerlator's GogCloudSavePaths and
|
||||
* EpicCloudSavePaths (device-proven there, August 2026): a store's save-location template expanded
|
||||
* against the game's Proton prefix and its install folder.
|
||||
*/
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Where a store keeps a game's saves on Windows, made concrete inside this game's Proton prefix.
|
||||
*
|
||||
* GOG gives a template per game in its public remote-config (`cloudStorage.locations[].location`):
|
||||
* a leading `<?TOKEN?>` (or `%TOKEN%`) and a path, e.g. ELDERBORN's
|
||||
* `<?APPLICATION_DATA_LOCAL_LOW?>/Hyperstrange/ELDERBORN`. Epic gives `CloudSaveFolder` in the
|
||||
* game's catalog attributes: a leading `{Token}`, with `{EpicId}` / `{AppName}` inside, e.g.
|
||||
* `{AppData}/Game/Saved/SaveGames`. Profile tokens map under the prefix's Wine user
|
||||
* (`drive_c/users/steamuser`), the install tokens to the game's folder. The path is walked against
|
||||
* what is on disk case-insensitively; a result outside its boundary is refused, never guessed.
|
||||
*/
|
||||
object CloudSavePaths {
|
||||
/** The Wine user every prefix has (Proton's). */
|
||||
fun profile(prefix: File): File = File(prefix, "drive_c/users/steamuser")
|
||||
|
||||
/** Expands a GOG location template; null for an unknown token or an escape. */
|
||||
fun gog(template: String, prefix: File, install: File): File? {
|
||||
val norm = template.replace('\\', '/').trim().trimStart('/')
|
||||
val match = Regex("^(<\\?[^?>]+\\?>|%[^%]+%)").find(norm) ?: return null
|
||||
val token = match.groupValues[1].trim('<', '>', '?', '%').uppercase()
|
||||
val profile = profile(prefix)
|
||||
val (base, boundary) = when (token) {
|
||||
"INSTALL" -> install to install
|
||||
"SAVED_GAMES" -> File(profile, "Saved Games") to profile
|
||||
"DOCUMENTS" -> File(profile, "Documents") to profile
|
||||
"APPLICATION_DATA_LOCAL", "LOCALAPPDATA" -> File(profile, "AppData/Local") to profile
|
||||
"APPLICATION_DATA_LOCAL_LOW" -> File(profile, "AppData/LocalLow") to profile
|
||||
"APPLICATION_DATA_ROAMING", "APPDATA" -> File(profile, "AppData/Roaming") to profile
|
||||
"USERPROFILE" -> profile to profile
|
||||
"PROGRAMDATA" -> File(prefix, "drive_c/ProgramData").let { it to it }
|
||||
else -> return null
|
||||
}
|
||||
return walk(base, boundary, norm.substring(match.value.length), fuzzy = false)
|
||||
}
|
||||
|
||||
/** Expands an Epic CloudSaveFolder; null for an unknown token or an escape. */
|
||||
fun epic(template: String, prefix: File, install: File, accountId: String, appName: String): File? {
|
||||
val norm = template.replace('\\', '/').trim().trimStart('/')
|
||||
val match = Regex("^\\{([^}]+)\\}").find(norm) ?: return null
|
||||
val profile = profile(prefix)
|
||||
val (base, boundary) = when (match.groupValues[1].lowercase()) {
|
||||
"installdir" -> install to install
|
||||
"userprofile" -> profile to profile
|
||||
"userdir" -> File(profile, "Documents") to profile
|
||||
"usersavedgames" -> File(profile, "Saved Games") to profile
|
||||
"appdata", "localappdata" -> File(profile, "AppData/Local") to profile
|
||||
"roamingappdata" -> File(profile, "AppData/Roaming") to profile
|
||||
else -> return null
|
||||
}
|
||||
val rest = norm.substring(match.value.length)
|
||||
.replace("{epicid}", accountId, ignoreCase = true)
|
||||
.replace("{appname}", appName, ignoreCase = true)
|
||||
return walk(base, boundary, rest, fuzzy = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Each segment matched against the folder's entries case-insensitively (and, for Epic, by letters
|
||||
* and digits alone when that names exactly one), else taken as written - a download creates it.
|
||||
*/
|
||||
private fun walk(base: File, boundary: File, rest: String, fuzzy: Boolean): File? {
|
||||
var dir = base
|
||||
for (seg in rest.trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }) {
|
||||
if (seg == "..") { dir = dir.parentFile ?: return null; continue }
|
||||
val kids = dir.listFiles()
|
||||
val existing = kids?.firstOrNull { it.name.equals(seg, ignoreCase = true) }
|
||||
?: if (fuzzy) kids?.filter { squash(it.name) == squash(seg) && squash(seg).isNotEmpty() }?.singleOrNull() else null
|
||||
dir = existing ?: File(dir, seg)
|
||||
}
|
||||
val bound = runCatching { boundary.canonicalPath }.getOrNull() ?: return null
|
||||
val dest = runCatching { dir.canonicalPath }.getOrNull() ?: return null
|
||||
return if (dest == bound || dest.startsWith(bound + File.separator)) dir else null
|
||||
}
|
||||
|
||||
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.stores.epic.EpicApiClient
|
||||
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
|
||||
import com.droiddeck.launcher.stores.epic.EpicPrefs
|
||||
import com.droiddeck.launcher.stores.gog.GogAuth
|
||||
import com.droiddeck.launcher.stores.gog.GogPrefs
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* GOG and Epic cloud saves for an installed store game: before a launch the cloud copy is brought
|
||||
* down where it is newer, after the game exits what changed goes up - newest wins, file by file.
|
||||
* Before a download overwrites anything, the folder is copied to the app's storage (the last
|
||||
* [BACKUPS] per game). Both launch paths reach it: the compat tool's request from
|
||||
* droiddeck-store-launch ([StoreLaunchRequests]) for every launch, and the Games tab's Manage saves.
|
||||
*
|
||||
* The save folder: GOG's remote-config location template (by the game's Galaxy client id) or
|
||||
* Epic's CloudSaveFolder catalog attribute, cached per game, made concrete by [CloudSavePaths]
|
||||
* inside the game's Proton prefix. A game whose store gives none has no cloud saves.
|
||||
*/
|
||||
object CloudSaves {
|
||||
private const val TAG = "CloudSaves"
|
||||
private const val BACKUPS = 3
|
||||
private const val PARALLEL = 6
|
||||
|
||||
/** What a sync did, for the log and the Manage saves row. */
|
||||
/**
|
||||
* What a sync did, for the log and the Manage saves row. [result]: ok, skipped (nothing to do or
|
||||
* not possible: [reason] says why), deferred (no prefix yet; it runs once Proton makes one),
|
||||
* failed. [conflicts]: files changed on both sides, left alone.
|
||||
*/
|
||||
class Result(val result: String, val files: Int, val bytes: Long, val reason: String, val conflicts: Int = 0) {
|
||||
val ok: Boolean get() = result != "failed"
|
||||
val line: String get() = "result=$result files=$files bytes=$bytes reason=$reason" + if (conflicts > 0) " conflicts=$conflicts" else ""
|
||||
}
|
||||
|
||||
/** A game's cloud state for the Manage saves row. */
|
||||
class Status(val supported: Boolean, val folder: File?, val lastSync: Long, val reason: String, val conflicts: List<String> = emptyList())
|
||||
|
||||
private fun game(context: Context, store: Store, id: String): Pair<File, StoreGameSidecar>? =
|
||||
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
|
||||
StoreGameSidecar.read(f)?.takeIf { it.store == store && it.id == id && it.isInstalled }?.let { f to it }
|
||||
}
|
||||
|
||||
/** The game's prefix: compatdata/<its shortcut appid>/pfx, wherever that library is. */
|
||||
fun prefix(context: Context, folder: File): File? {
|
||||
val appId = AddedGames.scan(context).firstOrNull { it.folder.absolutePath == folder.absolutePath }?.appId ?: return null
|
||||
return Library.protonPrefix(context, appId and 0xFFFFFFFFL)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context, store: Store) = if (store == Store.GOG) GogPrefs.get(context) else EpicPrefs.get(context)
|
||||
|
||||
fun lastSync(context: Context, store: Store, id: String): Long = prefs(context, store).getLong("cloud_sync_$id", 0L)
|
||||
|
||||
private fun markSynced(context: Context, store: Store, id: String) = prefs(context, store).edit().putLong("cloud_sync_$id", System.currentTimeMillis()).apply()
|
||||
|
||||
// ---- the save folder ------------------------------------------------------------------------
|
||||
|
||||
/** The store's template for this game: cached, else fetched; "" when the store gives none, null when it could not be asked. */
|
||||
fun template(context: Context, store: Store, id: String, folder: File, sidecar: StoreGameSidecar): String? {
|
||||
val p = prefs(context, store)
|
||||
if (p.contains("cloud_template_$id")) return p.getString("cloud_template_$id", "")
|
||||
val fetched = when (store) {
|
||||
Store.GOG -> gogTemplate(context, id, folder)
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
|
||||
val ns = sidecar.extra["namespace"].orEmpty()
|
||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
||||
}
|
||||
Store.AMAZON -> ""
|
||||
} ?: return null
|
||||
p.edit().putString("cloud_template_$id", fetched).apply()
|
||||
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
|
||||
return fetched
|
||||
}
|
||||
|
||||
private fun gogClientId(context: Context, id: String, folder: File): String? =
|
||||
GogPrefs.get(context).getString("client_id_$id", null)?.ifEmpty { null }
|
||||
?: runCatching { JSONObject(File(folder, "goggame-$id.info").readText()).optString("clientId").ifEmpty { null } }.getOrNull()
|
||||
|
||||
/** GOG's public remote-config: `content.Windows.cloudStorage.locations`, the one named "saves" first. */
|
||||
private fun gogTemplate(context: Context, id: String, folder: File): String? {
|
||||
val clientId = gogClientId(context, id, folder) ?: return ""
|
||||
return try {
|
||||
val c = URL("https://remote-config.gog.com/components/galaxy_client/clients/$clientId?component_version=2.0.45").openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000; c.readTimeout = 15_000
|
||||
c.setRequestProperty("User-Agent", "GOG Galaxy")
|
||||
if (c.responseCode !in 200..299) { c.disconnect(); return null }
|
||||
val cloud = JSONObject(c.inputStream.bufferedReader().readText()).optJSONObject("content")?.optJSONObject("Windows")?.optJSONObject("cloudStorage")
|
||||
c.disconnect()
|
||||
if (cloud?.optBoolean("enabled", false) != true) return ""
|
||||
val locations = cloud.optJSONArray("locations") ?: return ""
|
||||
val all = (0 until locations.length()).mapNotNull { locations.optJSONObject(it) }
|
||||
(all.firstOrNull { it.optString("name").equals("saves", true) } ?: all.firstOrNull())?.optString("location").orEmpty()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "gog remote-config: ${e.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** The concrete save folder in the prefix, or null with why. */
|
||||
private fun saveFolder(context: Context, store: Store, id: String): Pair<File?, String> {
|
||||
val (folder, sidecar) = game(context, store, id) ?: return null to "not-installed"
|
||||
val template = template(context, store, id, folder, sidecar) ?: return null to "store-unreachable"
|
||||
if (template.isEmpty()) return null to "no-cloud-saves"
|
||||
val pfx = prefix(context, folder) ?: return null to "no-prefix"
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
|
||||
fun status(context: Context, store: Store, id: String): Status {
|
||||
val (dir, reason) = saveFolder(context, store, id)
|
||||
return Status(reason != "no-cloud-saves" && reason != "not-installed", dir, lastSync(context, store, id), reason, conflicts(context, store, id))
|
||||
}
|
||||
|
||||
// ---- transport ------------------------------------------------------------------------------
|
||||
|
||||
private fun transport(context: Context, store: Store, id: String, folder: File): CloudTransport? = when (store) {
|
||||
Store.GOG -> {
|
||||
val account = StoreAccounts.read(context, Store.GOG)
|
||||
val userId = account?.optString("user_id").orEmpty()
|
||||
val galaxy = GogAuth.validToken(context)
|
||||
val clientId = gogClientId(context, id, folder)
|
||||
if (userId.isEmpty() || galaxy == null || clientId == null) null
|
||||
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId, account?.optString("refresh_token").orEmpty()) ?: galaxy)
|
||||
}
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context)
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON -> null
|
||||
}
|
||||
|
||||
/** GOG cloud storage wants a token issued to the game's own client: the Galaxy refresh token, exchanged with the game's id and secret. */
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String, refresh: String): String? {
|
||||
val secret = GogPrefs.get(context).getString("client_secret_$id", null)?.ifEmpty { null } ?: return null
|
||||
if (refresh.isEmpty()) return null
|
||||
return try {
|
||||
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
|
||||
val c = URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(secret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}").openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000; c.readTimeout = 15_000
|
||||
c.setRequestProperty("User-Agent", "GOG Galaxy")
|
||||
if (c.responseCode != 200) { c.disconnect(); return null }
|
||||
JSONObject(c.inputStream.bufferedReader().readText()).optString("access_token").ifEmpty { null }.also { c.disconnect() }
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
// ---- sync -----------------------------------------------------------------------------------
|
||||
|
||||
private fun baseline(context: Context, store: Store, id: String): Map<String, CloudPlan.Entry>? =
|
||||
CloudPlan.fromJson(prefs(context, store).getString("cloud_baseline_$id", null))
|
||||
|
||||
private fun saveBaseline(context: Context, store: Store, id: String, b: Map<String, CloudPlan.Entry>) =
|
||||
prefs(context, store).edit().putString("cloud_baseline_$id", CloudPlan.toJson(b)).apply()
|
||||
|
||||
/** Files changed on both sides since the last sync, waiting for Keep cloud / Keep local. */
|
||||
fun conflicts(context: Context, store: Store, id: String): List<String> =
|
||||
prefs(context, store).getString("cloud_conflicts_$id", "").orEmpty().split('\n').filter { it.isNotEmpty() }
|
||||
|
||||
private fun saveConflicts(context: Context, store: Store, id: String, names: Collection<String>) =
|
||||
prefs(context, store).edit().putString("cloud_conflicts_$id", names.joinToString("\n")).apply()
|
||||
|
||||
/** Cloud → device where the cloud copy changed (the first time: wherever it differs). [force]: conflicts too (Keep cloud). Blocking. */
|
||||
fun download(context: Context, store: Store, id: String, force: Boolean = false): Result =
|
||||
sync(context, store, id, "down") { dir, cloud -> downWith(context, store, id, dir, cloud, force) }
|
||||
|
||||
/** Device → cloud where the local copy changed, once a download has set the baseline. [force]: conflicts too (Keep local). Blocking. */
|
||||
fun upload(context: Context, store: Store, id: String, force: Boolean = false): Result =
|
||||
sync(context, store, id, "up") { dir, cloud -> upWith(context, store, id, dir, cloud, force) }
|
||||
|
||||
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
|
||||
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
|
||||
|
||||
/**
|
||||
* The pre-launch download, or - when Proton has not made the game's prefix yet (a first launch)
|
||||
* - a deferred one: answered at once, then run in the background as soon as the prefix's user
|
||||
* folder appears (within [PREFIX_WAIT_MS]), so the launch is not held up for it.
|
||||
*/
|
||||
fun downloadOrDefer(context: Context, store: Store, id: String): Result {
|
||||
val app = context.applicationContext
|
||||
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
|
||||
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
|
||||
Thread({
|
||||
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
|
||||
while (System.currentTimeMillis() < end) {
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
|
||||
}, "cloud-deferred").start()
|
||||
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
|
||||
}
|
||||
|
||||
private const val PREFIX_WAIT_MS = 60_000L
|
||||
|
||||
private fun sync(context: Context, store: Store, id: String, way: String, body: (File, CloudTransport) -> Result): Result {
|
||||
val result = try {
|
||||
val g = game(context, store, id)
|
||||
when {
|
||||
g == null -> Result("skipped", 0, 0, "not-installed")
|
||||
!g.second.cloud -> Result("skipped", 0, 0, "off")
|
||||
else -> {
|
||||
val (dir, reason) = saveFolder(context, store, id)
|
||||
val cloud = if (dir != null) transport(context, store, id, g.first) else null
|
||||
when {
|
||||
dir == null -> Result("skipped", 0, 0, reason)
|
||||
cloud == null -> Result("skipped", 0, 0, "not-signed-in")
|
||||
else -> body(dir, cloud).also { if (it.result == "ok") markSynced(context, store, id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: NoCloudSaves) {
|
||||
prefs(context, store).edit().putString("cloud_template_$id", "").apply()
|
||||
Result("skipped", 0, 0, "no-cloud-saves")
|
||||
} catch (e: Exception) {
|
||||
Result("failed", 0, 0, e.javaClass.simpleName)
|
||||
}
|
||||
return done(store, id, way, result)
|
||||
}
|
||||
|
||||
private fun done(store: Store, id: String, way: String, r: Result): Result {
|
||||
Log.i(TAG, "cloud ${store.id} $id $way ${r.line}")
|
||||
StoresState.logLine("cloud ${store.id} $id $way ${r.line}")
|
||||
return r
|
||||
}
|
||||
|
||||
private fun localFiles(dir: File): Map<String, File> =
|
||||
if (!dir.isDirectory) emptyMap()
|
||||
else dir.walkTopDown().filter { it.isFile }.associateBy { it.relativeTo(dir).path.replace(File.separatorChar, '/') }
|
||||
|
||||
internal fun md5(f: File): String = md5(f.readBytes())
|
||||
|
||||
internal fun md5(data: ByteArray): String = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) }
|
||||
|
||||
/** The cloud listing, with MD5 and time filled in (one HEAD each where the listing lacks them, in parallel). */
|
||||
private fun remote(cloud: CloudTransport, wanted: (String) -> Boolean): Map<String, CloudFile> {
|
||||
val files = cloud.list()
|
||||
val pool = Executors.newFixedThreadPool(PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("cloud"))
|
||||
for (f in files) if (wanted(f.name)) pool.execute { runCatching { cloud.details(f) } }
|
||||
pool.shutdown(); pool.awaitTermination(2, TimeUnit.MINUTES)
|
||||
return files.associateBy { it.name }
|
||||
}
|
||||
|
||||
internal fun downWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
|
||||
val files = localFiles(dir)
|
||||
val remote = remote(cloud) { it in files }
|
||||
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
|
||||
val base = baseline(context, store, id)
|
||||
val plan = CloudPlan.down(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
|
||||
val take = if (force) plan.transfer + plan.conflicts else plan.transfer
|
||||
if (take.isNotEmpty() && files.isNotEmpty()) backup(context, store, id, dir, "local")
|
||||
val next = HashMap(base.orEmpty())
|
||||
var bytes = 0L
|
||||
for (name in take) {
|
||||
val f = remote[name] ?: continue
|
||||
val (data, modified) = cloud.get(f)
|
||||
val dest = File(dir, name)
|
||||
if (!dest.canonicalPath.startsWith(dir.canonicalPath + File.separator)) continue
|
||||
dest.parentFile?.mkdirs()
|
||||
dest.writeBytes(data)
|
||||
if (modified > 0) dest.setLastModified(modified)
|
||||
bytes += data.size
|
||||
next[name] = CloudPlan.Entry(md5(data), f.md5, if (modified > 0) modified else f.modifiedMs)
|
||||
}
|
||||
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
|
||||
// A download that completed sets the baseline even when the cloud was empty: uploads may follow.
|
||||
saveBaseline(context, store, id, next)
|
||||
val left = if (force) emptyList() else plan.conflicts
|
||||
saveConflicts(context, store, id, left)
|
||||
return Result("ok", take.size, bytes, if (take.isEmpty()) "up-to-date" else "downloaded", left.size)
|
||||
}
|
||||
|
||||
internal fun upWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
|
||||
val base = baseline(context, store, id) ?: return Result("skipped", 0, 0, "no-baseline")
|
||||
val files = localFiles(dir)
|
||||
if (files.isEmpty()) return Result("skipped", 0, 0, "nothing-local")
|
||||
val remote = remote(cloud) { it in files }
|
||||
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
|
||||
val plan = CloudPlan.up(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
|
||||
val send = if (force) plan.transfer + plan.conflicts else plan.transfer
|
||||
val next = HashMap(base)
|
||||
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
|
||||
if (send.isNotEmpty()) {
|
||||
// The cloud copies about to be replaced are kept first, as a download keeps the local ones.
|
||||
backupCloud(context, store, id, cloud, send.mapNotNull { remote[it] })
|
||||
val data = send.associateWith { files.getValue(it).readBytes() }
|
||||
val stamped = cloud.put(data)
|
||||
for ((name, t) in stamped) if (t > 0) files[name]?.setLastModified(t)
|
||||
val after = remote(cloud) { it in data }
|
||||
for (name in data.keys) {
|
||||
val r = after[name]
|
||||
next[name] = CloudPlan.Entry(md5(data.getValue(name)), r?.md5, r?.modifiedMs ?: stamped[name] ?: -1L)
|
||||
}
|
||||
}
|
||||
saveBaseline(context, store, id, next)
|
||||
val left = if (force) emptyList() else plan.conflicts
|
||||
saveConflicts(context, store, id, left)
|
||||
return Result("ok", send.size, send.sumOf { files.getValue(it).length() }, if (send.isEmpty()) "up-to-date" else "uploaded", left.size)
|
||||
}
|
||||
|
||||
// ---- uploads that do not depend on one hook ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* A game launched with cloud saves is marked dirty in the app's storage when its pre-launch step
|
||||
* runs. The upload happens on the first of: the compat tool's exit request (`exit`), the end of
|
||||
* the session (`session-end`), or the app's next start for a mark a killed app left (`recovery`)
|
||||
* - so a session closed from the drawer, a game Steam killed, or a crash still uploads. The mark
|
||||
* goes only after an upload that went through, or one skipped on purpose with its reason logged.
|
||||
*/
|
||||
private fun marks(context: Context) = context.applicationContext.getSharedPreferences("stores.cloud", Context.MODE_PRIVATE)
|
||||
|
||||
fun markDirty(context: Context, store: Store, id: String) {
|
||||
marks(context).edit().putLong("dirty:${store.id}:$id", System.currentTimeMillis()).apply()
|
||||
}
|
||||
|
||||
fun dirty(context: Context): List<Pair<Store, String>> = marks(context).all.keys.mapNotNull { k ->
|
||||
val parts = k.split(':', limit = 3)
|
||||
if (parts.size == 3 && parts[0] == "dirty") Store.byId(parts[1])?.let { it to parts[2] } else null
|
||||
}
|
||||
|
||||
private val markLock = Any()
|
||||
|
||||
/** Why a skip is final: retrying cannot change it, so the mark goes. */
|
||||
private val FINAL_SKIPS = setOf("off", "no-cloud-saves", "no-baseline", "not-installed", "nothing-local")
|
||||
|
||||
/** Uploads [id] if it is marked dirty; null when it is not (another trigger got there first). */
|
||||
fun uploadDirty(context: Context, store: Store, id: String, trigger: String): Result? = synchronized(markLock) {
|
||||
val key = "dirty:${store.id}:$id"
|
||||
if (!marks(context).contains(key)) return null
|
||||
val r = sync(context, store, id, "up trigger=$trigger") { dir, cloud -> upWith(context, store, id, dir, cloud, force = false) }
|
||||
if (r.result == "ok" || (r.result == "skipped" && r.reason in FINAL_SKIPS)) marks(context).edit().remove(key).apply()
|
||||
r
|
||||
}
|
||||
|
||||
/**
|
||||
* Every dirty game, uploaded; [running] says a game session is up, in which case `recovery` waits
|
||||
* - a marked game may be the one running.
|
||||
*/
|
||||
fun uploadAllDirty(context: Context, trigger: String, running: Boolean = com.droiddeck.launcher.session.SessionState.running) {
|
||||
if (trigger == "recovery" && running) return
|
||||
for ((store, id) in dirty(context)) uploadDirty(context, store, id, trigger)
|
||||
}
|
||||
|
||||
private fun backupRoot(context: Context, store: Store, id: String) =
|
||||
File(context.filesDir, "stores/cloud-backups/${store.id}-${id.replace(Regex("[^A-Za-z0-9._-]"), "_")}")
|
||||
|
||||
/** The local folder as it is, before a download changes it; the last [BACKUPS] of each kind are kept. */
|
||||
private fun backup(context: Context, store: Store, id: String, dir: File, kind: String) {
|
||||
val root = backupRoot(context, store, id)
|
||||
runCatching { dir.copyRecursively(File(root, "$kind-${System.currentTimeMillis()}"), overwrite = true) }
|
||||
prune(root, kind)
|
||||
}
|
||||
|
||||
private fun backupCloud(context: Context, store: Store, id: String, cloud: CloudTransport, files: List<CloudFile>) {
|
||||
if (files.isEmpty()) return
|
||||
val root = backupRoot(context, store, id)
|
||||
val target = File(root, "cloud-${System.currentTimeMillis()}")
|
||||
for (f in files) {
|
||||
val dest = File(target, f.name)
|
||||
if (!dest.canonicalPath.startsWith(target.canonicalPath + File.separator)) continue
|
||||
dest.parentFile?.mkdirs()
|
||||
dest.writeBytes(cloud.get(f).first)
|
||||
}
|
||||
prune(root, "cloud")
|
||||
}
|
||||
|
||||
private fun prune(root: File, kind: String) {
|
||||
root.listFiles { f -> f.name.startsWith("$kind-") }?.sortedByDescending { it.name }?.drop(BACKUPS)?.forEach { it.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* The two stores' cloud-save services, after Bannerlator's GogCloudSaveManager and
|
||||
* EpicCloudSaveManager (device-proven there, August 2026): GOG's cloudstorage (one object per
|
||||
* file, Last-Modified and an MD5 ETag on a HEAD) and Epic's savesync data storage (a listing with
|
||||
* lastModified and signed read links; signed write links asked for per upload).
|
||||
*/
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** One file in a game's cloud: its key (a path with '/'), when it was written (-1 unknown) and its MD5 when known. */
|
||||
class CloudFile(val name: String, var modifiedMs: Long, var md5: String? = null, val readLink: String? = null)
|
||||
|
||||
/** A store's cloud for one game. Every call is blocking network. */
|
||||
interface CloudTransport {
|
||||
fun list(): List<CloudFile>
|
||||
/** Fills in [CloudFile.modifiedMs] / [CloudFile.md5] where the listing left them out. */
|
||||
fun details(file: CloudFile) {}
|
||||
/** The file's bytes and its cloud time (-1 unknown). */
|
||||
fun get(file: CloudFile): Pair<ByteArray, Long>
|
||||
/** Writes [files] (key → bytes); the cloud times assigned, where the service says (-1 unknown). */
|
||||
fun put(files: Map<String, ByteArray>): Map<String, Long>
|
||||
}
|
||||
|
||||
private const val TIMEOUT = 30_000
|
||||
|
||||
private fun open(url: String, method: String, token: String?, ua: String): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = TIMEOUT
|
||||
readTimeout = TIMEOUT
|
||||
setRequestProperty("User-Agent", ua)
|
||||
if (token != null) setRequestProperty("Authorization", "Bearer $token")
|
||||
}
|
||||
|
||||
private fun HttpURLConnection.body(): ByteArray = inputStream.use { input ->
|
||||
ByteArrayOutputStream().also { input.copyTo(it) }.toByteArray()
|
||||
}
|
||||
|
||||
private fun httpDate(s: String?): Long = try {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { timeZone = TimeZone.getTimeZone("GMT") }.parse(s!!.trim())?.time ?: -1L
|
||||
} catch (e: Exception) { -1L }
|
||||
|
||||
internal fun isoDate(s: String?): Long {
|
||||
if (s == null || s.length < 19) return -1L
|
||||
return try {
|
||||
java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
|
||||
clear()
|
||||
set(s.substring(0, 4).toInt(), s.substring(5, 7).toInt() - 1, s.substring(8, 10).toInt(),
|
||||
s.substring(11, 13).toInt(), s.substring(14, 16).toInt(), s.substring(17, 19).toInt())
|
||||
}.timeInMillis
|
||||
} catch (e: Exception) { -1L }
|
||||
}
|
||||
|
||||
/** Thrown when the store says the game has no cloud saves for this client. */
|
||||
class NoCloudSaves : Exception("no cloud saves")
|
||||
|
||||
/** GOG cloudstorage: `/v1/<userId>/<clientId>[/<key>]`. */
|
||||
class GogCloud(private val userId: String, private val clientId: String, private val token: String) : CloudTransport {
|
||||
private val base = "https://cloudstorage.gog.com/v1/$userId/$clientId"
|
||||
private val ua = "GOG Galaxy"
|
||||
|
||||
/** A path segment: '+' from a space is wrong in a path (it stores a second, '+'-named copy). */
|
||||
private fun key(name: String) = URLEncoder.encode(name, "UTF-8").replace("+", "%20")
|
||||
|
||||
override fun list(): List<CloudFile> {
|
||||
val c = open(base, "GET", token, ua)
|
||||
val code = c.responseCode
|
||||
if (code == 404) { c.disconnect(); return emptyList() }
|
||||
if (code !in 200..299) {
|
||||
val err = runCatching { c.errorStream?.readBytes()?.decodeToString().orEmpty() }.getOrDefault("")
|
||||
c.disconnect()
|
||||
if ("not_enabled_for_client" in err || "disabled" in err) throw NoCloudSaves()
|
||||
throw java.io.IOException("GOG cloud list HTTP $code")
|
||||
}
|
||||
val text = c.body().decodeToString().trim()
|
||||
c.disconnect()
|
||||
if (text.isEmpty() || text == "[]") return emptyList()
|
||||
// A plain newline-separated list of keys (checked on device); a JSON array is read too.
|
||||
if (text.startsWith("[")) {
|
||||
val a = JSONArray(text)
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull { o ->
|
||||
val n = o.optString("name").ifEmpty { return@mapNotNull null }
|
||||
val lm = o.optLong("last_modified", 0L)
|
||||
CloudFile(n, if (lm > 1_000_000_000_000L) lm else if (lm > 0) lm * 1000 else -1L)
|
||||
}
|
||||
}
|
||||
return text.lines().map { it.trim() }.filter { it.isNotEmpty() }.map { CloudFile(it, -1L) }
|
||||
}
|
||||
|
||||
override fun details(file: CloudFile) {
|
||||
if (file.modifiedMs > 0 && file.md5 != null) return
|
||||
val c = open("$base/${key(file.name)}", "HEAD", token, ua)
|
||||
if (c.responseCode in 200..299) {
|
||||
httpDate(c.getHeaderField("Last-Modified")).takeIf { it > 0 }?.let { file.modifiedMs = it }
|
||||
c.getHeaderField("ETag")?.replace("\"", "")?.trim()?.ifEmpty { null }?.let { file.md5 = it }
|
||||
}
|
||||
c.disconnect()
|
||||
}
|
||||
|
||||
override fun get(file: CloudFile): Pair<ByteArray, Long> {
|
||||
val c = open("$base/${key(file.name)}", "GET", token, ua)
|
||||
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("GOG cloud get HTTP $code") }
|
||||
val modified = httpDate(c.getHeaderField("Last-Modified"))
|
||||
return c.body().also { c.disconnect() } to modified
|
||||
}
|
||||
|
||||
override fun put(files: Map<String, ByteArray>): Map<String, Long> = files.mapValues { (name, data) ->
|
||||
val c = open("$base/${key(name)}", "PUT", token, ua)
|
||||
c.doOutput = true
|
||||
c.setRequestProperty("Content-Type", "application/octet-stream")
|
||||
c.setFixedLengthStreamingMode(data.size)
|
||||
c.outputStream.use { it.write(data) }
|
||||
val code = c.responseCode
|
||||
c.disconnect()
|
||||
if (code !in 200..299) throw java.io.IOException("GOG cloud put HTTP $code")
|
||||
// The server stamps the object itself; that time, so the next sync sees the two as equal.
|
||||
CloudFile(name, -1L).also { details(it) }.modifiedMs
|
||||
}
|
||||
}
|
||||
|
||||
/** Epic savesync: `/api/v1/access/egstore/savesync/<accountId>/<appName>/`. */
|
||||
class EpicCloud(private val accountId: String, private val appName: String, private val token: String) : CloudTransport {
|
||||
private val base = "https://datastorage-public-service-liveegs.live.use1a.on.epicgames.com/api/v1/access/egstore/savesync/$accountId/$appName/"
|
||||
private val ua = "EpicGamesLauncher/15.17.1-22692490"
|
||||
|
||||
override fun list(): List<CloudFile> {
|
||||
val c = open(base, "GET", token, ua)
|
||||
val code = c.responseCode
|
||||
if (code == 404) { c.disconnect(); return emptyList() }
|
||||
if (code !in 200..299) { c.disconnect(); throw java.io.IOException("Epic cloud list HTTP $code") }
|
||||
val root = JSONObject(c.body().decodeToString().ifEmpty { "{}" })
|
||||
c.disconnect()
|
||||
val files = root.optJSONObject("files") ?: return emptyList()
|
||||
return files.keys().asSequence().mapNotNull { k ->
|
||||
val e = files.optJSONObject(k) ?: return@mapNotNull null
|
||||
CloudFile(strip(k), isoDate(e.optString("lastModified", null)), e.optString("hash", "").ifEmpty { null }, e.optString("readLink", "").ifEmpty { null })
|
||||
}.toList()
|
||||
}
|
||||
|
||||
/** The listing names a file `<...>/<appName>/<key>`; the key is what is under the save folder. */
|
||||
private fun strip(key: String): String {
|
||||
val marker = "/$appName/"
|
||||
val i = key.indexOf(marker)
|
||||
if (i >= 0) return key.substring(i + marker.length)
|
||||
return key.removePrefix("$appName/")
|
||||
}
|
||||
|
||||
override fun get(file: CloudFile): Pair<ByteArray, Long> {
|
||||
val link = file.readLink ?: throw java.io.IOException("Epic cloud: no read link")
|
||||
val c = open(link, "GET", null, ua)
|
||||
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("Epic cloud get HTTP $code") }
|
||||
return c.body().also { c.disconnect() } to file.modifiedMs
|
||||
}
|
||||
|
||||
override fun put(files: Map<String, ByteArray>): Map<String, Long> {
|
||||
if (files.isEmpty()) return emptyMap()
|
||||
val c = open(base, "POST", token, ua)
|
||||
c.doOutput = true
|
||||
c.setRequestProperty("Content-Type", "application/json")
|
||||
val req = JSONObject().put("files", JSONArray(files.keys.toList())).toString().toByteArray()
|
||||
c.outputStream.use { it.write(req) }
|
||||
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("Epic cloud write links HTTP $code") }
|
||||
val links = JSONObject(c.body().decodeToString()).optJSONObject("files") ?: JSONObject()
|
||||
c.disconnect()
|
||||
val out = HashMap<String, Long>()
|
||||
for (k in links.keys()) {
|
||||
val link = links.optJSONObject(k)?.optString("writeLink", "")?.ifEmpty { null } ?: continue
|
||||
val name = strip(k)
|
||||
val data = files[name] ?: files[k] ?: continue
|
||||
val p = open(link, "PUT", null, ua)
|
||||
p.doOutput = true
|
||||
p.setRequestProperty("Content-Type", "application/octet-stream")
|
||||
p.setFixedLengthStreamingMode(data.size)
|
||||
p.outputStream.use { it.write(data) }
|
||||
val code = p.responseCode
|
||||
p.disconnect()
|
||||
if (code !in 200..299) throw java.io.IOException("Epic cloud put HTTP $code")
|
||||
out[name] = -1L
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.webkit.WebView
|
||||
import com.droiddeck.launcher.stores.gog.GogAuth
|
||||
|
||||
/** The three stores' sign-in flows for [StoreLoginActivity]. */
|
||||
internal object LoginFlows {
|
||||
private const val TAG = "StoreLogin"
|
||||
|
||||
fun forStore(store: Store, state: String): StoreLoginActivity.Flow? = when (store) {
|
||||
Store.GOG -> GogFlow(state)
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
}
|
||||
|
||||
/** Set by the Epic and Amazon backends when they are in the build; null keeps the chip's card honest. */
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
/**
|
||||
* GOG's implicit grant: the token arrives in the redirect URL's fragment. The CSRF state is
|
||||
* checked only when GOG echoes one back - its social-login path returns none, and treating
|
||||
* that as a mismatch threw away good tokens.
|
||||
*/
|
||||
private class GogFlow(private val state: String) : StoreLoginActivity.Flow {
|
||||
override val startUrl: String get() = GogAuth.authUrl(state)
|
||||
override val userAgent: String get() = GogAuth.GALAXY_UA
|
||||
override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX)
|
||||
|
||||
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
|
||||
val fragment = Uri.parse(url).fragment ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
|
||||
val frag = Uri.parse("x://x?$fragment")
|
||||
val returned = frag.getQueryParameter("state")
|
||||
if (returned != null && returned != state) {
|
||||
Log.w(TAG, "gog: state mismatch; redirect rejected")
|
||||
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
|
||||
}
|
||||
val access = frag.getQueryParameter("access_token")
|
||||
if (access.isNullOrEmpty()) {
|
||||
// The keys only, never the values: the fragment carries live tokens.
|
||||
Log.w(TAG, "gog: redirect without access_token; keys=${frag.queryParameterNames}")
|
||||
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
|
||||
}
|
||||
return try {
|
||||
GogAuth.saveLogin(activity, access, frag.getQueryParameter("refresh_token"), frag.getQueryParameter("user_id"))
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "gog: saving the sign-in failed: ${e.javaClass.simpleName}")
|
||||
activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Game sizes as found, kept per game AND the version they were found for, in
|
||||
* `filesDir/stores/<store>/sizes.json`: a size is never fetched twice for one version, and a
|
||||
* new version (a library refresh brings it) simply misses and is looked up again. The file carries
|
||||
* its format; one in another format is discarded, not read.
|
||||
*/
|
||||
class SizeCache(private val file: File) {
|
||||
private val sizes = HashMap<String, Pair<String, Long>>()
|
||||
|
||||
init {
|
||||
runCatching {
|
||||
val o = JSONObject(file.readText())
|
||||
if (o.optInt("format") == FORMAT) {
|
||||
val s = o.getJSONObject("sizes")
|
||||
for (k in s.keys()) s.optJSONObject(k)?.let { e -> sizes[k] = e.optString("v") to e.optLong("b") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun get(id: String, version: String): Long? = sizes[id]?.takeIf { it.first == version && it.second > 0 }?.second
|
||||
|
||||
@Synchronized
|
||||
fun put(id: String, version: String, bytes: Long) {
|
||||
if (bytes <= 0) return
|
||||
sizes[id] = version to bytes
|
||||
runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
val s = JSONObject()
|
||||
for ((k, v) in sizes) s.put(k, JSONObject().put("v", v.first).put("b", v.second))
|
||||
val tmp = File(file.path + ".tmp")
|
||||
tmp.writeText(JSONObject().put("format", FORMAT).put("sizes", s).toString())
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FORMAT = 1
|
||||
fun forStore(filesDir: File, store: Store) = SizeCache(File(filesDir, "stores/${store.id}/sizes.json"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Adds and removes a store game's shortcut in a Steam client that is running right now, so it
|
||||
* shows without waiting for the next client start. The runtime starts the client with its
|
||||
* DevTools port open (droiddeck-session writes it to session/agent/cdp-port) and the client's own
|
||||
* JavaScript API has `SteamClient.Apps.AddShortcut`; this speaks the small part of the DevTools
|
||||
* protocol that one `Runtime.evaluate` needs, over a plain socket, nothing else.
|
||||
*
|
||||
* Best-effort throughout: no session, no port, no SharedJSContext, or an API that moved and the
|
||||
* shortcut simply arrives with the shortcuts writer at the next start. The appid Steam derives
|
||||
* (CRC of the quoted exe plus the name) is the one the writer computes, so the two never disagree.
|
||||
*/
|
||||
object SteamLiveShortcuts {
|
||||
private const val TAG = "SteamLive"
|
||||
private const val TAG_NAME = "droiddeck-app"
|
||||
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
|
||||
|
||||
/** Adds every store game in [games] the client does not list yet. Blocking; worker thread. */
|
||||
fun sync(context: Context, games: List<AddedGames.Game>) {
|
||||
val wanted = games.filter { it.source != Library.ADDED }
|
||||
if (wanted.isEmpty() || !clientRunning()) return
|
||||
val list = JSONArray()
|
||||
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir))
|
||||
val js = """
|
||||
(async () => {
|
||||
const want = ${list};
|
||||
const have = await SteamClient.Apps.GetAllShortcuts();
|
||||
const strip = s => (s || "").replace(/^"|"$/g, "");
|
||||
let added = 0;
|
||||
for (const w of want) {
|
||||
if (have.some(a => a.data && strip(a.data.strExePath) === w.exe && a.data.strAppName === w.name)) continue;
|
||||
const id = await SteamClient.Apps.AddShortcut(w.name, w.exe, w.dir, "");
|
||||
if (!id) continue;
|
||||
try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {}
|
||||
try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {}
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
})()
|
||||
""".trimIndent()
|
||||
val result = evaluate(context, js) ?: return
|
||||
Log.i(TAG, "live shortcuts: $result added")
|
||||
if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client")
|
||||
}
|
||||
|
||||
/** Removes the shortcut with [appId] from the running client. Blocking; worker thread. */
|
||||
fun remove(context: Context, appId: Long) {
|
||||
if (!clientRunning()) return
|
||||
val result = evaluate(context, "(async () => { await SteamClient.Apps.RemoveShortcut(${appId and 0xFFFFFFFFL}); return 1; })()")
|
||||
if (result != null) Log.i(TAG, "live shortcuts: removed $appId")
|
||||
}
|
||||
|
||||
private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested
|
||||
|
||||
/** `Runtime.evaluate` of [expression] in the client's SharedJSContext; the value as text, or null. */
|
||||
private fun evaluate(context: Context, expression: String): String? {
|
||||
val portFile = File(LinuxRuntime.sessionRoot(context), "agent/cdp-port")
|
||||
val port = runCatching { portFile.readText().trim().toInt() }.getOrNull() ?: return null
|
||||
val targets = runCatching { JSONArray(get("http://127.0.0.1:$port/json")) }.getOrNull() ?: return null
|
||||
var wsUrl: String? = null
|
||||
for (i in 0 until targets.length()) {
|
||||
val t = targets.optJSONObject(i) ?: continue
|
||||
if (t.optString("title").contains("SharedJSContext") || t.optString("url").contains("SharedJSContext")) { wsUrl = t.optString("webSocketDebuggerUrl").ifEmpty { null }; break }
|
||||
}
|
||||
if (wsUrl == null) { Log.w(TAG, "no SharedJSContext target"); return null }
|
||||
return try {
|
||||
WebSocket(wsUrl, 8000).use { ws ->
|
||||
ws.sendText(JSONObject().put("id", 1).put("method", "Runtime.evaluate").put("params", JSONObject()
|
||||
.put("expression", expression).put("returnByValue", true).put("awaitPromise", true).put("userGesture", true)).toString())
|
||||
val deadline = System.currentTimeMillis() + 15_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val message = JSONObject(ws.receiveText() ?: return null)
|
||||
if (message.optInt("id") != 1) continue
|
||||
if (message.has("error")) { Log.w(TAG, "cdp: ${message.optJSONObject("error")}"); return null }
|
||||
val result = message.optJSONObject("result") ?: return null
|
||||
result.optJSONObject("exceptionDetails")?.let { Log.w(TAG, "js: ${it.optJSONObject("exception")?.optString("description") ?: it.optString("text")}"); return null }
|
||||
return result.optJSONObject("result")?.opt("value")?.toString()
|
||||
}
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cdp: ${e.javaClass.simpleName}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 3000; c.readTimeout = 3000
|
||||
try { return c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
|
||||
}
|
||||
|
||||
/** The least of RFC 6455 a localhost DevTools talk needs: upgrade, masked text out, text in. */
|
||||
private class WebSocket(url: String, timeoutMs: Int) : AutoCloseable {
|
||||
private val socket: Socket
|
||||
private val out: OutputStream
|
||||
private val input: DataInputStream
|
||||
|
||||
init {
|
||||
val u = URL(url.replaceFirst("ws://", "http://"))
|
||||
socket = Socket(u.host, if (u.port > 0) u.port else 80).apply { soTimeout = timeoutMs }
|
||||
out = socket.getOutputStream()
|
||||
input = DataInputStream(socket.getInputStream())
|
||||
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val path = u.path.ifEmpty { "/" } + (u.query?.let { "?$it" } ?: "")
|
||||
out.write(("GET $path HTTP/1.1\r\nHost: ${u.host}:${u.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: ${Base64.encodeToString(key, Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
|
||||
out.flush()
|
||||
val status = readLine(input) ?: throw java.io.IOException("no upgrade response")
|
||||
if (!status.contains(" 101 ")) throw java.io.IOException("upgrade refused: $status")
|
||||
while (true) { val line = readLine(input) ?: break; if (line.isEmpty()) break }
|
||||
}
|
||||
|
||||
private fun readLine(s: InputStream): String? {
|
||||
val buf = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b = s.read()
|
||||
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
|
||||
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
|
||||
buf.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendText(text: String) {
|
||||
val payload = text.toByteArray()
|
||||
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
|
||||
val frame = ByteArrayOutputStream()
|
||||
frame.write(0x81)
|
||||
when {
|
||||
payload.size < 126 -> frame.write(0x80 or payload.size)
|
||||
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
|
||||
else -> { frame.write(0x80 or 127); for (i in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * i)) and 0xFF).toInt()) }
|
||||
}
|
||||
frame.write(mask)
|
||||
for (i in payload.indices) frame.write((payload[i].toInt() xor mask[i % 4].toInt()) and 0xFF)
|
||||
out.write(frame.toByteArray()); out.flush()
|
||||
}
|
||||
|
||||
/** The next text message (fragments joined); null on close. */
|
||||
fun receiveText(): String? {
|
||||
val message = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b0 = input.read(); if (b0 < 0) return null
|
||||
val b1 = input.read(); if (b1 < 0) return null
|
||||
val fin = b0 and 0x80 != 0
|
||||
val opcode = b0 and 0x0F
|
||||
var len = (b1 and 0x7F).toLong()
|
||||
if (len == 126L) len = input.readUnsignedShort().toLong()
|
||||
else if (len == 127L) len = input.readLong()
|
||||
val masked = b1 and 0x80 != 0
|
||||
val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null
|
||||
if (len > 16L * 1024 * 1024) throw java.io.IOException("frame too large")
|
||||
val data = ByteArray(len.toInt()).also { input.readFully(it) }
|
||||
if (mask != null) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i % 4].toInt()).toByte()
|
||||
when (opcode) {
|
||||
0x8 -> return null
|
||||
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
0x1, 0x0 -> { message.write(data); if (fin) return message.toString("UTF-8") }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() { runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }; runCatching { socket.close() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
/**
|
||||
* The three storefronts the Stores section talks to. [id] is what the sidecar, the prefs file
|
||||
* names and the Games badge carry; it never changes once a game is installed under it.
|
||||
*/
|
||||
enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
GOG("gog", "GOG", "GOG"),
|
||||
EPIC("epic", "Epic Games", "Epic"),
|
||||
AMAZON("amazon", "Amazon Games", "Amazon");
|
||||
|
||||
companion object {
|
||||
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Each store's sign-in, kept in the app's private files: `filesDir/stores/<store>/credentials.json`.
|
||||
* Tokens never go to shared storage and never into a log; the file holds whatever the store's auth
|
||||
* flow handed back (access and refresh tokens, expiry, the account's display name, and for Amazon
|
||||
* the device registration), as one JSON object the store's own client reads and writes.
|
||||
*/
|
||||
object StoreAccounts {
|
||||
private const val TAG = "StoreAccounts"
|
||||
private const val FILE = "credentials.json"
|
||||
|
||||
/** The store's private folder: credentials, caches, the engine's scratch. */
|
||||
fun dir(context: Context, store: Store): File = File(File(context.filesDir, "stores"), store.id).apply { mkdirs() }
|
||||
|
||||
fun credentialsFile(context: Context, store: Store): File = File(dir(context, store), FILE)
|
||||
|
||||
/** The stored credentials, or null when signed out or unreadable (never logged: the text is the secret). */
|
||||
fun read(context: Context, store: Store): JSONObject? {
|
||||
val f = credentialsFile(context, store)
|
||||
if (!f.isFile) return null
|
||||
return try { JSONObject(f.readText()) } catch (e: Exception) { Log.w(TAG, "${store.id}: credentials unreadable (${e.javaClass.simpleName})"); null }
|
||||
}
|
||||
|
||||
fun write(context: Context, store: Store, json: JSONObject) {
|
||||
val f = credentialsFile(context, store)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(json.toString())
|
||||
tmp.setReadable(false, false); tmp.setReadable(true, true)
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
|
||||
fun clear(context: Context, store: Store) {
|
||||
credentialsFile(context, store).delete()
|
||||
}
|
||||
|
||||
/** The account's display name when signed in (the store's own name for it, or a stand-in), else null. */
|
||||
fun signedInAs(context: Context, store: Store): String? {
|
||||
val json = read(context, store) ?: return null
|
||||
if (json.optString("access_token", "").isEmpty()) return null
|
||||
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* The store's art, placed in the game's folder under the names AddedGameArt already looks for -
|
||||
* `cover.jpg` (the portrait capsule), `hero.jpg` and `header.jpg` (the wide art) - so a store game
|
||||
* shows real art in the Games tab and the session's shortcuts writer copies it into the client's
|
||||
* grid. Each image is decoded and re-encoded as an opaque JPEG over a dark ground: a store's PNG
|
||||
* with an alpha fade would otherwise end in white, which is what the washed-out cards were.
|
||||
*/
|
||||
object StoreArt {
|
||||
private const val TAG = "StoreArt"
|
||||
|
||||
/** Fetches what the sidecar names; a missing or unreadable image is skipped, never an error. Blocking. */
|
||||
fun fetchInto(folder: File, sidecar: StoreGameSidecar) {
|
||||
sidecar.cover?.let { place(it, File(folder, "cover.jpg")) }
|
||||
val wide = sidecar.hero ?: return
|
||||
if (place(wide, File(folder, "hero.jpg"))) {
|
||||
val header = File(folder, "header.jpg")
|
||||
if (!header.isFile) runCatching { File(folder, "hero.jpg").copyTo(header, overwrite = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun place(url: String, dst: File): Boolean {
|
||||
if (dst.isFile && dst.length() > 0) return true
|
||||
val bytes = get(url) ?: return false
|
||||
val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return false
|
||||
val opaque = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
|
||||
Canvas(opaque).apply { drawColor(Color.rgb(0x12, 0x14, 0x17)); drawBitmap(bitmap, 0f, 0f, null) }
|
||||
val tmp = File(dst.path + ".tmp")
|
||||
return try {
|
||||
tmp.outputStream().use { opaque.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
tmp.renameTo(dst)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${dst.name}: ${e.javaClass.simpleName}")
|
||||
tmp.delete()
|
||||
false
|
||||
} finally {
|
||||
bitmap.recycle(); opaque.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(url: String): ByteArray? {
|
||||
var c: HttpURLConnection? = null
|
||||
return try {
|
||||
c = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = 10_000; readTimeout = 20_000; setRequestProperty("User-Agent", StoreNet.BROWSER_UA) }
|
||||
if (c.responseCode != 200) null else c.inputStream.use { it.readBytes() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
|
||||
null
|
||||
} finally {
|
||||
c?.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
/**
|
||||
* One title as the Stores page draws it, whichever store produced it: a card on a shelf, a tile
|
||||
* in a grid, the hero of a game page. Library games and storefront results both project into
|
||||
* this, so one card composable serves every tab.
|
||||
*/
|
||||
data class CatalogItem(
|
||||
val store: Store,
|
||||
/** Store-native id: GOG product id, Epic catalog item id (offer id for storefront results), Amazon product id. */
|
||||
val id: String,
|
||||
val title: String,
|
||||
/** Wide (16:9-ish) art for cards and heroes. */
|
||||
val imageUrl: String?,
|
||||
/** Tall (2:3) box art when the store publishes one; falls back to [imageUrl]. */
|
||||
val tallImageUrl: String? = null,
|
||||
/** Comma-joined genre / tag line. */
|
||||
val tags: String = "",
|
||||
val isFree: Boolean = false,
|
||||
/** False when the endpoint gave no price at all: the price row then draws nothing. */
|
||||
val hasPrice: Boolean = false,
|
||||
/** Pre-formatted by the store ("$9.99"). */
|
||||
val finalPrice: String = "",
|
||||
val originalPrice: String = "",
|
||||
val discountPercent: Int = 0,
|
||||
/** The web product page: where "Get for free" and "View on <store>" land. */
|
||||
val storeUrl: String = "",
|
||||
val developer: String = "",
|
||||
val releaseDate: String = "",
|
||||
val description: String = "",
|
||||
/** True for a title the signed-in account owns (a library entry). */
|
||||
val owned: Boolean = false,
|
||||
/** The install size in bytes when known, else 0. */
|
||||
val sizeBytes: Long = 0L,
|
||||
/** Per-store identifiers the install needs (Epic namespace / catalog id / app name, Amazon entitlement / sku). */
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
/** Rated or tagged for adults by the store's own data; hidden from the storefront unless Show mature content is on. */
|
||||
val mature: Boolean = false,
|
||||
) {
|
||||
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
|
||||
/** Stable key across stores ("gog:1207658924"). */
|
||||
val key: String get() = "${store.id}:$id"
|
||||
}
|
||||
|
||||
/** A store's storefront shelves: what the Store tab shows above the account's own library. */
|
||||
class StoreShelves(
|
||||
val whatsNew: List<CatalogItem> = emptyList(),
|
||||
val deals: List<CatalogItem> = emptyList(),
|
||||
val free: List<CatalogItem> = emptyList(),
|
||||
val trending: List<CatalogItem> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
|
||||
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
|
||||
}
|
||||
|
||||
/** A game a store installed, as found on disk through its sidecar. */
|
||||
/** Store tags that mark adult content, by slug or name, lowercased. */
|
||||
private val MATURE_TAGS = setOf("mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content")
|
||||
|
||||
/** Whether a store's own tags mark a title as adult. */
|
||||
fun matureByTags(tags: Collection<String>): Boolean = tags.any { it.trim().lowercase() in MATURE_TAGS }
|
||||
|
||||
/**
|
||||
* Whether a store's own age ratings mark a title as adult: ESRB M (17) or AO (18), PEGI / USK / the
|
||||
* store's own rating 18, or any rating system's 17+. Ratings are ages, as GOG gives them.
|
||||
*/
|
||||
fun matureByAge(ages: Collection<Int>): Boolean = ages.any { it >= 17 }
|
||||
|
||||
/**
|
||||
* The Installed tab's cards for [store], from the installs on disk - the same source as its count:
|
||||
* each joined to its library item by store id (by title when the ids differ), else a card made
|
||||
* from the sidecar's own title and art, so an install shows even before the library has loaded.
|
||||
*/
|
||||
fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: List<CatalogItem>): List<CatalogItem> {
|
||||
val byId = library.associateBy { it.id }
|
||||
val byTitle = library.associateBy { it.title.lowercase() }
|
||||
return installed.filter { it.sidecar.store == store }.map { game ->
|
||||
val s = game.sidecar
|
||||
byId[s.id] ?: byTitle[s.title.lowercase()]
|
||||
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true)
|
||||
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A refreshed list with every unchanged item kept as the very object already shown, so a refresh
|
||||
* that lands while the grid is on screen recomposes only the cards that changed - no reset, the
|
||||
* scroll position and the pad's focus stay.
|
||||
*/
|
||||
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
|
||||
if (old.isNullOrEmpty()) return new
|
||||
val before = old.associateBy { it.key }
|
||||
val merged = new.map { n -> before[n.key]?.takeIf { it == n } ?: n }
|
||||
return if (merged.size == old.size && merged.indices.all { merged[it] === old[it] }) old else merged
|
||||
}
|
||||
|
||||
class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) {
|
||||
val key: String get() = "${sidecar.store.id}:${sidecar.id}"
|
||||
}
|
||||
|
||||
private val TEMPLATE_KEY = Regex("product_(description|feature)_\\d+")
|
||||
private val HTML_TAG = Regex("<[^>]+>")
|
||||
private val ENTITY = Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);")
|
||||
private val NAMED_ENTITIES = mapOf("amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'", "nbsp" to " ", "ndash" to "–", "mdash" to "—", "hellip" to "…", "trade" to "™", "reg" to "®", "copy" to "©")
|
||||
|
||||
/**
|
||||
* Store copy as plain text: GOG's account endpoints return template keys
|
||||
* (`product_description_2015545325`) for some products instead of words, and every store's
|
||||
* descriptions carry HTML. Those keys go, tags go, entities are decoded, whitespace is collapsed;
|
||||
* "" when nothing readable is left, so a page shows nothing rather than a placeholder.
|
||||
*/
|
||||
fun cleanStoreText(raw: String?): String {
|
||||
if (raw.isNullOrBlank()) return ""
|
||||
var s = raw.replace(TEMPLATE_KEY, " ")
|
||||
s = s.replace(Regex("(?i)<br\\s*/?>|</p>|</li>|</div>"), "\n").replace(HTML_TAG, " ")
|
||||
s = ENTITY.replace(s) { m ->
|
||||
val e = m.groupValues[1]
|
||||
when {
|
||||
e.startsWith("#x") -> e.substring(2).toIntOrNull(16)?.toChar()?.toString() ?: " "
|
||||
e.startsWith("#") -> e.substring(1).toIntOrNull()?.toChar()?.toString() ?: " "
|
||||
else -> NAMED_ENTITIES[e.lowercase()] ?: " "
|
||||
}
|
||||
}
|
||||
return s.lines().map { it.replace(Regex("[ \\t\\u00A0]+"), " ").trim() }.filter { it.isNotEmpty() }.joinToString("\n")
|
||||
}
|
||||
|
||||
/** Human sizes the way the Downloads page and the cards print them ("810.2 MB", "3.9 GB"). */
|
||||
fun formatBytes(bytes: Long): String = when {
|
||||
bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0)
|
||||
bytes >= 1_048_576L -> "%.1f MB".format(bytes / 1_048_576.0)
|
||||
bytes >= 1024L -> "%.0f KB".format(bytes / 1024.0)
|
||||
bytes >= 0L -> "$bytes B"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
fun formatSpeed(bytesPerSec: Long): String = if (bytesPerSec <= 0L) "" else "${formatBytes(bytesPerSec)}/s"
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which .exe in an install folder is the game. The store's own hint (a manifest's launch
|
||||
* executable) wins when the file exists; else the candidates, helpers and installers left out,
|
||||
* the one named most like the title first, then the shallowest. Relative to the folder, forward
|
||||
* slashes; "" when the folder has none.
|
||||
*/
|
||||
object StoreExe {
|
||||
private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config")
|
||||
|
||||
fun pick(folder: File, hint: String?, title: String): String {
|
||||
val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) }
|
||||
if (hinted != null && hinted.isFile) return relative(folder, hinted)
|
||||
val candidates = ArrayList<File>()
|
||||
collect(folder, candidates, 0)
|
||||
if (candidates.isEmpty()) return ""
|
||||
val key = title.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
val best = candidates.firstOrNull { f ->
|
||||
val n = f.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
n.isNotEmpty() && (n == key || (key.length >= 4 && key.startsWith(n)) || (n.length >= 4 && n.startsWith(key)))
|
||||
} ?: candidates.sortedWith(compareBy<File> { depth(folder, it) }.thenByDescending { it.length() }).first()
|
||||
return relative(folder, best)
|
||||
}
|
||||
|
||||
private fun collect(dir: File, out: MutableList<File>, depth: Int) {
|
||||
if (depth > 4) return
|
||||
val files = dir.listFiles() ?: return
|
||||
for (f in files) {
|
||||
if (f.isFile && f.name.endsWith(".exe", ignoreCase = true)) {
|
||||
val lower = f.name.lowercase()
|
||||
if (SKIP.none { lower.contains(it) }) out.add(f)
|
||||
}
|
||||
}
|
||||
for (f in files) if (f.isDirectory && !f.name.startsWith(".")) collect(f, out, depth + 1)
|
||||
}
|
||||
|
||||
private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' }
|
||||
|
||||
fun relative(root: File, f: File): String =
|
||||
f.absolutePath.removePrefix(root.absolutePath).trimStart('/').replace('\\', '/')
|
||||
}
|
||||
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user