Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx ef261d9724 double down on PD 2026-10-07 17:18:26 -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
Kurt Himebauch 1e877fa602 fix: give each new session the current screen's output size (#357) 2026-10-06 19:17:52 +00:00
Kurt Himebauch 906009a33a fix: share session logs in about a second instead of re-scrubbing them (#352) 2026-10-06 14:51:43 -04:00
The412BannerandThe412Banner 08559a02f8 Windows components: install .msi packages (XNA, PhysX, GFWL, MSXML...) without running them (#347)
* Windows components: install .msi packages (XNA, PhysX, GFWL, MSXML...) without running them

droiddeck-msi-install reads a Windows Installer package's tables with msiinfo and unpacks its
cabinets with cabextract, then lays out what Wine's msiexec would have installed: files at their
64-bit Windows paths (Program Files (x86), syswow64...), .NET assemblies in the GAC, side-by-side
assemblies in winsxs, and the registry values in the view the component writes through. Conditions,
features and the folder-setting custom actions are evaluated as on Windows 10 64-bit; what needs
the package's own code (DXSETUP, self-registration) is listed in the component's notes.

droiddeck-wincomponents puts a component's drive_c tree into the prefix (keeping the prefix's own
spelling of each folder) and writes its registry values into system.reg/user.reg before launch,
following Wine's link keys (Wow6432Node\Classes, CurrentControlSet...). Turning it off puts every
value back and takes an added PATH entry out again.

The app installs these from the Windows components menu like the DLL ones: it downloads the .msi
into the runtime and runs the installer there (GuestCommand). msitools 0.106 and cabextract 1.11
come from Arch Linux ARM, pinned in tools/msitools/packages.txt and hosted on the msitools-0.106-r1
release. wine-gecko stays hidden (Proton ships it) and wine-mono keeps waiting (uninstall step).

* Windows components: open with the top switch focused, by touch or by pad

The dialog's window starts in touch mode, where a switch cannot take focus, and the first switch
was asked once, two frames after the list loaded, only when a pad opened it - racing the dialog's
own Done focus. Now it always puts the window in pad mode, holds Done while the list loads and
then asks the top switch every frame until it has focus.

* Windows components: give msitools its own libraries; show a failed install on its row

msiinfo and cabextract sit with their libraries in /usr/local/lib/droiddeck-msitools, which the
loader does not search: every .msi install stopped at "libmsi-1.0.so.0: cannot open shared
object file". droiddeck-msi-install now runs them with that folder on LD_LIBRARY_PATH.

A failed install also says so under the component itself ("Could not install: ..."), not
only at the foot of the dialog, past the list.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 14:14:28 -04:00
Kurt Himebauch 1d93626453 feat: remove Force game windows fullscreen now that games run on their own Xwayland (#349) 2026-10-06 18:04:22 +00:00
Kurt Himebauch 9d0989374b feat: run Steam sessions on two Xwayland servers as SteamOS does (#344) 2026-10-06 17:56:13 +00:00
The412BannerandThe412Banner 3278afaa1a Fast path: hold every bind, so added games start in their own folder (#343)
* Fast path: hold every bind, so added games start in their own folder

libblfastpath kept the first 64 binds and dropped the rest without a word. A session has 87 (two
Games folders are the last two), so /root/Games/<folder>/... was mapped into the rootfs - the
empty mount point - instead of the bound storage. Wine resolves a new process's working directory
by those paths, found no drive for it and started every added game in C:\windows ("could not
find DOS drive for current working directory"): DiRT 3 did not mind, FEZ looks for
Content\Essentials.pak there and quits.

The library now holds 256, and turns itself off for the process when a bind does not fit or the
list runs past that, leaving the call to proot, which never maps it wrong. The app keeps the fast
path off for a session with more binds than that.

Verified on an AYANEO Pocket FIT: FEZ (added game) reaches its title screen; the warning is gone.

* Fast path: say in the session log when it stays off

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 13:48:01 -04:00
Kurt Himebauch bf221ffbbd fix: resolution presets keep the panel shape at 720p, 900p and 1080p (#337) 2026-10-06 13:19:46 -04:00
Kurt Himebauch c636eea355 fix: hold flood edges at the page edge once reached (#330) 2026-10-06 12:07:24 -04:00
Kurt Himebauch ffaa906cd1 fix: catch KGSL GEM_CLOSE at ioctl instead of exporting drmIoctl (#305)
* fix: avoid reusing retired KGSL GEM handles

* fix: defer closing GEM_CLOSE'd KGSL dma-buf descriptors

* Revert "fix: defer closing GEM_CLOSE'd KGSL dma-buf descriptors"

This reverts commit 191bf4b6f2.

* test: interpose drmIoctl without releasing GEM_CLOSE handles

* fix: catch KGSL GEM_CLOSE at ioctl instead of exporting drmIoctl
2026-10-06 11:52:16 -04:00
The412BannerandThe412Banner d61af961c1 Windows components for Steam and added games; no more redist installer prompts (#287)
* Put a game's Windows components into its Proton prefix at launch

droiddeck-wincomponents copies the DLLs of the components picked for a game
into compatdata/<appid>/pfx before Proton starts and hands their overrides to
WINEDLLOVERRIDES, so the registry is never touched. It re-checks every launch
(an older Proton deletes the files it once placed), takes a component's files
out again when it is turned off, and forgets Proton's config_info then so
Proton puts its own builtins back.

* Windows components for added games: picker, packages and their catalog

Settings > Games lists a Windows components row under each added game. Its
dialog switches components on per game; the first switch-on for any game
downloads the package (a catalog of its own beside desktop.json, installed
with the desktop package installer to /opt/droiddeck/wincomponents/<id>).
The picks are kept in the app and published to the runtime at every session
start for droiddeck-wincomponents to apply.

tools/wincomponents/package.py builds the packages: Bannerlator's eight
wincomponents archives re-rooted, and OpenAL from OpenAL Soft's release
(soft_oal.dll placed as OpenAL32.dll, 32- and 64-bit).

* Windows components: list the installed ones when the catalog cannot be reached

Offline the dialog only looked up ids it already knew - the catalog's and
the game's picks - so a component downloaded earlier never showed.

* Windows components: Bannerlator's list with Recommended, on the game's launch settings

The picker moves from Settings to a Windows components card in an added
game's launch settings. It offers Bannerlator's whole catalog
(components.json on winlator-contents): what the game's folder points at
first (Bannerlator's DependencyDetector, mapped to the DLL-copy twin where
there is one, oalinst -> oalinst_dll), then every component that installs
here, then the ones that need a Windows installer, greyed until DroidDeck can
run one.

A component installs by running its catalog steps into the runtime once:
archives downloaded (one download per archive for a batch), DLLs found by
name as Bannerlator does and copied to /opt/droiddeck/wincomponents/<name>,
overrides and bundled components written to component.json. The launch
script now takes catalog names (xaudio2.7), expands bundles (directmusic),
places files one level down (drivers/gm.dls) and keeps an override's own
mode (dsound=n). Drops the hand-made packages and their script.

* Windows components: Steam games too, and a pad that can drive the list

The card shows on a Steam title's launch settings as well, keyed by its
appid - the compatdata folder droiddeck-game-env reads - and Recommended
scans the game's install folder.

With a pad: Done takes focus while the catalog loads and the first
component once it is there (the list was not drawn yet when the dialog
asked for focus, so nothing had it); switches stay enabled during a
download and a press then is ignored (a disabled switch dropped the
pad's focus); LB/RB jump between Recommended, All, the installers and
Done.

* Redist markers from games' own install scripts; VC++ runtime as installed

The client still started a game's own _CommonRedist installer - its
script sits in the game's folder, often with another version in the
value name, and only Steamworks Shared's were read. On ARM64 Proton the
x64 VC++ 2015-2022 bundle then stops with "Microsoft Visual C++
2015-2022 Redistributable (arm64) is required" (reported on an AYN Odin 2
Portal Max).

- Read installscript*.vdf from every installed game in each library as
  well; the list is cached until an appmanifest changes, since this runs
  every quarter minute.
- Record the VC++ 2015-2022 runtime as installed where its installers
  look (VisualStudio\14.0\VC\Runtimes\{x86,x64,arm64}, both registry
  views), so any bundle - the client's or a game's own - finds it and
  exits; Proton's msvcp140/vcruntime140 serve the game.
- Join the Wow6432Node path instead of sub(): mawk and gawk read the
  replacement's backslashes differently (gawk, the runtime's, was right).

* Mark a game's install-script processes as run just before the client evaluates them

An Odin 3 bundle (0.3.1, Proton Experimental ARM64) shows the client
running its install-script evaluator on the first launch of three new
titles (409710, 2161700, 3404260) while their Steamworks Shared
redistributables (VC++ 2010/2012/2019/2022, DirectX) were still
downloading - 2161700 at 22:03:22, the shared app finished at 22:03:29 -
so the periodic seeder had nothing to mark yet and the installers ran.

The client writes the exact steps to legacycompat/evaluatorscript_<id>.vdf
and starts iscriptevaluator.exe through our launcher. droiddeck-game-env
now hands that script to droiddeck-seed-redists first, which marks every
process in it in the template and the prefixes before the evaluator
reads them.

The seeder's reader no longer assumes a depth: a block with a HasRunKey
is a process named by the key it opened under, wherever it sits.

* Windows components: keep a file that was not Proton's, put it back when turned off

A component's DLL replaced whatever had that name in the prefix, and
turning it off deleted ours, so a file a real installer (or the user) had
put there was gone for good. Before replacing a file that is neither
Proton's own (a symlink into Proton, or a Wine builtin/placeholder DLL)
nor an earlier copy of ours, keep it under
compatdata/<id>/.droiddeck-wincomponents-backup and move it back when the
component is turned off. The launch note says how many went back.

* Windows components: recommend what Steam installs, say what the game already has

- Steam titles: the appmanifest's SharedDepots (Steamworks Shared, app
  228980) list the VC++ 2005-2022 and DirectX redists Steam installs with
  the game; they live outside the game folder, so the folder scan never
  saw them. They join Recommended as "Steam installs VC++ 2010 with this
  game" (SteamRedists).
- Bannerlator's PrefixInstalledDetector, on compatdata/<id>/pfx:
  OpenAL32, Mono, PhysX and Games for Windows Live that are already there
  from elsewhere show "Already in this game". Files droiddeck-wincomponents
  placed itself do not count.
- The game page passes the prefix and, for Steam titles, the appid.

* Windows components: friendly names

The list showed catalog keys (oalinst_dll, vcredist2010_dll, dotnet452,
d3dcompiler_47). Bannerlator's catalog has no display name and some of
its descriptions do not fit the entry, so WinComponentNames gives one:
families by pattern (Visual C++ <year>, .NET Framework x.y[.z], XAudio,
D3DCompiler, MSXML, Wine Mono, DirectMusic parts), the rest from a
table, the key itself for anything new. The key stays in the row's
detail for logs and bug reports; both lists sort by the name, and the
game page's card names the picks the same way.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 10:46:27 -04:00
The412BannerandThe412Banner bc01b77752 Force SSBS for Proton games (libssbs.so), switch under FEX preset (#328)
* Force SSBS for a game's Wine: libssbs.so, opt in per game

Wine's arm64 signal code resumes threads from a Windows CONTEXT whose
Cpsr never carries PSTATE.SSBS, so every signal return leaves the
thread running with speculative store bypass disabled - slower on cores
such as Oryon. Bannerlator fixes it in its own Wine builds
(force_ssbs.diff, Sonicadvance1) and with an LD_PRELOAD adapter for
layers it cannot rebuild. DroidDeck's Protons (Valve, GE, CachyOS ARM64)
come prebuilt from Steam but run the same aarch64 Wine signal path, so
the adapter applies here: tools/linuxfs/ssbs/ssbs_adapter.c, the same
source built for glibc (+ sysv_signal), staged to
/usr/local/lib/libssbs.so.

droiddeck-game-env preloads it when the game's (or the shared)
environment sets DROIDDECK_FORCE_SSBS=1, and only into an ARM64 Proton.
Off unless asked for: the gain is unproven on Snapdragon 8 Gen 3.

For testing: SSBS_ADAPTER_OBSERVE=1 counts without changing anything
(the baseline) and SSBS_ADAPTER_DEBUG=1 logs the fixes; both also count
signals that ARRIVE while a thread runs without SSBS, which shows
whether anything besides signal returns clears it. Checked on an ARM64
Linux kernel: a handler that clears SSBS left 4 of 5 later signals
arriving with it clear; with the adapter, 0 of 5.

* Force SSBS: a switch under the FEX preset in Steam settings

Steam settings > Games gets "Force SSBS" right under the FEX preset, off
unless turned on. Like the preset it goes into the game environment's
defaults (DROIDDECK_FORCE_SSBS=1), published at once and read by
droiddeck-game-env on the next game launch, so a game's own environment
can still set it to 0.

Measured on the device with the adapter's counters (AYANEO Pocket FIT,
DiRT 3 on GE-Proton11-7-aarch64): observing only, 8192 of 8277 signals
found the thread running without SSBS; with the adapter, none did - it
keeps the bit set everywhere Wine clears it, as a rebuilt ntdll would.
The speed-up itself is reported on Oryon cores and was not measurable on
the FIT's 8 Gen 3, hence off by default.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 09:27:15 -04:00
UnbelievableFlavour a1b01a04a8 Added support for Adreno 6xx devices (#320) 2026-10-06 09:08:04 -04:00
Kurt Himebauch b44235c495 Add Native Steam background sleep and automatic wake (#291) 2026-10-06 00:15:39 -04:00
Kurt Himebauch b80d5db176 ci: require isolated version bumps (#303) 2026-10-06 03:26:14 +00:00
Kurt Himebauch d3c0d258b7 Return frontend-launched sessions to Android Home (#300) 2026-10-05 21:39:40 -04:00
xXJsonDeruloXx 9dcdbf245a Merge remote-tracking branch 'origin/main' into frontend-quit-return 2026-10-05 21:34:49 -04:00
xXJsonDeruloXx 369e218afb Return frontend-launched sessions to Android Home 2026-10-05 21:34:36 -04:00
Magarto 119d24bf6b Keep the 16:9 stretch (#63) working in Steam Deck mode (#133) 2026-10-05 21:28:31 -04:00
The412BannerandThe412Banner 2a905510d5 Device details on the GPU card; Adreno model and SoC name when the kernel leaves them out (#295)
* Device details on the GPU card, and the Adreno model when the kernel leaves it out

The Components page called an AYANEO Pocket FIT (Adreno 750) "Adreno"
and warned it was "Below tested hardware (Adreno 725 and newer)": its
kernel names the GPU "Adreno33v2", GpuInfo wanted three digits, and the
SoC model is blank. When KGSL's name has no model, take it from the
phone's Vulkan driver (once this process has asked it) or from the
platform code name (pineapple -> 750, kalama -> 740, sun -> 830, ...).

The GPU card now opens with a tap or A onto the device's details,
gathered once off the main thread (DeviceInfo): SoC (maker, model,
platform, soc0 id), GPU (model and where it came from, the kernel's
name, clock, load, temperature, support), CPU clusters, memory, system,
the phone's own Vulkan driver - device, API, driver version and build,
conformance, limits, the extensions DXVK and VKD3D lean on, Android's
certified version and level - and the drivers DroidDeck uses. A button
copies it all for a bug report; each section takes pad focus so the
d-pad walks the list.

Vulkan is asked through a new small native library (deviceinfo,
vkinfo.c) that opens the system loader with dlopen and keeps nothing
open.

* Device details: the chip's model and name, CPU clusters, GPU clock range

Ideas from ClusterTune (GPL-2.0, its approach, not its code), which
tunes clocks on AYN, Retroid and other handhelds:
- The SoC model is read where vendors put it - ro.soc.model,
  ro.vendor.qti.soc_model, Android's SOC_MODEL, ro.fota.platform (unless
  it is a branch name like "MSM_13.0") - since handhelds often leave
  Android's blank (Pocket FIT: SG8350P).
- SocNames names it: model codes we are sure of (QCS8550 -> Snapdragon
  8 Gen 2, SM8750 -> Snapdragon 8 Elite, ...), else the platform as a
  family (pineapple -> "Snapdragon 8 Gen 3 family"). The GPU card's
  subtitle shows it: "Snapdragon 8 Gen 3 family (SG8350P)".
- CPU clusters come from the cpufreq policies (the cores each covers,
  min-max speed) rather than grouping cores by top speed.
- GPU clock range from KGSL's step list, or a devfreq GPU node on
  non-Adreno chips (Mali), not only the top clock.

* Device details: drop Qualcomm's own "Driver Build:" label from the driver build line

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-05 20:32:51 -04:00
Kurt Himebauch da29919b5a Fix legacy Steam sleep and missed wake handshakes (#289)
Fix legacy Steam sleep and recover missed wake handshakes
2026-10-05 16:49:55 -04:00
輕鈴andKurt Himebauch 05608ac4d4 Refine Simplified Chinese localization (#232)
Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-05 15:58:59 -04:00
starwishHiandKurt Himebauch 135b84a501 Add French and Russian translate, add fr,ru,jp,ko in locales_config.xml And overlap #234 and #259 (#286)
* Create values-zh-rCN

values language of Simplified Chinese

* Delete app/src/main/res/values-zh-rCN

* Add Korean,Japanese and Tranitional Chinese (zh-TW,zh-HK,zh-MO) translations

* Delete app/src/main/res/values/values-ko/strings.xml

* Create strings.xml

Add korean translate

* Add French translate

* Add Russian translate

* Fix French translate error

* Add the fr,ko,ja,ru in locales_config.xml
Fix French bulid error

* Overlap #234 and #259

---------

Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-05 15:52:39 -04:00
MaxsTechReview e7cbba37d1 sign-apk: rename the agent provider's authority for the other packages (#285) 2026-10-05 14:41:38 -04:00
Kurt Himebauch d9bb5e15b0 Use plain-ID .steam files for frontend exports (#277) 2026-10-05 13:48:18 -04:00
The412BannerandThe412Banner 46cdc32cf1 Firefox: skip shared-memory seals (tabs crash on Android 16) (#280)
On Android 16 phones (Galaxy S25 SM-S931B, SM-S948B, RedMagic NP05J)
every Firefox and LibreWolf tab dies at once: Firefox seals the size of
the shared memory it hands a tab, adding the seal fails there, Firefox
only warns, and the tab refuses the unsealed memory ("IPDL protocol
error: Shared memory PlatformHandle is not safe to map") and exits on
signal 11. The window opens; no page ever loads.

MOZ_SHM_NO_SEALS=1 is Firefox's own switch to skip the seals
(ipc/glue/SharedMemoryPlatform_posix.cpp). Set it beside the sandbox
switches in all three places a browser starts from: the desktop session,
AppImages, and Flatpak sandboxes.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 13:37:08 -04:00
212 changed files with 23978 additions and 2442 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
+60 -3
View File
@@ -57,6 +57,22 @@ jobs:
exit 1
fi
- name: Version bumps are isolated
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
diff=$(git diff -U0 "$base..HEAD" -- app/build.gradle)
grep -Eq '^[+-][[:space:]]*version(Code|Name)[[:space:]]' <<<"$diff" || exit 0
other_files=$(git diff --name-only "$base..HEAD" | grep -v '^app/build.gradle$' || true)
other_gradle=$(grep -E '^[+-]' <<<"$diff" | grep -Ev '^(---|\+\+\+)' | grep -Ev '^[+-][[:space:]]*version(Code|Name)[[:space:]]' || true)
if [[ -n "$other_files" || -n "$other_gradle" ]]; then
echo "::error::versionCode/versionName changes must be isolated in their own PR"
echo "A version PR may only change the versionCode/versionName lines in app/build.gradle." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
run: |
@@ -157,7 +173,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -179,6 +195,10 @@ jobs:
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
mkdir -p "$D/usr/local/bin"
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
@@ -190,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"
@@ -230,6 +250,26 @@ jobs:
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
# The Windows components installer's tools (tools/msitools/packages.txt).
T="$D/usr/local/lib/droiddeck-msitools"
mkdir -p "$T" msi-pkgs
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
rm msi-pkgs/pkg.tar.xz
done
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
done
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
done
rm -rf msi-pkgs
ls -l "$T"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
@@ -305,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
@@ -343,11 +388,23 @@ jobs:
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
fi
SESSION=$(syms "$D/libblsession.so")
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle drmIoctl; do
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
done
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
if grep -qx drmIoctl <<<"$SESSION"; then
echo "::error::libblsession.so exports drmIoctl"; exit 1
fi
SSBS=$(syms "$D/libssbs.so")
for sym in sigaction signal bsd_signal sysv_signal; do
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
done
test -f "$D/usr/local/bin/droiddeck-session"
test -f "$D/usr/local/bin/droiddeck-proton-extra"
test -f "$D/usr/local/bin/droiddeck-msi-install"
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
done
- name: Cache the CMake build tree
uses: actions/cache@v4
+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).
+1 -1
View File
@@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
+4
View File
@@ -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 {
@@ -190,6 +192,8 @@ dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
// Bannerlator's Windows components come as .tar.xz (WinComponents).
implementation 'org.tukaani:xz:1.10'
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
// on-screen pad are Views underneath it, which is what they are in every Compose app.
implementation platform('androidx.compose:compose-bom:2024.02.00')
+4 -1
View File
@@ -4,7 +4,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -71,6 +70,10 @@
<action android:name="android.intent.action.CREATE_SHORTCUT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
+5
View File
@@ -44,6 +44,11 @@ add_subdirectory(waylandcomp)
add_library(sdnative SHARED sdnative/sdnative.c)
target_link_libraries(sdnative log)
# The device info window's Vulkan section: the phone's own driver, asked once through the system
# loader (opened with dlopen, so a phone without Vulkan still loads the app).
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
target_link_libraries(deviceinfo dl)
# Rebuild Termux terminal-emulator's PTY JNI helper with the app's NDK instead of carrying its
# prebuilt libtermux.so with a redundant libstdc++.so dependency.
add_library(termux SHARED termux/termux.c)
+112
View File
@@ -0,0 +1,112 @@
// What the phone's own Vulkan driver says about its GPU, for the app's device info window: one
// instance on the system loader, the first physical device's properties, a few limits and the
// extensions DXVK and VKD3D-Proton lean on. Returned as "key=value" lines; nothing is kept open.
#include <dlfcn.h>
#include <jni.h>
#include <stdio.h>
#include <string.h>
#define VK_NO_PROTOTYPES
#include <vulkan/vulkan.h>
#define LINE(...) do { if (used < sizeof(out)) used += snprintf(out + used, sizeof(out) - used, __VA_ARGS__); } while (0)
static const char *const EXTENSIONS[] = {
"VK_EXT_robustness2", "VK_EXT_transform_feedback", "VK_EXT_custom_border_color",
"VK_KHR_dynamic_rendering", "VK_EXT_extended_dynamic_state3", "VK_EXT_descriptor_indexing",
"VK_KHR_timeline_semaphore", "VK_EXT_mesh_shader", "VK_KHR_ray_query",
};
static const char *type_name(VkPhysicalDeviceType type) {
switch (type) {
case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated GPU";
case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete GPU";
case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual GPU";
case VK_PHYSICAL_DEVICE_TYPE_CPU: return "CPU";
default: return "other";
}
}
JNIEXPORT jstring JNICALL
Java_com_droiddeck_launcher_gpu_VulkanInfo_nativeQuery(JNIEnv *env, jclass clazz) {
static char out[4096];
size_t used = 0;
out[0] = 0;
void *lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
if (!lib) { LINE("error=no Vulkan loader\n"); return (*env)->NewStringUTF(env, out); }
PFN_vkGetInstanceProcAddr gipa = (PFN_vkGetInstanceProcAddr) dlsym(lib, "vkGetInstanceProcAddr");
PFN_vkCreateInstance create = gipa ? (PFN_vkCreateInstance) gipa(NULL, "vkCreateInstance") : NULL;
PFN_vkEnumerateInstanceVersion instanceVersion = gipa ? (PFN_vkEnumerateInstanceVersion) gipa(NULL, "vkEnumerateInstanceVersion") : NULL;
if (!create) { LINE("error=no vkCreateInstance\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
uint32_t loader = VK_API_VERSION_1_0;
if (instanceVersion) instanceVersion(&loader);
LINE("loader=%u.%u.%u\n", VK_API_VERSION_MAJOR(loader), VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader));
VkApplicationInfo app = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "DroidDeck device info",
.apiVersion = loader >= VK_API_VERSION_1_1 ? VK_API_VERSION_1_1 : VK_API_VERSION_1_0 };
VkInstanceCreateInfo info = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app };
VkInstance instance = VK_NULL_HANDLE;
if (create(&info, NULL, &instance) != VK_SUCCESS) { LINE("error=vkCreateInstance failed\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
#define GET(name) PFN_##name name = (PFN_##name) gipa(instance, #name)
GET(vkDestroyInstance); GET(vkEnumeratePhysicalDevices); GET(vkGetPhysicalDeviceProperties);
GET(vkGetPhysicalDeviceProperties2); GET(vkGetPhysicalDeviceMemoryProperties);
GET(vkEnumerateDeviceExtensionProperties); GET(vkGetPhysicalDeviceFeatures);
uint32_t count = 1;
VkPhysicalDevice device = VK_NULL_HANDLE;
VkResult listed = vkEnumeratePhysicalDevices ? vkEnumeratePhysicalDevices(instance, &count, &device) : VK_ERROR_INITIALIZATION_FAILED;
if ((listed != VK_SUCCESS && listed != VK_INCOMPLETE) || count == 0 || !device || !vkGetPhysicalDeviceProperties) {
LINE("error=no Vulkan device\n");
} else {
VkPhysicalDeviceProperties p;
vkGetPhysicalDeviceProperties(device, &p);
LINE("device=%s\n", p.deviceName);
LINE("type=%s\n", type_name(p.deviceType));
LINE("api=%u.%u.%u\n", VK_API_VERSION_MAJOR(p.apiVersion), VK_API_VERSION_MINOR(p.apiVersion), VK_API_VERSION_PATCH(p.apiVersion));
// Qualcomm packs its driver version as 10.10.12 bits, the same layout as VK_MAKE_VERSION.
LINE("driver_version=%u.%u.%u\n", p.driverVersion >> 22, (p.driverVersion >> 12) & 0x3ff, p.driverVersion & 0xfff);
LINE("vendor_id=0x%04x\ndevice_id=0x%08x\n", p.vendorID, p.deviceID);
LINE("max_image_2d=%u\n", p.limits.maxImageDimension2D);
// Driver name and build string (Vulkan 1.2 or VK_KHR_driver_properties).
uint32_t extCount = 0;
if (vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, NULL);
static VkExtensionProperties exts[512];
if (extCount > 512) extCount = 512;
if (extCount && vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, exts);
int driverProps = p.apiVersion >= VK_API_VERSION_1_2;
for (uint32_t i = 0; i < extCount; i++)
if (!strcmp(exts[i].extensionName, "VK_KHR_driver_properties")) driverProps = 1;
if (driverProps && vkGetPhysicalDeviceProperties2) {
VkPhysicalDeviceDriverProperties driver = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
VkPhysicalDeviceProperties2 p2 = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &driver };
vkGetPhysicalDeviceProperties2(device, &p2);
LINE("driver_name=%s\n", driver.driverName);
LINE("driver_info=%s\n", driver.driverInfo);
LINE("conformance=%u.%u.%u.%u\n", driver.conformanceVersion.major, driver.conformanceVersion.minor,
driver.conformanceVersion.subminor, driver.conformanceVersion.patch);
}
LINE("extension_count=%u\n", extCount);
for (size_t e = 0; e < sizeof(EXTENSIONS) / sizeof(*EXTENSIONS); e++) {
int has = 0;
for (uint32_t i = 0; i < extCount && !has; i++) has = !strcmp(exts[i].extensionName, EXTENSIONS[e]);
LINE("ext.%s=%s\n", EXTENSIONS[e], has ? "yes" : "no");
}
if (vkGetPhysicalDeviceFeatures) {
VkPhysicalDeviceFeatures f;
vkGetPhysicalDeviceFeatures(device, &f);
LINE("geometry_shader=%s\ntessellation_shader=%s\ntexture_bc=%s\n",
f.geometryShader ? "yes" : "no", f.tessellationShader ? "yes" : "no", f.textureCompressionBC ? "yes" : "no");
}
if (vkGetPhysicalDeviceMemoryProperties) {
VkPhysicalDeviceMemoryProperties m;
vkGetPhysicalDeviceMemoryProperties(device, &m);
VkDeviceSize largest = 0;
for (uint32_t i = 0; i < m.memoryHeapCount; i++)
if ((m.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) && m.memoryHeaps[i].size > largest) largest = m.memoryHeaps[i].size;
LINE("device_heap_mb=%llu\n", (unsigned long long) (largest >> 20));
}
}
if (vkDestroyInstance) vkDestroyInstance(instance, NULL);
dlclose(lib);
return (*env)->NewStringUTF(env, out);
}
@@ -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()
@@ -65,6 +65,9 @@ import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
import com.droiddeck.launcher.frontend.CoverArt
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.frontend.GameFiles
import com.droiddeck.launcher.frontend.GameFileSync
import com.droiddeck.launcher.frontend.GameLaunchIntent
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.RomsDialog
import com.droiddeck.launcher.files.InAppFilePicker
@@ -81,6 +84,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)
@@ -104,7 +112,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)
@@ -151,8 +159,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 forceFullscreen 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)
@@ -168,6 +175,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()
@@ -251,29 +268,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 ->
@@ -308,6 +328,7 @@ class MainActivity : ComponentActivity() {
private var settingsMode by mutableStateOf<String?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var forceSsbs by mutableStateOf(false)
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
@@ -319,12 +340,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)) }
}
@@ -355,15 +377,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
}
}
@@ -399,10 +421,15 @@ class MainActivity : ComponentActivity() {
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
saveAction(getString(R.string.game_frontend_files)) {
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(folder, game)
else com.droiddeck.launcher.frontend.GameFileSync.enable(this, folder)
ui.post { gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this) }
getString(R.string.game_file_exported, folder.path)
val saved = if (game != null) GameFiles.export(this, folder, game)
else {
GameFileSync.enable(this, folder)
folder
}
ui.post {
gameSyncFolder = GameFileSync.folder(this)
}
getString(R.string.game_file_exported, saved.path)
}
}
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
@@ -416,10 +443,10 @@ class MainActivity : ComponentActivity() {
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = null
if (request.action == Intent.ACTION_VIEW) {
if (GameLaunchIntent.accepts(request.action)) {
val copy = Intent(request)
Thread({
val id = com.droiddeck.launcher.frontend.GameFiles.readIntent(this, copy)
val id = GameLaunchIntent.read(this, copy)
ui.post {
if (intent === request && !isDestroyed) {
pendingGameLink = id
@@ -432,9 +459,9 @@ class MainActivity : ComponentActivity() {
}
}
private fun launchGame(game: Library.SteamGame): Boolean {
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) {
@@ -447,10 +474,12 @@ class MainActivity : ComponentActivity() {
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
return startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
}
@@ -462,10 +491,14 @@ class MainActivity : ComponentActivity() {
if (game == null) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game)) {
} else if (launchGame(game, returnHome = true)) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
}
}
@@ -477,10 +510,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
readGameIntent(intent)
gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this)
gameSyncFolder = GameFileSync.folder(this)
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)
@@ -518,6 +552,7 @@ class MainActivity : ComponentActivity() {
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,
@@ -556,7 +591,7 @@ class MainActivity : ComponentActivity() {
onSyncGameFiles = { pickGameExport(null) },
onStopGameFileSync = {
Thread({
com.droiddeck.launcher.frontend.GameFileSync.disable(this)
GameFileSync.disable(this)
ui.post { gameSyncFolder = null }
}, "game-file-stop-sync").start()
},
@@ -624,20 +659,17 @@ class MainActivity : ComponentActivity() {
logsEnabled = SessionPrefs.logsEnabled(this)
},
onShareLogs = {
Thread({
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
ui.post {
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
}, "share-logs").start()
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
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))
}
},
onClearLogs = {
Thread({
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()
@@ -648,6 +680,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)
@@ -680,7 +719,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()
},
@@ -693,7 +732,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)
@@ -703,7 +742,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)
@@ -712,9 +751,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) },
@@ -748,24 +787,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 },
)
@@ -783,9 +822,6 @@ class MainActivity : ComponentActivity() {
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
// The session's logs land in Downloads so a failed run can be handed over as a folder
// rather than dug out of app-private storage. targetSdk 28 means the old permission still
// grants exactly that.
val wanted = ArrayList<String>()
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
@@ -875,7 +911,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()
@@ -898,6 +934,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()
}
@@ -909,6 +950,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
@@ -934,9 +977,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()
}
}
@@ -967,7 +1014,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()
@@ -1013,10 +1060,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() }
}
}
@@ -1034,14 +1081,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(
@@ -1054,9 +1101,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() },
),
)
@@ -1092,14 +1139,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()
}
@@ -1109,7 +1156,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 {
@@ -1131,13 +1178,13 @@ class MainActivity : ComponentActivity() {
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
clientDirectAudio = clientDirectAudio,
forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
@@ -1145,6 +1192,7 @@ class MainActivity : ComponentActivity() {
storageOptions = storageOptions,
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
@@ -1163,6 +1211,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(
@@ -1189,6 +1238,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 ->
@@ -1197,7 +1250,6 @@ class MainActivity : ComponentActivity() {
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
onForceFullscreen = { on -> SessionPrefs.setForceFullscreen(this, on); forceFullscreen = on },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
@@ -1211,13 +1263,14 @@ 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)
storageDiagnostics = on
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
onSyncBackend = { id ->
SessionPrefs.setSyncBackend(this, id)
fastSync = SessionPrefs.fastSync(this)
@@ -1237,13 +1290,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() },
@@ -1254,7 +1307,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 },
),
@@ -1310,7 +1363,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() {
@@ -1339,6 +1392,7 @@ class MainActivity : ComponentActivity() {
showMapping = false
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
@@ -1356,11 +1410,11 @@ class MainActivity : ComponentActivity() {
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
forceFullscreen = SessionPrefs.forceFullscreen(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
@@ -1373,7 +1427,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 {
@@ -1392,9 +1446,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
}
}
@@ -1436,9 +1490,9 @@ class MainActivity : ComponentActivity() {
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(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.
@@ -1505,7 +1559,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
}
@@ -1521,10 +1578,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()
}
}
}
@@ -1557,7 +1614,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 ->
@@ -1582,6 +1639,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)
@@ -229,7 +234,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var fillScreen by mutableStateOf(true)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var effects by mutableStateOf(ScreenEffects.OFF)
@@ -242,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)
@@ -301,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
}
@@ -462,6 +467,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() },
@@ -497,23 +503,22 @@ 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,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
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,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onUpscaler = { m ->
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
WaylandCompositor.nativeSetUpscaler(m)
@@ -559,6 +564,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,
@@ -568,9 +578,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),
@@ -688,7 +702,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()
}
@@ -703,10 +717,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()
@@ -720,20 +734,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() {
@@ -741,31 +757,25 @@ 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
}
Thread({
val zip = runCatching { SessionLogShare.zipFolder(this, folder) }
.onFailure { Log.w(TAG, "could not package current session logs", it) }
.getOrNull()
uiHandler.post {
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", 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()
}
}
SessionLogShare.prepare(this, { folder }) { zip ->
if (zip == null) {
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, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
}
}
}, "share-session-logs").start()
}
}
private fun readPrefs() {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
hudOn = SessionPrefs.hudEnabled(this)
fillScreen = SessionPrefs.forceFullscreen(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
effects = SessionPrefs.screenEffects(this)
@@ -786,6 +796,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)
}
@@ -814,8 +825,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
@@ -856,7 +867,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)
}
@@ -872,14 +883,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)
}
}
}
}
@@ -887,29 +916,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) {
@@ -1145,6 +1174,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// the request landed in this instance's onNewIntent and went with its finish().
SessionState.relaunch?.let { next ->
SessionState.relaunch = null
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
setIntent(next)
recreate()
return@runOnUiThread
@@ -1169,14 +1199,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()
}
@@ -1226,8 +1257,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
@@ -1906,6 +1937,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
recreate()
return
}
// Home/notification resumes must retain the external frontend's return destination.
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
setIntent(intent)
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
handleHomeGuideIntent(intent)
@@ -2046,6 +2079,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* off, it just closes.
*/
override fun finish() {
if (isFinishing) return
if (!quitFlooded && !closeAtOnce && com.droiddeck.launcher.ui.Motion.scale != 0f &&
lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) {
if (!leaving) {
@@ -2056,6 +2090,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
super.finish()
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
com.droiddeck.launcher.ui.QuitFlood.take()
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
@@ -2071,6 +2111,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
/** How long the leaving flood may take before the session closes without it. */
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
@@ -2088,5 +2129,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))
}
}
}
@@ -15,6 +15,7 @@ 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.SessionLogShare
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionService
@@ -50,6 +51,7 @@ class AgentBridgeProvider : ContentProvider() {
val response = try {
when (method) {
"state" -> state(context).put("ok", true)
"logs" -> exportLogs(context)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
@@ -63,7 +65,7 @@ class AgentBridgeProvider : ContentProvider() {
private fun state(context: Context): JSONObject {
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
val session = JSONObject()
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
.put("phase", SessionState.phase.name)
@@ -101,10 +103,12 @@ class AgentBridgeProvider : ContentProvider() {
.put("session", session)
}
private fun latestSessionDirectory(): File? {
val parent = LinuxRuntime.debugLogDir()
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
private fun exportLogs(context: Context): JSONObject {
val dir = SessionState.logDirectory ?: SessionLogShare.latest(context)
?: return error("NO_SESSION_LOGS", "No session logs are available")
val zip = SessionLogShare.zipFolder(context, dir)
?: return error("NO_SESSION_LOGS", "No diagnostic logs are available")
return JSONObject().put("ok", true).put("sessionId", dir.name).put("path", zip.absolutePath)
}
private fun stop(context: Context): JSONObject {
@@ -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) }
}
}
@@ -109,8 +109,11 @@ object DeviceReport {
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize))
k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize))
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
val size = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panelSize, choice)
k(label, "$choice (${size.first}x${size.second})")
}
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
@@ -163,7 +166,6 @@ object DeviceReport {
k("proot fast path", SessionPrefs.prootFastPath(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
k("Microphone", SessionPrefs.micEnabled(context))
@@ -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) }
}
}
@@ -25,7 +25,7 @@ object GameEnvironment {
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
@@ -37,8 +37,12 @@ object GameEnvironment {
"DOTNET_EnableWriteXorExecute" to "0",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
// Steam settings' Force SSBS: droiddeck-game-env preloads libssbs.so into the game's Wine.
if (forceSsbs) put(FORCE_SSBS, "1")
}
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
}
@@ -36,11 +36,16 @@ object LogRedactor {
* WebAPI key is caught by [WEBAPI_KEY]. The value class excludes `<` and `>` so running this
* twice over an already-scrubbed line changes nothing.
*/
private val SECRET_KV = Regex(
"(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
private const val SECRET_KEYS =
"access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
"auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" +
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" +
"(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})"
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret"
private val SECRET_KV = Regex(
"(?i)\\b($SECRET_KEYS)" +
"([\"']?\\s*[=:]\\s*[\"']?)([^\\s\"'<>&;,]{1,})"
)
private val QUOTED_SECRET = Regex(
"(?i)\\b($SECRET_KEYS)([\"']?\\s*[=:]\\s*)(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')"
)
/** A Steam Guard code, only where the text around it says that is what it is. */
private val GUARD_CODE = Regex(
@@ -53,6 +58,7 @@ object LogRedactor {
"(?i)\\b(jwt|token|ticket|sessionid|steamloginsecure|machineauth)\\b[\\s=:]*([A-Za-z0-9+/=_\\-]{12,})"
)
private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}")
private val AUTHORIZATION = Regex("(?i)(\\b(?:proxy-)?authorization[\"']?\\s*[:=]\\s*[\"']?)[^\\r\\n\"']+")
private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b")
private val STEAMID3 = Regex("\\[U:1:(\\d+)]")
/** "external address 2607:..." / "external IP: 203.0.113.9" - the client stating ours. */
@@ -179,22 +185,6 @@ object LogRedactor {
return out
}
/** A file worth scrubbing: text (no NUL in its first 4 KB) and not huge. */
fun isText(file: java.io.File): Boolean = try {
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
val buf = ByteArray(4096)
val n = input.read(buf)
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
}
} catch (e: Exception) {
false
}
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
}
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
if (line.isEmpty()) return line
@@ -203,6 +193,11 @@ object LogRedactor {
out = GUID.replace(out, "<redacted:guid>")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
out = JWT_BASE64.replace(out, "<redacted:jwt>")
out = AUTHORIZATION.replace(out) { "${it.groupValues[1]}<redacted:authorization>" }
out = QUOTED_SECRET.replace(out) {
val quote = it.groupValues[3].first()
"${it.groupValues[1]}${it.groupValues[2]}$quote<redacted:token>$quote"
}
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
out = WEBAPI_KEY.replace(out, "<redacted:key>")
@@ -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)"
@@ -49,7 +49,7 @@ object SessionLogCapture {
) { line ->
synchronized(this) {
try {
writer?.apply { write(line); newLine(); flush() }
if (writer === out) out.apply { write(line); newLine(); flush() }
} catch (e: Exception) {
// A full or unmounted card must not take the session with it.
}
@@ -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,39 @@ 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/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 +1044,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 +1053,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 +1073,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 +1130,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 +1141,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 +1211,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)
@@ -0,0 +1,253 @@
package com.droiddeck.launcher.frontend
import java.io.File
import java.util.Locale
/**
* Best-effort detection of the Windows redistributables a game bundles, by scanning its install
* folder for the redist installers Steam/Epic/GOG ship (`_CommonRedist/`, `_Redist/`,
* `Redistributables/`, loose `vcredist_x64.exe`, …) and mapping them to entries in our component
* catalog (WinComponents, Bannerlator's components.json `name` keys). The result drives the
* Recommended part of a game's Windows components; the user still switches each one on.
* Bannerlator's detector, unchanged but for this note and its package.
*
* Wine-aware: keeps the redists that actually matter under Wine/DXVK (VC++ runtimes, .NET, PhysX,
* OpenAL, XACT, XNA, GFWL) and SKIPS the ones DXVK/VKD3D already provide (DX12 Agility SDK) or that
* aren't our components (anti-cheat, Rockstar/Social-Club/launcher setups). Never throws; returns an
* empty list on anything unexpected. Pure filesystem — no network, no Android deps (unit-testable).
*/
object DependencyDetector {
/** How a recommendation was found — drives the UI's "recommended vs optional" split. */
enum class Kind {
/** The game bundles the redist installer (in _CommonRedist etc.) — meant to be installed. */
BUNDLED,
/** A runtime DLL the game ships loose — usually already works from the game folder; install
* only if the game misbehaves. */
SHIPPED,
/** Steam installs it with the game from Steamworks Shared ([SteamRedists]). */
STEAM,
}
/** A recommended component: the catalog [componentName] to install, a display [label], the on-disk
* redist that triggered it ([reason]), and [kind] (bundled installer vs shipped DLL). */
data class Recommendation(val componentName: String, val label: String, val reason: String, val kind: Kind = Kind.BUNDLED)
private const val MAX_DEPTH = 5
private const val MAX_ENTRIES = 8000 // cost guard against giant game trees
/** A loose VC++ installer with no year in its path — resolved to "latest" only if no specific
* vcredistYYYY was found anywhere (else it's just a duplicate of the versioned one). */
private const val VCREDIST_LOOSE = "__vcredist_loose"
/** Directory names that are redist containers worth descending into. */
private val REDIST_DIR_RE = Regex(
"""(?i)^(_?commonredist|_?redist(ributables)?|__?installer|vcredist|vc_redist|directx|direct_x|physx|openal|dotnet(fx)?|\.net|xna|xact|redist|support)$"""
)
/** Skip whole subtrees that are never our components. */
private val SKIP_DIR_RE = Regex("""(?i)^(d3d12.*|.*agility.*|dxvk|vkd3d|battleye|easyanticheat|eac|anticheat)$""")
private val YEAR_TO_VCREDIST = mapOf(
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010",
"2012" to "vcredist2012", "2013" to "vcredist2013", "2015" to "vcredist2015",
"2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
)
private val LABELS = mapOf(
"vcredist2005" to "Visual C++ 2005", "vcredist2008" to "Visual C++ 2008",
"vcredist2010" to "Visual C++ 2010", "vcredist2012" to "Visual C++ 2012",
"vcredist2013" to "Visual C++ 2013", "vcredist2015" to "Visual C++ 2015-2017",
"vcredist2019" to "Visual C++ 2019", "vcredist2022" to "Visual C++ 2015-2022",
"physx" to "NVIDIA PhysX", "oalinst" to "OpenAL", "xact" to "DirectX XACT audio",
"xact_x64" to "DirectX XACT audio (x64)", "xna40" to "XNA Framework 4.0",
"xna31" to "XNA Framework 3.1", "d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
"XLiveRedist" to "Games for Windows Live", "dotnet40" to ".NET Framework 4.0",
"dotnet45" to ".NET Framework 4.5", "dotnet452" to ".NET Framework 4.5.2",
"dotnet46" to ".NET Framework 4.6", "dotnet472" to ".NET Framework 4.7.2",
"dotnet48" to ".NET Framework 4.8", "dotnet35" to ".NET Framework 3.5",
"dotnetcoredesktop6" to ".NET Desktop 6", "dotnetcoredesktop7" to ".NET Desktop 7",
"dotnetcoredesktop8" to ".NET Desktop 8",
)
/** Preferred install order (runtimes others may depend on first). */
private val INSTALL_ORDER = listOf(
"vcredist2005", "vcredist2008", "vcredist2010", "vcredist2012", "vcredist2013",
"vcredist2015", "vcredist2019", "vcredist2022",
"dotnet35", "dotnet40", "dotnet45", "dotnet452", "dotnet46", "dotnet472", "dotnet48",
"dotnetcoredesktop6", "dotnetcoredesktop7", "dotnetcoredesktop8",
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
)
/**
* Detect from the .exe the user picked, resolving the game root first: walk up from the exe's
* folder (bounded) to the nearest ancestor that holds a redist container (`_CommonRedist`, …) at
* its top level, and scan from there — so a nested exe (`…/Game/bin/x.exe`) still finds a root-level
* `_CommonRedist`, without over-reaching into a shared `steamapps/common` (which has no redist
* container of its own). Falls back to scanning the exe's own folder.
*/
fun detectForExe(exeFile: File): List<Recommendation> {
val start = exeFile.absoluteFile.parentFile ?: return emptyList()
var d: File? = start
var depth = 0
while (d != null && depth < 4) {
val hasRedistContainer = try {
d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
} catch (_: Throwable) { false }
if (hasRedistContainer) return detect(d)
d = d.parentFile
depth++
}
return detect(start)
}
/**
* Scan [gameDir] and return the recommended components, de-duplicated (first reason wins), in a
* stable install-friendly order. Empty when nothing is detected or [gameDir] isn't a directory.
*/
fun detect(gameDir: File): List<Recommendation> {
if (!gameDir.isDirectory) return emptyList()
val hits = LinkedHashMap<String, Recommendation>() // componentName -> first recommendation
var visited = 0
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
val name = f.name.lowercase(Locale.US)
val path = f.path.replace('\\', '/').lowercase(Locale.US)
// Bundled = in a redist container or an installer (exe/msi/cab); shipped = a loose runtime DLL.
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
Kind.BUNDLED else Kind.SHIPPED
for (comp in classify(path, name, isDir)) {
val existing = hits[comp]
if (existing == null) {
hits[comp] = Recommendation(comp, LABELS[comp] ?: comp, relativeReason(gameDir, f), kind)
} else if (existing.kind == Kind.SHIPPED && kind == Kind.BUNDLED) {
hits[comp] = existing.copy(kind = Kind.BUNDLED) // a bundled installer outranks a shipped DLL
}
}
}
fun walk(dir: File, depth: Int, insideRedist: Boolean) {
if (depth > MAX_DEPTH || visited > MAX_ENTRIES) return
val entries = dir.listFiles() ?: return
for (f in entries) {
if (visited++ > MAX_ENTRIES) return
val lower = f.name.lowercase(Locale.US)
if (f.isDirectory) {
if (SKIP_DIR_RE.matches(lower)) continue
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
// tree a few levels deep so runtime libraries the game ships (in bin/, etc.) are seen.
if (isRedistDir || depth < 3) walk(f, depth + 1, isRedistDir)
} else {
// Classify installers + runtime libraries (.dll/.exe/.msi) anywhere in the scanned
// tree; other files are ignored. Recommendations are advisory, so cast a wide net.
val ext = lower.substringAfterLast('.', "")
if (insideRedist || ext == "dll" || ext == "exe" || ext == "msi") consider(f, isDir = false, insideRedist = insideRedist)
}
}
}
return try {
walk(gameDir, 0, false)
resolveLooseVcredist(hits)
hits.values.sortedBy { INSTALL_ORDER.indexOf(it.componentName).let { i -> if (i < 0) 999 else i } }
} catch (_: Throwable) {
emptyList()
}
}
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
val loose = hits.remove(VCREDIST_LOOSE) ?: return
val hasSpecific = hits.keys.any { it in YEAR_TO_VCREDIST.values }
if (!hasSpecific) hits["vcredist2022"] = loose.copy(componentName = "vcredist2022", label = LABELS["vcredist2022"]!!)
}
private fun relativeReason(gameDir: File, f: File): String {
val g = gameDir.path.replace('\\', '/')
val p = f.path.replace('\\', '/')
return if (p.startsWith("$g/")) p.substring(g.length + 1) else f.name
}
/**
* Map a redist file/dir (its full lowercased [path], leaf [name], and whether it [isDir]) to
* catalog component name(s). Empty for things we deliberately skip.
*/
private fun classify(path: String, name: String, isDir: Boolean): List<String> {
// Hard skips first — DX12 is provided by DXVK/VKD3D; these aren't our components.
if (path.contains("d3d12") || path.contains("agility") ||
path.contains("battleye") || path.contains("anticheat") || path.contains("easyanticheat") ||
name.contains("socialclub") || name.contains("social-club") ||
name.contains("rockstar") || name == "setup.exe"
) return emptyList()
// Visual C++ runtimes — most important. Year comes from the path context.
if (path.contains("vcredist") || path.contains("vc_redist") || name.startsWith("vcredist") || name.startsWith("vc_redist")) {
val year = YEAR_TO_VCREDIST.keys.firstOrNull { path.contains(it) }
return when {
year != null -> listOf(YEAR_TO_VCREDIST.getValue(year))
isDir -> emptyList() // bare "vcredist" container — wait for the year subfolder / installer
else -> listOf(VCREDIST_LOOSE) // loose installer, no year → resolved post-walk
}
}
// VC++ runtime DLL the game ships (msvcp140.dll → 2015-2022, msvcp120.dll → 2013, …).
VCRUNTIME_DLL.entries.firstOrNull { name.startsWith(it.key) }?.let { return listOf(it.value) }
if (path.contains("openal") || name.startsWith("oalinst") || name == "openal32.dll" || name == "wrap_oal.dll") return listOf("oalinst")
if (path.contains("physx") || name.startsWith("physx")) return listOf("physx")
if (path.contains("xact") || name.startsWith("xactengine") || name.startsWith("x3daudio")) return listOf("xact", "xact_x64")
// Games for Windows Live — the redist folder, OR a GFWL library the game ships (xlive.dll,
// Codemasters' dbxLive32/64, gfwl*): GFWL titles need XLiveRedist to run even when they don't
// bundle the redist itself.
if (path.contains("xliveredist") || name.contains("xlive") || name.startsWith("gfwl") || name.startsWith("dbxlive")) {
return listOf("XLiveRedist")
}
if (path.contains("xna")) return listOf(if (path.contains("3.1") || path.contains("3_1") || path.contains("v31")) "xna31" else "xna40")
// .NET — Framework and Core/Desktop runtimes.
if (path.contains("dotnet") || path.contains(".net") || name.startsWith("ndp") || name.startsWith("dotnetfx") || name.contains("windowsdesktop-runtime")) {
val comp = dotnetComponent("$path $name")
return when {
comp != null -> listOf(comp)
isDir -> emptyList() // bare ".NET" container — wait for the versioned installer
else -> listOf("dotnet48") // unlabeled .NET Framework installer file → sensible default
}
}
// DirectX 11 helper DLLs the game ships (d3dx11_43.dll).
if (D3DX11_RE.matches(name)) return listOf("d3dx11")
// DirectX 9 helper DLLs / d3dcompiler / June-2010 DX redist (DXVK provides core d3d9 but NOT
// these helper DLLs).
if (name.startsWith("dxsetup") || name.startsWith("dxwebsetup") ||
D3DX9_RE.matches(name) || D3DCOMPILER_RE.matches(name) ||
(path.contains("directx") && name.endsWith(".exe"))
) return listOf("d3dx9")
return emptyList()
}
/** Runtime DLL prefix → the VC++ redistributable that provides it (checked with startsWith). */
private val VCRUNTIME_DLL = linkedMapOf(
"vcruntime140" to "vcredist2022", "msvcp140" to "vcredist2022", "msvcr140" to "vcredist2022", "concrt140" to "vcredist2022",
"msvcp120" to "vcredist2013", "msvcr120" to "vcredist2013",
"msvcp110" to "vcredist2012", "msvcr110" to "vcredist2012",
"msvcp100" to "vcredist2010", "msvcr100" to "vcredist2010",
"msvcp90" to "vcredist2008", "msvcr90" to "vcredist2008",
"msvcp80" to "vcredist2005", "msvcr80" to "vcredist2005",
)
private val D3DX9_RE = Regex("""d3dx9_\d+\.dll""")
private val D3DX11_RE = Regex("""d3dx11_\d+\.dll""")
private val D3DCOMPILER_RE = Regex("""d3dcompiler_\d+\.dll""")
/** Resolve a .NET component from installer/path text, or null when no version is discernible. */
private fun dotnetComponent(s: String): String? = when {
s.contains("desktop-runtime-8") || Regex("""\b8\.\d""").containsMatchIn(s) -> "dotnetcoredesktop8"
s.contains("desktop-runtime-7") || Regex("""\b7\.\d""").containsMatchIn(s) -> "dotnetcoredesktop7"
s.contains("desktop-runtime-6") || Regex("""\b6\.\d""").containsMatchIn(s) -> "dotnetcoredesktop6"
s.contains("ndp48") || s.contains("fx48") || s.contains("4.8") -> "dotnet48"
s.contains("ndp472") || s.contains("4.7.2") -> "dotnet472"
s.contains("ndp46") || s.contains("4.6") -> "dotnet46"
s.contains("ndp452") || s.contains("4.5.2") -> "dotnet452"
s.contains("dotnetfx45") || s.contains("ndp45") || s.contains("4.5") -> "dotnet45"
s.contains("dotnetfx40") || s.contains("ndp40") || s.contains("4.0") -> "dotnet40"
s.contains("dotnetfx35") || s.contains("3.5") -> "dotnet35"
else -> null // no version signal (e.g. a bare "DotNet" container dir)
}
}
@@ -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
@@ -12,7 +13,7 @@ import java.io.InputStream
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
object GameFiles {
const val EXTENSION = "droiddeck"
const val EXTENSION = "steam"
private const val MAX_BYTES = 128
private const val INVENTORY = ".droiddeck-sync.json"
private const val FORMAT = "droiddeck-game-files-v1"
@@ -46,83 +47,115 @@ object GameFiles {
}
if (count > MAX_BYTES) return null
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
return GameLaunchLink.parse(text) ?: text.takeIf(GameLaunchLink::validId)
return text.takeIf(GameLaunchLink::validId)
}
fun filename(game: Library.SteamGame): String {
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
.trim().trim('.').ifBlank { "Game" }
val points = sanitized.codePoints().limit(40).toArray()
val title = String(points, 0, points.size)
return "$title (${game.gameIdString}).$EXTENSION"
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
return "$title$suffix.$EXTENSION"
}
private fun contents(id: String) = GameLaunchLink.uri(id) + "\n"
private fun contents(id: String): String {
require(GameLaunchLink.validId(id))
return id
}
@Synchronized fun export(folder: File, game: Library.SteamGame): File {
require(folder.isDirectory) { "Folder is unavailable" }
val target = child(folder, filename(game))
@Synchronized fun export(context: Context, folder: File, game: Library.SteamGame): File =
exportNamed(context, folder, game, filename(game))
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)
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(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(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
val title = filename(game).substringBeforeLast(" (${game.gameIdString})")
val title = filename(game).substringBeforeLast(".")
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
.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()
val exports = games.distinctBy { it.gameId }.map { game ->
val target = child(folder, filename(game))
val unique = games.distinctBy { it.gameId }
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(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 file = if (previous.optString(target.name) == game.gameIdString && target.exists() &&
!matches(target, contents(game.gameIdString))) target else export(folder, game)
next.put(file.name, game.gameIdString)
val owned = inventoryOwns(context, folder, previous, target, game.gameIdString)
val file = if (owned && target.exists() &&
!matches(target, text)) target else exportNamed(context, folder, game, target.name)
if (!existed || owned) next.put(file.name, game.gameIdString)
file
}
// Write new exports before pruning; a failed write must not remove old launch files.
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 child(folder: File, name: String): File {
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(context, folder, name).let { old ->
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
}
}
}
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
}
@@ -0,0 +1,32 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.content.Intent
object GameLaunchIntent {
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
const val EXTRA_APP_ID = "app_id"
const val EXTRA_GAME_SOURCE = "game_source"
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
fun read(context: Context, intent: Intent): String? = when (intent.action) {
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
ACTION, GAMENATIVE_ACTION -> readId(intent)
else -> null
}
@Suppress("DEPRECATION")
internal fun readId(intent: Intent): String? = runCatching {
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
is Int -> value.takeIf { it > 0 }?.toString()
is String -> value.takeIf(GameLaunchLink::validId)
else -> null
}
id
}.getOrNull()
}
@@ -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? {
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import org.json.JSONObject
import java.io.File
/**
* Which catalog components a game's prefix already has from elsewhere - a real installer, the game,
* the user - so the list can say so instead of recommending them again. Bannerlator's detector,
* on DroidDeck's compatdata/<id>: only markers absent from a fresh Proton prefix count (OpenAL32,
* Mono, PhysX, Games for Windows Live); msvcp140, d3dx9 and the like ship with Proton and cannot
* tell. Files DroidDeck put there itself (droiddeck-wincomponents' state) are not counted.
* Pure filesystem reads; never throws.
*/
object PrefixInstalledDetector {
private const val STATE = ".droiddeck-wincomponents.json"
/** Detector names (oalinst, mono, physx, XLiveRedist) present in [compat]/pfx. */
fun detect(compat: File?): Set<String> = try {
val pfx = compat?.let { File(it, "pfx") }
if (pfx == null || !pfx.isDirectory) emptySet() else {
val ours = ours(compat)
val out = HashSet<String>()
val openal = listOf("system32", "syswow64").map { "drive_c/windows/$it/openal32.dll" }
if (openal.any { it !in ours && fileIgnoringCase(pfx, it) }) out += "oalinst"
if (File(pfx, "drive_c/windows/mono").isDirectory) out += "mono"
if (hasPhysX(pfx)) out += "physx"
if (registryContains(pfx, "Games for Windows")) out += "XLiveRedist"
out
}
} catch (_: Throwable) {
emptySet()
}
private fun ours(compat: File): Set<String> = try {
val files = JSONObject(File(compat, STATE).readText()).optJSONObject("files")
files?.keys()?.asSequence()?.map { it.lowercase() }?.toSet().orEmpty()
} catch (_: Exception) {
emptySet()
}
/** Windows names ignore case; the prefix's folder may spell them either way. */
private fun fileIgnoringCase(pfx: File, rel: String): Boolean {
val target = File(pfx, rel)
return target.parentFile?.listFiles()?.any { it.isFile && it.name.equals(target.name, ignoreCase = true) } == true
}
private fun hasPhysX(pfx: File): Boolean {
for (pf in arrayOf("drive_c/Program Files", "drive_c/Program Files (x86)")) {
val base = File(pfx, pf)
if (File(base, "NVIDIA Corporation/PhysX").isDirectory) return true
if (base.listFiles()?.any { it.isDirectory && it.name.contains("PhysX", ignoreCase = true) } == true) return true
}
return false
}
private fun registryContains(pfx: File, needle: String): Boolean =
arrayOf("system.reg", "user.reg").any { name ->
val f = File(pfx, name)
try {
f.isFile && f.length() < 32L * 1024 * 1024 &&
f.bufferedReader().use { r -> r.lineSequence().any { it.contains(needle, ignoreCase = true) } }
} catch (_: Throwable) {
false
}
}
}
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.frontend
import java.io.File
/**
* What Steam itself installs alongside a game: the Steamworks Shared redistributables (app 228980)
* listed under "SharedDepots" in the game's appmanifest. Those live in Steamworks Shared rather than
* the game's folder, so the folder scan ([DependencyDetector]) cannot see them; this reads Steam's
* list instead. Depots are matched to Bannerlator's catalog names; unknown ones are left out.
*/
object SteamRedists {
/** Steamworks Shared depot -> (catalog component, what Steam calls it). 2015-2019 runtimes are one family. */
val DEPOTS = linkedMapOf(
"228981" to ("vcredist2005_dll" to "VC++ 2005"),
"228982" to ("vcredist2008_dll" to "VC++ 2008"),
"228983" to ("vcredist2010_dll" to "VC++ 2010"),
"228984" to ("vcredist2012_dll" to "VC++ 2012"),
"228985" to ("vcredist2013_dll" to "VC++ 2013"),
"228986" to ("vcredist2015_dll" to "VC++ 2015"),
"228987" to ("vcredist2019_dll" to "VC++ 2017"),
"228988" to ("vcredist2019_dll" to "VC++ 2019"),
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
"228990" to ("d3dx9" to "DirectX (June 2010)"),
)
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
/** The game's appmanifest beside its install folder (steamapps/common/<dir> -> steamapps/). */
fun manifest(gameDir: File?, appId: Int): File? {
val steamapps = gameDir?.parentFile?.parentFile ?: return null
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
}
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
val text = try {
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
} catch (_: Exception) {
return emptyList()
}
val block = BLOCK.find(text)?.groupValues?.get(1) ?: return emptyList()
return ENTRY.findAll(block).mapNotNull { DEPOTS[it.groupValues[1]] }
.distinctBy { it.first }
.map { (component, label) -> DependencyDetector.Recommendation(component, label, label, DependencyDetector.Kind.STEAM) }
.toList()
}
}
@@ -0,0 +1,166 @@
package com.droiddeck.launcher.gpu
import android.app.ActivityManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.Locale
/**
* Everything the device info window shows about the phone's chip: SoC, GPU, CPU, memory, system,
* the phone's own Vulkan driver and the drivers DroidDeck uses. Reads files and asks Vulkan once
* ([VulkanInfo]), so [collect] belongs off the main thread. Values the phone does not give are
* left out rather than guessed.
*/
object DeviceInfo {
class Section(val title: String, val rows: List<Pair<String, String>>)
fun collect(context: Context): List<Section> {
val vk = VulkanInfo.query()
// After Vulkan has been asked, detection can use its name too.
val gpu = GpuInfo.detect()
return listOf(
chip(), gpu(gpu), cpu(), memory(context), system(), vulkan(context, vk), drivers(context, gpu),
).filter { it.rows.isNotEmpty() }
}
/** The whole window as plain text, for a bug report. */
fun text(sections: List<Section>): String = buildString {
append("DroidDeck device info\n")
for (s in sections) {
append('\n').append(s.title).append('\n')
for ((k, v) in s.rows) append(" ").append(k).append(": ").append(v).append('\n')
}
}
private fun MutableList<Pair<String, String>>.add(key: String, value: String?) {
if (!value.isNullOrBlank() && value != Build.UNKNOWN) add(key to value.trim())
}
private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null }
private fun prop(name: String): String = GpuInfo.systemProperty(name)
private fun chip() = Section("Chip (SoC)", buildList<Pair<String, String>> {
val model = SocNames.model()
val platform = prop("ro.board.platform")
add("Name", SocNames.name(model, platform))
add("Model", model)
if (Build.VERSION.SDK_INT >= 31) add("Maker", Build.SOC_MANUFACTURER)
add("Platform", platform)
add("Hardware", Build.HARDWARE)
add("Board", Build.BOARD)
val id = read("/sys/devices/soc0/soc_id")
val machine = read("/sys/devices/soc0/machine")
add("SoC id", listOfNotNull(id, machine).joinToString(" · "))
add("Family", read("/sys/devices/soc0/family"))
})
private fun gpu(gpu: GpuInfo) = Section("GPU", buildList<Pair<String, String>> {
add("GPU", gpu.name)
add("Family", gpu.family.label)
add("Model found from", when (gpu.modelSource) {
"kernel" -> "the kernel's GPU name"
"vulkan" -> "the Vulkan driver (the kernel's name has no model)"
"platform" -> "the platform (the kernel's name has no model)"
else -> "unknown"
})
add("Kernel name", gpu.kgslName)
val kgsl = "/sys/class/kgsl/kgsl-3d0"
add("Clock range", gpuRange())
add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" })
add("Temperature", read("$kgsl/temp")?.toLongOrNull()?.let { if (it > 1000) "${it / 1000} °C" else "$it °C" })
add("Support", gpu.supportText)
})
private fun cpu() = Section("CPU", buildList<Pair<String, String>> {
add("Cores", Runtime.getRuntime().availableProcessors().toString())
// Each cpufreq policy is one cluster: the cores it covers and their speed range.
val policies = File("/sys/devices/system/cpu/cpufreq").listFiles { f -> f.name.matches(Regex("policy\\d+")) }
?.sortedBy { it.name.removePrefix("policy").toInt() }.orEmpty()
for (p in policies) {
val cpus = (read("${p.path}/related_cpus") ?: read("${p.path}/affected_cpus"))?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty()
val min = read("${p.path}/cpuinfo_min_freq")?.toLongOrNull()
val max = read("${p.path}/cpuinfo_max_freq")?.toLongOrNull() ?: continue
val cores = if (cpus.size > 1) "cores ${cpus.first()}-${cpus.last()}" else "core ${cpus.firstOrNull() ?: p.name.removePrefix("policy")}"
add("Cluster ${p.name.removePrefix("policy")}", "${cpus.size.coerceAtLeast(1)} × $cores · " +
(if (min != null) ghz(min) + "–" else "") + ghz(max))
}
add("ABI", Build.SUPPORTED_ABIS.joinToString(", "))
})
private fun ghz(khz: Long) = String.format(Locale.US, "%.2f GHz", khz / 1e6)
/** The GPU's slowest and fastest step: KGSL's list, else a devfreq GPU node (Mali and others). */
private fun gpuRange(): String? {
fun mhz(hz: Long) = "${hz / 1_000_000} MHz"
val kgsl = read("/sys/class/kgsl/kgsl-3d0/gpu_available_frequencies")
?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
if (kgsl.isNotEmpty()) return "${mhz(kgsl.min())}–${mhz(kgsl.max())} (${kgsl.size} steps)"
read("/sys/class/kgsl/kgsl-3d0/max_gpuclk")?.toLongOrNull()?.let { return "up to ${mhz(it)}" }
val node = File("/sys/class/devfreq").listFiles()?.firstOrNull { f ->
val n = f.name.lowercase()
(n.contains("gpu") || n.contains("mali") || n.contains("kgsl-3d")) && listOf("bus", "bw", "memlat").none { n.contains(it) }
} ?: return null
val steps = read("${node.path}/available_frequencies")?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
if (steps.isNotEmpty()) return "${mhz(steps.min())}–${mhz(steps.max())} (${steps.size} steps)"
val min = read("${node.path}/min_freq")?.toLongOrNull()
val max = read("${node.path}/max_freq")?.toLongOrNull() ?: return null
return (if (min != null) "${mhz(min)}–" else "up to ") + mhz(max)
}
private fun memory(context: Context) = Section("Memory", buildList<Pair<String, String>> {
val am = context.getSystemService(ActivityManager::class.java) ?: return@buildList
val mi = ActivityManager.MemoryInfo().also(am::getMemoryInfo)
add("Total", FileUtils.sizeToString(mi.totalMem))
add("Available now", FileUtils.sizeToString(mi.availMem))
})
private fun system() = Section("System", buildList<Pair<String, String>> {
add("Device", "${Build.MANUFACTURER} ${Build.MODEL}")
add("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
add("Security patch", Build.VERSION.SECURITY_PATCH)
add("Kernel", System.getProperty("os.version"))
add("Build", Build.DISPLAY)
})
private fun vulkan(context: Context, vk: Map<String, String>) = Section("Vulkan (the phone's own driver)", buildList<Pair<String, String>> {
vk["error"]?.let { add("Error", it) }
add("Device", vk["device"])
add("Type", vk["type"])
add("API version", vk["api"])
add("Loader version", vk["loader"])
add("Driver", listOfNotNull(vk["driver_name"], vk["driver_version"]).joinToString(" "))
// Qualcomm's build string starts with its own label ("Driver Build: 43c70540de, ...").
add("Driver build", vk["driver_info"]?.replace(Regex("(?i)^driver build:\\s*"), ""))
add("Conformance", vk["conformance"])
add("Vendor / device id", listOfNotNull(vk["vendor_id"], vk["device_id"]).joinToString(" / "))
add("Max texture size", vk["max_image_2d"])
add("GPU memory heap", vk["device_heap_mb"]?.let { "$it MB" })
add("Extensions", vk["extension_count"])
val features = listOf("geometry_shader" to "Geometry shaders", "tessellation_shader" to "Tessellation", "texture_bc" to "BC textures")
for ((key, label) in features) add(label, vk[key])
for ((key, value) in vk) if (key.startsWith("ext.")) add(key.removePrefix("ext."), value)
// What Android certifies the phone for.
val pm = context.packageManager
val hw = pm.systemAvailableFeatures.associate { it.name to it.version }
hw[PackageManager.FEATURE_VULKAN_HARDWARE_VERSION]?.let {
add("Android-certified version", "${it shr 22}.${(it shr 12) and 0x3ff}.${it and 0xfff}")
}
hw[PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL]?.let { add("Android hardware level", it.toString()) }
})
private fun drivers(context: Context, gpu: GpuInfo) = Section("Drivers DroidDeck uses", buildList<Pair<String, String>> {
add("Driver mode", SessionPrefs.gpuDriverMode(context))
val turnip = TurnipDriver(context)
val choice = SessionPrefs.androidDriver(context).ifEmpty { turnip.autoId() }
add("Display driver", "${turnip.displayName(choice)} ${turnip.driverVersion(choice)}".trim())
val linux = LinuxVulkanDriverManager(context)
val id = SessionPrefs.linuxDriver(context)
add("Game driver", if (id.isEmpty()) "the runtime's own Turnip" else "${linux.getDriverName(id)} ${linux.getDriverVersion(id)}".trim())
if (gpu.oneUi8Gen2) add("Note", "One UI on an 8 Gen 2: needs the OneUI Turnip build")
})
}
@@ -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. */
@@ -16,10 +18,14 @@ data class GpuInfo(
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
val model: Int,
val family: Family,
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
/** The SoC as a person would say it ("Snapdragon 8 Gen 2 (QCS8550)"), or "" when the phone does not say. */
val soc: String,
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
val oneUi8Gen2: Boolean,
/** KGSL's own name for the GPU, as the kernel spells it ("Adreno740v2", "Adreno33v2"). */
val kgslName: String = "",
/** Where [model] came from: "kernel", "vulkan" or "platform", "" when unknown. */
val modelSource: String = "",
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
@@ -29,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 }
@@ -37,36 +46,82 @@ 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")
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
// Where vendors put the chip's model, named when it is known: "Snapdragon 8 Gen 2 (QCS8550)".
val soc = if (adreno) SocNames.label() else ""
val fromKernel = raw?.let { threeDigits(it) } ?: 0
// Some kernels name the GPU without its model (AYANEO's Pocket FIT: "Adreno33v2"). The
// Vulkan driver's own name, when this process has asked it, then the platform's code
// name stand in - the GPU is the same on every phone of a platform.
val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0
val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel()
val model = maxOf(fromKernel, fromVulkan, fromPlatform)
val source = when {
fromKernel > 0 -> "kernel"
fromVulkan > 0 -> "vulkan"
fromPlatform > 0 -> "platform"
else -> ""
}
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,
)
}
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
/** Qualcomm platform code names whose GPU is known for certain. */
private val PLATFORMS = mapOf(
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
"kalama" to 740, "pineapple" to 750, "sun" to 830,
)
internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int =
PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0
/** android.os.SystemProperties.get, which apps may read but not call directly. */
fun systemProperty(name: String): String = try {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: ""
} catch (_: Exception) {
""
}
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
!adreno -> Family.NOT_ADRENO
model == 0 -> Family.ADRENO_UNKNOWN
@@ -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),
)
/**
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.gpu
import android.os.Build
/**
* Which chip this is, as a person would say it. Handhelds often leave Android's SoC model blank
* (the AYANEO Pocket FIT does), so the model is looked for where vendors put it, the same order
* ClusterTune uses; then it is named from a table of models we are sure of, or from the platform
* code name as a family ("Snapdragon 8 Gen 3 family") when the model itself is a vendor variant.
*/
object SocNames {
/** Model codes whose product name is certain. */
private val MODELS = mapOf(
"SM8150" to "Snapdragon 855", "SM8250" to "Snapdragon 865", "SM8250-AC" to "Snapdragon 870",
"SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1",
"SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3",
"SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5",
"SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3",
"SM6375" to "Snapdragon 695",
)
/** Platform code names: every chip of one platform shares its CPU and GPU design. */
private val PLATFORMS = mapOf(
"msmnile" to "Snapdragon 855/860", "kona" to "Snapdragon 865/870", "lahaina" to "Snapdragon 888",
"taro" to "Snapdragon 8 Gen 1", "cape" to "Snapdragon 8+ Gen 1", "kalama" to "Snapdragon 8 Gen 2",
"pineapple" to "Snapdragon 8 Gen 3", "sun" to "Snapdragon 8 Elite",
)
/** The chip's model code ("QCS8550", "SG8350P"), or "" when the phone does not say. */
fun model(prop: (String) -> String = GpuInfo::systemProperty): String {
val sdk = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it != Build.UNKNOWN }.orEmpty() else ""
return listOf(prop("ro.soc.model"), prop("ro.vendor.qti.soc_model"), sdk, prop("ro.fota.platform"))
.map { it.trim() }
// ro.fota.platform is often a branch name ("MSM_13.0"), not a chip.
.firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty()
}
/** "Snapdragon 8 Gen 2", "Snapdragon 8 Gen 3 family", or null when neither the model nor the platform is known. */
fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? =
MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
/** For the GPU card: "Snapdragon 8 Gen 2 (QCS8550)", the bare model, or "". */
fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String {
val name = name(model, platform)
return when {
name != null && model.isNotEmpty() -> "$name ($model)"
name != null -> name
else -> model
}
}
}
@@ -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) {
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.gpu
import android.util.Log
/**
* The phone's own Vulkan driver, as it describes itself (native/deviceinfo): device name, API and
* driver versions, a few limits and the extensions DXVK/VKD3D lean on. Asked once per process and
* kept; the first [query] creates a Vulkan instance, so it belongs off the main thread.
*/
object VulkanInfo {
@Volatile private var cached: Map<String, String>? = null
@JvmStatic private external fun nativeQuery(): String
/** Blocking on first use. Empty-ish map with an "error" key when Vulkan cannot be asked. */
@Synchronized
fun query(): Map<String, String> {
cached?.let { return it }
val text = try {
System.loadLibrary("deviceinfo")
nativeQuery()
} catch (t: Throwable) {
Log.w("VulkanInfo", "query failed", t)
"error=" + (t.message ?: t.javaClass.simpleName)
}
val map = text.lineSequence().mapNotNull { line ->
val eq = line.indexOf('=')
if (eq <= 0) null else line.substring(0, eq) to line.substring(eq + 1)
}.toMap(LinkedHashMap())
cached = map
return map
}
/** What [query] found, if it has run in this process; never blocks. */
fun cachedOrNull(): Map<String, String>? = cached
}
@@ -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
@@ -300,7 +300,10 @@ object BwrapSpawner {
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX",
// Android 16 refuses the size seal Firefox puts on a tab's shared
// memory, and the tab then dies on every page (droiddeck-desktop).
"MOZ_SHM_NO_SEALS")) {
env.putIfAbsent(name, "1")
}
}
@@ -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('.')
}
}
@@ -25,7 +25,7 @@ object GuestCommand {
linkDir: File? = null, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
File(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
} catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
@@ -42,12 +42,12 @@ 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";
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
private static final String SYSTEM_FONTS = "/system/fonts";
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
public static final String DEBUG_LOG_DIR = "DroidDeck";
public static File debugLogDir() {
return new File(android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
public static File debugLogDir(Context context) {
return new File(context.getFilesDir(), "logs");
}
private LinuxRuntime() {}
@@ -207,6 +207,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 +466,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;
@@ -21,13 +21,15 @@ import java.security.MessageDigest
*/
object ProotFastPath {
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
const val MAX_BINDS = 256
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
fun key(root: File, binds: List<String>): String? {
if (binds.any { it.contains('|') } || root.path.contains('|')) return null
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
val digest = MessageDigest.getInstance("SHA-256")
.digest((root.path + "\n" + binds.joinToString("\n")).toByteArray())
return digest.take(12).joinToString("") { "%02x".format(it) }
@@ -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,10 +1,12 @@
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
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
import com.github.luben.zstd.ZstdOutputStream
@@ -247,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> {
@@ -273,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. */
@@ -362,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())
}
}
}
@@ -414,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))
@@ -439,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
/**
@@ -507,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) {
@@ -533,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) {
@@ -575,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) }
@@ -599,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)
@@ -660,16 +674,117 @@ object ComponentsManager {
return Catalog(items, o.optLong("fetchedAt"))
}
/**
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
* means every model in [families]. A row the user already chose in Components is left alone;
* a Steam update that puts the Proton's own files back is replaced on the next session.
*/
internal data class ForcedPackage(
val families: Set<GpuInfo.Family>,
val comp: String,
val file: String,
val release: String,
val models: Set<Int> = emptySet(),
) {
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
}
internal val FORCED_PACKAGES = listOf(
// DXVK 3 refuses to start unless the device exposes storageBuffer8BitAccess. Turnip on
// Adreno 6xx does not. 2.7.1 is the last DXVK 2 package on the Dxvk-Linux tag.
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
)
/**
* True when this Proton should receive the forced package. No active swap means the Proton's
* own copy. A record for a different Proton build means a Steam update put that copy back,
* whichever package was recorded. A record for this build is a Components choice and stays.
*/
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
activeFile.isNullOrEmpty() || !protonVersionMatches
/** The catalog row for this file and release, or null. */
internal fun catalogMatch(items: List<CatalogItem>, file: String, release: String): CatalogItem? =
items.firstOrNull { it.file == file && it.release == release }
/**
* Install every [FORCED_PACKAGES] row this GPU matches into each Proton that
* [forcedPackageWanted] selects. Returns a line for the log, or null when this GPU or these
* Protons need nothing. A failed fetch throws and the session logs it without stopping.
*/
fun ensureForcedPackages(context: Context): String? = ensureForcedPackages(
context, GpuInfo.detect(),
{ file, release -> catalogItem(context, file, release) },
{ download(context, it) {} },
{ id, file -> swap(context, id, file) },
)
internal fun ensureForcedPackages(
context: Context,
gpu: GpuInfo,
lookup: (file: String, release: String) -> CatalogItem?,
fetch: (CatalogItem) -> Unit,
apply: (protonId: String, file: String) -> String,
): String? {
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }
if (rules.isEmpty()) return null
val installed = protons(context)
val lines = mutableListOf<String>()
for (rule in rules) {
val state = loadState(context)
val targets = installed.filter { p ->
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version)
}
if (targets.isEmpty()) continue
val missing = missingPackage(context, rule, lookup, fetch)
if (missing != null) {
lines.add(missing)
continue
}
lines.add(targets.joinToString("; ") { apply(it.id, rule.file) })
}
return lines.joinToString("; ").ifEmpty { null }
}
/** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */
private fun missingPackage(
context: Context,
rule: ForcedPackage,
lookup: (file: String, release: String) -> CatalogItem?,
fetch: (CatalogItem) -> Unit,
): String? {
val wcp = File(packagesDir(context), safeName(rule.file))
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"
fetch(item)
return null
}
/** The cached Nightlies row for this file, fetching the listing when the cache does not have it. */
internal fun catalogItem(context: Context, file: String, release: String): CatalogItem? =
resolveCatalogItem(file, release, { catalog(context, false).items }, { catalog(context, true).items })
/** [cached] when it has the row, otherwise [refresh]. [refresh] is not called on a hit. */
internal fun resolveCatalogItem(
file: String,
release: String,
cached: () -> List<CatalogItem>,
refresh: () -> List<CatalogItem>,
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
/** 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)
}
}
}
@@ -59,7 +59,7 @@ object GameEnvironmentStore {
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
val json = encode(resolved)
// The Display page's texture filtering, as DXVK options the launcher appends to
// DXVK_CONFIG after the profiles - a user's own DXVK_CONFIG entry keeps its options.
@@ -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" }
}
}
@@ -31,11 +31,9 @@ object SessionArtifacts {
/** Written last; a folder without it did not get its ending. */
const val COMPLETE_MARKER = ".complete"
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
private const val SCRUBBED_MARKER = ".scrubbed-2"
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
fun collect(context: Context, dir: File, reason: String) {
SessionLogCapture.stopFor(dir)
try {
val wayland = File(dir, "wayland.log")
if (!wayland.exists()) {
@@ -47,8 +45,7 @@ object SessionArtifacts {
copySteamLogs(context, dir)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
scrubFolder(dir)
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
scrubTree(dir)
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
@@ -56,62 +53,52 @@ object SessionArtifacts {
}
}
/**
* Once, for session folders written before every file was scrubbed and before the device's own
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
* A marker records it. Runs at app start with [finishAbandoned].
*/
@Synchronized
fun scrubOlder(context: Context) {
val current = SessionPaths.current()
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
} ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
scrubFolder(dir)
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
}
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
SessionPaths.sessionFolders(context).filter {
it.parentFile == LinuxRuntime.debugLogDir(context) && it != SessionPaths.current() &&
!File(it, COMPLETE_MARKER).exists()
}.forEach { scrubTree(it) }
}
/**
* The session's own files (session.log, app.log, desktop.log, the Steam desktop client's
* steam-desktop.log, ...) were written as they happened, unscrubbed; this pass puts every one
* through the redactor before the folder can be shared. A file is rewritten only if a line
* changed. steam/ was scrubbed on the way in.
*/
private fun scrubFolder(dir: File) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
if (!LogRedactor.isText(f)) return@forEach
internal fun scrubTree(dir: File) {
SessionLogFiles.candidates(dir).forEach { file ->
if (!SessionLogFiles.safeFile(dir, file)) return@forEach
var tmp: File? = null
try {
val tmp = File(dir, ".${f.name}.scrub")
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
}
}
}
/** Steam's logs: redacted into steam/, never copied verbatim. */
private fun copySteamLogs(context: Context, dir: File) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
if (!logs.isDirectory) return
val out = File(dir, "steam").apply { mkdirs() }
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
try {
File(out, src.name).bufferedWriter().use { w ->
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
tmp = File.createTempFile(".redacted-", ".tmp", dir)
val size = file.length()
val modified = file.lastModified()
val limit = if (file.parentFile?.name == "steam") SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
tmp.bufferedWriter().use { SessionLogFiles.scrubTo(dir, file, it, limit) }
if (file.length() == size && file.lastModified() == modified) {
check(tmp.renameTo(file))
}
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${file.name}", e)
} finally {
tmp?.delete()
}
}
}
private fun copySteamLogs(context: Context, dir: File) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
if (!logs.isDirectory || java.nio.file.Files.isSymbolicLink(logs.toPath())) return
val out = File(dir, "steam")
if (java.nio.file.Files.isSymbolicLink(out.toPath()) || !(out.isDirectory || out.mkdirs())) return
SessionLogFiles.steamNames.map { File(logs, it) }.filter { it.exists() }.forEach { src ->
val tmp = File.createTempFile(".redacted-", ".tmp", out)
try {
tmp.bufferedWriter().use { SessionLogFiles.scrubTo(logs, src, it, SessionLogFiles.STEAM_MAX_BYTES) }
check(tmp.renameTo(File(out, src.name)))
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${src.name}", e)
} finally {
tmp?.delete()
}
}
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
}
/**
@@ -122,10 +109,10 @@ object SessionArtifacts {
*/
@Synchronized
fun finishAbandoned(context: Context) {
val parent = LinuxRuntime.debugLogDir()
val abandoned = parent.listFiles { f ->
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
}?.sortedWith(SessionPaths.chronological) ?: return
SessionLogMigration.run(context)
val abandoned = SessionPaths.sessionFolders(context).filter {
it.parentFile == LinuxRuntime.debugLogDir(context) && !File(it, COMPLETE_MARKER).exists()
}
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
@@ -143,12 +130,10 @@ object SessionArtifacts {
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
"session died of a crash, the entry is in there unless the device rebooted.\n"
)
if (newest) {
copySteamLogs(context, dir)
}
if (newest) copySteamLogs(context, dir)
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
scrubFolder(dir)
scrubTree(dir)
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
@@ -168,7 +153,7 @@ object SessionArtifacts {
val current = SessionPaths.current()
val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() }
val old = finished.dropLast(SessionPaths.KEEP_SESSIONS)
old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
old.forEach { SessionLogFiles.deleteTree(it) }
if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}")
}
@@ -181,10 +166,11 @@ object SessionArtifacts {
fun clearAll(context: Context): Int {
val current = SessionPaths.current()
val gone = SessionPaths.sessionFolders(context).filter { it != current }
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
gone.forEach { SessionLogFiles.deleteTree(it) }
LinuxRuntime.debugLogDir(context).listFiles { f -> f.name == SessionPaths.TOOLS_DIR || f.name.matches(Regex("tools-\\d+")) }
?.forEach { SessionLogFiles.deleteTree(it) }
// Before tools/, those logs sat loose beside the session folders.
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
LinuxRuntime.debugLogDir(context).listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
Log.i(TAG, "cleared ${gone.size} session folder(s)")
return gone.size
}
@@ -6,23 +6,40 @@ import android.view.WindowManager
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
object SessionDisplay {
const val MATCH_SCREEN = "screen"
const val DEFAULT_RESOLUTION = "1280x720"
/**
* Presets are heights, not sizes: "720p" is the panel's own shape at 720 lines, never narrower
* than 16:9 and never taller than the panel (SHAPE_AUTO). The client treats the output as the
* games' native resolution, so the height is what bounds a game's default size on a 1440p
* phone; the shape is what keeps the client's interface edge to edge on a 20:9 one (1600x720)
* and keeps games at 16:9 or wider on a foldable's near-square panel.
*/
const val DEFAULT_RESOLUTION = "720p"
private val PRESET_HEIGHTS = listOf(720, 900, 1080)
/** The height of a preset choice ("900p"), or null for Match screen and fixed sizes. */
fun presetHeight(choice: String): Int? =
if (choice.endsWith("p")) choice.dropLast(1).toIntOrNull()?.takeIf { it > 0 } else null
fun screenSize(panel: Pair<Int, Int>): Pair<Int, Int> =
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> =
if (choice == MATCH_SCREEN) screenSize(panel) else {
// Legacy displays may exceed the custom dialog's size limit. Keep their exact size.
val parts = choice.split('x').map { it.toIntOrNull() }
if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
else 1280 to 720
}
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> {
if (choice == MATCH_SCREEN) return screenSize(panel)
presetHeight(choice)?.let { return resolve(panel, it, SessionPrefs.SHAPE_AUTO) }
// Fixed sizes (custom, or a preset saved before presets were heights) keep their exact size,
// including legacy ones past the custom dialog's limit.
val parts = choice.split('x').map { it.toIntOrNull() }
return if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
else resolve(panel, 720, SessionPrefs.SHAPE_AUTO)
}
/** Fixed dimensions and the current panel, with no duplicate panel-size preset. */
fun resolutionOptions(panel: Pair<Int, Int>): List<String> =
listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080")
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate sizes. */
fun resolutionOptions(panel: Pair<Int, Int>): List<String> {
val panelHeight = minOf(panel.first, panel.second)
return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" }
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
}
fun panelSize(context: Context): Pair<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@@ -28,19 +28,24 @@ object SessionFiles {
*/
fun stage(context: Context, root: File) {
GameEnvironmentStore.publish(context)
WinComponents.publish(context)
val files = arrayOf(
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
"usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex",
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"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/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",
@@ -103,6 +108,10 @@ object SessionFiles {
"usr/local/lib/mangoapp/libtraceevent.so.1",
"usr/local/lib/mangoapp/libtracefs.so.1",
).map { it to it }
// What the Windows components installer reads .msi packages with (tools/msitools).
val msitools = listOf("msiinfo", "cabextract", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
.map { "usr/local/lib/droiddeck-msitools/$it" } +
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
// The patched wlroots (tools/wlroots) the desktop loads for its vulkan / gles2 renderers.
val wlroots = if (File(root, "usr/bin/labwc").isFile) {
arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so")
@@ -114,7 +123,7 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + wlroots + mangoapp + fexPreloads).filter { (asset, _) ->
) + wlroots + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
@@ -340,26 +349,9 @@ object SessionFiles {
indent + "}",
).joinToString(newline)
/**
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
* folder rather than dug out of app-private storage - but the session script redirects its own
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
* shell. So a public directory is used only once it is proven writable; otherwise the app's
* own files directory, which is bound into the session anyway, stands in.
*/
fun logDirectory(context: Context): File {
val public = LinuxRuntime.debugLogDir()
if (public.isDirectory || public.mkdirs()) {
val probe = File(public, ".writable")
try {
if (probe.createNewFile() || probe.isFile) {
probe.delete()
return public
}
} catch (ignored: Exception) {
}
fun logDirectory(context: Context): File =
LinuxRuntime.debugLogDir(context).apply {
check(!java.nio.file.Files.isSymbolicLink(toPath()))
check(isDirectory || mkdirs())
}
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
return File(context.filesDir, "logs").apply { mkdirs() }
}
}
@@ -0,0 +1,107 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.LogRedactor
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.io.Writer
import java.nio.channels.Channels
import java.nio.charset.CodingErrorAction
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.StandardOpenOption.READ
internal object SessionLogFiles {
const val MAX_BYTES = 64L * 1024 * 1024
const val STEAM_MAX_BYTES = 8L * 1024 * 1024
private const val MAX_LINE_CHARS = 64 * 1024
val steamNames = setOf(
"bootstrap_log.txt", "connection_log.txt", "console_log.txt", "content_log.txt",
"controller.txt", "compat_log.txt", "cef_log.txt", "cef_log.previous.txt",
"shader_log.txt", "systemaudiomanager.txt", "steamwebhelper.log", "webhelper.txt",
"steamui_html.txt", "steamui_system.txt", "steamui_library.txt", "steamui_login.txt",
"remote_connections.txt", "streaming_log.txt", "video.txt", "vrclient_steam.txt"
)
private val paths = setOf(
"session.log", "session.log.1", "app.log", "crash.log", "crash-app.txt",
"wayland.log", "audio.log", "device.txt", "network.txt", "events.jsonl",
"desktop.log", "steam-desktop.log", "steam.log", "pad.log", "storage.log",
"ended-without-teardown.txt", "droiddeck-esync/launches.log",
"droiddeck-esync/tools.tsv", "droiddeck-esync/wanted.tsv",
"droiddeck-esync/prefixes.json", "droiddeck-fex/status.json", "droiddeck-fex/Config.json"
) + steamNames.map { "steam/$it" }
fun candidates(folder: File): List<File> = paths.map { File(folder, it) }
.filter { Files.exists(it.toPath(), NOFOLLOW_LINKS) }
fun deleteTree(file: File) {
if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return
Files.walk(file.toPath()).use { stream ->
stream.iterator().asSequence().toList().asReversed().forEach { Files.deleteIfExists(it) }
}
}
fun safeFile(root: File, file: File): Boolean {
val base = root.toPath().toAbsolutePath().normalize()
var path = file.toPath().toAbsolutePath().normalize()
if (!path.startsWith(base) || path == base) return false
while (path != base) {
if (Files.isSymbolicLink(path)) return false
path = path.parent ?: return false
}
return !Files.isSymbolicLink(base) && Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS)
}
fun scrubTo(root: File, file: File, out: Writer, maxBytes: Long = MAX_BYTES) {
if (!safeFile(root, file)) throw IOException("unsafe path")
Files.newByteChannel(file.toPath(), READ, NOFOLLOW_LINKS).use { channel ->
var remaining = channel.size()
if (remaining >= maxBytes) throw IOException("size limit")
val source = Channels.newInputStream(channel)
val snapshot = object : InputStream() {
override fun read(): Int {
val one = ByteArray(1)
return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and 255
}
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
if (length == 0) return 0
if (remaining == 0L) return -1
val count = source.read(bytes, offset, minOf(length.toLong(), remaining).toInt())
if (count < 0) throw IOException("truncated snapshot")
remaining -= count
return count
}
}
val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
val reader = InputStreamReader(snapshot, decoder)
val line = StringBuilder()
val buffer = CharArray(4096)
fun writeLine() {
out.write(LogRedactor.redact(line.toString().removeSuffix("\r")))
out.write("\n")
line.setLength(0)
}
while (true) {
val count = reader.read(buffer)
if (count < 0) break
for (i in 0 until count) {
val char = buffer[i]
if (char == '\u0000') throw IOException("binary data")
if (char == '\n') writeLine() else {
if (line.length >= MAX_LINE_CHARS) throw IOException("line limit")
line.append(char)
}
}
}
if (line.isNotEmpty()) {
if (channel.size() != channel.position()) out.write("<incomplete live line withheld>\n")
else writeLine()
}
}
}
}
@@ -0,0 +1,72 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
internal object SessionLogMigration {
fun run(context: Context) {
val old = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), LinuxRuntime.DEBUG_LOG_DIR)
val target = LinuxRuntime.debugLogDir(context)
if (!old.isDirectory || Files.isSymbolicLink(old.toPath())) return
check(target.isDirectory || target.mkdirs())
old.listFiles().orEmpty().filter {
SessionPaths.isSessionFolder(it) || it.name == SessionPaths.TOOLS_DIR ||
(it.isFile && it.name.endsWith(".log"))
}.forEach { source ->
try {
move(source, target)
} catch (e: Exception) {
Log.w("SessionLogMigration", "could not move legacy logs into private storage", e)
}
}
}
internal fun move(source: File, target: File) {
check(!Files.isSymbolicLink(target.toPath()))
val paths = Files.walk(source.toPath()).use { it.iterator().asSequence().toList() }
check(paths.all { !Files.isSymbolicLink(it) &&
(Files.isRegularFile(it, NOFOLLOW_LINKS) || Files.isDirectory(it, NOFOLLOW_LINKS)) })
var destination = File(target, source.name)
var number = 1
val stem = Regex("^session-\\d{8}-\\d{6}").find(source.name)?.value ?: source.name
while (Files.exists(destination.toPath(), NOFOLLOW_LINKS)) {
val name = if (source.isFile) "${source.nameWithoutExtension}-${number++}.${source.extension}"
else "$stem-${number++}"
destination = File(target, name)
}
if (source.renameTo(destination)) return
val stage = File.createTempFile(".migrating-", ".tmp", target)
val versions = paths.associate { it.toFile().let { file -> file to (file.length() to file.lastModified()) } }
try {
if (source.isDirectory) { check(stage.delete()); check(stage.mkdir()) }
paths.forEach { path ->
val file = path.toFile()
val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
if (Files.isDirectory(path, NOFOLLOW_LINKS)) {
check(out.isDirectory || out.mkdirs())
} else {
check(file == source || SessionLogFiles.safeFile(source, file))
Files.newByteChannel(path, java.nio.file.StandardOpenOption.READ, NOFOLLOW_LINKS).use { input ->
out.outputStream().use { output -> java.nio.channels.Channels.newInputStream(input).copyTo(output) }
}
check(out.length() == versions.getValue(file).first)
}
}
check(versions.all { (file, version) -> file.length() == version.first && file.lastModified() == version.second })
paths.asReversed().forEach { path ->
val file = path.toFile()
val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
check(out.setLastModified(versions.getValue(file).second))
}
check(stage.renameTo(destination))
paths.asReversed().forEach { Files.delete(it) }
} finally {
stage.deleteRecursively()
}
}
}
@@ -3,70 +3,100 @@ package com.droiddeck.launcher.session
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
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
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Zips the most recent session's log folder and hands it to Android's share sheet. Works where
* the folder could not be written to Downloads (no storage permission, or a device that refuses
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
*/
object SessionLogShare {
private val main = Handler(Looper.getMainLooper())
/**
* How far the zip for a share has got, 0..1, or null when none is being made. The Share logs
* buttons show it in place of their label, so a tap is answered at once.
*/
var progress by mutableStateOf<Float?>(null)
private set
/**
* Zips [folder] on a worker thread with [progress] following along, then hands the zip (null
* if there was nothing to zip or it failed) to [done] on the main thread. Call on the main
* thread; a tap while a zip is being made is ignored.
*/
fun prepare(context: Context, folder: () -> File?, done: (File?) -> Unit) {
if (progress != null) return
progress = 0f
Thread({
val zip = runCatching { folder()?.let { dir -> zipFolder(context, dir) { p -> main.post { if (progress != null) progress = p } } } }
.onFailure { Log.w("SessionLogShare", "could not package session logs", it) }
.getOrNull()
main.post { progress = null; done(zip) }
}, "share-logs").start()
}
/** The newest session folder in either place logs are written, or null if there is none. */
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
/** Builds the zip (blocking). Returns null when there is no session to share. */
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
/** Builds a zip for one specific session folder (blocking). */
fun zipFolder(context: Context, folder: File): File? {
if (!folder.isDirectory) return null
val files = folder.walkTopDown().filter { it.isFile }.toList()
if (files.isEmpty()) return null
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
val zip = File(out, "DroidDeck-${folder.name}.zip")
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
// session pass yet, and the redactor changes nothing in a line that is already clean.
@Synchronized
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
if (!folder.isDirectory || java.nio.file.Files.isSymbolicLink(folder.toPath())) return null
val files = SessionLogFiles.candidates(folder).map { Triple(folder, it, it.relativeTo(folder).invariantSeparatorsPath) }
val liveRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
val live = if (folder == SessionPaths.current()) SessionLogFiles.steamNames
.filter { name -> files.none { it.third == "steam/$name" } }
.map { File(liveRoot, it) }.filter { java.nio.file.Files.exists(it.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) }
.map { Triple(liveRoot, it, "steam/${it.name}") } else emptyList()
val sources = files + live
if (sources.isEmpty()) return null
val out = File(context.cacheDir, "shared-logs").apply { SessionLogFiles.deleteTree(this); check(mkdirs()) }
val name = folder.name.replace(Regex("[^A-Za-z0-9._-]"), "_")
val zip = File(out, "DroidDeck-$name.zip")
val partial = File(out, ".export")
val stage = File.createTempFile("redacted-", ".tmp", context.cacheDir)
val omitted = ArrayList<String>()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
try {
ZipOutputStream(partial.outputStream().buffered()).use { z ->
sources.forEachIndexed { index, (root, src, relative) ->
val limit = if (relative.startsWith("steam/")) SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
val failure = runCatching {
stage.bufferedWriter().use { SessionLogFiles.scrubTo(root, src, it, limit) }
}.exceptionOrNull()
if (failure == null) {
z.putNextEntry(ZipEntry("$name/$relative"))
stage.inputStream().use { it.copyTo(z) }
z.closeEntry()
} else {
omitted += "$relative: omitted because it could not be safely sanitized."
}
onProgress?.invoke((index + 1).toFloat() / sources.size)
}
if (omitted.isNotEmpty()) {
z.putNextEntry(ZipEntry("$name/omitted.txt"))
z.write((omitted.joinToString("\n") + "\n").toByteArray(Charsets.UTF_8))
z.closeEntry()
}
}
check(partial.renameTo(zip))
return zip
} catch (e: Exception) {
partial.delete()
zip.delete()
throw e
} finally {
stage.delete()
}
return zip
}
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
/**
* The client's own logs as they stand, for a session shared while it runs. The session script
* copies them into steam/ only as it exits, so a zip made from the drawer had none - and
* controller.txt (which pad the client opened, the touch mode it set) is what a controller or
* touch report needs most. Only for the running session: an older folder would get this
* session's logs. The same files the script copies; nothing holding credentials is in logs/.
*/
private fun liveSteamLogs(context: Context, folder: File): List<File> {
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
}
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
fun shareIntent(context: Context, zip: File): Intent {
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
val send = Intent(Intent.ACTION_SEND)
@@ -75,6 +105,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)
}
}
@@ -7,29 +7,6 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
* named for the day, that day's session number and what was run, so the one a report is about can
* be picked out without opening any:
*
* ```
* Download/DroidDeck/2026-09-30-03-steam/
* device.txt what this device is, and every setting the session ran with
* session.log the guest session: proot, gamescope, the client's stdout
* wayland.log the app's compositor
* steam.log the Steam client's own log, scrubbed (Steam mode)
* steam/ the rest of the client's logs, scrubbed (Steam mode)
* desktop.log labwc, the panel and the programs on it (desktop mode)
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
* ```
*
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
* pruned with the rest.
*
* The folder is claimed by whoever starts first - the activity starts the compositor before the
* service starts the session - so both write into the same one, and a recreated activity (a
* foldable opening mid-session) joins the folder in progress instead of opening another.
*/
object SessionPaths {
private const val TAG = "SessionPaths"
@@ -50,12 +27,6 @@ object SessionPaths {
@Volatile
private var ended: File? = null
/**
* The folder for the session now starting, or the one already in progress. With logs turned
* off on the main screen the folder lives in the app's cache instead of Downloads - the
* scripts and the compositor still need somewhere to write - and [release] deletes it.
* [label] says what is being run (see [label]); without one it comes from the session's state.
*/
@Synchronized
fun beginOrCurrent(context: Context, label: String? = null): File {
dir?.let { if (it.isDirectory) return it }
@@ -104,8 +75,8 @@ object SessionPaths {
// next session may have claimed its own by the time the last one's collector gets here.
if (dir == ended) dir = null
// A folder kept in the cache was never meant to outlive its session.
if (ended != null && ended.path.startsWith(context.cacheDir.path)) {
com.droiddeck.launcher.core.FileUtils.delete(ended)
if (ended.parentFile == File(context.cacheDir, "session-logs")) {
SessionLogFiles.deleteTree(ended)
}
}
@@ -123,22 +94,28 @@ object SessionPaths {
/** Whether [f] is a session's folder, in either naming. */
fun isSessionFolder(f: File): Boolean =
f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name))
f.isDirectory && !java.nio.file.Files.isSymbolicLink(f.toPath()) &&
(NAMED.matches(f.name) || LEGACY.matches(f.name))
/**
* Oldest first: by day, then by when in the day. A folder from before the new naming sorts
* ahead of a new one from the same day, which it always predates.
*/
val chronological: Comparator<File> = compareBy { f ->
val chronological: Comparator<File> = compareBy<File> { f ->
NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" }
?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" }
?: f.name
}
}.thenBy { it.lastModified() }
/** Every session folder in the places logs are written, oldest first. */
fun sessionFolders(context: Context): List<File> =
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(context),
File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS),
com.droiddeck.launcher.runtime.LinuxRuntime.DEBUG_LOG_DIR))
.flatMap { parent ->
if (java.nio.file.Files.isSymbolicLink(parent.toPath())) emptyList()
else parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList()
}
.sortedWith(chronological)
internal fun slug(text: String): String =
@@ -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
@@ -8,8 +10,11 @@ import org.json.JSONObject
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
const val SUSPEND_AUTO = "auto"
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"
@@ -18,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)
@@ -155,49 +162,20 @@ 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()
}
/**
* Steam only: gamescope makes every game window the size of the screen. A game that resizes
* its own window when it loses focus (FlatOut) otherwise comes back smaller, drawn in a
* corner; a game that sets its own resolution and never looks at its window again (Quake 3)
* instead draws small in the bottom-left of the stretched one. A game whose resolution differs
* from the screen's (DiRT 3 at 1280x720) fights it: it rebuilds its swapchain on every forced
* resize and the picture flickers between its own size and the screen's, often from the first
* menu, before the player can reach its resolution setting. Off unless turned on.
*/
fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", false)
fun setForceFullscreen(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("forceFullscreen", on).apply()
writeForceFullscreenFlag(context)
}
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("stretch16x9", on).apply()
}
/**
* The same choice as a file the running session watches, so the drawer can change it live:
* the session hands every change to gamescope, which reads GAMESCOPE_FORCE_WINDOWS_FULLSCREEN
* off its root window whenever it changes. Written again at every session start so a file left
* by an earlier session never disagrees with the setting.
*/
fun writeForceFullscreenFlag(context: Context) {
runCatching {
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-fill")
.writeText(if (forceFullscreen(context)) "1\n" else "0\n")
}
}
/**
* DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On
@@ -515,7 +493,7 @@ object SessionPrefs {
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
val saved = prefs(context)
saved.getString("displayResolution.$mode", null)?.let { value ->
if (value == SessionDisplay.MATCH_SCREEN) return value
if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(value) != null) return value
parseResolution(value)?.let { return "${it.first}x${it.second}" }
}
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
@@ -525,7 +503,7 @@ object SessionPrefs {
}
fun setResolutionChoice(context: Context, mode: String, choice: String) {
val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else {
val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(choice) != null) choice else {
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
"${size.first}x${size.second}"
}
@@ -542,6 +520,22 @@ object SessionPrefs {
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
/**
* Force SSBS for Proton games: Wine resumes threads from a Windows CONTEXT that never carries
* PSTATE.SSBS, so they run with speculative store bypass disabled; libssbs.so keeps it set
* (on DiRT 3 / GE-Proton: from ~99% of a game's threads running without it to none). The
* speed-up is reported on Oryon cores (Snapdragon 8 Elite) and was not measurable on an
* 8 Gen 3, so it is off unless turned on. A game's own environment can still say
* DROIDDECK_FORCE_SSBS=0.
*/
fun forceSsbs(context: Context): Boolean = prefs(context).getBoolean("forceSsbs", false)
fun setForceSsbs(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("forceSsbs", on).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
runCatching { GameEnvironmentStore.publish(context) }
@@ -599,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. */
@@ -669,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
@@ -680,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
@@ -714,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"
@@ -722,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
@@ -760,13 +798,41 @@ object SessionPrefs {
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
?.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 (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 == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
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
@@ -43,6 +44,7 @@ import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/**
* Owns the running session: the proot tree, the audio daemon, the network link and the locks that
@@ -59,6 +61,9 @@ import java.util.Locale
* 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()
@@ -71,12 +76,21 @@ 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
/** An explicit Steam sleep request is independent of the background policy. */
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
private var suspendAttemptFailed = false
@@ -159,6 +173,7 @@ class SessionService : Service() {
ACTION_ACTIVITY_VISIBLE -> {
if (!SessionState.pipActive) pipTask = false
activityVisible = true
suspendDownloadNow = false
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
@@ -173,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
@@ -191,10 +214,15 @@ 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
suspendAttemptFailed = false
suspendController = null
@@ -249,9 +277,6 @@ class SessionService : Service() {
try {
SessionArtifacts.collect(this, dir, "session stopped")
} finally {
// Last, so everything above is in the file it is about - and only this session's:
// a session that replaced this one may already own the capture and the folder.
SessionLogCapture.stopFor(dir)
SessionPaths.release(this, dir)
}
}
@@ -303,6 +328,14 @@ class SessionService : Service() {
// The desktop's Steam launchers start the client there (droiddeck-steam-launch), through the
// same set-up as a Steam session: it gets what the client and its games are started with.
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
// Some GPUs need a component Proton does not ship. Apply those before the guest, so the
// next game launch copies them into the prefix. A download failure is logged and the
// session still starts; the next session tries again.
if (steamHere) {
runCatching { ComponentsManager.ensureForcedPackages(this) }
.onSuccess { if (it != null) Log.i(TAG, it) }
.onFailure { Log.w(TAG, "forced components", it) }
}
addClientEnvironment(guest, steamHere)
// Where the fast path's description of proot's view goes, once the binds are known.
val fastPathAt = guest.size
@@ -390,7 +423,7 @@ class SessionService : Service() {
// 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(
@@ -402,6 +435,9 @@ class SessionService : Service() {
guest.addAll(fastPathAt, env)
shellGuest.addAll(fastPathAt, env)
Log.i(TAG, "proot: fast path on (${prootBinds.size} binds)")
} ?: run {
Log.w(TAG, "proot: fast path off - ${prootBinds.size} binds (it holds ${ProotFastPath.MAX_BINDS}) or a path it cannot be told")
null
}
} else null
@@ -505,6 +541,7 @@ class SessionService : Service() {
},
)
mainHandler.post { updateSuspendPolicy() }
updateDownloadMonitoring()
}
}
@@ -537,6 +574,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)
@@ -590,9 +633,7 @@ class SessionService : Service() {
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
@@ -604,6 +645,7 @@ 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"))
guest.add("BANNER_AUDIO_DIRECT_DECAY=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,
@@ -732,6 +774,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")
@@ -793,7 +837,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.
@@ -852,6 +896,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
}
@@ -992,6 +1075,9 @@ 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()
suspendAttemptFailed = false
updateSuspendPolicy()
@@ -1070,11 +1156,28 @@ class SessionService : Service() {
private fun watchLaunchRequests(dir: File) {
launchWatcher?.stopWatching()
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready").forEach { File(dir, it).delete() }
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp",
"steam-native-sleep", "steam-native-sleep.tmp", "steam-native-ready", "steam-native-ready.tmp"
).forEach { File(dir, it).delete() }
val gen = sessionGen
@Suppress("DEPRECATION")
val watcher = object : android.os.FileObserver(dir.path, CLOSE_WRITE or MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (path == "steam-native-ready") {
val request = File(dir, path)
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
request.delete()
mainHandler.post {
if (gen != sessionGen || !SessionState.running || token != nativeSleepToken) return@post
nativeSleepReady = true
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
nativeSleepTimeout = null
writeSteamSleepState(token, "paused")
SessionEvents.record("steam.native_sleep_prepared")
updateSuspendPolicy()
}
return
}
if (path == "steam-sleep") {
val request = File(dir, path)
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
@@ -1142,18 +1245,30 @@ class SessionService : Service() {
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
manualPauseRequested = true
}
val controller = suspendController ?: return
val hidden = !activityVisible || !screenOn
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_MANUAL -> manualPauseRequested
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
else -> false
}
val controller = suspendController ?: return
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
val gen = sessionGen
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"nativeSleep" to (nativeSleepToken != null),
))
if (shouldSuspend) {
controller.freeze { success ->
@@ -1166,6 +1281,7 @@ class SessionService : Service() {
releaseLocks()
refreshNotification()
} else {
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session stopped")
@@ -1211,7 +1327,63 @@ class SessionService : Service() {
}
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy == SessionPrefs.SUSPEND_AUTO && activityVisible && screenOn) completeSteamSleep()
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
}
/** Let Steam prepare before freezing; retain the wake lock until that freeze completes. */
private fun prepareNativeSleep() {
val token = UUID.randomUUID().toString().replace("-", "")
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
nativeSleepTimeout = null
nativeSleepReady = true
nativeSleepFallback = true
writeSteamSleepState(token, "paused")
SessionEvents.record("steam.native_sleep_timeout")
updateSuspendPolicy()
}
nativeSleepTimeout = timeout
// A missing or stalled guest helper must not keep the device awake indefinitely.
mainHandler.postDelayed(timeout, 4000L)
}
private fun completeNativeSleep() {
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
nativeSleepTimeout = null
val token = nativeSleepToken ?: return
writeSteamSleepState(token, "awake")
val dir = LinuxRuntime.sessionRoot(this)
File(dir, "steam-native-sleep").delete()
File(dir, "steam-native-ready").delete()
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
}
/** Resume must also recover a Steam sleep whose request never reached Android. */
private fun requestSteamWake() {
if (!SessionState.running || SessionState.mode != MODE_STEAM) return
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-wake.tmp")
staged.writeText("wake\n")
check(staged.renameTo(File(dir, "steam-wake")))
}.onFailure { Log.w(TAG, "could not request Steam wake", it) }
}
private fun finishSessionStop(status: Int, stoppedGen: Int) {
@@ -1266,10 +1438,12 @@ class SessionService : Service() {
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
completeNativeSleep()
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
}
updateDownloadMonitoring()
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
@@ -1438,11 +1612,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())
@@ -1456,6 +1645,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>()
@@ -1480,6 +1703,7 @@ class SessionService : Service() {
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"
@@ -1499,6 +1723,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"
@@ -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
}
}
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.session
/**
* What to call a catalog component on screen. Bannerlator's catalog keys (vcredist2010_dll,
* oalinst_dll, d3dcompiler_47...) have no display name, and some descriptions do not fit the entry,
* so names come from here: families by pattern, the rest from a table, the key itself otherwise.
* The "_dll" twin of an installer (the direct DLL copy) shares the installer's name.
*/
object WinComponentNames {
private val TABLE = mapOf(
"oalinst" to "OpenAL", "XLiveRedist" to "Games for Windows Live", "gfw" to "Games for Windows Live",
"physx" to "NVIDIA PhysX", "xact" to "DirectX XACT audio", "xact_x64" to "DirectX XACT audio (64-bit)",
"xinput" to "XInput", "xna31" to "XNA Framework 3.1", "xna40" to "XNA Framework 4.0",
"d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
"directmusic" to "DirectMusic", "directplay" to "DirectPlay", "directshow" to "DirectShow",
"dsound" to "DirectSound", "dx8vb" to "DirectX 8 for Visual Basic", "mediafoundation" to "Media Foundation",
"quartz" to "DirectShow (quartz)", "amstream" to "DirectShow streams (amstream)", "devenum" to "DirectShow devices (devenum)",
"cnc-ddraw" to "cnc-ddraw (DirectDraw)", "gdiplus" to "GDI+", "gecko" to "Wine Gecko", "mono" to "Wine Mono",
"vcredist6" to "Visual C++ 6", "vcredist6sp6" to "Visual C++ 6 SP6",
"vcredist2015" to "Visual C++ 2015", "vcredist2019" to "Visual C++ 2015-2019", "vcredist2022" to "Visual C++ 2015-2022",
"vbrun6" to "Visual Basic 6 runtime", "mfc40" to "MFC 4.0", "mfc42" to "MFC 4.2",
"dotnetcore3" to ".NET Core 3", "dotnetcoredesktop3" to ".NET Desktop 3", "dotnetcoredesktop6" to ".NET Desktop 6",
"dotnetcoredesktop7" to ".NET Desktop 7", "dotnetcoredesktop8" to ".NET Desktop 8", "dotnet50" to ".NET 5",
"webview2" to "Microsoft WebView2", "powershell" to "PowerShell", "powershell_core" to "PowerShell Core",
"K-Lite" to "K-Lite Codec Pack", "VulkanRT" to "Vulkan Runtime", "ffdshow" to "ffdshow codecs",
"lavfilters702" to "LAV Filters 0.70.2", "lavfilters741" to "LAV Filters 0.74.1", "quicktime72" to "QuickTime 7.2",
"cjkfonts" to "CJK fonts", "jet40" to "Jet 4.0 database", "mdac28" to "MDAC 2.8", "wsh57" to "Windows Script Host 5.7",
"riched20" to "RichEdit 2.0", "msftedit" to "RichEdit 4.1 (msftedit)", "sqlite3" to "SQLite 3",
"win7" to "Windows 7 mode", "winXP" to "Windows XP mode", "gmdls" to "General MIDI sounds (gm.dls)",
)
private val VCREDIST = Regex("vcredist(\\d{4})")
private val DOTNET = Regex("dotnet(\\d)(\\d)(\\d?)(sp\\d)?")
private val D3DCOMPILER = Regex("d3dcompiler_(\\d+)")
private val MSXML = Regex("msxml(\\d)")
private val MONO = Regex("mono-([\\d.]+)")
private val XAUDIO = Regex("xaudio([\\d.]+)")
fun of(id: String): String {
val base = id.removeSuffix("_dll")
TABLE[base]?.let { return it }
VCREDIST.matchEntire(base)?.let { return "Visual C++ " + it.groupValues[1] }
DOTNET.matchEntire(base)?.let { m ->
val (major, minor, patch, sp) = m.destructured
return ".NET Framework $major.$minor" + (if (patch.isNotEmpty()) ".$patch" else "") +
(if (sp.isNotEmpty()) " " + sp.uppercase() else "")
}
D3DCOMPILER.matchEntire(base)?.let { return "D3DCompiler " + it.groupValues[1] }
MSXML.matchEntire(base)?.let { return "MSXML " + it.groupValues[1] }
MONO.matchEntire(base)?.let { return "Wine Mono " + it.groupValues[1] }
XAUDIO.matchEntire(base)?.let { return "XAudio " + it.groupValues[1] }
if (base.startsWith("dm") || base == "dswave" || base == "dsdmo") return "DirectMusic ($base)"
return id
}
}
@@ -0,0 +1,399 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.core.ArchivePaths
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.GuestCommand
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.zip.ZipInputStream
/**
* Windows components (OpenAL, d3dx9, XAudio, VC++ DLLs...) for a game's Proton prefix, picked per
* game, from Bannerlator's catalog (components.json on winlator-contents, Bottles' manifest format).
*
* Installing one runs its steps once, into the runtime: the archives it names are downloaded and
* its DLLs copied to /opt/droiddeck/wincomponents/<name>/{system32,syswow64}, with component.json
* holding its DLL overrides and the components it bundles. The picks go to the runtime as
* ~/.config/droiddeck/wincomponents.json, and droiddeck-wincomponents copies the picked components
* into the game's prefix at every launch (see that script for why every launch).
*
* A component whose installer is a Windows Installer package (.msi: XNA, PhysX, Games for Windows
* Live, MSXML...) installs here too, without running it: droiddeck-msi-install reads the package's
* tables in the runtime and lays out its files (Program Files, the .NET GAC, winsxs) and registry
* values the way Wine's msiexec would, under the same store folder. One that runs any other
* installer (.exe), unpacks a cabinet or edits the prefix's registry by itself is listed with the
* reason it cannot be installed yet.
*/
object WinComponents {
private const val TAG = "WinComponents"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
private const val SELECTION = "wincomponents.json"
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
private const val STORE = "opt/droiddeck/wincomponents"
private val ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
// register_dll is left out on purpose: the DLLs it names are ones Wine ships and has registered
// at the same path, so the native file at that path is the one the registration loads.
private val FILE_STEPS = setOf("download_archive", "archive_extract", "copy_dll", "copy_file", "override_dll", "register_dll")
private val INSTALLER_STEPS = setOf("install_exe", "install_msi")
private const val MSI_INSTALL = "/usr/local/bin/droiddeck-msi-install"
/** Where a downloaded .msi waits for the installer, inside the runtime so it can read it. */
private const val MSI_CACHE = "$STORE/.packages"
/** Catalog entries Proton already provides newer copies of, which an install would only shadow. */
private val PROTON_PROVIDES = setOf("gecko")
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
class Step(val action: String, val json: JSONObject) {
fun str(key: String): String = json.optString(key, "")
}
class Component(
val name: String, val description: String, val provider: String, val status: String,
val dependencies: List<String>, val steps: List<Step>,
)
enum class Support { READY, NEEDS_INSTALLER, UNSUPPORTED }
/** A step that installs a Windows Installer package, which droiddeck-msi-install does here. */
private fun isMsi(step: Step): Boolean = step.action in INSTALLER_STEPS && step.str("url").startsWith("https://") &&
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
.any { it.endsWith(".msi", ignoreCase = true) })
fun fetch(): List<Component>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
return try {
val arr = JSONObject(body).getJSONArray("components")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val name = o.optString("name")
if (!validId(name)) return@mapNotNull null
val steps = o.optJSONArray("steps") ?: JSONArray()
val deps = o.optJSONArray("dependencies") ?: JSONArray()
Component(
name, o.optString("description"), o.optString("provider"), o.optString("status"),
(0 until deps.length()).map { deps.getString(it) }.filter { validId(it) },
(0 until steps.length()).map { steps.getJSONObject(it).let { s -> Step(s.optString("action"), s) } },
)
}
} catch (e: Exception) {
Log.w(TAG, "catalog: $e"); null
}
}
/** Whether [c] can be installed here, its bundled components included. */
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
if (c.status != "ready" || depth > 8 || c.name in PROTON_PROVIDES) return Support.UNSUPPORTED
val installers = c.steps.filter { it.action in INSTALLER_STEPS }
val own = when {
installers.isNotEmpty() && installers.all { isMsi(it) } &&
c.steps.all { it in installers || it.action == "delete_dlls" || it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
installers.isNotEmpty() -> Support.NEEDS_INSTALLER
c.steps.all { it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
else -> Support.UNSUPPORTED
}
if (own != Support.READY) return own
return c.dependencies.map { all[it]?.let { d -> support(d, all, depth + 1) } ?: Support.UNSUPPORTED }
.maxOrNull() ?: Support.READY
}
private fun fileStepOk(step: Step): Boolean = when (step.action) {
"download_archive", "archive_extract" -> step.str("url").let { url ->
!url.startsWith("http") || url.startsWith("https://github.com/") &&
url.substringBefore('?').let { it.endsWith(".tar.xz") || it.endsWith(".zip") }
}
"copy_dll", "copy_file" -> ".." !in step.str("dest")
else -> true
}
/** The installed version of a component, or null. */
fun installed(context: Context, id: String): String? = runCatching {
JSONObject(File(LinuxRuntime.rootDir(context), "$STORE/$id/component.json").readText()).optString("version", "?")
}.getOrNull()
/** Every component in the runtime, by id. */
fun installedIds(context: Context): List<String> =
File(LinuxRuntime.rootDir(context), STORE).listFiles { f -> f.isDirectory && validId(f.name) }
.orEmpty().map { it.name }.filter { installed(context, it) != null }.sorted()
/**
* Installs [c] and the components it bundles that are not installed yet. [onProgress] gets a
* line for the step and 0..100 (or -1). Returns null on success, else a message.
*/
fun install(context: Context, c: Component, all: Map<String, Component>, onProgress: (String, Int) -> Unit): String? {
if (support(c, all) != Support.READY) return "${c.name} cannot be installed here yet"
val order = ArrayList<Component>()
fun visit(x: Component, depth: Int) {
if (depth > 8 || order.any { it.name == x.name }) return
x.dependencies.mapNotNull { all[it] }.forEach { visit(it, depth + 1) }
if (x.name == c.name || installed(context, x.name) == null) order.add(x)
}
visit(c, 0)
// One download per archive however many components copy out of it (DirectMusic's eleven
// parts all come from dxnt, 100 MB).
val work = File(context.cacheDir, "wincomponents").apply { FileUtils.delete(this); mkdirs() }
val archives = HashMap<String, File>()
return try {
for (x in order) {
onProgress(x.name, -1)
installOne(context, x, work, archives, onProgress)?.let { return "${x.name}: $it" }
}
null
} catch (e: Exception) {
Log.e(TAG, "install ${c.name}", e)
e.message ?: "Install failed"
} finally {
FileUtils.delete(work)
}
}
private fun installOne(context: Context, c: Component, work: File, archives: HashMap<String, File>, onProgress: (String, Int) -> Unit): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
val staging = File(root, "$STORE/.${c.name}.new").apply { FileUtils.delete(this); mkdirs() }
val overrides = ArrayList<String>()
var source: File? = null
var msi: JSONObject? = null
for (step in c.steps) when (step.action) {
// A component may have several packages (PowerShell's 32- and 64-bit): each adds to the
// same folder, the first one names the component.
"install_msi", "install_exe" -> {
val (result, problem) = installMsi(context, c, step, staging, onProgress)
if (result == null) return problem
msi = msi?.apply {
val notes = optJSONArray("notes") ?: JSONArray().also { put("notes", it) }
result.optJSONArray("notes")?.let { for (i in 0 until it.length()) notes.put(it.get(i)) }
} ?: result
}
// A catalog step that makes room for the installer's own copy; ours is copied anyway.
"delete_dlls" -> Unit
"download_archive", "archive_extract" -> {
val url = step.str("url")
if (!url.startsWith("http")) continue
source = archives[url] ?: run {
val name = url.substringBefore('?').substringAfterLast('/')
val file = File(work, "${archives.size}-$name")
val ok = Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }
if (!ok) return "download failed: $name"
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return "checksum mismatch: $name"
}
onProgress("${c.name}: unpacking $name", -1)
val dir = File(work, "${archives.size}-x").apply { mkdirs() }
if (!extract(file, dir)) return "could not unpack $name"
file.delete()
dir.also { archives[url] = it }
}
}
"copy_dll", "copy_file" -> {
val from = source ?: return "a copy step before any archive"
val dest = step.str("dest")
val (arch, base) = when (dest.substringBefore('/').lowercase()) {
"win64", "system32" -> "win64" to "system32"
"win32", "syswow64" -> "win32" to "syswow64"
else -> null to "system32"
}
val sub = dest.substringAfter('/', "")
copyMatching(from, step.str("file_name"), File(staging, if (sub.isEmpty()) base else "$base/$sub"), arch)
}
"override_dll" -> {
fun add(dll: String, type: String) {
if (dll.isEmpty()) return
overrides += when (type.ifEmpty { "native,builtin" }) {
"native,builtin" -> dll
"native" -> "$dll=n"
"builtin" -> "$dll=b"
"builtin,native" -> "$dll=b,n"
else -> dll
}
}
add(step.str("dll"), step.str("type"))
step.json.optJSONArray("bundle")?.let { b ->
for (i in 0 until b.length()) b.getJSONObject(i).let { add(it.optString("value"), it.optString("data")) }
}
}
"register_dll" -> Unit
else -> return "unsupported step ${step.action}"
}
val version = msi?.optString("version")?.takeIf { it.isNotEmpty() }
?: Regex("""\b(\d+\.\d+(\.\d+)*)\b""").find(c.description)?.value ?: "catalog"
val meta = JSONObject()
.put("id", c.name).put("version", version)
.put("overrides", JSONArray(overrides.distinct()))
.put("requires", JSONArray(c.dependencies))
msi?.let { meta.put("kind", "msi").put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
FileUtils.writeString(File(staging, "component.json"), meta.toString(1))
val target = File(root, "$STORE/${c.name}")
FileUtils.delete(target)
if (!staging.renameTo(target)) return "could not place the files"
return null
}
/**
* Downloads the component's .msi into the runtime and has droiddeck-msi-install lay it out in
* [staging]. Returns the installer's result (product, version, counts, notes), or null and why.
* Everything the installer prints also goes to Download/DroidDeck/tools.
*/
private fun installMsi(context: Context, c: Component, step: Step, staging: File, onProgress: (String, Int) -> Unit): Pair<JSONObject?, String> {
val root = LinuxRuntime.rootDir(context)
val url = step.str("url")
val name = url.substringBefore('?').substringAfterLast('/')
val cache = File(root, MSI_CACHE).apply { mkdirs() }
val file = File(cache, "${c.name}.msi")
try {
if (!Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }) {
return null to "download failed: $name"
}
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return null to "checksum mismatch: $name"
}
onProgress("${c.name}: installing", -1)
var result: JSONObject? = null
var problem: String? = null
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> onProgress("${c.name}: ${line.removePrefix("progress ")}", -1)
line.startsWith("result ") -> result = runCatching { JSONObject(line.removePrefix("result ")) }.getOrNull()
line.startsWith("error ") -> problem = line.removePrefix("error ")
}
}
val done = result
if (status != 0 || done == null) return null to (problem ?: "the installer stopped (status $status)")
return done to ""
} catch (e: Exception) {
Log.e(TAG, "msi ${c.name}", e)
return null to (e.message ?: "the installer failed")
} finally {
file.delete()
}
}
private fun digest(file: File, algorithm: String): String {
val md = MessageDigest.getInstance(algorithm)
FileInputStream(file).use { input ->
val buffer = ByteArray(1 shl 16)
while (true) { val n = input.read(buffer); if (n < 0) break; md.update(buffer, 0, n) }
}
return md.digest().joinToString("") { "%02x".format(it) }
}
private fun extract(archive: File, dest: File): Boolean = try {
if (archive.name.endsWith(".zip", ignoreCase = true)) ZipInputStream(BufferedInputStream(FileInputStream(archive))).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val out = ArchivePaths.inside(dest, entry.name)
if (out != null && !entry.isDirectory) {
out.parentFile?.mkdirs()
FileOutputStream(out).use { FileUtils.copy(zip, it) }
}
entry = zip.nextEntry
}
} else TarArchiveInputStream(XZCompressorInputStream(BufferedInputStream(FileInputStream(archive), 1 shl 16))).use { tar ->
var entry = tar.nextEntry
while (entry != null) {
val out = ArchivePaths.inside(dest, entry.name)
if (out != null && entry.isFile) {
out.parentFile?.mkdirs()
FileOutputStream(out).use { FileUtils.copy(tar, it) }
}
entry = tar.nextEntry
}
}
true
} catch (e: Exception) {
Log.w(TAG, "extract ${archive.name}", e); false
}
/**
* Bannerlator's rule: the manifest's own temp paths are Bottles', so the files are found by name
* anywhere in the unpacked tree, preferring the ones under the matching win32/win64 folder.
*/
private fun copyMatching(srcRoot: File, pattern: String, dest: File, arch: String?) {
if (pattern.isEmpty()) return
val rx = Regex("^" + pattern.split("*").joinToString(".*") { Regex.escape(it) } + "$", RegexOption.IGNORE_CASE)
var matches = srcRoot.walkTopDown().filter { it.isFile && rx.matches(it.name) }.toList()
if (arch != null) {
val inArch = matches.filter { it.path.contains("/$arch/", true) }
val other = if (arch == "win64") "win32" else "win64"
matches = inArch.ifEmpty { matches.filterNot { it.path.contains("/$other/", true) } }
}
dest.mkdirs()
matches.forEach { f -> f.copyTo(File(dest, f.name.lowercase()), overwrite = true) }
}
/** Removes a component from the runtime and from every game's picks. */
@Synchronized
fun uninstall(context: Context, id: String) {
require(validId(id))
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
}
/** Each game's picks, by the appid its prefix is named after. */
@Synchronized
fun read(context: Context): Map<String, List<String>> = try {
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
val games = json.optJSONObject("games") ?: JSONObject()
games.keys().asSequence().associateWith { app ->
val ids = games.getJSONArray(app)
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
}
} catch (_: java.io.FileNotFoundException) {
emptyMap()
}
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
@Synchronized
fun setPicks(context: Context, appKey: String, ids: List<String>) {
require(ids.all { validId(it) })
val all = read(context).toMutableMap()
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
save(context, all)
}
private fun save(context: Context, games: Map<String, List<String>>) {
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
}).toString()
write(File(context.filesDir, SELECTION), text)
publish(context, text)
}
/**
* Writes the picks where the launch script reads them; SessionFiles calls this at every session
* start too, so a runtime reinstalled since keeps them. An empty selection still writes a file,
* which is how a component turned off for every game is taken out of their prefixes.
*/
@Synchronized
fun publish(context: Context, text: String? = null) {
val body = text ?: runCatching { AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8) }.getOrNull() ?: return
val root = LinuxRuntime.rootDir(context)
if (root.isDirectory) write(File(root, GUEST_SELECTION), body)
}
private fun write(file: File, text: String) {
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
val atomic = AtomicFile(file)
val output = atomic.startWrite()
try {
output.write(text.toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
throw error
}
}
}
@@ -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))
@@ -90,7 +90,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
}
}
Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
Rise(2) { LaunchSettings(s, a, host) }
Rise(2) { LaunchSettings(s, a, host, current) }
Rise(3) { GameFileFolderActions(s, a) }
}
return
@@ -109,7 +109,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
GameActions(current, s, a)
}
SectionTitle(stringResource(R.string.games_launch_settings), null)
LaunchSettings(s, a, host)
LaunchSettings(s, a, host, current)
GameFileFolderActions(s, a)
}
}
@@ -174,7 +174,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)
@@ -101,6 +101,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,
@@ -169,6 +171,7 @@ class FrontEndActions(
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 +182,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,17 +307,19 @@ internal fun SetupPanel(
}
}
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
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 ->
@@ -322,10 +331,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
}
@@ -421,10 +430,20 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
* app-wide settings - each card opens the same page or menu Setup does.
*/
@Composable
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) {
// Three across, two on a narrow page; each row's cards share one height.
val columns = if (LocalNarrowPane.current) 2 else 3
val controller = a.controller
val context = androidx.compose.ui.platform.LocalContext.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
val wincompKey = game?.let {
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
else it.appId.takeIf { id -> id > 0 }?.toString()
}
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
add { m ->
@@ -436,7 +455,19 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
}
}
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
if (wincompKey != null) add { m ->
SettingCard(
stringResource(R.string.wincomp_title),
if (wincompPicks.isEmpty()) stringResource(R.string.wincomp_card_none) else wincompPicks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) },
"card:wincomp", m,
) { wincompOpen = inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard }
}
}
val opened = wincompOpen
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
) { wincompOpen = null }
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (row in cards.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
@@ -470,3 +501,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)
}
}
}
}
}
}
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more