Compare commits

...
30 Commits
Author SHA1 Message Date
Kurt Himebauch a58b0ac3e0 chore: bump version to 0.3.2 (12) (#437) 2026-10-08 10:49:33 -04:00
Kurt Himebauch e1700ba982 docs: point 0.3.2 full changelog at the 0.3.2 tag (#438) 2026-10-08 10:49:29 -04:00
Kurt Himebauch 736ac7db74 Draft 0.3.2 release notes (#417)
* docs: draft 0.3.2 release notes

* docs: update 0.3.2 release notes

* docs: update external storage install note

* docs: add keep downloads running demo gif to 0.3.2 notes

* fix: restore colours in keep downloads running gif

* docs: add native steam sleep demo gif to 0.3.2 notes

* fix: drop glitched frames from native steam sleep gif

* docs: add windows components screenshot to 0.3.2 notes

* docs: use newer windows components screenshot in 0.3.2 notes
2026-10-08 10:42:16 -04:00
MaxsTechReview 6e2966be43 fix: keep Steam's directory listings on FUSE libraries (#410)
* fix: keep Steam's directory listings on FUSE libraries

Before it creates, moves or deletes a content file the Steam client lists
the whole destination directory about ten times to match names
case-insensitively. On Android's FUSE storage every listing goes through
the media provider, so reserving space grew quadratically with the number
of files: 33 minutes for Haunted Paws Demo (3,938 files) in a Games folder.

The client's listings under steamapps/ on FUSE are now kept while the
directory's stat is unchanged, for at most five seconds, and patched by
the client's own creates, renames and removals. Other changes fail the
stat check and the directory is read again. BL_NO_DIR_CACHE=1 turns it off.

Haunted Paws Demo into the internal shared Games folder: reserving space
33m08s -> 25-52s, committing 6m15s -> 19-30s, install 39m50s -> 1m12s-1m51s.
Internal storage is unchanged (5s reserve).

* fix: drop stale directory listings on coarse-timestamp volumes

NTFS reports whole-second directory times and a fixed size, so a change
made outside the client's own calls could leave a cached listing stale.
Listings now carry an inotify watch and are dropped on any event the
client's own patched changes don't account for. A forked child stops
caching, since it shares the parent's queue.

Covers rewinddir, telldir and seekdir on cached listings, and directories
opened by the fast-path preload's opendir, which goes through fdopendir.
2026-10-08 08:50:00 -04:00
Kurt Himebauch 679948e792 Include PR descriptions and queue catalog updates (#421)
* Include a short PR description in build announcements

* Queue catalog refreshes without cancelling publishing runs
2026-10-08 00:46:34 -04:00
Kurt Himebauch e0fc8362b8 Fix PR build report lookup (#420)
Use standalone jq to filter paginated PR comments
2026-10-08 00:32:11 -04:00
Kurt Himebauch 9d8ff9615f Separate PR build announcements (#418)
* Separate PR build announcements and retire closed builds

* Isolate watcher test clocks and clean up the agent server
2026-10-08 00:23:55 -04:00
Tak-attackandxXJsonDeruloXx 1304809240 Improvements to the Japanese Translation (#314)
Update strings.xml

Improvements to the Japanese Translation
I have rewritten the translation to make it sound more natural in Japanese.

Also, since “Loader” in “Decky Loader” is a proper noun, I believe it does not need to be translated.

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:11:52 +00:00
kmnkv1andxXJsonDeruloXx 343a516e1a Update Russian translation (#324)
Translate the 103 missing strings (screen effects, scaling, Wine sync
backends, FEX for AppImages, Steam repair, device info) and fix existing
ones: inverted meaning in mode_suspend_note, content_add_roms and
wadb_connect_address, double space in the update/install buttons,
outdated AppImage/Proton hints, "1 приложений"-style counts, gender
agreement with substituted names, lowercase fragments that are joined
into sentences, and inconsistent terms (Setup, Developer options, Stop
session, Button mapping).

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:06:56 +00:00
0x142857andxXJsonDeruloXx 7c08cb58ae Share font defaults across runtime sessions (#252)
* Support CJK text with the device's own fonts

The Linux runtime's rootfs ships DejaVu alone, which has no CJK
glyphs, so Chinese, Japanese and Korean text in Steam Big Picture and
on the LXQt desktop draws as tofu boxes (issues #118, #206, #249).

Bind the device's own CJK fonts - Android's world-readable Noto CJK
.ttc, or DroidSansFallback / Source Han - into the guest under
/usr/share/fonts/droiddeck-cjk, so nothing is added to the ~790 MB
runtime image and the coverage follows the device. A new
droiddeck-fonts script, run by droiddeck-session before any program
that queries fontconfig, links the default rules, writes local.conf
with DejaVu first and the CJK families after it, and rebuilds the
fontconfig cache once per set of font files. The desktop's own
fontconfig block moves there, so every mode gets it.

* Fix CJK and Thai: bind the device font directory, not one file at a time

The first cut bound each device font with its own proot -b host:guest. A
per-file binding into a plain directory is not applied here: the guest sees a
zero-byte placeholder where the font should be (stat size=0, fc-scan empty),
so Chinese stayed tofu even though the app had "bound" the font. A directory
binding is what the rest of the runtime relies on, so bind /system/fonts as a
whole under /usr/share/fonts/droiddeck-device. That also brings Thai, Arabic,
Devanagari and the emoji fonts, which the runtime did not have either.

Verified on the AYN Thor: the session's fontconfig probe reports
sans=DejaVu Sans, zh=Noto Sans CJK SC, th=Noto Serif Thai, and Steam Big
Picture in Simplified Chinese renders without tofu (screenshot under
docs/development/evidence/).

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:06:23 +00:00
7e81668de2 Polish Spanish translation (#234)
* i18n: complete Spanish

Spanish gets the strings added since it landed and the ones that are only a format or a brand name, so it has the same keys as English, in the same order. Each English term keeps one Spanish word across the file, and a few existing strings are reworded to read right with the values they are filled with.

* i18n: use compilación for build in Spanish

* i18n: translate new strings to Spanish

---------

Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:05:09 +00:00
Kurt Himebauch d231564f8e feat: agent bridge wayland and gamescope coverage (#406)
* feat: run a guest agent in every session for commands, focus and the Steam client's DevTools

* feat: give the agent bridge game, input, UI and experiment commands behind a Setup toggle

* feat: droiddeckctl commands for the new bridge, a scenario runner and contract tests

* feat: run cleanup steps after every scenario run so a failure cannot leak into the next

* feat: restart a session that died between scenario runs with start --reuse

* fix: give droiddeckctl stop its own, longer wait for the session's logs

* fix: harden debugging tools access and output
2026-10-08 03:50:58 +00:00
Kurt Himebauch 37e89c5b4d harden logging (#414) 2026-10-08 02:10:06 +00:00
The412Banner e070bd72e0 fix: read droiddeck-env from ps (#413) 2026-10-07 21:38:33 -04:00
Kurt Himebauch 9310d19857 fix: backport upstream gamescope fix for games left behind Steam's loading screen (#401) 2026-10-07 15:28:11 -04:00
Kurt Himebauch 4454a6d39a fix: keep Updates channels and status side by side (#397) 2026-10-07 13:48:02 -04:00
MaxsTechReview 453f6f5180 Keep the shortcut settings changed in Steam (#393)
Steam saves a shortcut's Properties to shortcuts.vdf as soon as they
change and keeps its appid. The session rewrote every added game's entry
from the app's list before and after each client run, keeping only the
launch options, so a Target or Start In set in Steam, a new name or icon,
the hidden and overlay switches, the last played time and Steam's sort
name were all put back at the next exit.

droiddeck-steam-shortcuts now merges instead: an entry keeps every field
Steam has, and the app's exe is written only where the app changed it.
What the app last asked for and what Steam then had are recorded beside
shortcuts.vdf (.droiddeck-shortcuts.json). A Target changed in Steam to a
file inside the game's folder becomes the exe chosen in Steam > Settings >
Games, and a later pick there replaces it in Steam, so both show the same
exe. The appid now stays with the folder, so changing the exe no longer
turns the game into a new one with a new prefix, playtime and launch
options. A game whose folder is missing is set aside with its settings
and comes back with them. The exit-time merge runs before Steam's logs
are copied.
2026-10-07 09:32:15 -04:00
MaxsTechReview 6d0484941e Make Games folders Steam libraries and skip the zero-filled reservation (#388)
With Second library on Automatic, every writable Games folder is now a
Steam library: it gets the same private prefix and tool binds as the card,
the app hands the session the list (BL_STEAM_LIBRARIES), and
droiddeck-steam-library registers, relabels and forgets entries from it.
Steam offers them in the install dialog as "<folder> (<volume>)", and
Proton mapping, esync, the redist seeder, desktop entries and the compat
beat read every registered library instead of only the card. Games folder
libraries stop at the fast path's bind budget.

Steam's preallocation on Android's FUSE storage either failed outright
(NTFS answers EPERM, so installs were cancelled as "not enough disk
space") or zero-filled the whole download first (exFAT, minutes for a
small game). After fallocate is refused, the Steam client's ftruncate
extension of its own staging files is reported as done; the writes that
follow fill the file. A 76 MB install to a USB SSD now preallocates
instantly instead of taking about 3.5 minutes.

A library that is not there is left out of the list and forgotten until
it returns, imports whose folder is missing are set aside rather than
offered as broken installs, and the front end refreshes when storage is
mounted or removed.
2026-10-07 09:31:33 -04:00
The412Banner cf47910881 File manager: the Linux system's root under Storage (#392) 2026-10-07 08:12:02 -04:00
The412Banner 4f0f0cf5b1 File manager: the Linux system's root under Storage
A "Linux system" entry in the file manager's Storage list (the rail and the
drive menu) opens the root of the Linux system the sessions run in - the
app's files/linuxfs, the desktop's / - so its files can be browsed, copied
in and out without the desktop. Shown once the runtime is installed; Back
stops at that root like a drive's, and the drive label reads Linux system
anywhere under it. The label is translated in all nine languages.
2026-10-07 08:05:43 -04:00
Kurt Himebauch a78d5cb600 Let Steam draw at the session's full size above 1080p (#391) 2026-10-07 07:51:50 -04:00
Juan MartíandxXJsonDeruloXx 206c8a8846 Add Steam background download mode (#335)
* Add Steam background download mode

* Keep active Steam downloads running

* Make background downloads a toggle

* fix: keep background downloads alive between Steam's rate log lines

* Track all Steam content update types

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-07 10:36:49 +00:00
Kurt Himebauch bf17ce1151 fix: revert gamescope patch 0113 now that games run on their own Xwayland (#372) 2026-10-07 06:25:06 -04:00
MaxsTechReview ef38c975b9 Draw Steam's CJK and Thai text with the device's fonts (#374)
Steam ships no CJK font and the rootfs only has DejaVu, so a phone set to
Chinese, Japanese or Korean got Steam in that language with every character
drawn as a box. Bind Android's /system/fonts into the guest, index it again
whenever it changes, and prefer the matching Noto CJK face for each language.
2026-10-07 02:23:27 -04:00
The412Banner 61953c4f7b File manager: up/back from any start folder, plus a ".." up row (#377) 2026-10-07 00:05:06 -04:00
The412Banner 1f13285ab8 File manager: up/back work from any start folder, and a ".." row to go up
Opening the manager at a folder (a game's files, its Proton prefix, the
saves folder, or the picker's remembered folder) ran openDrive() on the
start dir, which pinned the up/back floor to that folder: the toolbar
arrow was greyed out and Back closed the screen. The start dir now opens
with loadDirectory(), keeping the volume-root floor computed for it, and
folders in the app's private storage (internal Steam library, prefixes)
floor at the app's files dir instead of themselves.

Adds a ".." (Up one level) row at the top of the list and grid whenever
the current folder is above its floor, sharing one goUp() with the
toolbar arrow. Covers both screens built on FileManagerScreen: the File
Manager and the in-app file/folder picker. Strings for all 9 locales.
2026-10-06 23:52:59 -04:00
MaxsTechReview 1bc23ac169 Language setting and translations: Spanish, Japanese, Korean, Chinese; Steam follows it (#187) (#284)
* l10n: move hard-coded UI text into string resources (#212)

About 650 user-facing strings were written in Kotlin and Java, so no
translation could reach them and the UI stayed half English in every
language. They now live in values/strings.xml.

- Dialogs, menus, status lines, toasts and errors shown to the user go
  through getString/stringResource.
- Code that matched on English text now uses IDs: installer and loading
  stages, Decky stages, storage labels and the Back button's order labels.
- Choice lists (upscalers, FPS limits, textures, themes, presets) carry
  @StringRes labels instead of English.
- Robolectric tests read the app's resources (includeAndroidResources).
- Six strings nothing used any more are dropped.

Existing translations are untouched; the new strings show in English
until they are translated.

* Layouts: fit longer text in other languages, and small phones

Found by screenshotting every page, tab and menu in each language on a
phone, a tablet, a 640dp phone and a foldable.

- Rail labels shrink to fit instead of being cut off (FitText).
- Tab rows tighten when they overflow and scroll the chosen tab into view.
- Menus fade over a down arrow when more entries are below, and open
  scrolled to the current choice.
- On a small phone the desktop card's button moves under its title,
  emulator tiles go two across, the GPU support verdict wraps and the
  Updates page stacks the channels under the status.

* l10n: translate every string into Spanish, Japanese, Korean and Chinese (#187)

Simplified Chinese, Traditional Chinese (Taiwan) and Traditional Chinese
(Hong Kong, reviewed for Hong Kong wording) are complete, as are Spanish,
Japanese and Korean: no missing keys and no placeholder mismatches.

* Language setting: follow the system, or a language picked in Setup; Steam uses it too (#187)

- Setup -> Launcher starts with a Language row: "System default (...)"
  and each language by its own name. With nothing picked the app follows
  the system language; a pick applies at once and stays until System
  default is picked again.
- Steam keeps its language in ~/.steam/registry.vdf under both
  Valve/Steam/language and Valve/Steamsteamglobal/language. Before each
  start of the client the guest helper droiddeck-steam-language writes
  both (and exportedsettings.json), and the client gets -language.
  SteamLanguage maps the app's language to Steam's names: english,
  spanish/latam, japanese, koreana, schinese, tchinese.

* l10n: translate the strings main added since, and Chinese review fixes

- Translate the 32 strings added on main since (Windows components,
  device details, Force SSBS, native Steam sleep) into all six languages.
- zh-CN: busy labels say what is in progress again (正在取消, 正在导出
  ...), as the other languages do.
- zh-CN: "set up" is 配置, as Setup is; 设置 stays for Settings.
- zh-TW: proton_compatible_note says 前綴 rather than "prefix".
2026-10-06 22:00:21 -04:00
MaxsTechReview 50f50b16e0 l10n: move hard-coded UI text into string resources (#212) (#282)
About 650 user-facing strings were written in Kotlin and Java, so no
translation could reach them and the UI stayed half English in every
language. They now live in values/strings.xml.

- Dialogs, menus, status lines, toasts and errors shown to the user go
  through getString/stringResource.
- Code that matched on English text now uses IDs: installer and loading
  stages, Decky stages, storage labels and the Back button's order labels.
- Choice lists (upscalers, FPS limits, textures, themes, presets) carry
  @StringRes labels instead of English.
- Robolectric tests read the app's resources (includeAndroidResources).
- Six strings nothing used any more are dropped.

Existing translations are untouched; the new strings show in English
until they are translated.
2026-10-06 21:59:31 -04:00
MaxsTechReview adc86fc7df Fix Audio Crackling on Pulse & DirectAudio (#338)
* Build the DirectAudio relay from source

The relay helper shipped as a prebuilt libdirectaudiorelay.so with no source in the
repo. Its source (The412Banner/directaudio @ 3548a4a, the commit the bundled driver
names in version.txt) now lives in tools/directaudio-relay, unmodified, and CI and
build_local.sh compile it into jniLibs the way proot is built. It shares
da_relay_proto.h with the PulseAudio sink, which is byte-identical to its own copy.

* DirectAudio relay: prime before playing, one consumer, safe buffer floors

- The output stream started before the client had its ring, so every connection
  opened with a run of short callbacks: six clicks per stream on a RedMagic, even idle.
  The callback now plays silence until the ring holds its target, and only a real
  underrun afterwards counts.
- An underrun ends the block with a short fade and waits for the ring to refill to
  target before resuming with a fade in, instead of zero-padding every short callback.
  Each callback spent starved grows the target, so one stall sizes it for the next.
  With a producer freezing 60 ms every 2 s: 14 underruns before, 1 after.
- During a reopen the outgoing and incoming streams could both drain the ring. Only
  the promoted stream reads it now; any other plays silence.
- The ring target starts at 40 ms instead of two bursts (8 ms) and keeps what it
  learned across reopens.
- The device buffer is at least two bursts; 12 ms was a single burst on devices that
  grant a 20 ms legacy stream.

* PulseAudio: the classic path plays through the native-rate AAudio sink

The classic path loaded the prebuilt 0.1.5 module, which opened AAudio at the daemon's
default 44.1 kHz: Android gave it a legacy stream (20 ms bursts) and resampled, after
PulseAudio had already resampled the 48 kHz clients down. Its data callback also waited
on the daemon's IO thread, and it started at two bursts and grew only after an audible
underrun. The app's own module-aaudio-sink, built by CI into the same bundle, now
carries that path:

- The stream opens at the device's rate (48 kHz) with the buffer at two bursts or
  30 ms, whichever is more, still growing a burst per underrun.
- A write that times out part way keeps the rest for the next write instead of
  dropping it.
- A stream reopened after a route change asks for the rate the first one got, so the
  sink keeps its format instead of unloading.

* Audio: DirectAudio sink at audio priority, deeper client buffers, no decay probing

- module-directaudio-sink's IO thread asks for nice -16 like module-aaudio-sink's, so
  the client's producer keeps up under game load.
- PULSE_LATENCY_MSEC=60 for the session: libpulse clients under FEX (the Steam
  client, winepulse when DirectAudio is off) keep enough queued to ride out a late
  wakeup.
- BANNER_AUDIO_DIRECT_DECAY=0: the driver's decay shrinks the device buffer every
  10 s of quiet until it underruns again, a click by design.

Both sit ahead of Downloads/droiddeck-env and per-game environment, which still win.

* Audio: drop the session-wide PULSE_LATENCY_MSEC

On an AYN Thor it took the Steam client's stream to 110 ms (60 ms tlength on top of the
sink's 40 ms) with no underruns to show for it, and the delay was audible. Clients pick
their own buffers again; the per-game option still sets it.

* PulseAudio sinks: wait in the poll loop, never in a blocking call

Clients' audio reaches a sink as messages its IO thread handles in pa_rtpoll_run. The AAudio
sink spent each iteration inside a blocking AAudioStream_write for up to a burst, and the
DirectAudio sink inside a futex wait for up to a burst, so a client's data sat unread and its
stream ran dry: a Chromium-shaped client (44.1 kHz float, 30 ms tlength, 10 ms minreq) had
304 underflows in 30 s on the AAudio sink, heard as crackling in the Steam client. Bursts of
20 ms (a legacy stream, an AYN Thor) made it worst.

- module-aaudio-sink writes without blocking, renders only what the device has room for, and
  comes back after half a burst. 0 underflows in 30 s, with and without 8 busy cores.
- module-directaudio-sink tops the ring up on the same half-burst timer instead of sleeping
  on the relay's futex.

* DirectAudio relay: vendor the source the shipped binary was built from

The import took 3548a4a, the commit the bundled driver names in version.txt, but the relay
binary that shipped came from e4727e9 on the same branch (feat/linux-relay-mic): its strings
match a build of that source exactly. 3548a4a has no --mic-fifo, so with the microphone on the
relay printed its usage and exited, and DirectAudio played nothing. This is e4727e9 as
published; the fixes made on top of 3548a4a follow in the next commit.

* DirectAudio relay: prime before playing, one consumer, safe buffer floors (on e4727e9)

The same changes as ca5d39b, carried onto the e4727e9 source: playback waits until the ring
holds its target, an underrun fades out and re-primes, starved callbacks grow the target, only
the promoted stream drains the ring, the ring target starts at 40 ms and survives reopens, and
the device buffer is at least two bursts.

* Steam client audio: DirectAudio by default

A fresh install plays the Steam client through the DirectAudio relay. Choosing Classic in the
Steam cog is stored and kept; only an install that never chose falls back to DirectAudio.
2026-10-06 17:10:00 -04:00
Juan Martí f3fd3a2307 feat: recognize Adreno 650 as supported (#326) 2026-10-06 16:02:54 -04:00
182 changed files with 20026 additions and 2368 deletions

No files matched your search

+2
View File
@@ -6,6 +6,7 @@ on:
push:
paths:
- 'tools/aaudio-sink/**'
- 'tools/directaudio-relay/**'
- '.github/workflows/build-aaudio-sink.yml'
workflow_dispatch:
@@ -25,6 +26,7 @@ jobs:
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/out"
tools/directaudio-relay/build.sh "$PWD/out"
- uses: actions/upload-artifact@v4
with:
name: audio-sink-modules
+6 -1
View File
@@ -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"
@@ -345,6 +345,11 @@ jobs:
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: Build the DirectAudio relay
run: |
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/directaudio-relay/build.sh app/src/main/jniLibs/arm64-v8a
- name: Cache proot
id: proot
+29
View File
@@ -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() }}
+14 -21
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@ permissions:
concurrency:
group: update-app-catalog
cancel-in-progress: false
queue: max
jobs:
catalog:
+1
View File
@@ -14,6 +14,7 @@ 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
app/src/main/assets/droiddeck-esync/
# Kotlin compiler session files (build-tool state, never source).
+4 -2
View File
@@ -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 {
@@ -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)
@@ -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,7 +160,7 @@ 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 clientDirectAudio by mutableStateOf(true)
private var stretch16x9 by mutableStateOf(false)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
@@ -170,6 +176,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 +269,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 +341,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 +356,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 +379,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 +423,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
@@ -442,7 +463,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 +516,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)
@@ -506,7 +528,7 @@ class MainActivity : ComponentActivity() {
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 +548,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,
@@ -637,9 +660,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 +675,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 +686,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)
@@ -691,7 +725,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 +738,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 +748,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 +757,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 +793,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 },
)
@@ -886,7 +920,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 +943,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 +959,8 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
unregisterReceiver(wifiLocationReceiver)
unregisterReceiver(mediaReceiver)
ui.removeCallbacks(mediaRefresh)
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
@@ -945,9 +986,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 +1023,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 +1069,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 +1090,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 +1110,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 +1148,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 +1165,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,6 +1187,7 @@ 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,
@@ -1174,6 +1220,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 +1247,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 ->
@@ -1221,7 +1272,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 +1299,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 +1316,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 +1372,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,6 +1419,7 @@ 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)
@@ -1384,7 +1436,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 +1455,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 +1498,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 +1569,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 +1588,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 +1624,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 +1649,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)
@@ -100,6 +100,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 +246,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 +306,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 +362,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 +468,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 +504,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 +512,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 +565,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 +579,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 +703,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 +718,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 +735,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 +758,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 +797,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 +826,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 +868,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 +884,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 +917,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 +1200,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 +1258,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
@@ -2013,6 +2053,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 +2131,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()
}
}
@@ -143,8 +143,7 @@ public class PulseAudioComponent extends SessionPart {
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("load-module module-aaudio-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
config.add("set-default-sink AAudioSink");
}
if (micFifoPath != null && !micFifoPath.isEmpty()) {
@@ -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) }
}
}
@@ -181,8 +181,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)
@@ -98,7 +98,7 @@ 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
scanGame(context, folder, out)
}
}
@@ -126,32 +126,59 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
run {
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 picked = SessionPrefs.addedGameExe(context, folder.path)
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val found = chosen ?: candidates.firstOrNull() ?: return
val foundGuest = guestPath(context, found)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
val name = 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, exe.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))
}
}
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 +188,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
}
@@ -33,6 +33,8 @@ object Library {
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 +86,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 +110,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 +136,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 +146,19 @@ 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" },
)
}
}).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? {
@@ -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(
@@ -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
@@ -102,7 +103,7 @@ 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"
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
return exclusive("setup") {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
@@ -114,11 +115,11 @@ 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"
} ?: context.getString(R.string.flatpakmgr_busy)
}
/**
@@ -126,51 +127,61 @@ object FlatpakManager {
* 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"
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
return exclusive("$verb:${id ?: "all"}") {
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")}")
}
}
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
} ?: context.getString(R.string.flatpakmgr_busy)
}
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('.')
}
}
@@ -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)
@@ -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" }
}
}
@@ -41,10 +41,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",
@@ -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
@@ -123,6 +124,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)
@@ -156,10 +162,9 @@ object SessionPrefs {
/**
* 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.
* sink. On unless the user picked Classic.
*/
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", false)
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", true)
fun setClientDirectAudio(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
@@ -588,8 +593,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 +680,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 +695,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 +739,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 +751,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 +798,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,7 @@ class SessionService : Service() {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
@@ -397,14 +424,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 +549,7 @@ class SessionService : Service() {
},
)
mainHandler.post { updateSuspendPolicy() }
updateDownloadMonitoring()
}
}
@@ -554,6 +582,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 +653,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
@@ -747,6 +785,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 +848,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.
@@ -867,6 +907,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 +1086,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 +1258,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 +1407,9 @@ class SessionService : Service() {
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
com.droiddeck.launcher.agent.AgentGuest.stop()
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 +1457,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 +1626,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 +1659,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 +1711,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 +1754,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()
}
@@ -49,12 +49,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
val tintOpen = host.open == "controller-tint"
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
Box {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: stringResource(R.string.ctrl_custom), tintOpen) {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second?.let { stringResource(it) } ?: stringResource(R.string.ctrl_custom), tintOpen) {
host.open = if (tintOpen) null else "controller-tint"
}
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
MenuItem(stringResource(name), checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTint(color)
host.open = null
}
@@ -90,8 +90,9 @@ fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String)
onBack = onBack,
) {
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) }
for ((id, name) in ControllerPrefs.mappable) {
ChoiceRow(host, "map-$id", name, null, ControllerPrefs.targets, mapping[id] ?: id) { onPick(id, it) }
ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) }
}
}
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
@@ -197,7 +197,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
)
if (row.tag.isNotEmpty()) Text(
row.tag, fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
driverTagLabel(row.tag), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
/**
* One line of text in a slot of fixed width - a rail label, a key cap - set as large as
* [maxSize] when it fits and smaller, down to [minSize], when it does not. Labels are as long as
* their language makes them ("Components" is "コンポーネント", "Componentes", "구성 요소"), so a
* fixed size cuts some of them off; below [minSize] the end is ellipsized rather than clipped.
*/
@Composable
fun FitText(
text: String,
maxSize: TextUnit,
minSize: TextUnit = 9.sp,
modifier: Modifier = Modifier,
fontWeight: FontWeight? = null,
color: Color = Color.Unspecified,
) {
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
val measurer = rememberTextMeasurer()
val style = LocalTextStyle.current
val width = constraints.maxWidth
val size = remember(text, width, maxSize, minSize, fontWeight, style) {
if (width == Constraints.Infinity) return@remember maxSize
var s = maxSize.value
while (s > minSize.value) {
val measured = measurer.measure(
text, style.copy(fontSize = s.sp, fontWeight = fontWeight ?: style.fontWeight),
maxLines = 1, softWrap = false,
)
if (measured.size.width <= width) break
s -= 0.5f
}
s.coerceAtLeast(minSize.value).sp
}
Text(
text, fontSize = size, fontWeight = fontWeight, color = color, maxLines = 1, softWrap = false,
overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
)
}
}
@@ -307,7 +307,7 @@ private fun Content(
Column {
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(
e.system.replaceFirstChar { it.uppercase() }.let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
}
@@ -334,7 +334,7 @@ private fun Content(
)
}
else Rise(5, Modifier.fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
}
} else {
if (pkg != null) Rise(2) {
@@ -379,7 +379,7 @@ private fun Content(
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BackLink(e.name) { onSelect("emu:${e.id}") }
Eyebrow(stringResource(R.string.content_desktop_system, e.system))
Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e)))
}
}
Rise(1) { Title(g.name) }
@@ -391,7 +391,7 @@ private fun Content(
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
PrimaryButton(stringResource(R.string.content_launch_in, e.name), enabled = !s.busy, main = true) { a.onRom(g) }
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, ok = false)
}
}
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
@@ -403,7 +403,7 @@ private fun Content(
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
}, wide = others.none { it.art != null })
}
}
@@ -419,7 +419,6 @@ private fun Content(
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
@@ -428,25 +427,33 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
BusyChip(s)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
// The button goes under the title unless the card is wide enough for both side by side: in some
// languages its label alone takes half a small phone's pane.
BoxWithConstraints(Modifier.fillMaxWidth()) {
val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (!narrow) actions()
}
if (!narrow) actions()
if (narrow) actions()
}
if (narrow) actions()
}
}
/** How wide the desktop card must be to keep its button beside the title. */
private val DesktopCardInlineWidth = 640.dp
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
private sealed class GridItem(val key: String) {
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
@@ -454,25 +461,33 @@ private sealed class GridItem(val key: String) {
data object Add : GridItem("add")
}
/** Wide tiles - three across, or a list on a narrow page. */
/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */
@Composable
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
items.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, item ->
key(item.key) {
val m = Modifier.weight(1f).fillMaxHeight()
val isFirst = first && r == 0 && i == 0
when (item) {
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
GridItem.Add -> AddTile(m, isFirst, onAdd)
BoxWithConstraints(Modifier.fillMaxWidth()) {
// A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the
// name no room at all.
val columns = when {
LocalNarrowPane.current -> 1
maxWidth < 720.dp -> 2
else -> 3
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
items.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, item ->
key(item.key) {
val m = Modifier.weight(1f).fillMaxHeight()
val isFirst = first && r == 0 && i == 0
when (item) {
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
GridItem.Add -> AddTile(m, isFirst, onAdd)
}
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
@@ -658,10 +673,14 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
* contrast - dimming it read as disabled rather than one press from installing.
*/
@Composable
private fun systemLabel(e: Library.Emulator): String =
if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() }
@Composable
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val system = e.system.replaceFirstChar { it.uppercase() }
val system = systemLabel(e)
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.LocalIndication
@@ -71,7 +72,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
if (!s.shortcutPicker) Rise(2) {
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) }
}
GameFileFolderActions(s, a)
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.focus.focusRequester
@@ -162,6 +163,7 @@ private fun RailItem(
contentAlignment = Alignment.Center,
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.testTag("rail-$key")
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
@@ -174,7 +176,7 @@ private fun RailItem(
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (height < 56.dp) 20.dp else 22.dp))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
FitText(label, maxSize = 12.sp, modifier = Modifier.padding(horizontal = 4.dp), fontWeight = FontWeight.SemiBold, color = fg)
}
if (badge) Box(
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
@@ -91,6 +91,8 @@ class FrontEndState(
val frameGenLabel: String,
val romsDir: String?,
val logsEnabled: Boolean,
/** Setup's Debugging tools toggle (AgentAccess): off unless the user turns it on. */
val agentCommands: Boolean = false,
val steamGames: List<Library.SteamGame>,
val emulators: List<Library.Emulator>,
val running: String?,
@@ -101,6 +103,8 @@ class FrontEndState(
val gameSyncFolder: String? = null,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
/** The language picked in Setup, or AppLanguage.SYSTEM to follow the system. */
val language: String = "",
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
val isHomeApp: Boolean = false,
val homeScreenEnabled: Boolean = false,
@@ -164,11 +168,13 @@ class FrontEndActions(
val onFiles: () -> Unit,
val onBrowseFiles: (File) -> Unit = {},
val onLogs: () -> Unit,
val onAgentCommands: () -> Unit = {},
val onShareLogs: () -> Unit = {},
val onClearLogs: () -> Unit = {},
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onLanguage: (String) -> Unit = {},
val onAppScale: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
@@ -179,10 +185,10 @@ class FrontEndActions(
val onBackActionsInverted: (Boolean) -> Unit = {},
val onRefreshPhantomStatus: () -> Unit = {},
val onOpenDeveloperOptions: (Int?) -> Unit = {},
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit,
val onFindWirelessAdbPort: (String, (Int?) -> Unit) -> Unit = { _, done -> done(null) },
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") },
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit,
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit,
val onCopyPhantomCommand: (Boolean) -> Unit = {},
val onDismissPhantomGate: () -> Unit = {},
val onStartWirelessAdbPairing: () -> Unit = {},
@@ -71,6 +71,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
@@ -158,7 +159,7 @@ internal fun SetupPanel(
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
val gpuName = remember { DeviceSupport.gpuName() }
val gpuName = remember { DeviceSupport.gpuName(ctx) }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
@@ -202,8 +203,8 @@ internal fun SetupPanel(
else -> stringResource(R.string.setup_gpu_unsupported)
},
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.name, gpuName)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.name, gpu.supportText.replaceFirstChar { it.lowercase() })
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.displayName(ctx), gpuName)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() })
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
},
)
@@ -237,7 +238,7 @@ internal fun SetupPanel(
PhantomProcessStatus.ENABLED -> stringResource(R.string.setup_limit_on)
PhantomProcessStatus.UNSET -> stringResource(R.string.setup_limit_unset)
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus)
},
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
@@ -250,8 +251,8 @@ internal fun SetupPanel(
// raw command live on the full page Wireless debugging opens.
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
Text(
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
if (limitBlocks) PhantomProcessLimit.gateInstructions(ctx, s.phantomProcessStatus)
else PhantomProcessLimit.instructions(ctx, s.phantomProcessStatus),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
Actions {
@@ -270,7 +271,13 @@ internal fun SetupPanel(
CheckRow(
if (signedIn) CheckState.OK else CheckState.WARN,
stringResource(R.string.setup_account),
s.offlineAccount?.let { if (s.offline) stringResource(R.string.setup_signed_in_offline, it) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in),
s.offlineAccount?.let {
when {
it.isEmpty() -> stringResource(if (s.offline) R.string.setup_signed_in_offline_unnamed else R.string.setup_signed_in_unnamed)
s.offline -> stringResource(R.string.setup_signed_in_offline, it)
else -> stringResource(R.string.setup_signed_in, it)
}
} ?: stringResource(R.string.setup_sign_in),
divider = false,
)
}
@@ -287,10 +294,10 @@ internal fun SetupPanel(
2 -> {
SettingsGroup(stringResource(R.string.setup_session)) {
ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
@@ -300,18 +307,20 @@ internal fun SetupPanel(
}
}
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
s.offlineAccount?.let { stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
LanguageRow(host, s.language, a.onLanguage)
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
@@ -323,10 +332,10 @@ internal fun SetupPanel(
)
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
@@ -493,3 +502,32 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/**
* The app's language: "System default" (what Android uses, named in brackets) or one of the
* languages the app ships, each in its own script. Steam is set to the same language when it starts.
*/
@Composable
private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit) {
val context = androidx.compose.ui.platform.LocalContext.current
val systemName = AppLanguage.displayName(AppLanguage.system(context))
val systemLabel = stringResource(R.string.setup_language_system, systemName)
SettingsRow(stringResource(R.string.setup_language), stringResource(R.string.setup_language_hint)) {
Box {
ValueChip(if (chosen == AppLanguage.SYSTEM) stringResource(R.string.setup_language_system_short) else AppLanguage.nativeName(chosen),
host.open == "language") { host.open = if (host.open == "language") null else "language" }
AnchoredMenu(host.open == "language", onDismiss = { if (host.open == "language") host.open = null }, title = stringResource(R.string.setup_language)) { firstItemFocus ->
MenuItem(systemLabel, checked = chosen == AppLanguage.SYSTEM, focusRequester = firstItemFocus) {
host.open = null
onPick(AppLanguage.SYSTEM)
}
AppLanguage.supported.forEach { tag ->
MenuItem(AppLanguage.nativeName(tag), checked = chosen == tag) {
host.open = null
onPick(tag)
}
}
}
}
}
}
@@ -178,7 +178,7 @@ private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: St
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@@ -235,9 +235,11 @@ private fun DeviceCard(s: GpuDriversState) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
// needs: a long one (in some languages) would squeeze the name to a letter a line.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
Icon(
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
@@ -38,6 +39,16 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
}
}
/** A [DriverRow.tag] as shown, in the app's language. */
@Composable
internal fun driverTagLabel(tag: String): String = when (tag) {
DriverRow.BUNDLED -> stringResource(R.string.driver_tag_bundled)
DriverRow.DOWNLOADED -> stringResource(R.string.driver_tag_downloaded)
DriverRow.IMPORTED -> stringResource(R.string.driver_tag_imported)
DriverRow.BUNDLE -> stringResource(R.string.driver_tag_bundle)
else -> tag
}
/** A release driver (Banners-Turnip, WinNative) that is not installed yet; [key] is its asset name. */
class DownloadRow(val key: String, val label: String, val detail: String, val progress: Int? = null)
@@ -48,13 +59,15 @@ class ModeSettings(
val hdr: Boolean,
val hdrReason: String?,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
val gpuDrivers: String = "Auto",
/** The drivers line; null shows Auto. */
val gpuDrivers: String? = null,
/** Frames per second the session is capped at; 0 = none. */
val fpsLimit: Int = 0,
val upscaler: Int = 0,
val upscaleSharpness: Int = 75,
val touchMode: String,
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val pipSupported: Boolean = false,
val pipAutoEnter: Boolean = false,
/** Steam only. */
@@ -62,11 +75,11 @@ class ModeSettings(
/** Steam only: whether single and double Back actions are swapped. */
val backActionsInverted: Boolean = false,
val directAudio: Boolean?,
val clientDirectAudio: Boolean = false,
val clientDirectAudio: Boolean = true,
val mic: Boolean?,
val renderer: String?,
val gameStorage: String? = null,
val storageOptions: List<Pair<String, String>> = emptyList(),
val storageOptions: List<com.droiddeck.launcher.session.GameStorage.Option> = emptyList(),
/** Steam only: record allocation and sampled storage timings in the next session's Share logs. */
val storageDiagnostics: Boolean = false,
val fexPreset: String? = null,
@@ -103,6 +116,8 @@ class ModeSettings(
val deckyChecking: Boolean = false,
val deckyStage: String? = null,
val deckyPercent: Int = -1,
/** The stage is the Decky plugin's binary download. */
val deckyDownloadingBinary: Boolean = false,
val deckyEnabled: Boolean = false,
val deckySessionRunning: Boolean = false,
)
@@ -120,6 +135,7 @@ class ModeSettingsActions(
val onUpscaleSharpness: (Int) -> Unit = {},
val onTouch: (String) -> Unit,
val onSuspendPolicy: (String) -> Unit,
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
val onPipAutoEnter: (Boolean) -> Unit = {},
val onOsc: (String) -> Unit,
val onBackActionsInverted: (Boolean) -> Unit = {},
@@ -217,7 +233,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, s.upscaler,
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
@@ -227,7 +243,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
@@ -239,7 +255,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
}
}
}
@@ -271,10 +287,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
@@ -288,11 +304,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
@@ -349,11 +372,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onChange = a.onDeckyEnabled,
)
SettingsRow(
"Plugins",
"Install a plugin ZIP for the next Steam session.",
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage?.startsWith("Downloading plugin binary") == true) "Downloading…" else "Install from ZIP",
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
@@ -471,12 +494,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
val internal = stringResource(R.string.mode_storage_internal)
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
add("off" to stringResource(R.string.mode_storage_internal))
for ((label, path) in s.storageOptions) add(path to label)
if (custom) add(s.gameStorage to stringResource(R.string.mode_storage_folder, s.gameStorage))
add(StorageChoice(
"", if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto),
stringResource(R.string.mode_storage_auto_short), "",
))
add(StorageChoice("off", internal, internal, ""))
for (o in s.storageOptions) add(StorageChoice(o.path, o.label, o.label, o.name))
if (custom) stringResource(R.string.mode_storage_folder, s.gameStorage).let { add(StorageChoice(s.gameStorage, it, it, it)) }
}
val open = host.open == "storage"
SettingsRow(
@@ -485,14 +514,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
highlighted = open,
) {
androidx.compose.foundation.layout.Box {
ValueChip(options.firstOrNull { it.first == s.gameStorage }?.second?.substringBefore(" -") ?: "-", open) { host.open = if (open) null else "storage" }
ValueChip(options.firstOrNull { it.path == s.gameStorage }?.chip ?: "-", open) { host.open = if (open) null else "storage" }
AnchoredMenu(
open, onDismiss = { if (host.open == "storage") host.open = null }, title = stringResource(R.string.mode_second_library),
note = stringResource(R.string.mode_storage_note),
) { firstItemFocus ->
options.forEachIndexed { index, (path, label) ->
MenuItem(label, checked = path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
a.onGameStorage(path, if (path.isEmpty() || path == "off") "" else label.substringBefore(" ·"))
options.forEachIndexed { index, choice ->
MenuItem(choice.label, checked = choice.path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
a.onGameStorage(choice.path, choice.saved)
host.open = null
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import android.view.KeyEvent
import androidx.compose.animation.animateColorAsState
@@ -63,40 +64,42 @@ import androidx.compose.ui.unit.sp
* modifier for the next key, a second tap locks it, a third lets it go. A controller moves over the
* keys with the d-pad and presses with A.
*/
private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null)
private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null, @StringRes val labelRes: Int = 0)
private fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted)
private fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes)
private val ROWS: List<List<PcKey>> = listOf(
listOf(
k("Esc", 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88),
k("Ins", 110), k("Del", 111), k("Home", 102), k("End", 107), k("PgUp", 104), k("PgDn", 109),
k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109),
),
listOf(
k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"),
k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"),
k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"),
k("=", 13, shifted = "+"), k("⌫ Backspace", 14, 2f),
k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f),
),
listOf(
k("Tab ⇥", 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"),
k("\\", 43, 1.5f, shifted = "|"),
),
listOf(
k("Caps", 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""),
k("Enter ⏎", 28, 2.25f),
k(R.string.pckb_key_enter, 28, 2.25f),
),
listOf(
k("⇧ Shift", 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"),
k("⇧ Shift", 54, 1.75f), k("↑", 103),
k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103),
),
listOf(
k("Ctrl", 29, 1.5f), k("Super", 125, 1.25f), k("Alt", 56, 1.25f), k("Space", 57, 6.25f),
k("AltGr", 100, 1.25f), k("Ctrl", 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f),
k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
),
)
@@ -171,6 +174,7 @@ fun PcKeyboard(
val state = mods[key.code] ?: MOD_OFF
val lit = state != MOD_OFF || (key.code == 58 && capsOn)
val label = when {
key.labelRes != 0 -> stringResource(key.labelRes)
shifted && key.shifted != null -> key.shifted
key.label.length == 1 && key.label[0].isLetter() && (shifted xor capsOn) -> key.label.uppercase()
else -> key.label
@@ -202,7 +202,7 @@ private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
SettingsGroup(stringResource(R.string.gate_status), compact = compact) {
Column(Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(PhantomProcessLimit.title(status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
Text(PhantomProcessLimit.title(LocalContext.current, status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
Body(stringResource(R.string.gate_polling))
}
}
@@ -226,22 +226,24 @@ class SecondScreenPresentation(
}
keys.addView(button)
}
key("Esc", "\u001b")
key("Tab", "\t")
key("Ctrl", "") { button ->
val ctrl = context.getString(R.string.pckb_key_ctrl)
val alt = context.getString(R.string.pckb_key_alt)
key(context.getString(R.string.pckb_key_esc), "\u001b")
key(context.getString(R.string.second_key_tab), "\t")
key(ctrl, "") { button ->
ctrlActive = !ctrlActive
button.text = if (ctrlActive) "Ctrl ✓" else "Ctrl"
button.text = if (ctrlActive) context.getString(R.string.second_key_on, ctrl) else ctrl
}
key("Alt", "") { button ->
key(alt, "") { button ->
altActive = !altActive
button.text = if (altActive) "Alt ✓" else "Alt"
button.text = if (altActive) context.getString(R.string.second_key_on, alt) else alt
}
key("↑", "\u001b[A")
key("↓", "\u001b[B")
key("←", "\u001b[D")
key("→", "\u001b[C")
key("Ctrl+C", "\u0003")
key("Ctrl+D", "\u0004")
key(context.getString(R.string.second_key_ctrl_c), "\u0003")
key(context.getString(R.string.second_key_ctrl_d), "\u0004")
keyStrip.addView(keys)
root.addView(keyStrip, LinearLayout.LayoutParams(-1, -2))
@@ -17,6 +17,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.animation.core.FastOutSlowInEasing
@@ -108,31 +109,24 @@ private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
)
/**
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
* order, and the screen never steps back.
* The checklist stage a loading line's [topic] belongs to, -1 when it says nothing about the stage.
* The lines are the session script's "== STEP" milestones and the installer's own progress; they
* arrive in order, and the screen never steps back.
*/
private fun stageOf(step: String, steam: Boolean): Int {
val t = step.lowercase()
return when {
"linux runtime" in t -> 0
"starting the session" in t -> 1
!steam && "desktop" in t -> 1
steam && "starting the steam client" in t -> 3
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
else -> -1
}
}
/** Download and install lines are worth reading as they are; the script's own notes are not. */
private fun readableStep(step: String): Boolean {
val t = step.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
private fun stageOf(topic: LoadingState.Topic, steam: Boolean): Int = when (topic) {
LoadingState.Topic.RUNTIME -> 0
LoadingState.Topic.SESSION -> 1
LoadingState.Topic.DESKTOP -> if (steam) -1 else 1
LoadingState.Topic.STEAM_STARTING -> if (steam) 3 else -1
LoadingState.Topic.STEAM -> if (steam) 2 else -1
LoadingState.Topic.OTHER -> -1
}
@Composable
fun LoadingOverlay(
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
/** What [step] is about, and whether it is a download or install line worth showing as it is (see [LoadingState]). */
topic: LoadingState.Topic, readable: Boolean,
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
/** An ended session's exit status and log path, shown small under the advice. */
endedDetail: String? = null,
@@ -147,12 +141,12 @@ fun LoadingOverlay(
var ball by remember { mutableStateOf<Pair<Offset, Float>?>(null) }
val stages = remember(steam) { loadStages(steam) }
var reached by remember { mutableStateOf(0) }
LaunchedEffect(step, steam) {
val at = stageOf(step, steam)
LaunchedEffect(step, topic, steam) {
val at = stageOf(topic, steam)
if (at > reached) reached = at
}
val active = reached.coerceAtMost(stages.lastIndex)
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
val detail = if (percent >= 0 || readable) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
val context = LocalContext.current
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
val games by produceState(emptyList<Library.SteamGame>()) {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.alpha
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
@@ -191,6 +192,7 @@ class DrawerActions(
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val backActionsInverted: Boolean,
val touchMode: String,
val touchAuto: String,
@@ -221,6 +223,7 @@ class DrawerActions(
val onQamButton: (Boolean) -> Unit = {},
val onKeyboardButton: (Boolean) -> Unit = {},
val onSuspendPolicy: (String) -> Unit,
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
val onBackActionsInverted: (Boolean) -> Unit,
val onTouch: (String) -> Unit,
val onFexPreset: (String) -> Unit,
@@ -308,7 +311,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
modifier = Modifier.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
)
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize().exposeTestTags(), contentAlignment = Alignment.CenterEnd) {
// A 4:3 or near-square screen (under 440dp tall) gets tighter padding and a shorter tab row,
// so the settings keep most of the height; a narrow one keeps some of the game in view.
val short = maxHeight < 440.dp
@@ -397,7 +400,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, a.upscaler,
SessionPrefs.upscalerChoices(LocalContext.current), a.upscaler,
note = stringResource(R.string.display_filter_note),
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
@@ -433,7 +436,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
}
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
}
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
@@ -499,12 +502,20 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (a.steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
) + if (a.steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
a.suspendPolicy,
note = stringResource(if (a.steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
chipModifier = focus.track(page, "suspend"),
onPick = a.onSuspendPolicy,
)
if (a.steam && a.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
a.steamDownloadsInBackground,
onChange = a.onSteamDownloadsInBackground,
)
}
SettingsGroup(stringResource(R.string.drawer_support)) {
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
@@ -611,7 +622,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
val stopDescription = stringResource(R.string.stop_session)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }.testTag("drawer-stop")
.clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick)
@@ -722,6 +733,7 @@ private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPag
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.testTag("drawer-tab-${item.name.lowercase()}")
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
@@ -747,7 +759,7 @@ private fun ScreenEffectsGroup(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
SettingsGroup(stringResource(R.string.drawer_effects)) {
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
val presets = ScreenEffectLooks.LOOKS.map { it.name to stringResource(it.label) }
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
@@ -825,7 +837,7 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
.controllerConfirm(onClick = pick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
Text(stringResource(mode.label), fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
RadioButton(selected, onClick = null,
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
}
@@ -842,9 +854,9 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.drawer_texture)) {
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
SessionPrefs.textureAnisotropyChoices(LocalContext.current), a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
SessionPrefs.textureLodBiasChoices(LocalContext.current), a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
}
}
@@ -1162,14 +1174,14 @@ private fun StopConfirm(
ConfirmChoice(
stringResource(R.string.common_cancel), danger = false, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() }
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused },
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }.testTag("drawer-stop-cancel"),
onClick = onCancel,
)
ConfirmChoice(
stringResource(R.string.drawer_stop), danger = true, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() }
.onFocusChanged { stopFocused = it.isFocused }
.onGloballyPositioned { stopCoords = it },
.onGloballyPositioned { stopCoords = it }.testTag("drawer-stop-confirm"),
) {
val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) }
onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat()))
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import android.view.KeyEvent
@@ -51,9 +52,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -67,6 +72,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
@@ -146,6 +152,7 @@ internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modif
* [bumpers]); the keycaps are for touch and stay out of the d-pad's path. The row scrolls sideways
* when a narrow page cannot fit every tab.
*/
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
internal fun TabStrip(
tabs: List<String>, selected: Int, onSelect: (Int) -> Unit,
@@ -154,11 +161,17 @@ internal fun TabStrip(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val shape = RoundedCornerShape(12.dp)
// Tabs that overflow (long labels on a small phone) first close up; only what still does not
// fit scrolls. Once tight the row stays tight, so it cannot flip back and forth.
val scroll = rememberScrollState()
var tight by remember(tabs) { mutableStateOf(false) }
LaunchedEffect(scroll.maxValue) { if (scroll.maxValue in 1 until Int.MAX_VALUE) tight = true }
val tabPadding = if (tight) 10.dp else 16.dp
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) {
BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
Row(
modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape)
.horizontalScroll(rememberScrollState()).padding(3.dp),
.horizontalScroll(scroll).padding(3.dp),
) {
tabs.forEachIndexed { i, label ->
val on = i == selected
@@ -166,17 +179,22 @@ internal fun TabStrip(
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val pick = { onSelect(i) }
val tabShape = RoundedCornerShape(9.dp)
// The chosen tab scrolls fully into view, so one at the end is not left cut off.
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
if (on) LaunchedEffect(Unit) { reveal.bringIntoView() }
Text(
label, fontSize = 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier
.bringIntoViewRequester(reveal)
.testTag("tab-$i")
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(tabShape)
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = tabPadding, vertical = 12.dp),
)
}
}
@@ -269,7 +287,34 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 6.dp),
)
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { content(firstItemFocus) }
// While entries remain below the fold the list fades out at the bottom over a down
// arrow, so a short screen (a phone in landscape) shows that the menu goes on.
val scroll = rememberScrollState()
Box(Modifier.weight(1f, fill = false)) {
Column(
Modifier
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
if (scroll.canScrollForward) {
val fade = 36.dp.toPx().coerceAtMost(size.height / 3)
drawRect(
androidx.compose.ui.graphics.Brush.verticalGradient(
listOf(Color.Black, Color.Transparent), startY = size.height - fade, endY = size.height,
),
topLeft = androidx.compose.ui.geometry.Offset(0f, size.height - fade),
size = androidx.compose.ui.geometry.Size(size.width, fade),
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
}
}
.verticalScroll(scroll),
) { content(firstItemFocus) }
if (scroll.canScrollForward) Icon(
Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = colors.onSurfaceVariant,
modifier = Modifier.align(Alignment.BottomCenter).size(20.dp),
)
}
if (note != null) {
Spacer(Modifier.height(4.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
@@ -280,6 +325,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
}
}
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MenuItem(
label: String, checked: Boolean, enabled: Boolean = true, detail: String? = null,
@@ -291,9 +337,13 @@ fun MenuItem(
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shift by animateFloatAsState(if (hot) 2f else 0f, Motion.sp(0.5f), label = "miShift")
// A menu taller than the screen opens scrolled to the current choice, not past it.
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
if (checked) LaunchedEffect(Unit) { reveal.bringIntoView() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth()
.bringIntoViewRequester(reveal)
.graphicsLayer { translationX = shift.dp.toPx() }
.then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(RoundedCornerShape(8.dp))
@@ -408,7 +458,7 @@ fun <T> ChoiceRow(
val open = host.open == key
SettingsRow(label, hint, highlighted = open, hintLines = hintLines) {
Box {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier) { host.open = if (open) null else key }
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
options.forEachIndexed { index, (value, text) ->
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
@@ -424,7 +474,7 @@ fun <T> ChoiceRow(
@Composable
fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) =
SettingsRow(label, hint) {
ToggleSwitch(checked, enabled, label, chipModifier) { host.open = null; onChange(it) }
ToggleSwitch(checked, enabled, label, chipModifier.testTag("setting-$key")) { host.open = null; onChange(it) }
}
/** An on/off switch: one tap or one A press flips it, where a menu of On and Off took three. */
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.animation.core.FastOutSlowInEasing
@@ -91,7 +92,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
Spacer(Modifier.height(18.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
// Enabled without a runtime: the session's loading screen installs it first.
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay)
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = a.onPlay)
Cog(size = 54.dp, onClick = a.onSteamSettings)
}
}
@@ -185,7 +185,7 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
FlathubApi.categories.forEach { c -> PillButton(c.label, selected = false) { onCategory(c.id) } }
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
}
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
@@ -195,7 +195,7 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
Rise(4 + i) { SectionTitle(title, apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: "loading…") }
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
}
}
@@ -222,7 +222,7 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
FlathubApi.categories.forEach { c ->
PillButton(c.label, selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
}
}
}
@@ -0,0 +1,14 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
/**
* Compose test tags as Android resource ids below this node, so UI Automator (`uiautomator dump`,
* droiddeckctl ui-dump) sees stable ids such as `rail-steam` or `setting-logs` rather than only
* labels that change with the language. Tags are named in docs/agent-control.md.
*/
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.exposeTestTags(): Modifier = semantics { testTagsAsResourceId = true }
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
@@ -9,13 +10,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.R
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.session.SessionPrefs
class Palette(
val id: String, val label: String, val detail: String,
val id: String, @StringRes val label: Int, @StringRes val detail: Int,
val background: Color, val surface: Color, val surfaceVariant: Color, val line: Color, val line2: Color,
val onBackground: Color, val onSurfaceVariant: Color,
val primary: Color, val primary2: Color, val onPrimary: Color, val signal: Color,
@@ -30,28 +32,28 @@ object Themes {
val all: List<Palette> = listOf(
Palette(
GRAPHITE, "Graphite", "Graphite surfaces, with the logo's own orb blue for everything selected, focused or pressed.",
GRAPHITE, R.string.theme_graphite, R.string.theme_graphite_detail,
background = Color(0xFF0A0B0D), surface = Color(0xFF121417), surfaceVariant = Color(0xFF1A1D22), line = Color(0xFF262A31), line2 = Color(0xFF343A43),
onBackground = Color(0xFFF2F4F7), onSurfaceVariant = Color(0xFF9AA3AF),
primary = Color(0xFF1A9FFF), primary2 = Color(0xFF1487DB), onPrimary = Color(0xFF03111F), signal = Color(0xFF1A9FFF),
good = Color(0xFF4CD37F),
),
Palette(
PAPER, "Paper on black", "White is the primary, the icon's blue is the signal for rules, rings and dots.",
PAPER, R.string.theme_paper, R.string.theme_paper_detail,
background = Color(0xFF000000), surface = Color(0xFF0F0F10), surfaceVariant = Color(0xFF191A1C), line = Color(0xFF262729), line2 = Color(0xFF343638),
onBackground = Color(0xFFF9F9F9), onSurfaceVariant = Color(0xFF8E9196),
primary = Color(0xFFF9F9F9), primary2 = Color(0xFFD9DADD), onPrimary = Color(0xFF000000), signal = Color(0xFF136CE0),
good = Color(0xFF4CD37F),
),
Palette(
ICON_BLUE, "Icon blue", "Black ground, white type, the blue for everything selected, focused or pressed.",
ICON_BLUE, R.string.theme_icon_blue, R.string.theme_icon_blue_detail,
background = Color(0xFF050608), surface = Color(0xFF0E1116), surfaceVariant = Color(0xFF161B23), line = Color(0xFF1F2630), line2 = Color(0xFF2A3340),
onBackground = Color(0xFFF2F4F7), onSurfaceVariant = Color(0xFF8A93A0),
primary = Color(0xFF136CE0), primary2 = Color(0xFF0B4FB0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF136CE0),
good = Color(0xFF4CD37F),
),
Palette(
ELECTRIC, "Electric navy", "The blue brighter, on surfaces that lean navy instead of grey.",
ELECTRIC, R.string.theme_electric, R.string.theme_electric_detail,
background = Color(0xFF070A10), surface = Color(0xFF0F1522), surfaceVariant = Color(0xFF16203A), line = Color(0xFF1E2A47), line2 = Color(0xFF2A3A5E),
onBackground = Color(0xFFEEF3FF), onSurfaceVariant = Color(0xFF8B9AB8),
primary = Color(0xFF2E86FF), primary2 = Color(0xFF136CE0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF2E86FF),
@@ -15,6 +15,7 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -104,25 +105,32 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(top = 6.dp),
)
val checked = u.catalog?.checkedAt?.takeIf { it > 0 }
// The relative time comes capitalized to stand alone (Spanish "Hace 5 minutos"); here it
// ends a sentence.
if (!LocalNarrowPane.current) Text(
if (u.checking) stringResource(R.string.common_checking) else if (checked != null) stringResource(R.string.upd_checked, ago(checked)) else stringResource(R.string.upd_not_checked),
if (u.checking) stringResource(R.string.common_checking) else if (checked != null) stringResource(R.string.upd_checked, ago(checked).replaceFirstChar { it.lowercase() }) else stringResource(R.string.upd_not_checked),
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.store_check_updates), busy = u.checking, enabled = !u.checking && u.stage == null, onClick = ua.onCheck)
}
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
// Landscape has the width for channels and status side by side; a narrow pane stacks them.
if (LocalNarrowPane.current) {
StatusPanel(s, u, ua, me)
Box(Modifier.height(18.dp))
ChannelLabel()
ChannelPicker(u, ua)
} else {
// The label sits over both columns, so the status card lines up with the first channel.
ChannelLabel()
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
// Landscape has the width for channels and status side by side; a narrow pane, or a small
// phone's (where each channel's name and date would be cut to a word), stacks them. The
// Thor's top screen leaves about 690dp here and keeps them side by side.
val stacked = LocalNarrowPane.current || maxWidth < 600.dp
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
if (stacked) {
StatusPanel(s, u, ua, me)
Box(Modifier.height(18.dp))
ChannelLabel()
ChannelPicker(u, ua)
} else {
// The label sits over both columns, so the status card lines up with the first channel.
ChannelLabel()
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
}
}
}
}
@@ -179,7 +187,7 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
}
if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
if (installBlock != null && offered) {
Text(installBlock, fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
Text(installBlock.message(androidx.compose.ui.platform.LocalContext.current), fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
}
if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp))
val installable = release?.apk != null && installBlock == null && !s.sessionRunning
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more