Compare commits

...
Author SHA1 Message Date
The412Banner df88872c98 Stores: cache first, refreshed behind; sizes per version; smaller cards
A store's library shows from its saved copy at once and is refreshed in
the background at most every six hours, or from the account row's
Refresh. The result is merged so unchanged cards keep their objects:
only changed cards recompose, and the grid's scroll and the pad's focus
stay. Shelves saved on an earlier run count as fresh for three hours.

Sizes are kept per game and version in filesDir/stores/<store>/
sizes.json, format-versioned, and looked up again only for a new
version. An installed card shows its size on disk with a small green
check after it.

Cards are a quarter smaller: 112 dp least width for shelf cards and grid
cells, the grid fitting as many columns as the width allows, so a
density setting changes the column count instead of the card size.
2026-10-09 09:59:10 -04:00
The412Banner 5109fa4839 Stores: the size lookup has a block body for its early returns 2026-10-09 09:52:20 -04:00
The412Banner 3c64c6b3b8 Stores: every owned card shows its size
Epic's and Amazon's library answers carry no size, so their cards
showed none. A card on screen, or close to it, now has its size looked
up once - Epic's from the build manifest (the files this device
installs), Amazon's from its download manifest - three at a time in the
background, and kept in the store's prefs where the library reads it.
The size sits at the right of the title strip in the title's own type;
nothing shows until it is known, and a marker keeps the slot.
2026-10-09 09:49:23 -04:00
The412Banner f5c343bde0 Stores: the Library dropdown opens under its tab, as wide as its entries
The dropdown used the menus' right-aligned placement and their 220 dp
minimum, so under a tab near the left it was pushed to the edge over
the rail. AnchoredMenu has a compact mode now - under its anchor, left
edges aligned, as wide as its entries between the tab's width and
260 dp - which the Library dropdown uses.
2026-10-09 09:40:01 -04:00
The412Banner 6626cf99b7 Stores: two tabs, Store and Library, with Library's filter in a dropdown
The All tab's count mixed owned games with whatever the shelves had
loaded (GOG: Library 33, All 120). A store now has Store and Library;
Library's dropdown filters All - every owned game, the default and the
tab's count - or Installed, and the tab reads the filter. The old All
view is gone. Amazon has Library alone. Search follows the filter; the
shoulder buttons cycle Store and Library.
2026-10-09 09:17:40 -04:00
The412Banner 1d18b43bc2 Stores: the chip row has no box of its own
The sticky chip row painted the theme's flat background colour, a near
black box against the front end's tinted background. It paints nothing
now; the content scrolling beneath is clipped at the row's edge and,
once scrolled, faded out there through its own alpha rather than a
colour laid over it.
2026-10-09 08:48:32 -04:00
The412Banner 62502877fc Stores: Downloads rows act with icons; finished rows below one Clear
The row actions are round icon buttons with their labels as
descriptions: pause, resume, play, retry, and a cancel that asks with a
red delete and an undo. Downloads under way stay on top; finished ones
sit below a line whose X clears them all - rows only, a failed
download's kept files stay for its game page's Clear - with installed
games as a compact row with Play. Records the pre-PR device checks.
2026-10-09 08:12:03 -04:00
The412Banner d62b96ac15 Games: the saves menu shows values, not explanations
Import saves and the two Export rows lose their descriptive subtitles,
in Manage saves and Cloud saves alike; the rows that carry live values
keep them. The three strings are removed from every locale.
2026-10-09 07:46:31 -04:00
The412Banner fd11ed82c4 Stores: the Cloud saves view does not move under the pad
It opened with a Checking row and re-laid out a second later, shifting
every row and the pad's focus. The switch, Upload and Download are now
there from the first frame - the switch focused, its state read from
the sidecar; the two actions disabled with an ellipsis until the cloud
check is back - and only their subtitles change after. A conflict turns
Upload and Download into Keep local and Keep cloud in place.
2026-10-09 07:04:24 -04:00
The412Banner 39ff0a653d Stores: one place for a store game's saves, called Cloud saves
A GOG or Epic game's hero button reads Cloud saves and opens its saves
view, which now holds the Cloud saves switch with the sync status,
Upload, Download and the conflict actions, or No cloud saves when the
store keeps none. The switch is gone from the cards: GOG has no card
left, Epic's keeps its sign-in, offline and Resolve actions. Steam and
Custom games keep Manage saves. Records the device proof of cloud saves
with ELDERBORN.
2026-10-09 06:54:31 -04:00
The412Banner 6a175f1097 Stores: cloud uploads on every exit path; downloads keep going in the background
Cloud saves no longer depend on the compat tool's exit hook alone,
which never runs when the session is closed from the drawer. The
pre-launch step marks the game dirty in the app's storage, and the
upload runs on the first of the exit request, the end of the session
(after teardown) or the app's next start for a mark a killed app left,
not while a session runs. The mark goes only after an upload went
through or a final skip; each logs its trigger. The planner test runs
under Robolectric for org.json.

Downloads keep going with the screen off: while one runs the service
holds a Wi-Fi lock (high-performance before Android 14, low-latency
from it) and a partial wake lock taken with a timeout and renewed with
the progress, both released as soon as nothing runs. The notification
has a real progress bar and the Downloads row's line (game, percent,
sizes, speed, time left; the stage while installing), Pause / Resume,
and a Cancel that first asks Delete / Keep for ten seconds. A paused
download leaves a notice to resume from; an installed game posts a
notice that opens it in Games, a failed one opens Downloads. On
Android 13 and later notifications are asked for once, the first time
an install starts from a screen.
2026-10-09 06:36:29 -04:00
The412Banner cc981aa69f Fetch core: a latency brake on the window, and stalled hosts handed off
5G hotspot run (ELDERBORN, GOG): the window grew 31 -> 84 while each request's
delay went from 3 s to 11 s and throughput stayed at 10-20 MB/s, and the Fastly
host froze at 49 MB for the rest of the run while gcdn carried everything, its
requests waiting out their timeout. Both live in the shared core, so GOG, Epic
and Amazon all get the fixes.

Latency: every request's time to first byte is measured; the lowest per-probe
mean is the run's baseline. A probe whose mean is 2.5x the baseline and at
least 250 ms above it stops growth (hold:latency) unless throughput rose 25 %;
if the delay keeps rising on the next probe the window gives back 15 %
(shrink:latency, then the usual cooldown). A fast link's TTFB stays within a
few round trips however wide the window gets, so it is not affected. The
fetch-window line gains ttfb, ttfb_base and stalled_hosts.

Stalls: bytes are counted per host as they arrive (whole-body mode now reads
the body piece by piece too). A host whose in-flight requests receive nothing
for 10 s while another host delivers has those requests cancelled and put
back at the head of the queue, and rests for 30 s (doubling per repeat, up to
5 min, reset by a success): host-stall host=... requeued=N. A cancelled
request is not held against the item.

Deadlines: whole-body requests keep one deadline for headers and body; a
stream may now sit at most 30 s without a byte (or the store's own timeout
when shorter), so a dead connection on a single-host run cannot hold an item.

Host tests: a simulated rising-delay link (hold, then x0.85 shrinks, never
grows again), a fast link (keeps doubling), and a two-host run where one host
freezes after 100 bytes (detected, items moved, well inside the timeout).
2026-10-09 06:32:44 -04:00
The412Banner 41422407c4 Stores: cloud saves recognise GOG games, wait for a new prefix, never overwrite blindly
On device nothing ran for ELDERBORN: a GOG game without arguments has
no launcher .bat, Steam runs its exe, and droiddeck-store-launch only
looked for the .bat. It now recognises the exe too, looking through the
folders above it for the sidecar, and every path logs a result line.

A first launch has no prefix yet: the download is answered as deferred
and runs once Proton has made the prefix's user folder, within a minute,
without holding the launch.

Uploads are safe now. CloudPlan decides per file against this device's
baseline - the local and cloud state at the last sync: no upload before
a download has completed once, the first download lets the cloud win,
and a file changed on both sides is a conflict that Manage saves offers
to settle with Keep cloud or Keep local. The cloud copy is backed up
before an upload replaces it, as the local folder is before a download.
2026-10-09 06:29:36 -04:00
The412Banner b323a0b33e Stores: GOG's engine gets every CDN host and reports bytes between files
Wires the engine's new contract (0849a7c): the native start takes the
whole CDN set from the secure link, and the engine's periodic byte
progress - every 250 ms, files still in flight included - drives the
bar and the speed, so a large file no longer moves the bar in one jump.
The count shown adds what earlier runs and verified skips already
credited and never goes back.
2026-10-09 06:10:20 -04:00
The412Banner 0849a7c18e GOG engine: every CDN from the secure link, and byte progress between files
Device report (ELDERBORN): the run used one CDN (used=1/1, fastly) at 12-13 MB/s
with the window pinned at its ceiling, and the progress bar jumped because
onProgress only fires when a whole file is finished.

nativeStart's third parameter becomes cdnBases: String[] - every urls[] entry of
the secure-link answer (or the one dependency store base). Bases are deduped by
host, each chunk item carries one URL per host and the fetch core spreads the
window across them with a per-host cap of max(6, ceil(workers / hosts)); one base
behaves as before. The start line logs hosts=a,b,c.

A ticker thread beside the fetch reports onBytes(done, total) every 250 ms when
the value moved: assembled bytes plus in-flight files (gen2: finished chunks;
gen1: the current attempt's pieces, reset on retry so nothing counts twice).
A listener without onBytes still works. JNI.md has the new signature and
callback. Epic (per ~1 MiB chunk) and Amazon (per written piece) already report
more often than 4/s.
2026-10-09 06:04:44 -04:00
The412Banner 889e783520 Stores: GOG downloads use every CDN host; progress bars glide
GOG's secure link lists several CDN hosts and only the first was taken,
so every request went to one host. All are parsed now and kept as one
set, refreshed together when the link expires; the built-in pool takes
them in turn and the native engine gets the first until its contract
takes a list. The progress bar fill glides to each new value over a
quarter second, drawn without relayout.
2026-10-09 06:02:37 -04:00
The412Banner 90a8a526d5 Tests: the Proton launchers start the game through bl_run
bl_run execs Proton as before, and waits for it only for a store game
with cloud saves; the game-environment check now looks for that line.
2026-10-09 05:47:16 -04:00
The412Banner 1580e3ee64 Stores: GOG and Epic cloud saves
Ported from Bannerlator's cloud-save managers. Before every launch the
cloud copy comes down where it is newer: droiddeck-store-launch asks the
app and waits up to 15 s. For a store game with cloud saves it tells the
compat tool so, and the tool waits for Proton instead of exec'ing it,
then asks for the upload without waiting. Newest wins per file, with an
MD5 check so a file the game only touched is not sent again; a download
first copies the folder to the app's storage (last three kept).

The save folder is GOG's remote-config location template (by the game's
Galaxy client id) or Epic's CloudSaveFolder catalog attribute, expanded
inside the game's Proton prefix or install folder by CloudSavePaths,
which refuses a path that leaves its boundary. GOG uploads with a token
issued to the game's own client (its secret is kept at install from now
on), Epic with signed write links. The GOG and Epic cards get a Cloud
saves switch kept in the sidecar; Manage saves shows the last sync with
Upload and Download, or No cloud saves.
2026-10-09 05:39:40 -04:00
The412Banner 935b70c585 Stores: the EOS overlay is removed; sign-in goes through the browser
Device-proven on bf81104: Metalstorm's EOS corrective action fell back to
a browser, the hand-off opened it in Android's browser, the user approved,
and the game signed in. With the overlay pointer set the game exited
while loading on every launch, so the overlay goes: no switch on the
Epic card, no download, no OverlayPath. A launch removes a pointer an
earlier build wrote, and the app deletes an earlier 656 MB download once;
an overlay field in an older sidecar is read past. The work is parked on
park/epic-eos-overlay. The browser hand-off, the Epic card's sign-in,
offline and Resolve actions, and BL_DEBUG_BROWSER stay.
2026-10-09 05:25:59 -04:00
The412Banner bf811040ae Session: a cold-start game launch waits for the Steam interface
A game launched from the Games tab with no session running sat at
Launching: the session put steam://rungameid on the client's command
line, the client took it seconds before its interface was up, and its
launch stopped waiting for a response to ShowInterstitials that nothing
gave. The same launch from inside Steam went through. The URL is now
held back and handed over through the in-session steam-game request once
gamescope's focus is the Steam interface and four seconds have passed,
or after two minutes regardless.
2026-10-09 04:40:09 -04:00
The412Banner 272cd60b19 Stores: a game's web links reach Android - the hand-off was never packaged
EOS's browser flow never reached Android because the hand-off script
was not in the APK: the build copies only usr/local/bin/droiddeck-*,
steam-compatibility and usr/bin/** of the overlay into the assets, so
usr/local/lib/droiddeck/browser/xdg-open was skipped, the session staged
nothing, the wrapper left the PATH alone, and Wine's winebrowser ran the
runtime's own xdg-open, which has no browser to open in a Steam session.
GE-Proton's winebrowser is Wine's own: its Browsers list, then xdg-open
by name, with no steam://openurl route.

The script is now usr/local/bin/droiddeck-open-url: named as Wine's
browser in the prefix by its absolute path, linked as xdg-open first on
the game's PATH, and reading the session channel from a file when Wine
does not pass BL_LAUNCH_DIR on. BL_DEBUG_BROWSER=1 adds Wine's shell and
winebrowser traces. The Epic card offers Resolve Epic sign-in, Epic's
account page behind its sign-in, where a pending corrective action is
presented.
2026-10-09 04:30:53 -04:00
The412Banner a844483080 Stores: Epic overlay off by default, switches saved on disk, registry writes checked
With the overlay pointer set Metalstorm exits while loading; without it
the game signs in and runs. The overlay now defaults to off: the
sidecar's epic block carries a version, and a block from before it (the
old default wrote overlay=true unasked) reads as off, in the app and in
droiddeck-store-launch alike. The overlay is fetched only for a game that
turns it on.

The card's switches read the sidecar fresh, write it and read it back,
and show what was read back. droiddeck-store-launch waits for a
wineserver still holding the prefix before appending to user.reg - one
that exits rewrites the file and drops the line - reads the value back
and logs what the file holds. Its docstring is raw. The game-PATH
xdg-open logs each call as scheme://host only and accepts
steam://openurl.
2026-10-09 00:09:39 -04:00
The412Banner 1d1de10b2d Stores: the Epic code reaches the game on its command line only
Metalstorm's Player.log printed its environment, and with it the
exchange code: the launcher read the code into a variable the game
inherited. The variable (now with a plain name) is cleared on the same
line that starts the game - cmd has expanded it there already - so the
game gets the code as arguments and nothing of it in its environment.
A launcher from an earlier build is rewritten before the next Epic
launch, from the app and from the Steam-launch request alike.
2026-10-09 00:03:34 -04:00
The412Banner 7ab0c12c97 Stores: no description line that only repeats the title
Metalstorm's page showed its own name again under the hero, where the
store's description was just the title. Such a description is now no
description.
2026-10-08 23:56:19 -04:00
The412Banner cd85eb1624 Stores: an unreadable overlay folder reads as no overlay
droiddeck-store-launch's overlay check raised on a folder it may not
read (the CI runner's /root); it now treats that as not installed, as it
must for any launch.
2026-10-08 23:46:21 -04:00
The412Banner dd2b2b08c3 Stores: an Epic card in a game's launch settings
Epic games get an Epic card in the Games tab's launch settings: Epic
sign-in (EOS) on, Launch offline off, Epic overlay on. The choices live
in the game's sidecar and survive an update, so both launch paths read
the same thing - the app before its own launches and droiddeck-store-
launch for one from the Steam client: no code when sign-in is off or
offline, the overlay pointer written or removed per the switch.
2026-10-08 23:39:42 -04:00
The412Banner 97a0f1553f Stores: Epic games get the EOS overlay, and their web links open on Android
Metalstorm's EOS sign-in asked for a one-time consent, could not load an
overlay to show it, and fell back to a browser flow that never finished.
Epic's own overlay component (the mechanism from Legendary's lfs/eos.py;
credits in EpicOverlay.kt) is now downloaded once with the account's
token into the runtime, after an Epic library sync or at the first
launch that finds it missing. At each launch droiddeck-store-launch
writes the one pointer, HKCU Software\Epic Games\EOS OverlayPath, into
the game's prefix, appended to user.reg as the DirectAudio key is, and
the app logs epic overlay installed=<build> prefix=<appid>.

Wine's browser is set to xdg-open, and on a game's PATH a small xdg-open
hands http(s) addresses to the app, which opens them in Android's
browser, so a device-code sign-in can be finished on the phone.
2026-10-08 23:31:34 -04:00
The412Banner 78e4cb8369 Stores: an Epic game signs in however it is launched
Metalstorm started from the Steam client's own Play button came up as a
guest: the exchange code was only minted when a launch began in the app.
The compat tool now runs droiddeck-store-launch on every real launch. It
finds the store launcher among Steam's arguments and, for an Epic game,
asks the app through the session's stores/req and stores/resp folders,
waiting up to five seconds. The app refreshes the token, mints the code
and writes it into the game's own folder; the code never crosses the
channel or the log. No answer and the game starts with its offline
identity; a code file older than five minutes is dropped first.

Every attempt logs epic launch id=... code=yes|no reason=...; a sign-in
that ran out dims the Epic chip and brings back the sign-in card.
2026-10-08 23:18:20 -04:00
The412Banner 64d5f92391 Stores: Amazon has Installed and Library only
Amazon has no public catalog, so its Store tab repeated the library row
for row and All was the library again. Its tabs are Installed and
Library; the open-on Store choice falls back to Library for it.
2026-10-08 23:05:38 -04:00
The412Banner 3dbc4ecc6d Stores: shelf titles only; the chip row keeps its own ground
The shelves lose their subtitle lines. The chip row sits on the page's
background with room under it, and the content scrolling beneath is
clipped there, with a short fade where it meets the row.
2026-10-08 22:56:38 -04:00
The412Banner 2f1e0a1348 Stores: GOG's shelf cache is re-read with the mature mark
Shelves cached by the previous build have no mature field and would show
every title until they aged out; the cache key moves to v2.
2026-10-08 22:45:38 -04:00
The412Banner ff9807ad29 Stores: Show mature content, off by default
The storefront's shelves and search leave out titles the store itself
rates or tags as adult unless Show mature content is on (Setup > Stores
and the cog). Library, Installed and owned games always show, and a title
without rating data stays. GOG's catalog gives ratings[].ageRating per
system and content tags; 17+ in any system or a mature / nsfw / sexual
content / nudity tag marks a title. Epic's store GraphQL carries no age
rating on an offer, so only an adult content tag would mark one.
2026-10-08 22:45:22 -04:00
The412Banner 37b6f336a7 Store engines: every thread they create runs at nice +10
On the device the menus' animations and controller navigation slowed for as
long as an Epic or GOG download ran: the engine's threads competed with the UI
at nice 0. Each thread the library starts now lowers itself first - the run's
worker (all three stores), the inflate/hash/write pool, the throughput
reporter, GOG's verify pass, the Epic assembler, and through on_thread_start
the tokio runtime's blocking pool (DNS). The JNI calling thread is the app's
and is left alone. Behaviour is otherwise unchanged; any process-worker count
from 1 up was and is accepted.

The plan/start line of every run logs the worker's actual nice value
(priority=nice10). libc becomes a direct dependency; it was already in the
tree through tokio at the pinned version.
2026-10-08 22:35:57 -04:00
The412Banner 1a527e8ed1 Stores: a download no longer slows the UI
Engines report per chunk or per file, and every report published a new
queue that the whole front end read - the rail badge was computed from
it in the root composition. Progress-only changes now reach the UI at
most four times a second (a stage or state change at once); each row and
card reads only its own entry; the list is replaced only when rows come,
go or change state; the badge count changes only when it changes; the
notification redraws at most once a second.

Download threads run at background priority - the queue's runner and
every manager's pool - and the tier leaves two cores for the UI. The
stores log is written in half-second batches.
2026-10-08 22:35:52 -04:00
The412Banner 1379396b99 Stores: the Installed tab draws what its count counts
The tab's count came from the installs on disk and its grid from the
library filtered by id, so two installs could read Installed (2) over an
empty grid. Both now come from installedCards: each install joined to its
library item by store id, or by title when the ids differ, else a card
made from the sidecar's own title and art.
2026-10-08 22:22:28 -04:00
The412Banner e835f5c523 Stores: GOG rows show 0 B, their disk size and a counted Install stage
A size of zero read as nothing ("Downloading 0% ·  / 1.1 GB"); it reads
0 B now, and small sizes in bytes. GOG and Amazon report the size on disk
from their manifests, shown beside the location as Epic's is. GOG writes
files in place while it downloads, so its Install stage has no file move
to count; it and Amazon's now count the finishing steps (the record and
launcher, the art, Steam) instead of sitting empty. GOG's plan line says
the window the tier gives the engine (Max = 96).
2026-10-08 22:16:17 -04:00
The412Banner 4748a217b3 Stores: a stage a store skips reads complete once a later one starts
Epic checks chunks while it fetches and never enters Verify; its Verify
segment stayed empty while Install filled. A stage before the active one
is now drawn full whether or not it was entered.
2026-10-08 22:05:10 -04:00
The412Banner 96d8678dd3 Stores: the stores log lives under filesDir/logs/stores
Beside the session folders, where every log is headed, so the log share
and the stores agree on one place.
2026-10-08 21:21:03 -04:00
The412Banner e0b0af4290 Stores: the log goes to daily files, not a panel
The Downloads page's engine log is gone. StoresState.logLine still
redacts every line and writes it to logcat, and now appends it to an
app-private file per day, filesDir/stores/logs/stores-<date>.log: seven
days kept (pruned as the app starts), each file rolling to .1 near 2 MB,
written in order on one background thread. The session's Share logs zip
carries these files under stores/, so they can be attached to a report.
2026-10-08 21:13:39 -04:00
The412Banner 8940b0ca32 Stores: redact every store log line, engine lines included
StoresState.logLine is where every store line goes, the native engines'
included; it now passes the text through StoreLog.redactLine before the
Downloads log and logcat see it. Each URL keeps its scheme, host and a
plain first path segment only - a GOG secure link carries its token in
the path - with no query, fragment or userinfo; token-like values and
Authorization / Cookie headers are blanked outside URLs as well.
2026-10-08 21:07:57 -04:00
The412Banner cb2f7c0109 Stores: stages named for what they do, both sizes, Cancel deletes
Epic's check of the files already there belongs to Manifest (Checking
n/N), not a Verify at 100% before anything is fetched. Install measures
its own write rate and ETA from the assembler's bytes and shows files
n/N. A row shows the download as fetched (compressed) against its total,
and the size on disk beside the location; Epic keeps the disk size for
the card and page.

Cancel asks twice and removes everything the install wrote - folder,
sidecar, chunk caches, scratch - unless the folder is a finished install
being repaired, which keeps itself; the row reads Cancelled for three
seconds, then goes. Pause keeps everything. A failed row reads Failed
with what is kept and offers Resume and Clear, Clear deleting as Cancel
does; the game page offers Resume install and Clear for an unfinished
install. Library sync is a thin bar and its count.
2026-10-08 21:01:17 -04:00
The412Banner a5f608b9d7 Stores: progress per stage, and the install stage reports its own
A 13 GB Epic install spent three minutes assembling behind a still bar.
The entry now carries the active stage's own count - an amount and, when
it counts files, the files - and which stages are already behind it. Epic
reports its verify pass and its assembly (native and the fallback loop),
GOG its chunk-cache removal. The Downloads row draws one segment per
stage, passed ones full and the active one filling, and reads e.g.
Installing 42% · 120/515 files; speed and ETA show only while
downloading, and leaving Download clears the measured speed. Cards and
the game page follow the active stage's percent.
2026-10-08 20:51:51 -04:00
The412Banner 236eeddeec docs: the progress-log entry matches the current Stores UI 2026-10-08 20:36:01 -04:00
The412Banner 3a90ac0665 Stores: the art is the card and the Downloads row; Install dialog back to rows
A store card is its art edge to edge: sharp above, and under its one line
a blurred, darkened copy of the same picture placed to meet it without a
seam (blur on API 31+, a stronger scrim below). A Downloads row is the
game's wide art cropped to the row with a dark wash from the left, its
copy in white there and the actions on the right; the thumbnail is gone.

The Install dialog is one plain line, Install <game>:, over one row per
place (icon, name, free space), Cancel small beside the title.
2026-10-08 20:28:40 -04:00
The412Banner dd4c4a294c Stores: cards are art and one line; no flavor text
A store card is its art and one line: the title, and right-aligned the
one value the tab does not already say - a check when installed, Resume,
the download percentage, the install size, or Free / price / discount -
with a thin bar over the art while downloading. The source chip, the In
library chip and the card's button are gone; every action lives on the
game page, which A on a card opens.

Setup > Stores and the cog popup keep only their control labels; the
download-controls caption, the Downloads footnote, the sign-in blurbs and
footnote, the engine note and the Install-to caption are gone, and empty
states are a few words. The Install-to dialog is the game's name over
"Install to" and the two tiles. A Custom game's hero has no eyebrow; its
chip says it.
2026-10-08 20:20:02 -04:00
The412Banner a0bd480576 Stores: back to text source chips, no third-party marks
Reverts 01c7248. The store and Steam glyphs are gone with their drawables;
the source chips are the text chips again (GOG, Epic, Amazon, Steam,
Custom) in the store colours, and the Stores chip row has its coloured
dots back, dimmed while signed out.
2026-10-08 20:11:10 -04:00
The412Banner 01c7248965 Stores: source chips show the store's mark instead of its name
GOG, Epic Games, Amazon Games and Steam glyphs from Simple Icons (CC0) as
vector drawables, a folder-plus for a Custom game, each in a small rounded
square in the source's colours with the name as its description. Rows,
store cards and Downloads show the mark alone; the Games hero and a store
game's page show the mark and the name. The store chips atop the Stores
page carry the marks in place of the dots, dimmed while signed out.
2026-10-08 19:59:57 -04:00
The412Banner b6903db58c Stores: an install that stops is an unfinished install, not a Custom game
A half-installed Epic game on the card (files up to a 0-byte one, 9.1 GB
of .chunks, no sidecar) showed in Games as a Custom folder: the sidecar
was only written at the very end, the queue's row lives in memory, and
the scan took any sidecar-less folder under a store root for a hand-added
game.

The sidecar is now written when an install starts, in state installing,
and rewritten finished at its end; a finished install being repaired
keeps its record. Folders still installing, or with no sidecar, are never
scanned as games; the Stores card and page offer Resume install, which
reuses the folder (and an Epic .chunks beside it) instead of a fresh one.
Writing the launcher or the finished record fails the download with its
message; the art and the Steam side stay best-effort.

The Epic cache holds whole chunk windows shared with files this device
does not install, so it outgrows the game; the free-space check now runs
after the delta pass and counts the missing chunks and files on their own
volumes.

The Install-to dialog is a 440 dp card with two tiles that are the
action, one line of caption and Cancel in the title row; the cog popup
is capped at the same width.
2026-10-08 19:52:28 -04:00
The412Banner 33caa8a57c Epic assembler: free a file's chunks before reporting the file done
The host tests caught the order: onProgress fired while the chunk only that
file used was still on disk. A progress event now means the file is written,
verified and its chunks released.
2026-10-08 19:37:44 -04:00
The412Banner fbd068c6b8 Epic: the engine takes the chunk cache path, and assembles the files itself
EpicNative.run passes chunkCacheDir after installDir, as the contract now
has it: "" keeps the cache beside the game, a path is the scratch cache in
the app's own storage that an install to a card uses. EpicNative.assemble
wraps the new nativeAssemble the same blocking way: the pending files are
written from the cache and each chunk goes right after its last use, then
the folder. The manager hands both runs the same path, uses the assembler
when it starts and keeps its own write loop as the fallback; the engine's
cache no longer has to sit beside a game on a card.
2026-10-08 19:32:14 -04:00
The412Banner 35dfd99013 Epic engine: AssemblePlan is Debug (the host tests unwrap it); CI checks the nativeAssemble export 2026-10-08 19:29:33 -04:00
The412Banner 6ca3052eb7 Stores: installs go to internal storage by default, a card is offered at Install time
The default root for all three stores is the app's own storage, whatever
the session's Game storage says. With a card in the device, Install asks
where first - internal or the card, free space per target, pad-focusable,
last pick only as the dialog's default. A card that is not the Steam
library is bound at its own fixed place, so a game's guest path and appid
hold from one session to the next; a library card keeps the library's
bind and the installs already registered there keep their appid. Every
root is scanned and uninstall works for either.

The Downloads row carries the game's art as a small thumbnail and names
where the game is going; the game page names where it went. Epic installs
on a card keep their in-flight chunks in the app's cache (the Java pool
and the assembly; the engine's own cache stays beside the game until its
contract takes a cache path), cleaned on cancel and uninstall.
2026-10-08 19:24:12 -04:00
The412Banner 2d70341214 Epic engine: resolve_cache_dir's parameter no longer shadows chunk_cache_dir() 2026-10-08 19:23:26 -04:00
The412Banner 5a1e5deba9 Epic engine: the chunk cache can live apart from the install, as scratch
An install to an SD card wrote every byte twice to the card (chunk cache under
<installDir>/.chunks, then the assembled file); at 26 MB/s the second write
starved the fetchers and the download averaged 11 MB/s.

nativeStart gains chunkCacheDir, right after installDir: "" keeps today's
<installDir>/.chunks, any other directory is where the chunks go. The plan line
logs cache_dir and whether it is scratch. The fetch core is untouched.

The lifecycle the coordinator asked for - a chunk deleted as soon as the last
file that uses it is assembled and verified, the directory removed after a
successful run, everything left for resume on cancel or failure - can only be
decided by whoever assembles, and today that is the Kotlin loop. So the engine
gains nativeAssemble: the same loop (part order, same wording on a missing
chunk) with the file's SHA-1 computed as it is written, and with a scratch
cache the GUID reference count across the pending files that frees each chunk
early. With "" nothing is deleted early and the cache goes at the end, as the
Kotlin loop's deleteDir. One Listener and one nativeCancel/nativeRelease pair
serve both runs. JNI.md carries the new signatures and semantics.

CI also runs the crate's host unit tests, informational for now.
2026-10-08 19:21:08 -04:00
The412Banner a2433f1965 Stores: the speed shown is the install rate, and the queue's two knobs share one row
The engine's speed is a burst rate at the network side: on a card that
writes at 26 MB/s the fetchers wait on the writer, its windows jump between
0.4 and 20 MB/s while the bar advances at the write rate. The queue now
measures the shown speed from the byte deltas itself - an exponential
average over three seconds, sampled at most every quarter second - and
derives the ETA from it, for all three stores; the engine's figure stays
in its log lines and is stripped from the detail line under the bar.

The speed tier and downloads-at-a-time controls sit on one compact row
with a single caption, on the Downloads page and in the cog's popup.
2026-10-08 19:09:16 -04:00
The412Banner f9db495e3f Stores: an installed game's page keeps only Uninstall; Launch, prefix and files live in Games 2026-10-08 18:51:07 -04:00
The412Banner fdb9cbb7a5 Stores: the game page is the art, its actions and the description, nothing else below 2026-10-08 18:50:11 -04:00
The412Banner 79e1d30d6c Stores: progress log records the GOG proof on the Pocket FIT 2026-10-08 18:45:31 -04:00
The412Banner a4841dff9d Stores: every install goes to Steam, no switch; the Games hero names where a game lives
The user's call: adding a store game to Steam is not optional. The setting
is gone from the cog, Setup and the prefs; the sidecar has no addToSteam
field (an older sidecar's is read past and ignored), the scan lists every
store install, and the game page shows a passive In Steam chip instead of
a button.

The Games hero's eyebrow for a never-played store game now reads Games
storage (the chip names the store) and Custom game for a hand-added
folder, in step with the Custom chip; Steam titles keep their label.
2026-10-08 18:38:38 -04:00
The412Banner 21aeab0126 Stores: the source chip of a hand-added game reads Custom 2026-10-08 18:29:41 -04:00
The412Banner fd0c6f124f Stores: the pad keeps its place on the game page, and no template keys in a description
A card opening its page took the pad's focus with it, so the next press
landed on the rail. Each move on the page now says where focus goes:
the game page's main action (or its back link), the card the page was
opened from on the way back, the first card after a tab or chip change;
the page is a focus group, so Left is the way to the rail.

GOG's account endpoints return template keys (product_description_<id>)
for some products, and the v2 record repeats them. Store text now has
those keys dropped, tags stripped and entities decoded; the library
tries the product's lead, then the v2 overview and description, and shows
nothing when all are templates. Epic's and Amazon's text is cleaned too.
2026-10-08 18:19:40 -04:00
The412Banner 9f5a2f39cd Stores: card art that does not fade to white, and real art for store games in Games
GOG's library drew the product's images.background, the store page's
fade-out backdrop, which ends in white on a card. The library now takes
the Galaxy library art from api.gog.com/v2/games (galaxyBackgroundImage,
boxArtImage), cached with the sync; the catalog's product-id filter was
tried and is ignored by the service. Epic cards prefer the DieselGameBox
images, Amazon the second background and never the logo. Cards draw over
a dark surface with a fade into the card at the foot.

On install the store's cover and hero are placed in the game folder as
opaque JPEGs under the names AddedGameArt looks for, so the Games tab and
the client's grid get real art for a store game.
2026-10-08 17:32:09 -04:00
The412Banner 61a9a48e70 Stores: progress log brought up to the three stores 2026-10-08 17:17:36 -04:00
The412Banner 9c0f87a4c8 Stores: a finished install rebuilds the Installed tab and the Games list 2026-10-08 17:12:40 -04:00
The412Banner b5ae6cc63f Stores: refuse an absolute exe in a sidecar instead of trimming it; developer notes and the README line 2026-10-08 17:11:03 -04:00
The412Banner 4bcfca58e2 Stores: Amazon Games - device sign-in, library, installs with the FuelPump environment
Amazon's PKCE device sign-in in StoreLoginActivity and a device
registration for tokens; the entitlements as the library, with posters
backfilled from SteamGridDB since Amazon ships none. Amazon has no public
catalog, so the Store tab's shelves are cut from the account's own games.
An install asks GetGameDownload for the root, parses manifest.proto
(LZMA/XZ, the app's xz library), and fetches every file through the native
engine or eight Java threads, SHA-256 checked. fuel.json or the exe scorer
names the exe; the sidecar carries the Amazon Games Services variables.
2026-10-08 17:05:05 -04:00
The412Banner 0c59f551af Stores: Epic Games - web sign-in, library, store shelves, installs, per-launch sign-in code
Epic's web login in StoreLoginActivity (the code read off the redirect's
JSON page and exchanged for tokens with the launcher's public client);
the library from the library service enriched by the catalog; the Store
tab from the store's GraphQL and the free-games feed. An install runs the
ported pipeline: manifest API, manifest binary (or the older JSON), base
files plus the device's language, a delta pass over what is on disk, the
chunk fetch through the native engine or the built-in pool, assembly.
The manifest's launch exe names the game's exe; the sidecar carries the
EpicPortal identity arguments, and a launch gets a fresh exchange code
through the launcher .bat. The EOS overlay is never provisioned.
2026-10-08 17:05:05 -04:00
The412Banner 0687e3477e Stores: two compile fixes in the shared net and login code 2026-10-08 17:05:05 -04:00
The412Banner f467bb22e8 Stores: GOG end to end - sign-in, library, public shelves, installs through the queue
GOG's OAuth page in the new StoreLoginActivity (the Galaxy client identity,
a browser identity for a social sign-in hop), the token kept in the app's
private credentials file and refreshed through auth.gog.com. The library
syncs the owned ids against a cache and fetches each product's details;
the Store tab reads catalog.gog.com's public feeds (new, deals, free,
trending). An install runs Bannerlator's GOG pipeline made blocking under
the download queue: gen2 chunks through the native engine when the library
is there (secure-link refresh on expiry), the built-in pool otherwise,
gen1 ranges, and the plain installer as the last resort; the sidecar is
written and the game registered with Steam when it is done.
2026-10-08 16:51:47 -04:00
The412Banner dad1aa2dc7 Stores: the sidecar test under Robolectric, for a real org.json 2026-10-08 16:51:47 -04:00
The412Banner 9f71b427bf Stores: the section's skeleton, store sidecar on added games, source badges, Setup gate
A new Stores rail item (GOG, Epic Games, Amazon Games), off by default behind
Setup > Stores. The page is full width: four chips and a cog whose popup
holds the section's settings (which tab a store opens on, add installs to
Steam, speed tier); a signed-out store shows its sign-in card.

A store install is an added game: its folder sits under the Games storage
with a .droiddeck-store.json sidecar that AddedGames.scanGame reads for the
exe, the title and the source, so the existing listing -> shortcuts writer
path registers it in Steam; a running client gets it live over its DevTools
port. Games that need arguments or environment get a launcher .bat the
shortcut runs. Every Games row and hero now carries a source chip.

The download queue, its foreground service and the Downloads page are in
place for the store clients that follow.
2026-10-08 16:40:30 -04:00
The412Banner 0091b751f4 Store engines: Cargo.lock trimmed to this crate's closure
The lockfile cargo wrote on the first CI build (run 37838353755): Bannerlator's
pins for every crate still used, the Steam-only crates (tungstenite, rsa, aes,
lzma-rs, ruzstd, the blocking client's extras) dropped. 185 packages, down from
223; no version moved, only the duplicate rand/webpki-roots lines collapsed.
2026-10-08 16:24:43 -04:00
The412Banner 05097b0eea Build the store engines with cargo-ndk, binaries out of tree
Gradle task buildRustStores runs cargo ndk for arm64-v8a (API 26) into
app/build/rustJniLibs, one more jniLibs source, before preBuild; -PskipRust=true
leaves the library out for a checkout without a Rust toolchain. cargo-ndk exports
the NDK clang/llvm-ar for ring (rustls), the step Bannerlator's CI does by hand.

CI adds the Android target and cargo-ndk, caches the cargo registry, cargo-ndk
and the crate's target tree keyed on rustc + Cargo.lock, and after the build
checks the exports in the apk's libdroiddeckstores.so and that the lockfile did
not move (uploading it when it did). tools/build_local.sh gets the same step,
DROIDDECK_SKIP_RUST=1 to opt out.
2026-10-08 16:17:21 -04:00
The412Banner 401a650b5b Store download engines: the GOG, Epic and Amazon Rust crate
Bannerlator's bl-steam-client store modules (store_dl/{gog,epic,amazon}) and the
adaptive fetch core they run on, as their own cdylib: libdroiddeckstores.so.
None of the Steam CM/depot code comes along; the handful of tunables and the two
error types fetch_core borrowed from the depot writer live in fetch_tuning.rs,
value for value, so a fetch-window log line still reads against the same notes.

JNI exports keep every method name and signature and bind to
com.droiddeck.launcher.stores.{gog.GogNative,epic.EpicNative,amazon.AmazonNative};
StoresNative.nativeVersion() is the load probe for Setup. JNI.md is the contract
the Kotlin side mirrors. Log tags follow the class names (GogNative, EpicNative).

The Cargo.lock is Bannerlator's as is; cargo prunes it to this crate's closure on
the first build and CI reports the trimmed file.
2026-10-08 16:17:20 -04:00
Kurt Himebauch 134fa167fe feat: install Flatpak apps from a .flatpak file (#446) 2026-10-08 14:13:32 -04:00
The412BannerandThe412Banner da42a5d7a7 DirectAudio from its own release: driver set per Proton interface (CachyOS fix), the Steam client's own DirectAudio sink, binaries out of tree (#439)
DirectAudio from its own release: driver set per Proton audio interface (CachyOS fix), the Steam client's own DirectAudio sink, binaries out of tree

Games
- Two driver sets ship from the pinned DirectAudio release (tools/directaudio/release.env =
  directaudio-linux-v1.1.0, sha256-checked by tools/directaudio/fetch.sh in CI and
  build_local.sh): linux-wine11 for Valve Proton 11 / Experimental / GE-Proton, and
  linux-wine11-systhread for Proton-CachyOS, whose Wine carries the system-thread mmdevapi
  interface. steam-compatibility picks by whether the Proton's own winepulse.so imports
  PsCreateSystemThread, never by the version string (both say wine-11.0; the wrong set is
  silence). No matching set: DirectAudio off, Proton's audio runs, logged.
- Proton-CachyOS's launcher pre-sets WINE_AUDIO_DRIVER=pulse,alsa and its mmdevapi reads that
  before the registry (Valve's and GE's do not read it): the wrapper exports
  WINE_AUDIO_DRIVER=directaudio,pulse as well as writing the registry. On CachyOS this also
  makes DirectAudio engage on a game's first launch.
- A wineserver from an earlier Steam verb can save its in-memory registry over the appended
  Audio value; the wrapper says so when one is live. Writing through it (wine reg add) and
  creating a missing prefix first (proton run wineboot) were tried on the device and withdrawn:
  a second Proton/Wine run inside Steam's launch sequence got launches aborted.
- Tests: both sets, no set, no prefix (34 pass).

Steam client audio
- DirectAudio = module-directaudio-native-sink, the game driver's AAudio engine inside the
  PulseAudio daemon, one step from Android (adaptive buffer, two-burst floor, primed ring with
  fades, reopen on route change, watchdog, limiter). Default. Shared output = the previous relay
  route, kept. New pref clientAudioRoute; the old boolean is not read. If the native sink cannot
  open its stream the daemon keeps running (.nofail) and ensureClientSink loads the plain AAudio
  sink instead, logged to audio.log. Audio bundle stamp bumped.

Out of tree
- app/src/main/assets/directaudio/ is generated and ignored; tools/directaudio-relay/ and the
  vendored module-directaudio-sink.c + da_relay_proto.h are gone; tools/aaudio-sink keeps only
  module-aaudio-sink (the fallback). The release carries the relay and sink field fixes by
  MaxsTechReview (#338), credited there and in its AUTHORS.

Device-proven on an AYANEO Pocket FIT: client sink 12 ms / 4 ms bursts, 0 underruns; FEZ on
cachyos-11.0-20261005-slr on DirectAudio (took pulse before); FEZ on Proton Experimental
unchanged; Curse of the Dead Gods on CachyOS from a cold prefix, DirectAudio on first launch.

Co-authored-by: The412Banner <[email protected]>
2026-10-08 12:35:00 -04:00
MaxsTechReview b6c1d3c09c Land taps and the pointer on what is drawn under them (#389)
The compositor takes touch and pointer positions over its whole output and
removes the letterbox bars itself, but the session sent them already
measured from the picture's edge, so the bars were taken off twice. Any
session whose output is a different shape from the panel (16:9 on a 16:10
tablet or a foldable, a fixed resolution on a wide phone) had taps and the
touchpad pointer pulled away from the centre, by up to a bar's height at
the edges: on a 2400x1504 panel showing 1280x720, a tap near the top
reached the first row and one near the bottom landed 32 rows low. Stretch
to panel hid it because that output has no bars.

Direct touch, the touchpad and mouse pointer, and the second-screen pointer
now send positions over the whole view, so the compositor's own mapping
puts them on the pixel under the finger.
2026-10-08 11:52:45 -04:00
Kurt Himebauch a58b0ac3e0 chore: bump version to 0.3.2 (12) (#437) 2026-10-08 10:49:33 -04:00
Kurt Himebauch e1700ba982 docs: point 0.3.2 full changelog at the 0.3.2 tag (#438) 2026-10-08 10:49:29 -04:00
Kurt Himebauch 736ac7db74 Draft 0.3.2 release notes (#417)
* docs: draft 0.3.2 release notes

* docs: update 0.3.2 release notes

* docs: update external storage install note

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

* fix: restore colours in keep downloads running gif

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

* fix: drop glitched frames from native steam sleep gif

* docs: add windows components screenshot to 0.3.2 notes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* i18n: use compilación for build in Spanish

* i18n: translate new strings to Spanish

---------

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

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

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

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

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

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

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

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

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

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

* Keep active Steam downloads running

* Make background downloads a toggle

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

* Track all Steam content update types

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Audio: drop the session-wide PULSE_LATENCY_MSEC

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

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

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

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

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

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

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

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

* Steam client audio: DirectAudio by default

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

No files matched your search

+86 -3
View File
@@ -210,7 +210,7 @@ jobs:
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
@@ -331,6 +331,11 @@ jobs:
set -euo pipefail
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
# DirectAudio - the game driver (one set per Wine audio interface), the Android-side helper and
# the Steam client's sinks - comes from a pinned release of The412Banner/directaudio, checksummed,
# exactly like gamescope; nothing compiled for audio is kept in this repo.
- name: Fetch DirectAudio (tools/directaudio/release.env)
run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
- name: Build the AAudio sink into the audio bundle
run: |
set -euo pipefail
@@ -340,12 +345,11 @@ jobs:
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
rm -rf pa-bundle sink-out
- name: Cache proot
id: proot
uses: actions/cache@v4
@@ -401,6 +405,39 @@ jobs:
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
done
# The store download engines (app/src/main/rust/stores) are Rust, built by Gradle's
# buildRustStores task through cargo-ndk. rustup and cargo are on the runner image; the Android
# target and cargo-ndk are added here. Cache = cargo registry + cargo-ndk + the crate's target
# tree, keyed on the rustc version and Cargo.lock, so a source-only change rebuilds this crate
# on top of the cached dependency objects, and a lockfile change still starts from the
# previous objects (restore-keys).
- name: Rust toolchain for the store engines (aarch64-linux-android)
id: rust
run: |
set -euo pipefail
rustup target add aarch64-linux-android
echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
rustc -V; cargo -V
- name: Cache cargo registry and the store engines' target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-ndk
app/src/main/rust/stores/target
key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}
restore-keys: |
stores-${{ steps.rust.outputs.rustc }}-
- name: cargo-ndk
run: |
set -euo pipefail
command -v cargo-ndk >/dev/null || cargo install cargo-ndk --locked
cargo ndk --version
- name: Cache the CMake build tree
uses: actions/cache@v4
with:
@@ -420,6 +457,52 @@ jobs:
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
- name: Check the store engines' library
# The exports Kotlin binds must be in the apk's libdroiddeckstores.so, or the stores fail on
# the device with an UnsatisfiedLinkError instead of here. The lockfile check is the
# reproducibility guard: cargo must not have had to re-resolve anything.
run: |
set -euo pipefail
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
NM="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
SO=app/build/rustJniLibs/arm64-v8a/libdroiddeckstores.so
test -s "$SO"
ls -la "$SO"
unzip -l app/build/outputs/apk/release/app-release.apk | grep -q 'lib/arm64-v8a/libdroiddeckstores.so' \
|| { echo "::error::libdroiddeckstores.so is not in the apk"; exit 1; }
for sym in \
Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion \
Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart \
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeStart \
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeAssemble \
Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart; do
"$NM" -D --defined-only "$SO" | grep -q " ${sym}\$" \
|| { echo "::error::libdroiddeckstores.so does not export $sym"; exit 1; }
done
git diff --stat -- app/src/main/rust/stores/Cargo.lock
git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \
|| { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; }
- name: Store engines host unit tests
# The crate's #[test] functions are pure host-side logic (manifest parsing, chunk plans,
# assembly from a cache, hashes), so a plain `cargo test` on the runner's own architecture
# runs them; the Android build above compiles none of them. Informational on this first
# run - the point is to learn whether they pass - and bounded so a hang can never hold
# the apk. Make it blocking once it is known green.
timeout-minutes: 10
continue-on-error: true
run: |
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
- name: Upload the regenerated Cargo.lock
if: failure()
uses: actions/upload-artifact@v4
with:
name: stores-cargo-lock
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
+29
View File
@@ -13,13 +13,28 @@ permissions:
contents: read
pull-requests: read
concurrency:
group: pr-release-${{ github.event.pull_request.number }}
cancel-in-progress: false
queue: max
jobs:
cleanup:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check the current PR state
id: state
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .state)
echo "closed=$([ "$state" = closed ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
if: ${{ steps.state.outputs.closed == 'true' }}
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
@@ -27,6 +42,7 @@ jobs:
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Delete the test build
if: ${{ steps.state.outputs.closed == 'true' }}
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -42,6 +58,19 @@ jobs:
done
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
- uses: actions/checkout@v4
if: ${{ steps.state.outputs.closed == 'true' }}
with:
ref: main
persist-credentials: false
- name: Retire the Discord PR announcement
if: ${{ steps.state.outputs.closed == 'true' }}
env:
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: python3 tools/release/pr_discord.py retire
catalog:
needs: cleanup
if: ${{ always() }}
+14 -21
View File
@@ -26,6 +26,7 @@ permissions:
concurrency:
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
cancel-in-progress: false
queue: max
jobs:
authorize:
@@ -178,6 +179,10 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-pr-signed-*
@@ -307,39 +312,27 @@ jobs:
$NEXT
EOF
)
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --slurp | \
jq "[.[][] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$mark\"))] | first // {}")
id=$(jq -r '.id // empty' <<<"$comment")
if [ -n "$id" ]; then
state=$(jq -r '.body | split("\n")[] | select(startswith("<!-- droiddeck-discord: ") and endswith(" -->"))' <<<"$comment")
if [ -n "$state" ]; then body=$(printf '%s\n%s' "$body" "$state"); fi
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
else
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
fi
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
# only a PR's first test build is, and later ones update its release quietly.
- name: Post to Discord
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
- name: Update the Discord PR announcement
if: ${{ always() && steps.publish.outcome == 'success' }}
env:
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
RELEASE: ${{ steps.publish.outputs.release }}
APK: ${{ steps.publish.outputs.apk }}
run: |
set -euo pipefail
if [ -z "$WEBHOOK" ]; then
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
exit 0
fi
short=${HEAD_SHA:0:7}
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get")
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
echo "Posted: $content"
run: python3 tools/release/pr_discord.py publish
catalog:
needs: publish
+1
View File
@@ -13,6 +13,7 @@ permissions:
concurrency:
group: update-app-catalog
cancel-in-progress: false
queue: max
jobs:
catalog:
+5
View File
@@ -14,6 +14,10 @@ app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
# The store download engines (app/src/main/rust/stores) are cross-compiled by Gradle into
# app/build/rustJniLibs; cargo's own build tree stays next to the crate. Neither is source.
app/src/main/rust/stores/target/
app/src/main/assets/droiddeck-esync/
# Kotlin compiler session files (build-tool state, never source).
@@ -34,3 +38,4 @@ __pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
app/src/main/assets/directaudio/
+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). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
+37 -2
View File
@@ -79,8 +79,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 11
versionName "0.3.1"
versionCode 12
versionName "0.3.2"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -165,6 +165,8 @@ android {
testOptions {
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
unitTests.returnDefaultValues = true
// Robolectric tests start activities whose text is now all string resources.
unitTests.includeAndroidResources = true
}
compileOptions {
@@ -186,6 +188,39 @@ def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
}
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
// The GOG / Epic / Amazon download engines are Rust (app/src/main/rust/stores, ported from
// Bannerlator's bl-steam-client crate). cargo-ndk cross-compiles them for arm64-v8a into the build
// directory, which is one more jniLibs source below: nothing compiled is committed, the same rule
// the preloads, proot and the DirectAudio relay follow. cargo-ndk exports the NDK clang/llvm-ar as
// the C compiler for the one C-compiling crate (ring, under rustls) - the step Bannerlator's CI
// does by hand - and reads the NDK from ANDROID_NDK_HOME, which is set to the NDK this build pins.
// -PskipRust=true builds without the library, for a checkout with no Rust toolchain: the stores
// then report themselves unavailable instead of the build failing.
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
def buildRustStores = tasks.register('buildRustStores', Exec) {
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
onlyIf { !skipRust }
inputs.dir(rustCrateDir.dir('src'))
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
outputs.dir(rustJniLibsDir)
workingDir rustCrateDir
// The configuration cache serialises the task, so everything it needs is resolved here, not
// in doFirst. ~/.cargo/bin is where rustup puts cargo and cargo-ndk; an IDE-launched Gradle
// does not always have it on PATH.
def cargoBin = "${System.getProperty('user.home')}/.cargo/bin"
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
environment 'CARGO_TERM_COLOR', 'never'
// --platform 26 = minSdkVersion: the engines link against the API 26 libc, like the C++ code.
commandLine 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
'build', '--release'
}
android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
+14
View File
@@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Store download progress and "installed" / "failed" notices (Android 13+). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- The session keeps running while the app is not on screen: a foreground service holds the
process at perceptible priority, and a high-performance WiFi lock keeps a backgrounded
download from being throttled to nothing by the radio's power-save. -->
@@ -127,6 +129,13 @@
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<!-- A store's own sign-in page (GOG, Epic Games, Amazon Games) in a WebView. -->
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
@@ -163,6 +172,11 @@
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
<service
android:name=".stores.download.StoreDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -1,115 +0,0 @@
# DirectAudio for the Linux Steam client (relay build)
This is the driver for games that run under **Valve's ARM64 Proton inside a Linux
runtime on Android** - Bannerlator's linuxfs session with the native Steam client -
where the game process is a glibc process and cannot touch Android's audio
libraries itself.
Two pieces, both required:
| piece | what it is | where it runs |
|---|---|---|
| `aarch64-unix/winedirectaudio.so` + the two PE shells | the Wine `mmdevapi` driver, glibc build, **no AAudio inside** | in the game, inside the Linux rootfs |
| `directaudio-relay` | a small bionic program that owns the AAudio output and microphone streams | on the Android side, under the app's uid |
They meet over a unix socket. The driver connects, sends its launch config, and
gets back two shared-memory rings: one it fills with mixed game audio, one the
helper fills with microphone audio. The helper plays and records; the driver
keeps doing everything else it always did.
## Which Proton
Built inside `ValveSoftware/wine` at `proton_11.0`. The private `mmdevapi`
interface it implements is byte-identical across **Proton 11.0 (ARM64)**,
**Proton Experimental (ARM64)** and our own Wine-11 layers (checked 2026-09-19),
so this one build serves any of them. A future Proton on a Wine 12 base will
need a rebuild - the interface is not versioned, and a mismatch is silence, not
an error.
## Installing the driver next to Proton (not into it)
Steam verifies and updates its Proton depots, so files added under
`steamapps/common/Proton .../files/lib/wine/` will not survive. Put the driver
in a directory of its own and point Wine at it:
```
<somewhere>/directaudio/lib/wine/aarch64-unix/winedirectaudio.so
<somewhere>/directaudio/lib/wine/aarch64-windows/winedirectaudio.drv
<somewhere>/directaudio/lib/wine/i386-windows/winedirectaudio.drv
```
and set, for the game process:
```
WINEDLLPATH=<somewhere>/directaudio/lib/wine
```
Wine searches `WINEDLLPATH` entries with the per-arch subdirectories appended,
so both the PE shell and the unixlib are found there. In Bannerlator's runtime
the natural place to export it is the `bannerlator-proton` compatibility-tool
wrapper, which every Proton launch goes through.
## Selecting the driver
`mmdevapi` only tries the drivers named in the registry (or its built-in
`pulse,alsa,oss,coreaudio` list, which does not know about this one). Set, in the
game's prefix:
```
wine reg add "HKCU\Software\Wine\Drivers" /v Audio /d directaudio /f
```
Under Proton the prefix is `compatdata/<appid>/pfx`; run the command with that
prefix as `WINEPREFIX` using Proton's own `bin-arm64/wine`, or write the key from
the wrapper before it hands off to Proton.
## Running the helper
```
directaudio-relay --socket <path> [--log]
```
- Run it **under the app's uid**, the way the app already runs its PulseAudio
daemon. Android checks `RECORD_AUDIO` against the calling uid, so a helper
started any other way can play but cannot record.
- Start it **before** the game: the driver asks "is a helper there?" once, when
Wine picks an audio driver, and falls back to unavailable if not.
- The socket path must be reachable from inside the Linux rootfs. Bannerlator
binds the app's files directory and its runtime directory into the session at
their own paths, so any socket under either works unchanged on both sides.
- The helper serves any number of game processes; each gets its own streams.
It exits on `SIGTERM`; a game exiting tears down that game's streams only.
## Telling the driver where the helper is
```
BANNER_AUDIO_DIRECT_RELAY=<the same socket path>
```
Unset, the driver looks for `$XDG_RUNTIME_DIR/directaudio-relay`.
## Everything else is unchanged
All `BANNER_AUDIO_DIRECT_*` knobs mean what they mean in the in-process build:
`_PERF`, `_ADAPTIVE`, `_DECAY`, `_MS`, `_MAXMS`, `_BF`, `_MBF`, `_EXCLUSIVE`,
`_WATCHDOG`, `_STALL_MS`, `_DECAY_*`, `_LOG`, `_PERIOD_MS`, `_MINPERIOD_MS`, and
the live-config mailbox `_RUNTIME` (the helper watches the file; it must be a
path the helper can read on the Android side). **`BANNER_AUDIO_DIRECT_MIC=1`**
exposes the capture endpoint exactly as before: the helper opens the input
stream (`VOICE_COMMUNICATION` preset) when the game connects and makes it hot on
the game's first capture start.
One thing is different: there is one extra hand-off, the ring. The helper's
callback pulls a burst from it, so the driver keeps two bursts queued (about 8 ms
on a 192-frame device) on top of the AAudio buffer the presets control, and the
helper raises that if a callback ever finds the ring short. `get_latency` reports
the sum, so a game that sizes its own buffers from it still sees the truth.
## Proving it is running
- Helper side: `logcat -s DA-Relay:I` shows `hello from <exe>`, then `open: buffer
... ring target ...`, then `capture open` / `capture start` for the mic.
- Game side: the session log carries `DirectAudio: relay: connected - ...`.
- The game process's `/proc/<pid>/maps` shows `winedirectaudio.so` and **no**
`libaaudio.so` (it is in the helper's maps instead). The AudioFlinger track is
owned by the helper's pid.
@@ -1,4 +0,0 @@
directaudio linux-wine11 relay build
source 3548a4a730e8d01cb0cb0268d51a2fb8f23dcd33
wine ValveSoftware/wine@proton_11.0
unixlib.h sha256 fdbed263338d0719c4d12e02b045c23721de33219ec8878e4c2d7ebc9efcbe09
@@ -11,6 +11,14 @@ import com.droiddeck.launcher.session.GpuClockPin
* sandboxes are started through (BwrapSpawner).
*/
class App : Application() {
override fun attachBaseContext(base: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
com.droiddeck.launcher.core.AppLanguage.systemChanged(newConfig)
}
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
@@ -21,5 +29,10 @@ class App : Application() {
GpuClockPin.clearLeftover(this)
BwrapSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(this)
// The game stores the Stores section can talk to; the native engine loads only when one needs it.
com.droiddeck.launcher.stores.StoresState.init(this)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
}
}
@@ -63,7 +63,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
val message = runCatching(work).getOrElse { e -> activity.getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
ui.post {
compBusy = null
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
@@ -79,7 +79,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
@@ -91,8 +91,8 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
activity.getString(R.string.comp_stored, pkg.version)
}.getOrElse { e -> activity.getString(R.string.comp_download_failed, e.message ?: e.javaClass.simpleName) }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
@@ -19,6 +19,8 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
/** True while the stage is the plugin's binary download (DeckyManager.downloadingBinary). */
var deckyDownloadingBinary by mutableStateOf(false)
var deckySupervisor by mutableStateOf(false)
var deckyReleaseRequest = 0
@@ -41,11 +43,11 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
}.getOrElse { error -> activity.getString(R.string.decky_install_failed, error.message ?: error.javaClass.simpleName) }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
@@ -55,16 +57,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
fun importPluginZip(archive: File) {
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyStage = "Starting plugin import…"; deckyPercent = -1
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.installPluginZip(activity, archive) { label, value ->
ui.post { deckyStage = label; deckyPercent = value }
val binary = DeckyManager.downloadingBinary
ui.post { deckyStage = label; deckyPercent = value; deckyDownloadingBinary = binary }
}
}.getOrElse { error -> "Plugin import failed: ${error.message ?: error.javaClass.simpleName}" }
}.getOrElse { error -> activity.getString(R.string.decky_plugin_failed, error.message ?: error.javaClass.simpleName) }
ui.post {
deckyStage = null; deckyPercent = -1
val message = problem ?: "Plugin installed. Restart Steam to load it."
deckyStage = null; deckyPercent = -1; deckyDownloadingBinary = false
val message = problem ?: activity.getString(R.string.decky_plugin_installed)
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "import-decky-plugin").start()
@@ -37,7 +37,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
// Empty until the first refresh: the activity has no context yet while its fields are made.
var releaseStatus by mutableStateOf("")
var releaseChecking by mutableStateOf(false)
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
@@ -57,10 +58,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
private var autoCheckedThisProcess = false
fun state() = GpuDriversState(
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
autoStatus = autoStatus, releaseStatus = releaseStatus.ifEmpty { activity.getString(R.string.drivers_not_checked) }, checking = releaseChecking,
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
activeBundle = activeBundle,
@@ -73,10 +74,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
val bundles = DriverBundle.all(activity)
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
DriverRow(id, it.label, activity.getString(if (linux) R.string.drivers_bundle_sets_display else R.string.drivers_bundle_sets_runtime), true, DriverRow.BUNDLE)
}
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
if (id.isEmpty()) DriverRow("", activity.getString(R.string.gpu_runtime_default), activity.getString(R.string.drivers_runtime_builtin), false)
else bundleRow(id, linux = true) ?: DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
@@ -92,8 +93,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val auto = td.autoId()
androidRows = buildList {
add(DriverRow(
TurnipDriver.AUTO, "Auto - picked by GPU",
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
TurnipDriver.AUTO, activity.getString(R.string.gpu_auto_by_gpu),
if (auto == "system") activity.getString(R.string.drivers_system_vulkan) else activity.getString(R.string.drivers_bundled, td.displayName(auto)),
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
@@ -109,7 +110,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
fun summary(): String {
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
}
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
@@ -125,7 +127,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val mb = p.assets.sumOf { it.size } / 1_048_576.0
PairRow(
key = p.key, name = p.name, version = p.version,
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
detail = if (!p.complete) activity.getString(R.string.drivers_half_published) else activity.getString(R.string.drivers_pair_size, mb),
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
installed = displayId != null && linuxId != null,
active = displayId != null && linuxId != null &&
@@ -153,11 +155,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (!force && autoCheckedThisProcess) return
autoCheckedThisProcess = true
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
return
}
releaseChecking = true
autoStatus = "Checking for the latest drivers…"
autoStatus = activity.getString(R.string.drivers_checking_latest)
Thread({
val cached = TurnipReleases.cached(activity)
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
@@ -172,9 +174,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val pair = pairs.firstOrNull { it.key == key }
val row = pairRows.firstOrNull { it.key == key }
when {
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
row?.active == true -> autoStatus = problem?.let { activity.getString(R.string.drivers_up_to_date_check_failed, it) }
?: activity.getString(R.string.upd_current_status)
pair == null || !pair.complete ->
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
else -> installPair(pair, auto = true)
}
}
@@ -197,7 +200,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (!pair.complete) return
pairBusy = pair.key
pairPercent = 0
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
Thread({
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
@@ -217,7 +220,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
SessionPrefs.setAndroidDriver(activity, displayId)
SessionPrefs.setLinuxDriver(activity, linuxId)
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
val restart = displayChanged && CompositorHost.isStarted
if (auto) {
val previous = SessionPrefs.gpuAutoInstalled(activity)
for (id in previous - setOf(displayId, linuxId)) {
@@ -230,14 +233,18 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
TurnipReleases.forget(activity, id)
}
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
autoStatus = activity.getString(
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
pair.name, pair.version,
)
} else {
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(activity, activity.getString(if (restart) R.string.drivers_using_restart else R.string.drivers_using, pair.name, pair.version), android.widget.Toast.LENGTH_LONG).show()
}
}.onFailure { e ->
Log.w(TAG, "driver pair ${pair.key}", e)
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
if (auto) autoStatus = why ?: "Download failed"
val why = if (e is IllegalArgumentException) e.message else activity.getString(R.string.comp_download_failed, e.message)
if (auto) autoStatus = why ?: activity.getString(R.string.user_apps_download_failed)
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
}
refreshDrivers()
@@ -298,7 +305,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
try {
LinuxVulkanDriverManager(activity).installDriver(uri, name)
} catch (runtime: IllegalArgumentException) {
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
throw IllegalArgumentException(activity.getString(R.string.drivers_not_a_driver))
}
}
}
@@ -307,10 +314,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
e.message
} catch (e: Exception) {
Log.w(TAG, "driver import", e)
"Import failed: ${e.message}"
activity.getString(R.string.drivers_import_failed, e.message)
}
ui.post {
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
val done = bundle?.let { useBundle(it) }
?: name?.let { activity.getString(R.string.comp_imported, it) } ?: activity.getString(R.string.drivers_imported_unnamed)
android.widget.Toast.makeText(
activity, problem ?: done,
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
@@ -340,8 +348,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
DriverBundle.select(activity, bundle)
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
return "Using ${bundle.label} as both the runtime and display driver." +
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
return activity.getString(
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
bundle.label,
)
}
/**
@@ -354,7 +364,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (bundle != null) {
DriverBundle.remove(activity, bundle)
TurnipReleases.forget(activity, bundle.id)
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted_bundle, bundle.label), android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
return
}
@@ -367,7 +377,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
}
TurnipReleases.forget(activity, id)
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted, id), android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
@@ -378,26 +388,28 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
.filter { it.bundle || it.linux == linux }
.filter { a -> installedIdOf(a) == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
val mb = activity.getString(R.string.common_size_mb, a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
null -> activity.getString(R.string.drivers_not_checked)
else -> {
val latest = check.latest.joinToString(" · ") { "${it.first} ${it.second}" }
if (check.failed.isEmpty()) activity.getString(R.string.drivers_release_status, latest, ago(check.checkedAt))
else activity.getString(R.string.drivers_release_status_failed, latest, check.failed.joinToString(), ago(check.checkedAt))
}
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
m < 1 -> activity.getString(R.string.upd_just_now)
m < 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_minutes, m.toInt(), m)
m < 48 * 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_hours, (m / 60).toInt(), m / 60)
else -> activity.resources.getQuantityString(R.plurals.drivers_ago_days, (m / (24 * 60)).toInt(), m / (24 * 60))
}
}
@@ -405,16 +417,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
releaseStatus = activity.getString(R.string.drivers_checking_sources)
Thread({
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
Log.w(TAG, "latest Turnip check", e); e.message ?: activity.getString(R.string.drivers_check_failed)
}
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
if (problem != null) releaseStatus = "Couldn't check: $problem"
if (problem != null) releaseStatus = activity.getString(R.string.upd_couldnt_check, problem)
}
}, "turnip-release-check").start()
}
@@ -433,13 +445,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
activity.getString(R.string.comp_download_failed, e.message)
}
ui.post {
releaseProgress.remove(assetName)
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
val installed = activity.getString(
if (asset.bundle) R.string.drivers_installed_pick_either else R.string.drivers_installed_pick,
asset.name.removeSuffix(".zip"),
)
android.widget.Toast.makeText(
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
activity, problem ?: installed,
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
@@ -7,6 +7,11 @@ import com.droiddeck.launcher.session.SessionService
/** Receives the system Home launch and routes it without consuming KEYCODE_HOME. */
class HomeActivity : Activity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
routeHome()
@@ -35,6 +35,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.ui.exposeTestTags
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
@@ -84,6 +85,11 @@ import com.droiddeck.launcher.input.SecondScreenDisplays
* the store, downloads, settings - is the client's own job once [SessionActivity] has it on screen.
*/
class MainActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private val ui = Handler(Looper.getMainLooper())
private val drivers = DriverMenus(this, ui)
private val components = ComponentsMenu(this, ui)
@@ -107,7 +113,7 @@ class MainActivity : ComponentActivity() {
private var stage by mutableStateOf("")
private var percent by mutableIntStateOf(-1)
private var failed by mutableStateOf(false)
private var frameGenLabel by mutableStateOf("Off")
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
@@ -154,11 +160,16 @@ class MainActivity : ComponentActivity() {
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var clientDirectAudio by mutableStateOf(false)
private var stretch16x9 by mutableStateOf(false)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var gameStoresEnabled by mutableStateOf(false)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
private var navRequest by mutableStateOf<String?>(null)
private var storesShowMature by mutableStateOf(false)
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
@@ -170,6 +181,16 @@ class MainActivity : ComponentActivity() {
refreshWifiDiscovery()
}
}
private val mediaRefresh = Runnable {
refresh()
refreshAddedGames()
}
private val mediaReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
ui.removeCallbacks(mediaRefresh)
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
}
}
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
@@ -253,29 +274,32 @@ class MainActivity : ComponentActivity() {
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, R.string.main_saves_close_session, android.widget.Toast.LENGTH_LONG).show()
return
}
onSavePicked = { zip ->
saveAction("Importing into $name") {
val (written, backup) = GameSaves.import(game(), zip)
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
saveAction(getString(R.string.main_saves_importing, name)) {
val (written, backup) = GameSaves.import(this, game(), zip)
val kind = GameSaves.layoutOf(zip)?.label(this) ?: getString(R.string.saves_zip)
resources.getQuantityString(
if (backup != null) R.plurals.main_saves_imported_backup else R.plurals.main_saves_imported,
written, written, kind, name,
)
}
}
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), getString(R.string.main_saves_pick_zip, name), GameSaves.savesDir().parentFile?.parentFile?.path))
}
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
onSavePicked = { dir ->
saveAction("Exporting $name") {
val (zip, count) = GameSaves.export(game(), layout, dir)
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
saveAction(getString(R.string.main_saves_exporting, name)) {
val (zip, count) = GameSaves.export(this, game(), layout, dir)
resources.getQuantityString(R.plurals.main_saves_exported, count, count, layout.label(this), zip.path.removePrefix("/storage/emulated/0/"))
}
}
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
@@ -322,12 +346,13 @@ class MainActivity : ComponentActivity() {
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backActionsInverted by mutableStateOf(false)
private var renderer by mutableStateOf("vulkan")
private var gameStorage by mutableStateOf("")
private var storageDiagnostics by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
}
@@ -336,6 +361,7 @@ class MainActivity : ComponentActivity() {
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var agentCommands by mutableStateOf(false)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var homeScreenEnabled by mutableStateOf(false)
@@ -358,15 +384,15 @@ class MainActivity : ComponentActivity() {
if (intent?.component?.className == SessionActivity::class.java.name) {
when {
busy || LinuxRuntimeInstaller.isBusy() -> {
android.widget.Toast.makeText(this, "Wait for the runtime operation to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_runtime, android.widget.Toast.LENGTH_SHORT).show()
return
}
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_proton_install, android.widget.Toast.LENGTH_SHORT).show()
return
}
pkgStage != null -> {
android.widget.Toast.makeText(this, "Wait for the desktop app install to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
return
}
}
@@ -402,7 +428,7 @@ class MainActivity : ComponentActivity() {
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
saveAction(getString(R.string.game_frontend_files)) {
val saved = if (game != null) GameFiles.export(folder, game)
val saved = if (game != null) GameFiles.export(this, folder, game)
else {
GameFileSync.enable(this, folder)
folder
@@ -417,6 +443,7 @@ class MainActivity : ComponentActivity() {
}
private fun readGameIntent(request: Intent) {
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
if (shortcutPicker) {
// The shortcut picker must only offer games from the scan started for this request.
@@ -442,7 +469,7 @@ class MainActivity : ComponentActivity() {
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (SessionState.running) {
@@ -495,6 +522,7 @@ class MainActivity : ComponentActivity() {
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
frameGenLabel = getString(R.string.frame_gen_off)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
@@ -502,11 +530,15 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
storesShowMature = SessionPrefs.storesShowMature(this)
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
@@ -526,12 +558,13 @@ class MainActivity : ComponentActivity() {
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
appScale = appScale,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -554,6 +587,12 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
navigate = navRequest,
storesShowMature = storesShowMature,
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
@@ -564,7 +603,8 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
// A store game may need something done first (an Epic game its sign-in code).
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
@@ -637,9 +677,13 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
},
onAgentCommands = {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
},
onShareLogs = {
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
},
@@ -648,7 +692,7 @@ class MainActivity : ComponentActivity() {
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
ui.post {
android.widget.Toast.makeText(this,
if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.",
if (cleared == 0) getString(R.string.main_no_logs_to_clear) else resources.getQuantityString(R.plurals.main_logs_cleared, cleared, cleared),
android.widget.Toast.LENGTH_SHORT).show()
}
}, "clear-logs").start()
@@ -659,6 +703,13 @@ class MainActivity : ComponentActivity() {
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
// Redrawn in the new language; Setup's page and tab are saved state.
recreate()
}
},
onAppScale = { percent ->
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
@@ -669,6 +720,12 @@ class MainActivity : ComponentActivity() {
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
onNavigated = { navRequest = null },
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
onLibraryChanged = { refreshAddedGames(); refresh() },
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
@@ -691,7 +748,7 @@ class MainActivity : ComponentActivity() {
val result = runCatching {
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
}
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging pairing failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_pair_failed) }
ui.post { complete(error) }
}, "wireless-adb-pair").start()
},
@@ -704,7 +761,7 @@ class MainActivity : ComponentActivity() {
onWirelessAdbApply = { host, port, enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging command failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_command_failed) }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -714,7 +771,7 @@ class MainActivity : ComponentActivity() {
onSetPhantomProcessLimit = { enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging is unavailable" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_unavailable) }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -723,9 +780,9 @@ class MainActivity : ComponentActivity() {
},
onCopyPhantomCommand = { enabled ->
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.adbCommand(enabled)),
ClipData.newPlainText(getString(R.string.main_adb_clip_label), PhantomProcessLimit.adbCommand(enabled)),
)
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_adb_copied, android.widget.Toast.LENGTH_SHORT).show()
},
onDismissPhantomGate = { showPhantomGate = false },
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
@@ -759,24 +816,24 @@ class MainActivity : ComponentActivity() {
path = romsDir,
onChoose = {
showRoms = false
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, "Choose the ROMs folder", romsDir))
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_roms), romsDir))
},
onClear = { SessionPrefs.setRomsDir(this, ""); romsDir = null; showRoms = false },
onDismiss = { showRoms = false },
)
showNonAdreno?.let { release ->
ConfirmDialog(
title = "Not an Adreno GPU",
text = "Turnip supports Adreno GPUs. On ${com.droiddeck.launcher.core.DeviceSupport.gpuName()}, Steam may show a black screen. Download: ${"%.0f".format(release.size / 1e6)} MB.",
confirm = "Install anyway",
title = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_title),
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@MainActivity), release.size / 1e6),
confirm = androidx.compose.ui.res.stringResource(R.string.main_install_anyway),
onConfirm = { showNonAdreno = null; install(release) },
onDismiss = { showNonAdreno = null },
)
}
if (showRemove) ConfirmDialog(
title = "Remove Linux runtime",
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
confirm = "Remove",
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
confirm = androidx.compose.ui.res.stringResource(R.string.store_remove),
onConfirm = { showRemove = false; removeRuntime() },
onDismiss = { showRemove = false },
)
@@ -870,6 +927,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
refreshWifiDiscovery()
// Back from a store's sign-in page: the Stores chips show the account at once. A store
// install finishing while this screen is up rebuilds the Games list through the listener.
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
@@ -886,7 +947,7 @@ class MainActivity : ComponentActivity() {
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post { if (showComponents) components.refreshComponents() }
}, "components-queue").start()
@@ -909,6 +970,11 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
registerReceiver(mediaReceiver, android.content.IntentFilter().apply {
for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED,
Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action)
addDataScheme("file")
})
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
refreshSecondScreenDisplays()
}
@@ -920,6 +986,10 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
unregisterReceiver(wifiLocationReceiver)
unregisterReceiver(mediaReceiver)
ui.removeCallbacks(mediaRefresh)
// An install finishing while this screen is away is picked up by onResume's refresh.
com.droiddeck.launcher.stores.StoresState.libraryListener = null
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
@@ -945,9 +1015,13 @@ class MainActivity : ComponentActivity() {
try {
HomeApp.launch(this, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
android.widget.Toast.makeText(this, "Could not open ${app.label} on $target", android.widget.Toast.LENGTH_SHORT).show()
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
val message = when {
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
}
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
}
}
@@ -978,7 +1052,7 @@ class MainActivity : ComponentActivity() {
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
saveBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
val message = runCatching(work).getOrElse { e -> getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
ui.post {
saveBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
@@ -1024,10 +1098,10 @@ class MainActivity : ComponentActivity() {
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
components.componentAction("Importing") {
components.componentAction(getString(R.string.comp_busy_importing)) {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
}
}
@@ -1045,14 +1119,14 @@ class MainActivity : ComponentActivity() {
requestInitialFocus = focusComponentsContent,
onProton = { components.chooseProton(it) },
onComp = { components.compComp = it },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, components.compComp); "The waiting swap was cancelled." } } },
onDeletePackage = { file -> components.componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_swapping)) { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_restoring)) { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_cancelling)) { ComponentsManager.cancelQueued(this, pid, components.compComp); getString(R.string.comp_swap_cancelled) } } },
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { showComponents = false },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
@@ -1065,9 +1139,9 @@ class MainActivity : ComponentActivity() {
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_linux))) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
),
)
@@ -1103,14 +1177,14 @@ class MainActivity : ComponentActivity() {
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
pkgId = id; pkgStage = getString(R.string.store_starting); pkgPercent = -1
Thread({
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, "${entry.name}: $problem", android.widget.Toast.LENGTH_LONG).show()
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, entry.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
@@ -1120,7 +1194,7 @@ class MainActivity : ComponentActivity() {
private fun removePackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = if (entry.kind == "appimage") "Removing ${entry.name}…" else "Forgetting ${entry.name}…"; pkgPercent = -1
pkgId = id; pkgStage = getString(if (entry.kind == "appimage") R.string.main_removing_named else R.string.main_forgetting_named, entry.name); pkgPercent = -1
Thread({
DesktopCatalog.remove(this, entry)
ui.post {
@@ -1142,12 +1216,12 @@ class MainActivity : ComponentActivity() {
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
clientDirectAudio = clientDirectAudio,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
@@ -1174,6 +1248,7 @@ class MainActivity : ComponentActivity() {
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyDownloadingBinary = decky.deckyDownloadingBinary,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
),
ModeSettingsActions(
@@ -1200,6 +1275,10 @@ class MainActivity : ComponentActivity() {
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onSteamDownloadsInBackground = { enabled ->
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
steamDownloadsInBackground = enabled
},
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackActionsInverted = { inverted ->
@@ -1207,7 +1286,6 @@ class MainActivity : ComponentActivity() {
backActionsInverted = inverted
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
@@ -1221,7 +1299,7 @@ class MainActivity : ComponentActivity() {
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
onGameStorage = { path, label -> setGameStorage(path, label) },
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
},
onStorageDiagnostics = { on ->
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
@@ -1248,13 +1326,13 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose a folder of your own games", addedGamesDirs.lastOrNull())) },
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
onPickAddedGameExe = { folder ->
pendingAddedGame = folder
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder))
},
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
@@ -1265,7 +1343,7 @@ class MainActivity : ComponentActivity() {
decky.deckySupervisor = false
},
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Decky plugin ZIP"))
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
},
onDismiss = { settingsMode = null },
),
@@ -1321,7 +1399,7 @@ class MainActivity : ComponentActivity() {
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
}
private fun fetchAddedGameArt() {
@@ -1368,10 +1446,10 @@ class MainActivity : ComponentActivity() {
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
@@ -1384,7 +1462,7 @@ class MainActivity : ComponentActivity() {
Thread({
drivers.refreshDrivers()
val games = scanAddedGames()
val storage = GameStorage.options(this).map { it.label to it.path }
val storage = GameStorage.options(this)
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
ui.post {
@@ -1403,9 +1481,9 @@ class MainActivity : ComponentActivity() {
/** A second Steam library, proven writable first; "" = internal only. */
private fun setGameStorage(path: String, label: String) {
if (path.isNotEmpty() && path != SessionPrefs.GAME_STORAGE_OFF) {
val problem = GameStorage.prepare(path)
val problem = GameStorage.prepare(this, path)
if (problem != null) {
android.widget.Toast.makeText(this, "Not usable: $problem", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, getString(R.string.main_storage_not_usable, problem), android.widget.Toast.LENGTH_LONG).show()
return
}
}
@@ -1446,10 +1524,11 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "Desktop"
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: getString(R.string.main_running_program)
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
@@ -1516,7 +1595,10 @@ class MainActivity : ComponentActivity() {
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
getString(
R.string.phantom_warning, PhantomProcessLimit.title(this, phantomProcessStatus),
PhantomProcessLimit.instructions(this, phantomProcessStatus), PhantomProcessLimit.adbCommand(),
)
} else null
}
@@ -1532,10 +1614,10 @@ class MainActivity : ComponentActivity() {
} catch (error: Exception) {
if (displayId != null) {
runCatching { startActivity(intent) }
android.widget.Toast.makeText(this, "Could not open Settings on the bottom screen; opened it on the main screen.", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, R.string.main_settings_main_screen, android.widget.Toast.LENGTH_LONG).show()
} else {
startActivity(Intent(android.provider.Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
android.widget.Toast.makeText(this, "Open Developer options in Settings", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, R.string.main_open_dev_options, android.widget.Toast.LENGTH_LONG).show()
}
}
}
@@ -1568,7 +1650,7 @@ class MainActivity : ComponentActivity() {
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = if (LinuxRuntimeInstaller.isRemoving()) "Removing Linux runtime" else "Starting…"
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
percent = -1
Thread({
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
@@ -1593,6 +1675,7 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
private const val MEDIA_SETTLE_MS = 1500L
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
@@ -24,7 +24,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
if (protonBusyId != null || SessionState.running) return
ProtonExtras.unqueue(activity, tool)
protonBusyId = id
protonStage = "Starting…"
protonStage = activity.getString(R.string.store_starting)
protonPercent = -1
Thread({
val problem = ProtonExtras.install(activity, tool) { label, value ->
@@ -44,7 +44,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = "Removing ${tool.name}…"
protonStage = activity.getString(R.string.main_removing_named, tool.name)
protonPercent = -1
Thread({
ProtonExtras.remove(activity, tool)
@@ -45,6 +45,7 @@ import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.input.EvdevKeys
import com.droiddeck.launcher.input.InputSpace
import com.droiddeck.launcher.input.KeyboardHost
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.SessionClipboard
@@ -100,6 +101,11 @@ import kotlin.math.abs
* the HUD line, the loading overlay, the drawer and its dialogs.
*/
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
private var pipUi by mutableStateOf(false)
private var pipAutoEnter by mutableStateOf(false)
@@ -241,6 +247,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
private var fexPreset by mutableStateOf("")
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var onScreenButtonsVisible by mutableStateOf(false)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
@@ -300,7 +307,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onCreate(savedInstanceState)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, "Wait for runtime removal to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
finish()
return
}
@@ -356,6 +363,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
SessionState.padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
@@ -461,6 +469,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
topic = loading.topic, readable = loading.readable,
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
onCancel = { SessionService.stop(this@SessionActivity); finish() },
@@ -496,7 +505,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
@@ -504,8 +513,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
@@ -556,6 +566,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onSteamDownloadsInBackground = { enabled ->
SessionPrefs.setSteamDownloadsInBackground(this@SessionActivity, enabled)
steamDownloadsInBackground = enabled
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
@@ -565,9 +580,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
val message = when {
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
}
Toast.makeText(this@SessionActivity, message, Toast.LENGTH_SHORT).show()
}
},
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity),
@@ -685,7 +704,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
uiHandler.post {
drawerComponents = snap
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
}, "drawer-components").start()
}
@@ -700,10 +719,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val current = active ?: "orig:$build"
if (queued != null && value == current) {
// Picking what is already in place again cancels the swap waiting for the game.
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
ComponentsManager.cancelQueued(this, protonId, comp); getString(R.string.comp_swap_cancelled)
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
else ComponentsManager.swap(this, protonId, value)
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
}.getOrElse { e -> getString(R.string.session_swap_failed, e.message ?: e.javaClass.simpleName) }
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
@@ -717,20 +736,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
else intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
private fun pausedTitle(): String = when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "The desktop is paused"
SessionService.MODE_RUN -> (com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program") + " is paused"
else -> "Steam is paused"
SessionService.MODE_DESKTOP -> getString(R.string.session_desktop_paused)
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
?.let { getString(R.string.session_named_paused, it) } ?: getString(R.string.session_program_paused)
else -> getString(R.string.load_steam_paused)
}
private fun loadingTitle(): String = when (loadingMode()) {
SessionService.MODE_DESKTOP ->
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) "Starting Steam on the desktop"
else "Starting the desktop"
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) getString(R.string.session_starting_steam_desktop)
else getString(R.string.session_starting_desktop)
SessionService.MODE_RUN -> {
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
"Starting " + (com.droiddeck.launcher.frontend.Library.nameForProgram(program) ?: "the program")
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
?: getString(R.string.session_starting_program)
}
else -> "Starting Steam"
else -> getString(R.string.load_starting_steam)
}
private fun shareCurrentSessionLogs() {
@@ -738,17 +759,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// ended screen's Share logs is for that one.
val folder = SessionPaths.current() ?: SessionPaths.lastEnded()
if (folder == null || !folder.isDirectory) {
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
Toast.makeText(this, R.string.session_no_logs, Toast.LENGTH_LONG).show()
return
}
SessionLogShare.prepare(this, { folder }) { zip ->
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
Toast.makeText(this, R.string.session_logs_archive_failed, Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
Toast.makeText(this, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
}
}
}
@@ -777,6 +798,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
}
@@ -805,8 +827,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
else "downloading Proton Experimental (ARM64)"
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
var failedComponent: String? = null
@@ -847,7 +869,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
loading.percent = -1
loading.step = "Starting the session…"
loading.say(getString(R.string.session_starting_session), LoadingState.Topic.SESSION, readable = false)
// The surface may have come and gone while the download ran; start on the live one.
if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder)
}
@@ -863,14 +885,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "session-failure-artifacts").start()
}
/**
* The loading screen's lines for one package: downloading it (with and without its size), checking it,
* unpacking it; the checklist [topic] they belong to, and whether the checking line is shown as it is.
*/
private class PackageLines(
val downloading: Int, val downloadingMb: Int, val checking: Int, val unpacking: Int,
val topic: LoadingState.Topic, val checkingReadable: Boolean,
)
/** Reports one package's download on the loading screen: "<what> · 332 of 791 MB", checking, unpacking. */
private fun progressFor(what: String, mb: Long) = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p ->
uiHandler.post {
loading.percent = p
loading.step = when {
stage.startsWith("Downloading") -> if (p >= 0 && mb > 0) "downloading $what · ${p * mb / 100} of $mb MB" else "downloading $what"
stage.startsWith("Verifying") -> "checking $what"
else -> "unpacking $what"
private fun progressFor(what: PackageLines, mb: Long) = object : com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener {
override fun onProgress(stage: String, percent: Int) =
onProgress(com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.OTHER, stage, percent)
override fun onProgress(step: com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
uiHandler.post {
loading.percent = percent
when (step) {
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.DOWNLOADING -> loading.say(
if (percent >= 0 && mb > 0) getString(what.downloadingMb, percent * mb / 100, mb) else getString(what.downloading),
what.topic, readable = true,
)
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.VERIFYING ->
loading.say(getString(what.checking), what.topic, what.checkingReadable)
else -> loading.say(getString(what.unpacking), what.topic, readable = true)
}
}
}
}
@@ -878,29 +918,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Null when the runtime is in, else the loading screen's closing line. */
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
?: return getString(R.string.session_runtime_catalog_unreachable)
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor("the Linux runtime", release.size / 1_000_000))
return if (ok) null else "The Linux runtime did not install. Check the runtime in Setup and press Play again."
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
}
/** Null when the desktop package is in, else the loading screen's closing line. */
private fun installDesktop(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading the desktop" }
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
?: return "Could not reach the desktop catalog. Check the connection and press Desktop again."
?: return getString(R.string.session_desktop_catalog_unreachable)
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry,
progressFor("the desktop", entry.size / 1_000_000))
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
}
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
private fun installProton(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) }
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
?: return "catalog unreachable"
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
return catalog.install(this, entry, progressFor(PROTON_LINES, entry.size / 1_000_000))
}
override fun surfaceCreated(holder: SurfaceHolder) {
@@ -1161,14 +1201,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
val what = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
SessionService.MODE_DESKTOP -> "The desktop"
else -> "Steam"
val stopped = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
?.let { getString(R.string.session_ended_named, it) } ?: getString(R.string.session_ended_program)
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
loading.showEnded(
hint?.text ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
)
focusEndedScreen()
}
@@ -1218,8 +1259,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
} else if (enosys >= 8) {
EndHint("The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again.")
EndHint(resources.getQuantityString(R.plurals.session_hint_enosys, enosys, enosys,
getString(R.string.perf_title), getString(R.string.perf_seccomp), getString(R.string.perf_xalia)))
} else null
} catch (e: Exception) {
null
@@ -1446,18 +1487,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun movePointer(x: Float, y: Float) {
val width = surfaceView.width.takeIf { it > 0 } ?: return
val height = surfaceView.height.takeIf { it > 0 } ?: return
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
val drawnH = out.second * scale
val left = (width - drawnW) / 2f
val top = (height - drawnH) / 2f
val px = ((x - left) / drawnW * 1920f).toInt().coerceIn(0, 1919)
val py = ((y - top) / drawnH * 1080f).toInt().coerceIn(0, 1079)
WaylandCompositor.nativeSendPointer(1, px, py)
showCursor(x.coerceIn(left, left + drawnW), y.coerceIn(top, top + drawnH))
val rect = drawnRect() ?: return
val cx = x.coerceIn(rect.left, rect.right)
val cy = y.coerceIn(rect.top, rect.bottom)
WaylandCompositor.nativeSendPointer(1, InputSpace.x(cx, surfaceView.width), InputSpace.y(cy, surfaceView.height))
showCursor(cx, cy)
}
/**
@@ -1604,10 +1638,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (width <= 0f || height <= 0f) return
val nx = (x / width).coerceIn(0f, 1f)
val ny = (y / height).coerceIn(0f, 1f)
WaylandCompositor.nativeSendPointer(1, (nx * 1919f).toInt(), (ny * 1079f).toInt())
drawnRect()?.let { rect ->
showCursor(rect.left + nx * rect.width(), rect.top + ny * rect.height())
}
val rect = drawnRect() ?: return
val px = rect.left + nx * rect.width()
val py = rect.top + ny * rect.height()
WaylandCompositor.nativeSendPointer(1, InputSpace.x(px, surfaceView.width), InputSpace.y(py, surfaceView.height))
showCursor(px, py)
}
private fun closeSecondScreen(reset: Boolean) {
@@ -1654,9 +1689,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
val rect = drawnRect() ?: return false
fun sendTouch(action: Int, index: Int) {
val x = ((event.getX(index) - rect.left) / rect.width()).coerceIn(0f, 1f)
val y = ((event.getY(index) - rect.top) / rect.height()).coerceIn(0f, 1f)
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index), (x * 1919f).toInt(), (y * 1079f).toInt())
val x = event.getX(index).coerceIn(rect.left, rect.right)
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index),
InputSpace.x(x, surfaceView.width), InputSpace.y(y, surfaceView.height))
}
when (event.actionMasked) {
// A first finger starts a new gesture, and Android has ended every earlier one: whatever
@@ -2013,6 +2049,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = false)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
@@ -2090,5 +2127,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Compositor scale modes (Container.FULLSCREEN_* values): 1 = fit with bars, centred. */
private const val SCALE_FIT = 1
private const val ALIGN_CENTER = 0
private val RUNTIME_LINES = PackageLines(
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
LoadingState.Topic.RUNTIME, checkingReadable = true,
)
private val DESKTOP_LINES = PackageLines(
R.string.session_desktop_downloading, R.string.session_desktop_downloading_mb,
R.string.session_desktop_checking, R.string.session_desktop_unpacking,
LoadingState.Topic.DESKTOP, checkingReadable = false,
)
private val PROTON_LINES = PackageLines(
R.string.session_proton_downloading, R.string.session_proton_downloading_mb,
R.string.session_proton_checking, R.string.session_proton_unpacking,
LoadingState.Topic.STEAM, checkingReadable = false,
)
}
}
@@ -46,7 +46,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
onAllowInstalls = {
askPermission = false
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
},
onDismissPermission = { askPermission = false; pending = null },
)
@@ -77,7 +77,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
catalog = it
follow = AppUpdates.follow(activity, it)
error = null
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
}.onFailure { if (force) error = activity.getString(R.string.upd_couldnt_check, it.message ?: it.javaClass.simpleName) }
}
}, "app-updates").start()
}
@@ -90,20 +90,20 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
private fun install(r: AppUpdates.Release) {
if (stage != null) return
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
AppUpdates.installBlock(r)?.let { error = it; return }
if (SessionState.running) { error = "Stop the running session first, then update"; return }
val apk = r.apk ?: run { error = activity.getString(R.string.upd_no_download); return }
AppUpdates.installBlock(r)?.let { error = it.message(activity); return }
if (SessionState.running) { error = activity.getString(R.string.upd_stop_first); return }
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
error = null
stage = "Downloading"
stage = activity.getString(R.string.upd_stage_downloading)
percent = 0
Thread({
val result = runCatching {
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
ui.post { stage = "Installing"; percent = -1 }
ui.post { stage = activity.getString(R.string.upd_stage_installing); percent = -1 }
SelfInstaller.install(activity, file)
}
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
result.onFailure { e -> ui.post { stage = null; error = e.message ?: activity.getString(R.string.upd_failed) } }
}, "app-update-install").start()
}
@@ -114,14 +114,14 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
stage = "Waiting for you to confirm"
if (confirm == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
stage = activity.getString(R.string.upd_stage_confirm)
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
PackageInstaller.STATUS_SUCCESS -> stage = null
else -> {
stage = null
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
error = SelfInstaller.describeFailure(activity, status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
}
}
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.agent
import android.content.Context
/**
* Who may run code through the agent bridge. Reading state, starting and stopping sessions,
* launching and quitting games and sending input only need the shell (the provider's DUMP
* permission). Running commands in the guest, evaluating JavaScript in the Steam client, session
* environment, binary overrides and preference writes reach inside the app's sandbox - the guest
* holds the Steam login - which the shell of a stock device otherwise cannot. They require
* Setup > Session > Debugging tools to be on, in every build. The bridge itself cannot turn it on.
*/
object AgentAccess {
const val MAX_REQUEST_CHARS = 256 * 1024
private const val PREFS = "agent"
private const val KEY_COMMANDS = "commands"
/** The preferences file the bridge must never write: set-pref refuses it. */
const val PROTECTED_PREFS = PREFS
fun commandsAllowed(context: Context): Boolean =
prefs(context).getBoolean(KEY_COMMANDS, false)
/** The Setup toggle's own state. */
fun commandsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_COMMANDS, false)
fun setCommandsEnabled(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean(KEY_COMMANDS, enabled).apply()
if (!enabled) runCatching { AgentEnv.revoke(context) }
.onFailure { android.util.Log.w("AgentAccess", "Could not finish clearing debugging experiments") }
}
fun requireCommands(context: Context) {
if (!commandsAllowed(context)) throw AgentException(
"AGENT_COMMANDS_DISABLED",
"Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
)
}
fun requireStart(context: Context, request: org.json.JSONObject) {
if (request.optString("mode") == "run") requireCommands(context)
}
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
/** A refusal with a stable code, answered as {"ok": false, "error": {code, message}}. */
class AgentException(val code: String, message: String) : RuntimeException(message)
@@ -10,13 +10,14 @@ import android.net.Uri
import android.os.Binder
import android.os.Bundle
import android.os.Process
import android.util.Base64
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
@@ -25,7 +26,14 @@ import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** A shell-only control surface, installed only by debug builds. */
/**
* The shell's control surface (docs/agent-control.md), in every build: the provider and its start
* Activity need android.permission.DUMP, held by the shell and privileged or explicitly granted callers. Commands that
* reach into the app's sandbox are further gated by [AgentAccess].
*
* A method's request is JSON, base64-encoded in the "request" extra; every answer is JSON in the
* "json" key of the returned Bundle, {"ok": true, ...} or {"ok": false, "error": {code, message}}.
*/
class AgentBridgeProvider : ContentProvider() {
override fun onCreate(): Boolean {
val appContext = context?.applicationContext ?: return false
@@ -48,17 +56,53 @@ class AgentBridgeProvider : ContentProvider() {
}
val response = try {
when (method) {
"state" -> state(context).put("ok", true)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
val request = decode(extras?.getString(EXTRA_REQUEST))
// Binder identity is the shell's; everything below acts as the app.
val token = Binder.clearCallingIdentity()
try {
dispatch(context, method, request)
} finally {
Binder.restoreCallingIdentity(token)
}
} catch (e: AgentException) {
error(e.code, e.message ?: e.code)
} catch (e: Exception) {
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
}
return Bundle().apply { putString(RESULT_JSON, response.toString()) }
val json = response.toString().let {
if (it.length <= 384 * 1024) it else error("RESPONSE_TOO_LARGE", "Reduce the command output or select fewer fields").toString()
}
return Bundle().apply { putString(RESULT_JSON, json) }
}
private fun dispatch(context: Context, method: String, request: JSONObject): JSONObject = when (method) {
"state" -> state(context).put("ok", true)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
"launch" -> launch(context, request)
"focus" -> guest(context, JSONObject().put("kind", "focus"), 15_000)
"quit" -> guest(context, JSONObject().put("kind", "quit").put("appId", request.opt("appId"))
.put("timeout", request.optDouble("timeout", 15.0)), timeoutMs(request, 15.0, 30_000))
"input" -> AgentInput.handle(request)
"ui" -> AgentUi.handle(request)
"guest" -> {
AgentAccess.requireCommands(context)
guest(context, JSONObject(request.toString()).put("kind", "exec"), timeoutMs(request, 30.0, 10_000))
}
"cdp" -> {
AgentAccess.requireCommands(context)
guest(context, JSONObject(request.toString()).put("kind", "cdp"), timeoutMs(request, 15.0, 5_000))
}
"env" -> env(context, request)
"override" -> {
AgentAccess.requireCommands(context)
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
}
"prefs" -> prefs(context, request)
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
}
private fun state(context: Context): JSONObject {
@@ -91,13 +135,31 @@ class AgentBridgeProvider : ContentProvider() {
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
session.put("failure", failure)
// gamescope's focus as last published by the guest agent, without the window lists
// (the focus command returns those, fresh).
val focus = AgentGuest.focus(context)?.let { snap ->
JSONObject()
.put("focusedApp", snap.opt("focusedApp") ?: JSONObject.NULL)
.put("focusedWindow", snap.opt("focusedWindow") ?: JSONObject.NULL)
.put("focusableApps", snap.optJSONArray("focusableApps") ?: JSONArray())
.put("baselayerAppIds", snap.optJSONArray("baselayerAppIds") ?: JSONArray())
.put("seq", snap.optInt("seq"))
.put("t", snap.optLong("t"))
}
session.put("focus", focus ?: JSONObject.NULL)
val hello = AgentGuest.hello(context)
return JSONObject()
.put("schema", 1)
.put("schema", SCHEMA)
.put("build", BuildConfig.BUILD_LABEL)
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
.put("runtime", JSONObject()
.put("installed", LinuxRuntime.isInstalled(context))
.put("version", runtimeVersion ?: JSONObject.NULL))
.put("agent", JSONObject()
.put("guest", hello != null)
.put("guestFocus", hello?.optBoolean("focus") ?: false)
.put("commands", AgentAccess.commandsAllowed(context)))
.put("session", session)
}
@@ -131,6 +193,67 @@ class AgentBridgeProvider : ContentProvider() {
return JSONObject().put("ok", true).put("command", "resume")
}
private fun launch(context: Context, request: JSONObject): JSONObject {
val appId = request.optString("appId")
if (!GameLaunchLink.validId(appId)) throw AgentException("INVALID_APP_ID", "appId must be a decimal Steam app id")
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) {
throw AgentException("NO_STEAM_SESSION", "Games launch inside a running Steam session")
}
if (!SessionService.launchGame(context, appId)) throw AgentException("LAUNCH_REJECTED", "The session did not accept the launch")
SessionEvents.record("agent.launch_requested", mapOf("appId" to appId))
return JSONObject().put("ok", true).put("command", "launch").put("appId", appId)
}
private fun env(context: Context, request: JSONObject): JSONObject {
AgentAccess.requireCommands(context)
val result = when (val op = request.optString("op", "list")) {
"list" -> AgentEnv.list(context)
"set" -> {
val lines = request.optJSONArray("lines") ?: JSONArray()
AgentEnv.set(context, List(lines.length()) { lines.optString(it) }, request.optBoolean("persistent"))
}
"clear" -> AgentEnv.clear(context, request.optString("scope", "next"))
else -> throw AgentException("INVALID_REQUEST", "env op '$op' is not list, set or clear")
}
return result.put("ok", true).put("command", "env")
}
private fun prefs(context: Context, request: JSONObject): JSONObject {
AgentAccess.requireCommands(context)
val file = request.optString("file")
val result = when (val op = request.optString("op", "get")) {
"get" -> AgentPrefs.get(context, file)
"set" -> AgentPrefs.set(context, file, request.optString("key"), request.optString("type", "string"),
if (request.isNull("value")) null else request.optString("value"))
else -> throw AgentException("INVALID_REQUEST", "prefs op '$op' is not get or set")
}
return result.put("ok", true).put("command", "prefs")
}
/** A guest agent request; its own answer already carries ok or error. */
private fun guest(context: Context, request: JSONObject, timeoutMs: Long): JSONObject =
AgentGuest.call(context, request, timeoutMs)
/** The guest's own timeout plus slack for the round trip. */
private fun timeoutMs(request: JSONObject, defaultSeconds: Double, slackMs: Long): Long {
val seconds = if (request.has("timeout")) request.opt("timeout")?.toString()?.toDoubleOrNull()
?: throw AgentException("INVALID_REQUEST", "timeout must be a number") else defaultSeconds
if (!seconds.isFinite() || seconds <= 0 || seconds > 600) {
throw AgentException("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
}
return (seconds * 1000).toLong() + slackMs
}
private fun decode(encoded: String?): JSONObject {
if (encoded.isNullOrBlank()) return JSONObject()
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) throw AgentException("INVALID_REQUEST", "The encoded request exceeds 256 KiB")
return try {
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
} catch (e: Exception) {
throw AgentException("INVALID_REQUEST", "The request extra is not base64 JSON")
}
}
private fun error(code: String, message: String) = JSONObject()
.put("ok", false)
.put("error", JSONObject().put("code", code).put("message", message))
@@ -143,6 +266,9 @@ class AgentBridgeProvider : ContentProvider() {
companion object {
const val RESULT_JSON = "json"
const val EXTRA_REQUEST = "request"
/** 2 added agent and session.focus; everything in 1 is unchanged. */
const val SCHEMA = 2
private val recoveryComplete = CountDownLatch(1)
fun awaitRecovery() {
@@ -0,0 +1,222 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.StandardOpenOption.READ
/**
* Experiment switches an agent sets without touching Download/droiddeck-env or rebuilding the apk:
*
* - environment lines (KEY=VALUE) for the session, applied after the user's own droiddeck-env, so
* they win. "next" lines are taken by the next session that starts and dropped when it ends;
* "persistent" ones stay until cleared.
* - binary overrides: a file pushed to the app's external files (agent-inbox/<name>) is installed
* as /opt/droiddeck-agent/bin/<name> in the rootfs and put first on the next session's PATH -
* a gamescope build under test, say. Removed when that session ends.
*
* Kept in the app's own files (agent-env.json), never in a place the shell can write.
*/
object AgentEnv {
private const val TAG = "AgentEnv"
private const val FILE = "agent-env.json"
private const val GUEST_BIN = "/opt/droiddeck-agent/bin"
private const val BASE_PATH = "/usr/local/bin:/usr/bin:/bin"
private val NAME = Regex("[A-Za-z0-9._-]{1,64}")
private val KEY = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val lock = Any()
fun inbox(context: Context): File = File(context.getExternalFilesDir(null), "agent-inbox")
fun list(context: Context): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
load(context)
}
/** Add [lines] for the next session ([persistent] = every session until cleared). */
fun set(context: Context, lines: List<String>, persistent: Boolean): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
if (lines.any { line -> KEY.matchEntire(line.substringBefore('=', "")) == null || !line.contains('=') || line.any { it == '\n' || it == '\r' || it == '\u0000' } }) {
throw AgentException("INVALID_ENV", "Environment entries must be single KEY=VALUE lines without NUL bytes")
}
val state = load(context)
val key = if (persistent) "persistent" else "pending"
val merged = merge(strings(state.optJSONArray(key)), lines)
state.put(key, JSONArray(merged))
save(context, state)
state
}
/** Drop lines: [scope] is "next", "persistent" or "all". */
fun clear(context: Context, scope: String): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
val state = load(context)
when (scope) {
"next" -> state.put("pending", JSONArray())
"persistent" -> state.put("persistent", JSONArray())
"all" -> { state.put("pending", JSONArray()); state.put("persistent", JSONArray()); state.put("pendingOverrides", JSONArray()) }
else -> throw AgentException("INVALID_SCOPE", "Scope must be next, persistent or all")
}
save(context, state)
state
}
/** Install agent-inbox/[name] as an override for the next session. */
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
val source = File(inbox(context), name)
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
val target = File(bin(context), name)
target.parentFile?.mkdirs()
val staged = File.createTempFile("override-", ".tmp", target.parentFile)
try {
Files.newInputStream(source.toPath(), READ, NOFOLLOW_LINKS).use { input -> staged.outputStream().use { input.copyTo(it) } }
} catch (e: Exception) {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
}
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
}
source.delete()
val state = load(context)
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
save(context, state)
state.put("installed", "$GUEST_BIN/$name")
}
/**
* At session start: the lines this session gets, after the user's droiddeck-env. Pending lines
* and overrides become this session's own, to be dropped by [endSession].
*/
fun beginSession(context: Context): List<String> = synchronized(lock) {
if (!AgentAccess.commandsAllowed(context)) {
revoke(context)
return@synchronized emptyList()
}
val state = load(context)
val active = strings(state.optJSONArray("pending"))
val overrides = strings(state.optJSONArray("pendingOverrides"))
state.put("active", JSONArray(active)).put("pending", JSONArray())
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
save(context, state)
// Anything else there was left by a session that never reached endSession (the app died).
bin(context).listFiles()
?.filter { it.name !in overrides }?.forEach { it.delete() }
val lines = strings(state.optJSONArray("persistent")) + active
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
withPath
}
fun revoke(context: Context) = synchronized(lock) {
val state = load(context)
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) state.put(key, JSONArray())
save(context, state)
bin(context).listFiles()?.forEach { it.delete() }
}
/** At session end: this session's lines and override binaries go. */
fun endSession(context: Context) = synchronized(lock) {
val state = load(context)
val overrides = strings(state.optJSONArray("activeOverrides"))
val pending = strings(state.optJSONArray("pendingOverrides"))
val bin = bin(context)
overrides.filter { it !in pending }.forEach { File(bin, it).delete() }
state.put("active", JSONArray()).put("activeOverrides", JSONArray())
save(context, state)
}
/** A PATH= line among [lines] (the user's or an agent's) is extended rather than replaced. */
private fun pathOf(lines: List<String>) =
lines.lastOrNull { it.startsWith("PATH=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } ?: BASE_PATH
private fun bin(context: Context): File {
val root = LinuxRuntime.rootDir(context).canonicalFile.toPath()
val directory = root.resolve(GUEST_BIN.removePrefix("/"))
var path = directory
while (path != root) {
if (Files.isSymbolicLink(path)) throw AgentException("OVERRIDE_FAILED", "The override directory must not contain symbolic links")
path = path.parent
}
return directory.toFile()
}
private fun merge(existing: List<String>, added: List<String>): List<String> {
val keys = added.map { it.substringBefore('=') }.toSet()
return existing.filter { it.substringBefore('=') !in keys } + added
}
private fun strings(array: JSONArray?): List<String> =
if (array == null) emptyList() else List(array.length()) { array.optString(it) }.filter { it.isNotEmpty() }
private fun load(context: Context): JSONObject {
val file = File(context.filesDir, FILE)
val state = try { if (file.isFile) JSONObject(file.readText()) else JSONObject() } catch (e: Exception) { JSONObject() }
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) {
if (!state.has(key)) state.put(key, JSONArray())
}
return state
}
private fun save(context: Context, state: JSONObject) {
val file = File(context.filesDir, FILE)
val staged = File(context.filesDir, "$FILE.tmp")
staged.writeText(state.toString())
if (!staged.renameTo(file)) {
staged.delete()
throw AgentException("ENV_WRITE_FAILED", "Could not save the debugging environment")
}
}
}
/** Typed writes to the app's SharedPreferences, for driving a settings matrix without the UI. */
object AgentPrefs {
fun set(context: Context, file: String, key: String, type: String, value: String?): JSONObject {
AgentAccess.requireCommands(context)
validateFile(file)
if (file == AgentAccess.PROTECTED_PREFS) throw AgentException("PROTECTED_PREFS", "The agent preferences are set on the device only")
if (file.isBlank() || key.isBlank()) throw AgentException("INVALID_PREF", "file and key are required")
val prefs = context.getSharedPreferences(file, Context.MODE_PRIVATE)
val previous = prefs.all[key]
val editor = prefs.edit()
try {
when (type) {
"remove" -> editor.remove(key)
"bool" -> editor.putBoolean(key, value?.toBooleanStrictOrNull() ?: throw NumberFormatException("not true/false"))
"int" -> editor.putInt(key, value!!.toInt())
"long" -> editor.putLong(key, value!!.toLong())
"float" -> editor.putFloat(key, value!!.toFloat())
"string" -> editor.putString(key, value ?: "")
else -> throw AgentException("INVALID_PREF", "type must be bool, int, long, float, string or remove")
}
} catch (e: RuntimeException) {
if (e is AgentException) throw e
throw AgentException("INVALID_PREF", "The value is not a $type")
}
if (!editor.commit()) throw AgentException("PREF_WRITE_FAILED", "Could not write $file/$key")
return JSONObject().put("file", file).put("key", key).put("type", type)
.put("previous", previous ?: JSONObject.NULL).put("value", prefs.all[key] ?: JSONObject.NULL)
}
fun get(context: Context, file: String): JSONObject {
AgentAccess.requireCommands(context)
validateFile(file)
val all = context.getSharedPreferences(file, Context.MODE_PRIVATE).all
return JSONObject().put("file", file).put("values", JSONObject(all.mapValues { it.value ?: JSONObject.NULL }))
}
private fun validateFile(file: String) {
if (!Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}").matches(file)) {
throw AgentException("INVALID_PREF", "Preference files must be simple names")
}
}
}
@@ -0,0 +1,128 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.FileObserver
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionState
import org.json.JSONObject
import java.io.File
import java.util.UUID
/**
* The app's side of the guest agent (tools/linuxfs/overlay/usr/local/bin/droiddeck-agent): requests
* go in as files under the session root's agent directory and answers come back the same way, so
* whatever runs does so inside the live session with its own environment.
*/
object AgentGuest {
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
private var observer: FileObserver? = null
private var lastFocusKey: String? = null
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "agent")
/**
* Before each session: nothing of the last one's daemon, answers or focus may be read as
* current. Then watch for the new daemon's hello and focus snapshots, which go into the
* session's events.jsonl.
*/
fun reset(context: Context) {
synchronized(lock) {
observer?.stopWatching()
observer = null
lastFocusKey = null
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@Suppress("DEPRECATION")
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
when (path) {
"hello.json" -> readJson(File(dir, path))?.let {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
}
}
}.also { it.startWatching() }
}
}
/** At the end of a session: no more events for a folder that is being closed. */
fun stop() {
synchronized(lock) {
observer?.stopWatching()
observer = null
}
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
"focusableApps" to snapshot.optJSONArray("focusableApps")?.toString(),
"baselayerAppIds" to snapshot.optJSONArray("baselayerAppIds")?.toString(),
)
val key = fields.values.joinToString("|")
synchronized(lock) {
if (key == lastFocusKey) return
lastFocusKey = key
}
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
}
/** The daemon's hello, when it is running in the current session. */
fun hello(context: Context): JSONObject? {
if (!SessionState.running) return null
val hello = readJson(File(dir(context), "hello.json")) ?: return null
val pid = hello.optInt("pid", -1)
return hello.takeIf { pid > 1 && File("/proc/$pid").exists() }
}
/** The newest focus snapshot the daemon published, when a session is running. */
fun focus(context: Context): JSONObject? =
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
val dir = dir(context)
val requests = File(dir, "req").apply { mkdirs() }
val answers = File(dir, "resp").apply { mkdirs() }
val id = UUID.randomUUID().toString()
val staged = File(requests, "$id.json.tmp")
val published = File(requests, "$id.json")
val answer = File(answers, "$id.json")
staged.writeText(request.toString())
if (!staged.renameTo(published)) {
staged.delete()
throw AgentException("AGENT_UNAVAILABLE", "Could not hand the request to the guest agent")
}
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (answer.isFile) {
if (answer.length() > 1024 * 1024) {
answer.delete()
throw AgentException("RESPONSE_TOO_LARGE", "The guest response exceeds 1 MiB")
}
val result = readJson(answer)
answer.delete()
return result ?: throw AgentException("INVALID_AGENT_RESPONSE", "The guest agent's answer was not JSON")
}
Thread.sleep(POLL_MS)
}
published.delete()
Log.w(TAG, "no answer to ${request.optString("kind")} in ${timeoutMs}ms")
throw AgentException("TIMEOUT", "The guest agent did not answer within ${timeoutMs / 1000}s")
}
private fun readJson(file: File): JSONObject? = try {
if (file.isFile && file.length() <= 1024 * 1024) JSONObject(file.readText()) else null
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,221 @@
package com.droiddeck.launcher.agent
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.wayland.WaylandCompositor
import org.json.JSONObject
/**
* Input into the running session the way a player gives it: keys and touches through the
* compositor (what the session screen sends), and the virtual pad through the same PadBridge the
* on-screen controls drive, so games that ignore touch still see a controller.
*/
object AgentInput {
/** Out of the way of real fingers, which Android numbers from 0. */
private const val TOUCH_ID = 9
private const val TOUCH_SCALE_X = 1919f
private const val TOUCH_SCALE_Y = 1079f
fun handle(request: JSONObject): JSONObject {
if (!SessionState.running || !SessionState.firstFrameSeen) {
throw AgentException("SESSION_NOT_READY", "Input needs a running session that has drawn its first frame")
}
return when (val type = request.optString("type")) {
"key" -> key(request)
"text" -> text(request)
"tap" -> tap(request)
"swipe" -> swipe(request)
"pad" -> pad(request)
"stick" -> stick(request)
"trigger" -> trigger(request)
else -> throw AgentException("INVALID_INPUT", "Unknown input type '$type'")
}.put("ok", true).put("command", "input").put("type", request.optString("type"))
}
private fun key(request: JSONObject): JSONObject {
val name = request.optString("key")
val code = KEYS[name.uppercase()] ?: name.toIntOrNull()
?: throw AgentException("INVALID_INPUT", "Unknown key '$name' (a name such as ENTER or an evdev code)")
val modifiers = request.optJSONArray("modifiers")?.let { array ->
List(array.length()) { i ->
KEYS[array.optString(i).uppercase()] ?: throw AgentException("INVALID_INPUT", "Unknown modifier '${array.optString(i)}'")
}
}.orEmpty()
when (val action = request.optString("action", "tap")) {
"down" -> WaylandCompositor.nativeSendKey(code, 1)
"up" -> WaylandCompositor.nativeSendKey(code, 0)
"tap" -> {
modifiers.forEach { WaylandCompositor.nativeSendKey(it, 1) }
WaylandCompositor.nativeSendKey(code, 1)
hold(request, 40)
WaylandCompositor.nativeSendKey(code, 0)
modifiers.asReversed().forEach { WaylandCompositor.nativeSendKey(it, 0) }
}
else -> throw AgentException("INVALID_INPUT", "Key action '$action' is not tap, down or up")
}
return JSONObject().put("code", code)
}
/** Typed as key presses (US layout), which reaches X programs that have no text-input support. */
private fun text(request: JSONObject): JSONObject {
val text = request.optString("text")
if (request.optBoolean("commit")) {
WaylandCompositor.textInputCommit(text)
return JSONObject().put("length", text.length).put("via", "text-input")
}
val missing = text.filter { TYPED[it] == null }
if (missing.isNotEmpty()) throw AgentException("INVALID_INPUT", "Cannot type '${missing.toSet().joinToString("")}' as keys; use commit")
val delay = request.optLong("delayMs", 15)
for (ch in text) {
val (code, shift) = TYPED.getValue(ch)
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 1)
WaylandCompositor.nativeSendKey(code, 1)
WaylandCompositor.nativeSendKey(code, 0)
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 0)
Thread.sleep(delay)
}
return JSONObject().put("length", text.length).put("via", "keys")
}
private fun tap(request: JSONObject): JSONObject {
val (x, y) = point(request, "x", "y")
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x, y)
hold(request, 60)
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x, y)
return JSONObject().put("touch", org.json.JSONArray().put(x).put(y))
}
private fun swipe(request: JSONObject): JSONObject {
val (x1, y1) = point(request, "x", "y")
val (x2, y2) = point(request, "x2", "y2")
val duration = request.optLong("durationMs", 300).coerceIn(16, 10_000)
val steps = (duration / 16).toInt().coerceAtLeast(2)
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x1, y1)
for (i in 1..steps) {
Thread.sleep(duration / steps)
WaylandCompositor.nativeSendTouch(1, TOUCH_ID, x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
}
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x2, y2)
return JSONObject().put("from", org.json.JSONArray().put(x1).put(y1)).put("to", org.json.JSONArray().put(x2).put(y2))
}
/**
* A point in the compositor's touch space (0..1919 x 0..1079 whatever the session's size), from
* the session's output pixels (space "output", the default: what the guest draws at) or 0..1
* fractions (space "fraction").
*/
private fun point(request: JSONObject, xKey: String, yKey: String): Pair<Int, Int> {
if (!request.has(xKey) || !request.has(yKey)) throw AgentException("INVALID_INPUT", "$xKey and $yKey are required")
val x = request.getDouble(xKey)
val y = request.getDouble(yKey)
val (fx, fy) = when (val space = request.optString("space", "output")) {
"fraction" -> x to y
"output" -> {
val (w, h) = SessionState.outputSize
x / (w - 1).coerceAtLeast(1) to y / (h - 1).coerceAtLeast(1)
}
else -> throw AgentException("INVALID_INPUT", "space '$space' is not output or fraction")
}
return (fx.coerceIn(0.0, 1.0) * TOUCH_SCALE_X).toInt() to (fy.coerceIn(0.0, 1.0) * TOUCH_SCALE_Y).toInt()
}
private fun pad(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val name = request.optString("button").lowercase()
if (name == "qam") {
bridge.triggerQam()
return JSONObject().put("button", name)
}
val button = BUTTONS[name]
if (button == null && name !in DPAD) throw AgentException("INVALID_INPUT", "Unknown pad button '$name'")
val apply = { s: PadState, down: Boolean ->
when (name) {
"up" -> s.up = down
"down" -> s.down = down
"left" -> s.left = down
"right" -> s.right = down
else -> s.press(button!!, down)
}
}
when (val action = request.optString("action", "tap")) {
"down" -> bridge.applyTouch { apply(it, true) }
"up" -> bridge.applyTouch { apply(it, false) }
"tap" -> {
bridge.applyTouch { apply(it, true) }
hold(request, 120)
bridge.applyTouch { apply(it, false) }
}
else -> throw AgentException("INVALID_INPUT", "Pad action '$action' is not tap, down or up")
}
return JSONObject().put("button", name)
}
private fun stick(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val right = request.optString("stick", "left") == "right"
val x = request.optDouble("x", 0.0).toFloat().coerceIn(-1f, 1f)
val y = request.optDouble("y", 0.0).toFloat().coerceIn(-1f, 1f)
bridge.applyTouch { if (right) { it.rightX = x; it.rightY = y } else { it.leftX = x; it.leftY = y } }
hold(request, 300)
bridge.applyTouch { if (right) { it.rightX = 0f; it.rightY = 0f } else { it.leftX = 0f; it.leftY = 0f } }
return JSONObject().put("stick", if (right) "right" else "left")
}
private fun trigger(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val right = request.optString("trigger", "right") == "right"
val value = request.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)
bridge.applyTouch { if (right) it.rightTrigger = value else it.leftTrigger = value }
hold(request, 200)
bridge.applyTouch { if (right) it.rightTrigger = 0f else it.leftTrigger = 0f }
return JSONObject().put("trigger", if (right) "right" else "left")
}
private fun hold(request: JSONObject, defaultMs: Long) =
Thread.sleep(request.optLong("holdMs", defaultMs).coerceIn(0, 10_000))
private val DPAD = setOf("up", "down", "left", "right")
private val BUTTONS = mapOf(
"a" to PadState.A, "b" to PadState.B, "x" to PadState.X, "y" to PadState.Y,
"lb" to PadState.LB, "rb" to PadState.RB, "select" to PadState.SELECT, "back" to PadState.SELECT,
"start" to PadState.START, "menu" to PadState.START, "l3" to PadState.L3, "r3" to PadState.R3,
"guide" to PadState.GUIDE, "steam" to PadState.GUIDE,
)
/** Linux evdev codes (input-event-codes.h) by name. */
private val KEYS: Map<String, Int> = buildMap {
"1234567890".forEachIndexed { i, c -> put(c.toString(), 2 + i) }
"QWERTYUIOP".forEachIndexed { i, c -> put(c.toString(), 16 + i) }
"ASDFGHJKL".forEachIndexed { i, c -> put(c.toString(), 30 + i) }
"ZXCVBNM".forEachIndexed { i, c -> put(c.toString(), 44 + i) }
(1..10).forEach { put("F$it", 58 + it) }
put("F11", 87); put("F12", 88)
putAll(mapOf(
"ESC" to 1, "MINUS" to 12, "EQUAL" to 13, "BACKSPACE" to 14, "TAB" to 15, "LEFTBRACE" to 26,
"RIGHTBRACE" to 27, "ENTER" to 28, "LEFTCTRL" to 29, "SEMICOLON" to 39, "APOSTROPHE" to 40,
"GRAVE" to 41, "LEFTSHIFT" to 42, "BACKSLASH" to 43, "COMMA" to 51, "DOT" to 52, "SLASH" to 53,
"RIGHTSHIFT" to 54, "LEFTALT" to 56, "SPACE" to 57, "CAPSLOCK" to 58, "RIGHTCTRL" to 97,
"RIGHTALT" to 100, "HOME" to 102, "UP" to 103, "PAGEUP" to 104, "LEFT" to 105, "RIGHT" to 106,
"END" to 107, "DOWN" to 108, "PAGEDOWN" to 109, "INSERT" to 110, "DELETE" to 111, "LEFTMETA" to 125,
"CTRL" to 29, "SHIFT" to 42, "ALT" to 56, "META" to 125, "SUPER" to 125, "RETURN" to 28, "ESCAPE" to 1,
))
}
/** US-layout characters as (evdev code, shift). */
private val TYPED: Map<Char, Pair<Int, Boolean>> = buildMap {
for (c in 'a'..'z') put(c, KEYS.getValue(c.uppercase()) to false)
for (c in 'A'..'Z') put(c, KEYS.getValue(c.toString()) to true)
for (c in '0'..'9') put(c, KEYS.getValue(c.toString()) to false)
"!@#$%^&*()".forEachIndexed { i, c -> put(c, KEYS.getValue(((i + 1) % 10).toString()) to true) }
putAll(mapOf(
' ' to (57 to false), '\n' to (28 to false), '\t' to (15 to false),
'-' to (12 to false), '_' to (12 to true), '=' to (13 to false), '+' to (13 to true),
'[' to (26 to false), '{' to (26 to true), ']' to (27 to false), '}' to (27 to true),
';' to (39 to false), ':' to (39 to true), '\'' to (40 to false), '"' to (40 to true),
'`' to (41 to false), '~' to (41 to true), '\\' to (43 to false), '|' to (43 to true),
',' to (51 to false), '<' to (51 to true), '.' to (52 to false), '>' to (52 to true),
'/' to (53 to false), '?' to (53 to true),
))
}
}
@@ -38,6 +38,10 @@ class AgentStartActivity : Activity() {
Log.e(TAG, "missing agent start request")
return null
}
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) {
Log.w(TAG, "agent start request is too large")
return null
}
return try {
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
} catch (e: Exception) {
@@ -47,6 +51,13 @@ class AgentStartActivity : Activity() {
}
private fun startSession(request: JSONObject) {
try {
AgentAccess.requireStart(this, request)
} catch (e: AgentException) {
Log.w(TAG, e.code)
finish()
return
}
val requestedMode = request.optString("mode")
val sessionMode = when (requestedMode) {
"steam" -> SessionService.MODE_STEAM
@@ -0,0 +1,157 @@
package com.droiddeck.launcher.agent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.view.inspector.WindowInspector
import androidx.compose.ui.platform.ViewRootForTest
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* The app's own UI as an agent sees it: every Compose node in every window of the process (popups
* and menus included), read from Compose's semantics rather than through UI Automator, which on a
* dual-screen device dumps whichever window has focus - often not this app's. Nodes carry their
* test tag (ui/TestTags.kt), text, content description, role, state and on-screen bounds; click
* and set-text act on a node by tag or text, so a test needs no coordinates.
*/
object AgentUi {
private const val TIMEOUT_MS = 5_000L
/** Setup's Debugging tools toggle: the bridge never acts on its own gate (AgentAccess). */
private const val GATE_TAG = "setting-agent-commands"
fun handle(request: JSONObject): JSONObject = when (val op = request.optString("op", "dump")) {
"dump" -> onMain { dump(request.optBoolean("all")) }
"click" -> onMain { act(request) { node -> perform(node, SemanticsActions.OnClick) } }
"set-text" -> onMain {
val text = request.optString("text")
act(request) { node ->
node.config.getOrNull(SemanticsActions.SetText)?.action?.invoke(AnnotatedString(text))
?: throw AgentException("NOT_EDITABLE", "That node takes no text")
}
}
else -> throw AgentException("INVALID_REQUEST", "ui op '$op' is not dump, click or set-text")
}.put("ok", true).put("command", "ui")
private fun roots(): List<Pair<View, ViewRootForTest>> {
if (Build.VERSION.SDK_INT < 29) throw AgentException("UNSUPPORTED", "The UI dump needs Android 10 or later")
val found = mutableListOf<Pair<View, ViewRootForTest>>()
fun walk(view: View) {
if (view is ViewRootForTest) found += view to view
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
WindowInspector.getGlobalWindowViews().filter { it.isAttachedToWindow && it.isShown }.forEach(::walk)
return found
}
private fun dump(all: Boolean): JSONObject {
val windows = JSONArray()
for ((view, root) in roots()) {
val nodes = JSONArray()
val location = IntArray(2).also { view.getLocationOnScreen(it) }
fun visit(node: SemanticsNode) {
val json = describe(node, location)
if (all || json.has("tag") || json.has("text") || json.has("description") || json.optBoolean("clickable")) nodes.put(json)
node.children.forEach(::visit)
}
visit(root.semanticsOwner.rootSemanticsNode)
windows.put(JSONObject()
.put("display", view.display?.displayId ?: JSONObject.NULL)
.put("window", view.rootView.javaClass.simpleName)
.put("nodes", nodes))
}
return JSONObject().put("windows", windows)
}
private fun describe(node: SemanticsNode, origin: IntArray): JSONObject {
val config = node.config
val json = JSONObject().put("id", node.id)
config.getOrNull(SemanticsProperties.TestTag)?.let { json.put("tag", it) }
val text = textFields(config)
text.keys().forEach { json.put(it, text.get(it)) }
config.getOrNull(SemanticsProperties.Role)?.let { json.put("role", it.toString()) }
config.getOrNull(SemanticsProperties.ToggleableState)?.let { json.put("toggle", it.name) }
config.getOrNull(SemanticsProperties.Selected)?.let { json.put("selected", it) }
config.getOrNull(SemanticsProperties.Focused)?.let { json.put("focused", it) }
if (config.contains(SemanticsProperties.Disabled)) json.put("disabled", true)
if (config.contains(SemanticsActions.OnClick)) json.put("clickable", true)
if (config.contains(SemanticsActions.SetText)) json.put("editable", true)
val b = node.boundsInWindow
json.put("bounds", JSONArray()
.put(origin[0] + b.left.toInt()).put(origin[1] + b.top.toInt())
.put(origin[0] + b.right.toInt()).put(origin[1] + b.bottom.toInt()))
return json
}
internal fun textFields(config: SemanticsConfiguration): JSONObject {
val password = config.contains(SemanticsProperties.Password)
val json = JSONObject()
for ((key, value) in listOf(
"text" to config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text },
"editableText" to config.getOrNull(SemanticsProperties.EditableText)?.text,
"description" to config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" "),
"state" to config.getOrNull(SemanticsProperties.StateDescription),
)) value?.let { json.put(key, if (password) "<redacted>" else it) }
return json
}
/**
* The node a request names by "tag" (exact) or "match" (text or description containing it,
* ignoring case); "index" picks among several, in tree order.
*/
private fun act(request: JSONObject, action: (SemanticsNode) -> Unit): JSONObject {
val tag = request.optString("tag").takeIf { it.isNotEmpty() }
val text = if (tag == null) request.optString("match").takeIf { it.isNotEmpty() } else null
if (tag == null && text == null) throw AgentException("INVALID_REQUEST", "Name the node by tag or match")
val matches = mutableListOf<Pair<SemanticsNode, IntArray>>()
for ((view, root) in roots()) {
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
fun visit(node: SemanticsNode) {
val config = node.config
val hit = if (tag != null) config.getOrNull(SemanticsProperties.TestTag) == tag
else (config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
?: config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ") ?: "")
.contains(text!!, ignoreCase = true)
if (hit) matches += node to origin
node.children.forEach(::visit)
}
visit(root.semanticsOwner.rootSemanticsNode)
}
val index = request.optInt("index", 0)
val (node, origin) = matches.getOrNull(index)
?: throw AgentException("NO_SUCH_NODE", "No node with ${if (tag != null) "tag '$tag'" else "text '$text'"} (${matches.size} found)")
if (generateSequence(node) { it.parent }.any { it.config.getOrNull(SemanticsProperties.TestTag) == GATE_TAG }) {
throw AgentException("PROTECTED_CONTROL", "Debugging tools are switched on the device only")
}
action(node)
return JSONObject().put("matches", matches.size).put("node", describe(node, origin))
}
private fun perform(node: SemanticsNode, key: androidx.compose.ui.semantics.SemanticsPropertyKey<androidx.compose.ui.semantics.AccessibilityAction<() -> Boolean>>) {
val action = node.config.getOrNull(key)?.action ?: throw AgentException("NOT_CLICKABLE", "That node has no click action")
action()
}
private fun <T> onMain(block: () -> T): T {
if (Looper.myLooper() == Looper.getMainLooper()) return block()
val result = AtomicReference<Result<T>>()
val done = CountDownLatch(1)
Handler(Looper.getMainLooper()).post {
result.set(runCatching(block))
done.countDown()
}
if (!done.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) throw AgentException("TIMEOUT", "The UI thread did not answer")
return result.get().getOrThrow()
}
}
@@ -28,7 +28,9 @@ public class PulseAudioComponent extends SessionPart {
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
public static final String SOCKET_NAME = "PS0";
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r5";
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
private final File workingDir;
/** Where the daemon's own output is kept for this session, or null for logcat only. */
@@ -40,8 +42,6 @@ public class PulseAudioComponent extends SessionPart {
* turns that one stream into a source the client can see, named DirectAudioMic.
*/
private final String micFifoPath;
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
private String relaySocketPath;
private volatile int pid = -1;
public PulseAudioComponent(Context context) {
@@ -54,15 +54,6 @@ public class PulseAudioComponent extends SessionPart {
this.micFifoPath = micFifoPath;
}
/**
* Route the daemon's output through the DirectAudio relay at this socket instead of an AAudio
* stream of its own. The relay owns the stream outside proot, with its adaptive buffer; the
* daemon only fills a shared ring. Set before {@link #start()}; the relay may start later.
*/
public void setRelaySocket(String path) {
this.relaySocketPath = path;
}
/** CRC-32 of the bundled pulseaudio.tzst, or "?" if it cannot be read. About 600 KB; cheap. */
private String bundleChecksum() {
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
@@ -106,6 +97,7 @@ public class PulseAudioComponent extends SessionPart {
String have = FileUtils.readString(stamp);
String want = BUNDLE_STAMP + "-" + bundleChecksum();
if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile()
|| !new File(modulesDir, "arm64/module-directaudio-native-sink.so").isFile()
|| !new File(workingDir, "pactl").isFile()
|| have == null || !want.equals(have.trim())) {
Log.i(TAG, "unpacking pulseaudio.tzst (" + want + "; had " + have + ")");
@@ -139,14 +131,15 @@ public class PulseAudioComponent extends SessionPart {
// the sink to 0% and the session plays silence.
// The sink's name is what the client's Audio settings show as the output device, so it
// says which road the sound takes.
if (relaySocketPath != null && !relaySocketPath.isEmpty()) {
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
config.add("set-default-sink DirectAudio");
} else {
// The classic sink: the adaptive AAudio module 0.1.5 shipped, unchanged.
config.add("load-module module-aaudio-classic-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
config.add("set-default-sink AAudioSink");
}
// The client's sound: DirectAudio's own engine, in this process, one step from Android.
// decay=0: shrinking the device buffer after a quiet spell probes below what works and that
// probe is a click; the client plays menus. Between .nofail and .fail a module that cannot
// open its stream does not take the daemon down; ensureClientSink() then loads the plain
// AAudio sink in its place.
config.add(".nofail");
config.add("load-module module-directaudio-native-sink sink_name=DirectAudio performance_mode=1 adaptive=1 decay=0 limiter=1 watchdog=1 volume=1.0");
config.add("set-default-sink DirectAudio");
config.add(".fail");
if (micFifoPath != null && !micFifoPath.isEmpty()) {
// The format is the helper's, fixed at s16le/48000/mono: it resamples when the device
// grants another input rate, so the daemon is never told a rate the bytes are not.
@@ -178,6 +171,74 @@ public class PulseAudioComponent extends SessionPart {
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
});
Thread guard = new Thread(() -> ensureClientSink(out), "pulse-sink-guard");
guard.setDaemon(true);
guard.start();
}
/**
* The fallback: once the daemon answers, if the DirectAudio sink is not
* there (the module failed to load or to open its stream), load the plain AAudio sink so the
* client is never left without an output. Says so in the audio log either way.
*/
private void ensureClientSink(java.io.PrintWriter out) {
long deadline = System.currentTimeMillis() + 15000L;
String sinks = null;
while (System.currentTimeMillis() < deadline && pid > 1) {
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
}
String line;
if (sinks == null) {
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
} else {
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
&& pactl("set-default-sink", "AAudioSink") != null;
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
Log.w(TAG, line);
}
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
}
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
private String pactl(String... args) {
File pactl = new File(workingDir, "pactl");
if (!pactl.isFile() || pid <= 1) return null;
java.lang.Process process = null;
try {
File modules = new File(workingDir, "modules/arm64");
ArrayList<String> cmd = new ArrayList<>();
cmd.add(pactl.getAbsolutePath());
java.util.Collections.addAll(cmd, args);
ProcessBuilder builder = new ProcessBuilder(cmd);
builder.directory(workingDir);
builder.redirectErrorStream(true);
builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir);
builder.environment().put("HOME", workingDir.getAbsolutePath());
builder.environment().put("TMPDIR", workingDir.getAbsolutePath());
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
process = builder.start();
String output;
try (InputStream in = process.getInputStream()) {
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
return null;
}
return process.exitValue() == 0 ? output : null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
}
}
@Override
@@ -0,0 +1,129 @@
package com.droiddeck.launcher.core
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.os.LocaleList
import java.util.Locale
/**
* The language the app shows itself in.
*
* Nothing chosen (the default) means the system's: Android's own language, or the per-app language
* Android 13+ lets a user set for DroidDeck in its settings, exactly as Android resolves it. Picking
* one in Setup overrides that until the user picks "System default" again. Each activity and service
* applies the choice to its own context ([wrap], [applyTo]), so it works on every Android version
* the app runs on rather than only where the platform's per-app language API exists.
*
* Steam follows the same choice: [SteamLanguage] turns [effective] into the client's language.
*/
object AppLanguage {
/** The stored value meaning "follow the system". */
const val SYSTEM = ""
/** The languages the app ships, as BCP 47 tags, in the order the picker lists them. */
val supported: List<String> = listOf("en", "es", "fr", "ru", "ja", "ko", "zh-CN", "zh-TW", "zh-HK")
private const val PREFS = "language"
private const val KEY = "app_language"
/** The locale Android gave the process before any choice here was applied. */
@Volatile private var systemLocale: Locale? = null
/** The stored choice: one of [supported], or [SYSTEM]. */
fun chosen(context: Context): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, SYSTEM)
?.takeIf { it in supported } ?: SYSTEM
fun setChosen(context: Context, tag: String) {
val value = if (tag in supported) tag else SYSTEM
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, value).commit()
val locale = value.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
Locale.setDefault(locale)
// Code that reads strings through the application context sees the new language too.
val app = context.applicationContext
@Suppress("DEPRECATION")
app.resources.updateConfiguration(
Configuration(app.resources.configuration).apply { setLocales(LocaleList(locale)) },
app.resources.displayMetrics,
)
}
/** The locale Android chose for the app, ignoring the choice made here. */
fun system(context: Context): Locale =
systemLocale ?: context.resources.configuration.locales[0] ?: Locale.getDefault()
/** The locale the app is shown in: the chosen language, or else the system's. */
fun effective(context: Context): Locale =
chosen(context).takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
/** Remembers the system's locale; call with the untouched base context, before [wrap]. */
fun noteSystem(base: Context) {
if (systemLocale == null) systemLocale = base.resources.configuration.locales[0]
}
/**
* [base], showing the chosen language: for an Application's or a Service's attachBaseContext.
* Only the locale is overridden, so screen size, density and night mode keep following the
* device as they change.
*/
fun wrap(base: Context): Context {
noteSystem(base)
val tag = chosen(base).ifEmpty { return base }
val locale = Locale.forLanguageTag(tag)
Locale.setDefault(locale)
return base.createConfigurationContext(override(locale))
}
/**
* Applies the chosen language to an activity; call from its attachBaseContext, after super.
* applyOverrideConfiguration keeps the locale through rotation and window resizes, which an
* activity here handles itself rather than being recreated.
*/
fun applyTo(activity: Activity, base: Context) {
noteSystem(base)
val tag = chosen(base).ifEmpty { return }
val locale = Locale.forLanguageTag(tag)
Locale.setDefault(locale)
activity.applyOverrideConfiguration(override(locale))
}
/** Follows a change of the system's language while the process lives (Application.onConfigurationChanged). */
fun systemChanged(config: Configuration) {
config.locales[0]?.let { systemLocale = it }
}
// A new Configuration's fontScale is 1, which as an override would undo the user's font size;
// 0 leaves it undefined, so only the locale is changed.
private fun override(locale: Locale) = Configuration().apply { fontScale = 0f; setLocales(LocaleList(locale)) }
/** Which of the app's languages [locale] reads as, or null when the app does not ship it. */
fun supportedTag(locale: Locale): String? = when (locale.language) {
"en", "es", "fr", "ru", "ja", "ko" -> locale.language
"zh" -> when {
locale.script == "Hans" -> "zh-CN"
locale.country == "HK" || locale.country == "MO" -> "zh-HK"
locale.script == "Hant" || locale.country == "TW" -> "zh-TW"
else -> "zh-CN"
}
else -> null
}
/** [locale]'s language named in itself, without its region unless the app tells them apart. */
fun displayName(locale: Locale): String =
supportedTag(locale)?.let(::nativeName) ?: locale.getDisplayLanguage(locale).replaceFirstChar { it.titlecase(locale) }
/** A language's name in that language, for the picker: what a reader of it looks for. */
fun nativeName(tag: String): String = when (tag) {
"en" -> "English"
"es" -> "Español"
"fr" -> "Français"
"ru" -> "Русский"
"ja" -> "日本語"
"ko" -> "한국어"
"zh-CN" -> "简体中文"
"zh-TW" -> "繁體中文(台灣)"
"zh-HK" -> "繁體中文(香港)"
else -> Locale.forLanguageTag(tag).let { it.getDisplayName(it) }
}
}
@@ -167,7 +167,6 @@ object DeviceReport {
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
k("Microphone", SessionPrefs.micEnabled(context))
k("On-screen controls", SessionPrefs.oscMode(context))
k("Touch mode", SessionPrefs.touchMode(context))
@@ -181,8 +180,14 @@ object DeviceReport {
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
h("Device switch files")
SessionService.envSwitchFile(context)?.takeIf { it.isFile }?.let { f ->
k("droiddeck-env", FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
}
if (File(Environment.getExternalStorageDirectory(), "Download/droiddeck-env").isFile) {
k("Download/droiddeck-env", "(ignored - it moved to Android/data/${context.packageName}/files)")
}
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
"droiddeck-osc", "droiddeck-no-pad",
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.os.Build
import com.droiddeck.launcher.R
import java.io.File
/**
@@ -17,8 +19,8 @@ object DeviceSupport {
File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
/** The chip as the device names it, for the card that explains the refusal. */
fun gpuName(): String {
fun gpuName(context: Context): String {
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { "this GPU" }
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
}
}
@@ -40,7 +40,7 @@ object HostProcess {
if (onLine == null) builder.redirectOutput(File("/dev/null"))
val process = builder.start()
val pid = pidOf(process)
Log.i(TAG, "started pid $pid: $command")
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
if (onLine != null) {
Thread({
try {
@@ -59,7 +59,7 @@ object HostProcess {
}
pid
} catch (e: Exception) {
Log.e(TAG, "could not start: $command", e)
Log.e(TAG, "could not start: ${argv.first()}", e)
-1
}
}
@@ -60,6 +60,15 @@ object LogRedactor {
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
private val MAC = Regex("(?i)(?<![0-9a-z:-])[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\\1){4}[0-9a-f]{2}(?![0-9a-z:-])")
private val MAC_FIELD = Regex(
"(?i)(\\bmac(?:[_ ]?address)?[\"']?\\s*[:=]\\s*[\"']?)[0-9a-f]{2}([:-])" +
"(?:[0-9a-f]{2}\\2){4}[0-9a-f]{2}(?![0-9a-z:-])"
)
private val DEVICE_IDENTIFIER = Regex(
"(?i)(\\b(?:android[_ ]?id|(?:device|controller)[_ ]?serial|serial(?:[_ ]?number)?|build\\.serial)" +
"[\"']?\\s*[:=]\\s*)(\"[^\"\\r\\n<>]+\"|'[^'\\r\\n<>]+'|[^\\s\"'<>&;,\\[\\]{}]+)"
)
/**
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
@@ -196,7 +205,7 @@ object LogRedactor {
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
* out.
*/
const val RULES_VERSION = 1
const val RULES_VERSION = 2
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
@@ -208,6 +217,13 @@ object LogRedactor {
if (line.isEmpty()) return line
return try {
var out = line
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
out = MAC.replace(out, "<redacted:mac>")
out = DEVICE_IDENTIFIER.replace(out) {
val value = it.groupValues[2]
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
}
out = GUID.replace(out, "<redacted:guid>")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
out = JWT_BASE64.replace(out, "<redacted:jwt>")
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
import android.content.Context
import android.os.Build
import android.provider.Settings
import com.droiddeck.launcher.R
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
enum class PhantomProcessStatus {
@@ -98,29 +99,30 @@ object PhantomProcessLimit {
fun blocksSteam(status: PhantomProcessStatus): Boolean =
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
fun title(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
fun title(context: Context, status: PhantomProcessStatus): String = context.getString(when (status) {
PhantomProcessStatus.ENABLED -> R.string.phantom_title_on
PhantomProcessStatus.UNSET -> R.string.phantom_title_unset
PhantomProcessStatus.UNREADABLE -> R.string.phantom_title_unreadable
PhantomProcessStatus.DISABLED -> R.string.phantom_title_off
PhantomProcessStatus.NOT_APPLICABLE -> R.string.phantom_title_not_required
})
fun instructions(context: Context, status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE ->
context.getString(R.string.phantom_instructions_fix, fixSentence(context))
PhantomProcessStatus.DISABLED -> context.getString(R.string.phantom_instructions_off)
PhantomProcessStatus.NOT_APPLICABLE -> context.getString(R.string.phantom_instructions_not_applicable)
}
fun instructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
fun gateInstructions(context: Context, status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence(context)
else -> instructions(context, status)
}
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
else -> instructions(status)
}
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
fun fixSentence(context: Context, sdk: Int = Build.VERSION.SDK_INT): String =
context.getString(if (hasDeveloperToggle(sdk)) R.string.phantom_fix_toggle else R.string.phantom_fix_no_toggle)
/** English: for the device report. */
fun reportValue(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import com.droiddeck.launcher.R
import com.flyfishxu.kadb.Kadb
import com.flyfishxu.kadb.cert.KadbCert
import java.io.File
@@ -37,21 +38,21 @@ object WirelessAdbFix {
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
KadbCert.set(identity.first, identity.second)
writeAtomically(cert, identity.first)
writeAtomically(key, identity.second)
writeAtomically(context, cert, identity.first)
writeAtomically(context, key, identity.second)
}
private fun writeAtomically(destination: File, contents: ByteArray) {
private fun writeAtomically(context: Context, destination: File, contents: ByteArray) {
val temporary = File(destination.parentFile, "${destination.name}.tmp")
temporary.writeBytes(contents)
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
check(temporary.renameTo(destination)) { context.getString(R.string.adbfix_save_failed) }
}
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
loadOrCreateIdentity(context.applicationContext)
Kadb.pair(host, port, pairingCode, "DroidDeck")
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
writeAtomically(context, File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
writeAtomically(context, File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
}
private fun savedConnection(context: Context): SavedConnection? {
@@ -73,6 +74,7 @@ object WirelessAdbFix {
private fun saveConnection(context: Context, host: String, port: Int) {
writeAtomically(
context,
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
"$host\n$port".toByteArray(),
)
@@ -171,11 +173,11 @@ object WirelessAdbFix {
Kadb.create(host, port).use { adb ->
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
val change = adb.shell(command)
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
check(change.exitCode == 0) { change.allOutput.ifBlank { context.getString(R.string.adbfix_command_failed, change.exitCode) } }
}
val result = adb.shell(PhantomProcessLimit.verifyCommand())
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
context.getString(R.string.adbfix_not_confirmed, result.allOutput.trim())
}
}
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
@@ -186,7 +188,7 @@ object WirelessAdbFix {
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
val appContext = context.applicationContext
val saved = savedConnection(appContext)
?: error("No Wireless debugging pairing is saved")
?: error(appContext.getString(R.string.adbfix_no_pairing))
val cachedPort = saved.port
if (cachedPort != null) {
val cachedResult = runCatching {
@@ -201,7 +203,7 @@ object WirelessAdbFix {
}
} else {
val discoveredPort = findConnectPort(appContext, saved.host)
?: error("Enter the current Wireless debugging IP address & Port")
?: error(appContext.getString(R.string.adbfix_enter_address))
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
}
}
@@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class WirelessAdbPairingService : Service() {
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
sealed interface Stage {
data object Idle : Stage
data object Waiting : Stage
@@ -35,7 +38,7 @@ class WirelessAdbPairingService : Service() {
private var pairingHost: String? = null
private var pairingPort: Int? = null
@Volatile private var working = false
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
private val timeout = Runnable { finish(Stage.Failed(getString(R.string.adbpair_timed_out))) }
override fun onBind(intent: Intent?): IBinder? = null
@@ -75,13 +78,13 @@ class WirelessAdbPairingService : Service() {
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) = Unit
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
main.post { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed_code, errorCode))) }
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
}
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
.onSuccess { nsd = manager; discovery = listener }
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
.onFailure { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed, it.localizedMessage.toString()))) }
}
private fun resolveListener() = object : NsdManager.ResolveListener {
@@ -107,34 +110,34 @@ class WirelessAdbPairingService : Service() {
return
}
if (code.length != 6) {
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
show(Stage.CodeNeeded(getString(R.string.adbpair_six_digits)))
return
}
working = true
show(Stage.Working("Pairing…"))
show(Stage.Working(getString(R.string.adbpair_pairing)))
Thread({
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
if (paired.isFailure) {
main.post {
working = false
pairingPort = null
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
show(Stage.CodeNeeded(getString(R.string.adbpair_code_rejected)))
}
return@Thread
}
main.post {
stopDiscovery()
show(Stage.Working("Paired. Connecting…"))
show(Stage.Working(getString(R.string.adbpair_connecting)))
}
val result = runCatching {
val connectPort = WirelessAdbFix.localConnectPort(this)
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
main.post { show(Stage.Working("Applying the setting…")) }
?: error(getString(R.string.adbpair_port_not_found))
main.post { show(Stage.Working(getString(R.string.adbpair_applying))) }
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
}
main.post {
working = false
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: getString(R.string.adbpair_command_failed)) } ?: Stage.Done)
}
}, "wireless-adb-pairing").start()
}
@@ -174,9 +177,9 @@ class WirelessAdbPairingService : Service() {
private fun notification(stage: Stage): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.adbpair_channel_name),
NotificationManager.IMPORTANCE_HIGH).apply {
description = "Asks for the Wireless debugging pairing code while Settings is open"
description = getString(R.string.adbpair_channel_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
@@ -193,41 +196,40 @@ class WirelessAdbPairingService : Service() {
.setCategory(Notification.CATEGORY_STATUS)
when (stage) {
Stage.Waiting, Stage.Idle -> builder
.setContentTitle("Pair Wireless debugging")
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
.setStyle(Notification.BigTextStyle().bigText(
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
.setContentTitle(getString(R.string.adbpair_title_pair))
.setContentText(getString(R.string.adbpair_waiting_text))
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_waiting_long)))
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
is Stage.CodeNeeded -> {
val reply = PendingIntent.getService(this, 2,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else PendingIntent.FLAG_UPDATE_CURRENT)
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
val input = RemoteInput.Builder(KEY_CODE).setLabel(getString(R.string.adbpair_code_label)).build()
val text = stage.error ?: getString(R.string.adbpair_code_hint)
builder
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
.setContentTitle(getString(if (stage.error == null) R.string.adbpair_title_code else R.string.adbpair_title_retry))
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
.addAction(Notification.Action.Builder(null, getString(R.string.adbpair_enter_code), reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
}
is Stage.Working -> builder
.setContentTitle("Setting up Steam")
.setContentTitle(getString(R.string.adbpair_title_working))
.setContentText(stage.step)
.setProgress(0, 0, true)
.setOngoing(true)
.setOnlyAlertOnce(true)
Stage.Done -> builder
.setContentTitle("Steam is ready")
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
.setContentTitle(getString(R.string.adbpair_title_done))
.setContentText(getString(R.string.adbpair_done_text))
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_done_text)))
.setAutoCancel(true)
is Stage.Failed -> builder
.setContentTitle("Wireless debugging setup stopped")
.setContentTitle(getString(R.string.adbpair_title_failed))
.setContentText(stage.error)
.setStyle(Notification.BigTextStyle().bigText(stage.error))
.setAutoCancel(true)
@@ -56,6 +56,8 @@ data class RailItem(
val selected: Boolean,
/** >0 shows a small accent count badge on the item's icon (expanded and collapsed). */
val badge: Int = 0,
/** The under-icon label when the rail is collapsed, for names too long to fit; null derives one. */
val shortLabel: String? = null,
// onClick stays LAST so existing call sites can pass it as a trailing lambda; badge is an
// optional named param before it (default 0).
val onClick: () -> Unit,
@@ -73,7 +75,6 @@ private fun collapsedLabel(label: String): String {
when (label) {
"ENVIROMENT" -> return "ENVIRON" // the app's existing (mis)spelling, kept uppercase
"WIN COMPONENTS" -> return "WIN COMP"
"Downloads" -> return "Downlds"
}
if (label.contains(' ')) return label // multi-word → wraps to 2 lines
return if (label.length > 9) label.take(8) + "…" else label
@@ -317,7 +318,7 @@ fun CollapsibleRail(
RailItemIcon(item, iconTint)
Spacer(Modifier.height(2.dp))
Text(
collapsedLabel(item.label),
item.shortLabel ?: collapsedLabel(item.label),
color = labelColor,
fontSize = 7.5.sp,
lineHeight = 8.5.sp,
@@ -15,6 +15,11 @@ import java.io.File
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
class FileManagerActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
@@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
@@ -61,6 +62,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -107,7 +109,7 @@ private fun FileContextMenuItems(
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
text = { Text(stringResource(R.string.fm_select)) },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
@@ -115,7 +117,7 @@ private fun FileContextMenuItems(
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
text = { Text(stringResource(R.string.fm_properties)) },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
@@ -123,7 +125,7 @@ private fun FileContextMenuItems(
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
text = { Text(if (isFavorite) stringResource(R.string.fm_favorite_remove) else stringResource(R.string.fm_favorite_add)) },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
@@ -136,25 +138,25 @@ private fun FileContextMenuItems(
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
text = { Text(stringResource(R.string.fm_rename)) },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.fm_copy)) },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
text = { Text(stringResource(R.string.fm_cut)) },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
text = { Text(stringResource(R.string.common_delete)) },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
@@ -184,6 +186,7 @@ internal fun FileItemRow(
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val context = LocalContext.current
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
@@ -276,7 +279,7 @@ internal fun FileItemRow(
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
if (!isDir) append(FileOps.formatBytes(context, file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -285,7 +288,7 @@ internal fun FileItemRow(
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
Icon(Icons.Filled.MoreVert, stringResource(R.string.fm_actions), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
@@ -310,6 +313,47 @@ internal fun FileItemRow(
}
}
// The ".." row at the top of a folder: one level up, styled like a folder row so it reads as part
// of the list and takes controller focus the same way.
@Composable
internal fun ParentFolderRow(compact: Boolean = false, onTap: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onTap),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "..",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.fm_up_one_level),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
@@ -338,7 +382,7 @@ internal fun FavoritesList(
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
text = stringResource(R.string.fm_favorites),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
@@ -351,7 +395,7 @@ internal fun FavoritesList(
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
text = stringResource(R.string.fm_pin_current),
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
@@ -366,7 +410,7 @@ internal fun FavoritesList(
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
text = stringResource(R.string.fm_no_favorites),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
@@ -431,7 +475,7 @@ private fun FavoriteCard(
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
text = stringResource(loc.driveLabel),
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
@@ -443,9 +487,9 @@ private fun FavoriteCard(
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
FavStorage.INTERNAL -> stringResource(R.string.games_internal)
FavStorage.SD -> stringResource(R.string.fm_drive_sd)
FavStorage.OTHER -> stringResource(R.string.fm_drive_storage)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
@@ -464,7 +508,7 @@ private fun FavoriteCard(
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
contentDescription = stringResource(R.string.fm_unpin_favorite),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
@@ -8,6 +8,8 @@ import androidx.compose.material.icons.filled.Image
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import java.io.File
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
@@ -77,7 +79,7 @@ enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
@StringRes val driveLabel: Int, // Internal, SD card, or Storage
val displayPath: String // the unix absolute path
)
@@ -87,15 +89,20 @@ fun describeLocation(file: File): FavLocation {
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
return FavLocation(FavStorage.INTERNAL, R.string.fm_drive_internal, abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
return FavLocation(FavStorage.SD, R.string.fm_drive_sd, abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
// The Linux system's root (the app's files/linuxfs, the sessions' /) and anything under it.
if (Regex("/files/linuxfs(/|$)").containsMatchIn(abs)) {
return FavLocation(FavStorage.OTHER, R.string.fm_drive_linux, abs)
}
return FavLocation(FavStorage.OTHER, R.string.fm_drive_storage, abs)
}
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -41,6 +42,7 @@ import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Image
@@ -76,6 +78,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -140,7 +145,14 @@ fun FileManagerScreen(
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") File("/storage/$name") else rootDir
}
else -> rootDir
// A game's own folder or Proton prefix lives in the app's private storage, which has no
// volume above it: floor at the app's files dir so up/back still reach the library roots.
else -> {
val appFiles = runCatching { context.filesDir.canonicalPath }.getOrNull()
generateSequence(rootDir) { it.parentFile }
.firstOrNull { appFiles != null && runCatching { it.canonicalPath }.getOrNull() == appFiles }
?: rootDir
}
}
mutableStateOf(vol)
}
@@ -229,7 +241,9 @@ fun FileManagerScreen(
loadDirectory(dir)
}
LaunchedEffect(Unit) { openDrive(rootDir) }
// Open the start dir without openDrive(): that pins the floor to the start dir itself, which
// greyed out up/back whenever the manager was opened at a game's folder.
LaunchedEffect(Unit) { loadDirectory(rootDir) }
// System/gesture Back: while the Favorites view is open it closes that first; otherwise
// it goes up one directory. Only at the current drive's root with Favorites closed is it
@@ -243,6 +257,14 @@ fun FileManagerScreen(
if (parent != null && parent.exists()) loadDirectory(parent)
}
// One level up, never above the current drive's root: shared by the toolbar arrow and the
// ".." row at the top of the list.
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
fun goUp() {
val parent = currentDir.parentFile
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
}
// Resolve a non-colliding destination in [dir] for [name] (foo.txt -> "foo (1).txt").
fun uniqueDestination(dir: File, name: String): File {
var candidate = File(dir, name)
@@ -261,11 +283,11 @@ fun FileManagerScreen(
fun performDelete(file: File) {
scope.launch {
isOperationRunning = true
operationLabel = "Deleting..."
operationLabel = context.getString(R.string.fm_op_deleting)
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
isOperationRunning = false
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, "Delete failed", Toast.LENGTH_SHORT).show()
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
}
}
@@ -288,7 +310,7 @@ fun FileManagerScreen(
operationJob = scope.launch {
operationProgress = 0f
operationDeterminate = true
operationLabel = if (cut) "Moving..." else "Copying..."
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
var applyToAll: ConflictChoice? = null
@@ -324,10 +346,10 @@ fun FileManagerScreen(
}
// Progress is per item; with a batch the label carries the overall position.
operationLabel = buildString {
append(if (cut) "Moving" else "Copying")
if (sources.size > 1) append(" ${done + 1}/${sources.size}")
append(" - ").append(src.name)
operationLabel = if (sources.size > 1) {
context.getString(if (cut) R.string.fm_op_moving_batch else R.string.fm_op_copying_batch, done + 1, sources.size, src.name)
} else {
context.getString(if (cut) R.string.fm_op_moving_item else R.string.fm_op_copying_item, src.name)
}
var lastPct = -1
val onProgress = FileOps.ProgressCallback { copied, total ->
@@ -355,9 +377,9 @@ fun FileManagerScreen(
loadDirectory(currentDir, resetScroll = false)
val message = when {
failed > 0 -> "$done done, $failed failed"
skipped > 0 -> "$done done, $skipped skipped"
sources.size > 1 -> "$done items ${if (cut) "moved" else "copied"}"
failed > 0 -> context.getString(R.string.fm_paste_failed, done, failed)
skipped > 0 -> context.getString(R.string.fm_paste_skipped, done, skipped)
sources.size > 1 -> context.resources.getQuantityString(if (cut) R.plurals.fm_paste_moved else R.plurals.fm_paste_copied, done, done)
else -> null
}
if (message != null) Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
@@ -367,32 +389,32 @@ fun FileManagerScreen(
fun performRename(file: File, newName: String) {
val target = File(file.parentFile, newName)
if (target.exists()) {
Toast.makeText(context, "\"$newName\" already exists", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
return
}
scope.launch {
isOperationRunning = true
operationLabel = "Renaming..."
operationLabel = context.getString(R.string.fm_op_renaming)
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
isOperationRunning = false
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, "Rename failed", Toast.LENGTH_SHORT).show()
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
}
}
fun createFolder(parent: File, name: String) {
val target = File(parent, name)
if (target.exists()) {
Toast.makeText(context, "\"$name\" already exists", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
return
}
scope.launch {
isOperationRunning = true
operationLabel = "Creating folder..."
operationLabel = context.getString(R.string.fm_op_creating_folder)
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
isOperationRunning = false
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, "Could not create folder", Toast.LENGTH_SHORT).show()
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
}
}
@@ -409,6 +431,9 @@ fun FileManagerScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val drives = remember(storageTick) { StorageRoots.list(context) }
val linuxRoot = remember(storageTick) {
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
}
// ── Dialogs ──
@@ -416,12 +441,12 @@ fun FileManagerScreen(
var folderName by remember { mutableStateOf("") }
OutlinedAlertDialog(
onDismissRequest = { showNewFolderDialog = false },
title = { Text("New Folder") },
title = { Text(stringResource(R.string.fm_new_folder)) },
text = {
OutlinedTextField(
value = folderName,
onValueChange = { folderName = it },
label = { Text("Folder name") },
label = { Text(stringResource(R.string.fm_folder_name)) },
singleLine = true,
)
},
@@ -429,9 +454,9 @@ fun FileManagerScreen(
TextButton(onClick = {
showNewFolderDialog = false
if (folderName.isNotBlank()) createFolder(currentDir, folderName)
}) { Text("Create") }
}) { Text(stringResource(R.string.fm_create)) }
},
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -439,12 +464,12 @@ fun FileManagerScreen(
var newName by remember(renameTarget) { mutableStateOf(renameTarget?.name ?: "") }
OutlinedAlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text("Rename") },
title = { Text(stringResource(R.string.fm_rename)) },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("New name") },
label = { Text(stringResource(R.string.fm_new_name)) },
singleLine = true,
)
},
@@ -453,9 +478,9 @@ fun FileManagerScreen(
val file = renameTarget
renameTarget = null
if (file != null && newName.isNotBlank()) performRename(file, newName)
}) { Text("Rename") }
}) { Text(stringResource(R.string.fm_rename)) }
},
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -473,15 +498,15 @@ fun FileManagerScreen(
val file = selectedEntry ?: return
OutlinedAlertDialog(
onDismissRequest = { selectedEntry = null },
title = { Text("Delete?") },
text = { Text("Delete \"${file.name}\" permanently?") },
title = { Text(stringResource(R.string.fm_delete_title)) },
text = { Text(stringResource(R.string.fm_delete_text, file.name)) },
confirmButton = {
TextButton(onClick = {
selectedEntry = null
performDelete(file)
}) { Text("Delete") }
}) { Text(stringResource(R.string.common_delete)) }
},
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -489,10 +514,10 @@ fun FileManagerScreen(
val victims = pendingBulkDelete
OutlinedAlertDialog(
onDismissRequest = { pendingBulkDelete = emptyList() },
title = { Text("Delete ${victims.size} item${if (victims.size == 1) "" else "s"}?") },
title = { Text(pluralStringResource(R.plurals.fm_delete_items_title, victims.size, victims.size)) },
text = {
Column {
Text("This can't be undone.")
Text(stringResource(R.string.fm_cannot_undo))
Spacer(Modifier.height(6.dp))
// Name a few so an accidental Select-All is obvious before it's too late.
victims.take(5).forEach {
@@ -500,7 +525,7 @@ fun FileManagerScreen(
}
if (victims.size > 5) {
Text(
"…and ${victims.size - 5} more",
pluralStringResource(R.plurals.fm_and_more, victims.size - 5, victims.size - 5),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
@@ -516,19 +541,19 @@ fun FileManagerScreen(
isOperationRunning = true
var failed = 0
victims.forEachIndexed { i, f ->
operationLabel = "Deleting ${i + 1}/${victims.size} - ${f.name}"
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
}
isOperationRunning = false
operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (failed > 0) {
Toast.makeText(context, "$failed couldn't be deleted", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
}
}
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
}) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -537,12 +562,12 @@ fun FileManagerScreen(
val isDir = conflict.isDirectory
OutlinedAlertDialog(
onDismissRequest = { pendingConflict = null },
title = { Text("\"${conflict.name}\" already exists") },
title = { Text(stringResource(R.string.fm_already_exists, conflict.name)) },
text = {
Column {
Text(
if (isDir) "Merge adds and replaces files inside the existing folder."
else "Overwrite replaces the existing file.",
if (isDir) stringResource(R.string.fm_conflict_merge_hint)
else stringResource(R.string.fm_conflict_overwrite_hint),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
@@ -553,15 +578,15 @@ fun FileManagerScreen(
checked = conflictApplyToAll,
onCheckedChange = { conflictApplyToAll = it },
)
Text("Apply to all conflicts", fontSize = 12.sp)
Text(stringResource(R.string.fm_conflict_apply_all), fontSize = 12.sp)
}
}
Spacer(Modifier.height(4.dp))
listOf(
(if (isDir) ConflictChoice.MERGE else ConflictChoice.OVERWRITE) to
(if (isDir) "Merge" else "Overwrite"),
ConflictChoice.KEEP_BOTH to "Keep both",
ConflictChoice.SKIP to "Skip",
(if (isDir) stringResource(R.string.fm_conflict_merge) else stringResource(R.string.fm_conflict_overwrite)),
ConflictChoice.KEEP_BOTH to stringResource(R.string.fm_conflict_keep_both),
ConflictChoice.SKIP to stringResource(R.string.fm_conflict_skip),
).forEach { (choice, label) ->
TextButton(
modifier = Modifier.fillMaxWidth(),
@@ -573,7 +598,7 @@ fun FileManagerScreen(
confirmButton = {},
dismissButton = {
TextButton(onClick = { conflictChoice = ConflictChoice.SKIP; pendingConflict = null }) {
Text("Cancel")
Text(stringResource(R.string.common_cancel))
}
},
)
@@ -603,7 +628,7 @@ fun FileManagerScreen(
) {
Icon(Icons.Filled.Folder, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Select this folder")
Text(stringResource(R.string.fm_select_this_folder))
}
}
// ── Path bar ──
@@ -614,15 +639,11 @@ fun FileManagerScreen(
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
IconButton(onClick = {
val parent = currentDir.parentFile
// Don't climb above the current drive's root.
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
}, enabled = currentDir != currentRoot) {
Icon(Icons.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.primary)
IconButton(onClick = { goUp() }, enabled = canGoUp) {
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
}
val currentDriveLabel = describeLocation(currentDir).driveLabel
val currentDriveLabel = stringResource(describeLocation(currentDir).driveLabel)
// Dim the drive chip while the Favorites list is open (it's not the active context).
val driveChipAlpha = if (showFavorites) 0.45f else 1f
Box {
@@ -666,13 +687,23 @@ fun FileManagerScreen(
} else {
Toast.makeText(
context,
"${drive.label} is mounted but not readable right now",
context.getString(R.string.fm_drive_not_readable, drive.label),
Toast.LENGTH_SHORT,
).show()
}
},
)
}
linuxRoot?.let { root ->
MenuItemDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.fm_drive_linux)) },
leadingIcon = {
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
},
onClick = { showDriveMenu = false; openDrive(root) },
)
}
}
}
@@ -680,7 +711,7 @@ fun FileManagerScreen(
if (showFavorites) {
Text(
text = "Favorites",
text = stringResource(R.string.fm_favorites),
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
@@ -718,7 +749,7 @@ fun FileManagerScreen(
) {
Icon(Icons.Filled.CreateNewFolder, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(5.dp))
Text("New Folder", color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
Text(stringResource(R.string.fm_new_folder), color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
}
}
IconButton(onClick = {
@@ -727,19 +758,19 @@ fun FileManagerScreen(
}) {
Icon(
if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView,
if (gridView) "List view" else "Grid view",
if (gridView) stringResource(R.string.fm_list_view) else stringResource(R.string.fm_grid_view),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { showSearch = !showSearch; if (!showSearch) searchQuery = "" }) {
Icon(Icons.Filled.Search, "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Filled.Search, stringResource(R.string.fm_search), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Box {
IconButton(onClick = { showSortMenu = true }) {
Icon(Icons.Filled.Sort, "Sort", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Filled.Sort, stringResource(R.string.fm_sort), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
listOf("name" to "Name", "date" to "Date modified", "size" to "Size")
listOf("name" to stringResource(R.string.fm_sort_name), "date" to stringResource(R.string.fm_sort_date), "size" to stringResource(R.string.fm_prop_size))
.forEach { (key, label) ->
DropdownMenuItem(
text = {
@@ -758,7 +789,7 @@ fun FileManagerScreen(
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
DropdownMenuItem(
text = { Text(if (compactRows) "Comfortable rows" else "Compact rows") },
text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) },
onClick = {
compactRows = !compactRows
browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply()
@@ -766,7 +797,7 @@ fun FileManagerScreen(
},
)
DropdownMenuItem(
text = { Text(if (showHidden) "Hide hidden files" else "Show hidden files") },
text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) },
onClick = {
showHidden = !showHidden
browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply()
@@ -781,9 +812,9 @@ fun FileManagerScreen(
// Star toggle: open/close the dedicated Favorites list.
IconButton(onClick = { showFavorites = !showFavorites }) {
if (showFavorites) {
Icon(Icons.Filled.Star, "Hide favorites", tint = MaterialTheme.colorScheme.primary)
Icon(Icons.Filled.Star, stringResource(R.string.fm_hide_favorites), tint = MaterialTheme.colorScheme.primary)
} else {
Icon(Icons.Filled.StarBorder, "Show favorites", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -794,7 +825,7 @@ fun FileManagerScreen(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
placeholder = { Text("Filter this folder", fontSize = 13.sp) },
placeholder = { Text(stringResource(R.string.fm_filter_folder), fontSize = 13.sp) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
)
}
@@ -821,7 +852,7 @@ fun FileManagerScreen(
)
if (freeSpace > 0) {
Text(
"${FileOps.formatBytes(freeSpace)} free",
stringResource(R.string.fm_free_space, FileOps.formatBytes(context, freeSpace)),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1,
@@ -842,7 +873,7 @@ fun FileManagerScreen(
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
"${selectedPaths.size} selected",
stringResource(R.string.fm_selected_count, selectedPaths.size),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
modifier = Modifier.weight(1f),
@@ -855,7 +886,7 @@ fun FileManagerScreen(
else entries.map { it.absolutePath }.toSet()
},
contentPadding = selBarPadding,
) { Text(if (selectedPaths.size == entries.size) "None" else "All", fontSize = 12.sp) }
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
@@ -866,7 +897,7 @@ fun FileManagerScreen(
selectedPaths = emptySet()
},
contentPadding = selBarPadding,
) { Text("Copy", fontSize = 12.sp) }
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
@@ -877,19 +908,19 @@ fun FileManagerScreen(
selectedPaths = emptySet()
},
contentPadding = selBarPadding,
) { Text("Cut", fontSize = 12.sp) }
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
contentPadding = selBarPadding,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) { Text("Delete", color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
onClick = { selectionMode = false; selectedPaths = emptySet() },
contentPadding = selBarPadding,
) { Text("Done", fontSize = 12.sp) }
) { Text(stringResource(R.string.fm_done), fontSize = 12.sp) }
}
}
@@ -903,16 +934,16 @@ fun FileManagerScreen(
.clickable { performPaste() }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Filled.ContentPaste, "Paste", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
val what = if (clipboardFiles.size == 1) clipboardFiles.first().name
else "${clipboardFiles.size} items"
else pluralStringResource(R.plurals.fm_items, clipboardFiles.size, clipboardFiles.size)
Text(
"Paste $what${if (isCutOperation) " (move)" else ""} here",
if (isCutOperation) stringResource(R.string.fm_paste_move_here, what) else stringResource(R.string.fm_paste_here, what),
color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f),
)
TextButton(onClick = { clipboardFiles = emptyList(); isCutOperation = false }) {
Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
Text(stringResource(R.string.common_cancel), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
}
}
}
@@ -944,7 +975,7 @@ fun FileManagerScreen(
isOperationRunning = false
operationDeterminate = false
loadDirectory(currentDir, resetScroll = false)
}) { Text("Cancel", fontSize = 12.sp) }
}) { Text(stringResource(R.string.common_cancel), fontSize = 12.sp) }
}
}
Spacer(Modifier.height(4.dp))
@@ -970,38 +1001,40 @@ fun FileManagerScreen(
// shown in pick mode (the themed picker keeps its slim layout). Built each recompose (cheap)
// so it tracks the current drive/favourites without stale click lambdas.
val fmRailState = rememberRailState("filemanager")
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File) =
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath) {
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File, shortLabel: String? = null) =
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath, shortLabel = shortLabel) {
showFavorites = false; openDrive(dir)
}
val storageItems = buildList {
add(locItem("Internal", Icons.Filled.Smartphone, File("/storage/emulated/0")))
add(locItem(stringResource(R.string.fm_drive_internal), Icons.Filled.Smartphone, File("/storage/emulated/0")))
drives.filter { it.removable }.forEach { d ->
add(RailItem(d.label, Icons.Filled.SdStorage, !showFavorites && currentRoot.absolutePath == d.dir.absolutePath) {
showFavorites = false; if (d.readable) openDrive(d.dir)
})
}
// The Linux system the sessions run in: its own / (the desktop's root), once installed.
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
}
val quickItems = buildList {
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem("Downloads", Icons.Filled.Download, it)) }
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
?.let { add(locItem("ROMs", Icons.Filled.SportsEsports, it)) }
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem("Session logs", Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem("Pictures", Icons.Filled.Image, it)) }
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
}
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
.map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } }
val locationSections = buildList {
add(RailSection("STORAGE", storageItems))
if (quickItems.isNotEmpty()) add(RailSection("QUICK", quickItems))
if (favItems.isNotEmpty()) add(RailSection("FAVORITES", favItems))
add(RailSection(stringResource(R.string.fm_rail_storage), storageItems))
if (quickItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_quick), quickItems))
if (favItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_favorites), favItems))
}
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
// The slim picker keeps its layout: no rail.
if (!pickMode) {
CollapsibleRail(state = fmRailState, title = "Files", sections = locationSections, outlinedItems = true)
CollapsibleRail(state = fmRailState, title = stringResource(R.string.fm_rail_title), sections = locationSections, outlinedItems = true)
}
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
// ── Favorites list OR file list ──
@@ -1012,7 +1045,7 @@ fun FileManagerScreen(
onPinCurrent = {
FavoritesStore.add(context, currentDir.absolutePath)
favTick++
Toast.makeText(context, "Added \"${currentDir.name}\" to Favorites", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show()
},
onJump = { dir ->
showFavorites = false
@@ -1021,7 +1054,7 @@ fun FileManagerScreen(
onUnpin = { dir ->
FavoritesStore.remove(context, dir.absolutePath)
favTick++
Toast.makeText(context, "Removed \"${dir.name}\" from Favorites", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show()
},
modifier = Modifier.fillMaxSize(),
)
@@ -1041,6 +1074,9 @@ fun FileManagerScreen(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
) {
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
ParentFolderRow(compact = compactRows, onTap = { goUp() })
}
items(shownEntries, key = { it.absolutePath }) { file ->
val isFav = remember(file.absolutePath, favTick) {
FavoritesStore.isFavorite(context, file.absolutePath)
@@ -1095,8 +1131,8 @@ fun FileManagerScreen(
showMenuFor = null
Toast.makeText(
context,
if (nowFav) "Added \"${file.name}\" to Favorites"
else "Removed \"${file.name}\" from Favorites",
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
else context.getString(R.string.fm_favorite_removed, file.name),
Toast.LENGTH_SHORT,
).show()
},
@@ -1106,13 +1142,16 @@ fun FileManagerScreen(
}
} else
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
if (canGoUp) item(key = "..") {
ParentFolderRow(compact = compactRows, onTap = { goUp() })
}
if (entries.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text("Empty directory", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.fm_empty_directory), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
@@ -1173,8 +1212,8 @@ fun FileManagerScreen(
showMenuFor = null
Toast.makeText(
context,
if (nowFav) "Added \"${file.name}\" to Favorites"
else "Removed \"${file.name}\" from Favorites",
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
else context.getString(R.string.fm_favorite_removed, file.name),
Toast.LENGTH_SHORT,
).show()
},
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.files
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -19,9 +21,9 @@ object FileOps {
/** Byte-accurate copy progress (copied/total bytes), reported as the copy proceeds. */
fun interface ProgressCallback { fun onProgress(copiedBytes: Long, totalBytes: Long) }
fun formatBytes(bytes: Long): String {
if (bytes <= 0) return "0 bytes"
val units = arrayOf("bytes", "KB", "MB", "GB", "TB")
fun formatBytes(context: Context, bytes: Long): String {
if (bytes <= 0) return context.getString(R.string.fm_size_zero)
val units = arrayOf(context.getString(R.string.fm_unit_bytes), "KB", "MB", "GB", "TB")
val group = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt().coerceIn(0, units.lastIndex)
return String.format(Locale.ENGLISH, "%.2f", bytes / Math.pow(1024.0, group.toDouble())) + " " + units[group]
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.droiddeck.launcher.R
import com.droiddeck.launcher.ui.DroidDeckTheme
import java.io.File
@@ -26,6 +27,11 @@ import java.io.File
* - [EXTRA_PICKER_TITLE] the header
*/
class FilePickerActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -50,7 +56,7 @@ class FilePickerActivity : ComponentActivity() {
pickDirMode = pickDir,
pickExtensions = extensions,
initialDir = initialDir,
pickerTitle = title ?: if (pickDir) "Choose a folder" else "Choose a file",
pickerTitle = title ?: getString(if (pickDir) R.string.fm_choose_folder else R.string.fm_choose_file),
onPick = { file ->
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
finish()
@@ -25,6 +25,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -66,7 +68,7 @@ internal fun FilePropertiesDialog(
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
title = { Text(stringResource(R.string.fm_properties)) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
@@ -79,14 +81,14 @@ internal fun FilePropertiesDialog(
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(stringResource(R.string.fm_prop_location), file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
stringResource(R.string.fm_prop_type),
if (file.isDirectory) stringResource(R.string.fm_type_folder)
else file.extension.uppercase().let { if (it.isBlank()) stringResource(R.string.fm_type_file) else stringResource(R.string.fm_type_ext_file, it) },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
if (!file.isDirectory) PropertyLine(stringResource(R.string.fm_prop_size), FileOps.formatBytes(context, file.length()))
PropertyLine(stringResource(R.string.fm_prop_modified), dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
@@ -95,8 +97,8 @@ internal fun FilePropertiesDialog(
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
label = stringResource(R.string.fm_read_only),
description = stringResource(R.string.fm_read_only_hint),
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
@@ -108,7 +110,7 @@ internal fun FilePropertiesDialog(
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.fm_read_only_failed, Toast.LENGTH_SHORT).show()
}
}
},
@@ -117,10 +119,10 @@ internal fun FilePropertiesDialog(
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
label = stringResource(R.string.fm_hidden),
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
stringResource(R.string.fm_hidden_unsupported)
else stringResource(R.string.fm_hidden_hint),
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
@@ -134,14 +136,14 @@ internal fun FilePropertiesDialog(
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.fm_hidden_failed, Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.fm_done)) } },
)
}
@@ -5,6 +5,7 @@ import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import com.droiddeck.launcher.R
import java.io.File
/**
@@ -63,7 +64,7 @@ object StorageRoots {
// Internal storage is never removable and never absent; seed it first so it heads the menu.
volumeOf(PRIMARY_KEY).apply {
label = "Internal"
label = context.getString(R.string.fm_drive_internal)
removable = false
candidates += File(INTERNAL_PATH)
}
@@ -109,13 +110,13 @@ object StorageRoots {
entry.seeds += dir
}
val roots = volumes.values.map { it.toRoot() }
val roots = volumes.values.map { it.toRoot(context) }
val internal = roots.filter { !it.removable }
val removable = roots.filter { it.removable }.sortedBy { it.label }
return disambiguate(internal + removable)
}
private fun Volume.toRoot(): StorageRoot {
private fun Volume.toRoot(context: Context): StorageRoot {
// Prefer a path we can actually list; a readable child beats an unreadable root.
val dir = candidates.firstOrNull(::canBrowse)
?: candidates.firstNotNullOfOrNull { root -> seeds.firstNotNullOfOrNull { deepestReadable(root, it) } }
@@ -125,14 +126,14 @@ object StorageRoots {
?: File("/storage/$key")
return StorageRoot(
label = label ?: defaultLabel(),
label = label ?: defaultLabel(context),
dir = dir,
removable = removable,
readable = canBrowse(dir),
)
}
private fun Volume.defaultLabel(): String = if (removable) "SD card" else key
private fun Volume.defaultLabel(context: Context): String = if (removable) context.getString(R.string.fm_drive_sd) else key
/** Appends the volume id to any label used by more than one volume, so entries stay tellable apart. */
private fun disambiguate(roots: List<StorageRoot>): List<StorageRoot> {
@@ -183,12 +184,12 @@ object StorageRoots {
* name like "android", which tells the user nothing about which card they are looking at.
*/
private fun labelFor(context: Context, volume: StorageVolume, uuid: String?): String {
if (volume.isPrimary) return "Internal"
if (volume.isRemovable) return "SD card"
if (volume.isPrimary) return context.getString(R.string.fm_drive_internal)
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
val description = volume.getDescription(context)?.trim()
return description?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?: uuid
?: "Storage"
?: context.getString(R.string.fm_drive_storage)
}
private fun isEmulated(dir: File): Boolean = dir.absolutePath.startsWith(EMULATED_PREFIX)
@@ -6,6 +6,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import java.io.File
import java.util.zip.CRC32
@@ -16,13 +18,21 @@ object AddedGames {
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
class Game(
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
val folder: File, val name: String,
/** The game's own .exe: what the icon is read from and what the settings page lists. */
val exe: File,
/** The guest path the shortcut runs: [exe], or a store install's launcher .bat in front of it. */
val guestExe: String, val guestDir: String,
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
val appId: Long,
/** What steam://rungameid/ takes for a shortcut. */
val gameId: Long,
val candidates: List<File>,
val steamAppId: Int? = null,
/** Where the game came from: a store's id, or [Library.ADDED] for a folder the user added. */
val source: String = Library.ADDED,
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
val storeId: String? = null,
) {
fun folderName(): String = folder.name
}
@@ -66,9 +76,16 @@ object AddedGames {
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms ->
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
}
// A store root on a card has a bind of its own, before the library rule: the same folder
// keeps the same guest path (and appid) whether or not that card is also the Steam library.
StoreInstallRoot.externalGuestPath(context, host)?.let { return it }
GameStorage.effective(context)?.let { lib ->
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
}
// Inside the runtime's own tree (store installs on internal storage live at
// root/Games/Stores): the tree is the session's / and needs no bind.
val runtime = LinuxRuntime.rootDir(context).absolutePath
if (path.startsWith("$runtime/")) return "/" + path.removePrefix("$runtime/")
return null
}
@@ -98,10 +115,15 @@ object AddedGames {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
if (folder.name.lowercase() in steamInstalls) continue
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
// The store install root sits inside the Games storage; its games are scanned below, per store.
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
scanGame(context, folder, out)
}
}
// Games the Stores section installed: one folder each under <root>/<Store>/, with a sidecar.
// An unfinished one (sidecar still `installing`, or none at all) is neither a game nor Custom.
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
return out.distinctBy { it.folder.canonicalPath }
}
@@ -125,33 +147,67 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
run {
// A store install carries a sidecar; every one of them is a shortcut.
val sidecar = StoreGameSidecar.read(folder)
val candidates = candidates(folder)
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val exe = chosen ?: candidates.firstOrNull() ?: return
val guestExe = guestPath(context, exe)
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
val name = folder.name
val picked = SessionPrefs.addedGameExe(context, folder.path)
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
// A store install says which exe it is; the folder's guess only when that file is gone.
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
// The store's launcher .bat stands in front of the exe when the game needs arguments or
// environment; it is what the shortcut runs. Dropped when the user picked another exe.
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
val foundGuest = guestPath(context, launch)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
val name = sidecar?.title ?: folder.name
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val appId = crc or 0x80000000L
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
val record = config?.let { shortcutRecord(it, appId) }
val rev = record?.optInt("rev", 0) ?: 0
val adopted = guestPath(context, folder)
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
val exe = adopted ?: found
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
val guestDir = guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
val steamId = config?.let { steamRoute(it, appId) }
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id))
}
}
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
val users = File(root, "config/loginusers.vdf").readText()
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
?: return@runCatching null
val account = recent.groupValues[1].toLong() - 76561197960265728L
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
.optInt(appId.toString()).takeIf { it > 0 }
private fun accountConfig(root: File): File? {
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
return account?.let { File(root, "userdata/$it/config") }
}
private fun steamRoute(config: File, appId: Long): Int? = runCatching {
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
}.getOrNull()
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
}.getOrNull()
internal fun steamTarget(folder: File, folderGuest: String, record: JSONObject?, guestExe: String, seen: Int): File? {
if (record == null || record.optInt("rev", 0) == seen) return null
val asked = unquote(record.optJSONObject("app")?.optString("Exe"))
val steam = unquote(record.optJSONObject("steam")?.optString("Exe"))
if (asked != guestExe || steam.isEmpty() || steam == asked || !steam.startsWith("$folderGuest/")) return null
val file = File(folder, steam.removePrefix("$folderGuest/"))
val inside = runCatching { file.canonicalPath.startsWith(folder.canonicalPath + File.separator) }.getOrDefault(false)
return file.takeIf { inside && it.isFile }
}
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
fun writeListing(context: Context, games: List<Game>): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
@@ -161,6 +217,7 @@ object AddedGames {
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
// The art, as the session sees it: the app's cache is bound at its own path, a file in
// the game's folder at the folder's guest path.
val art = AddedGameArt.resolve(context, g)
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
@@ -37,18 +38,18 @@ object GameFileSync {
}
@Synchronized private fun sync(context: Context, folder: File) {
check(LinuxRuntime.isInstalled(context)) { "Install the runtime first" }
check(LinuxRuntime.isInstalled(context)) { context.getString(R.string.game_sync_err_runtime) }
// A disconnected library must not be interpreted as a mass uninstall.
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { "Steam library is unavailable" }
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
val storage = GameStorage.effective(context)
val storageSetting = SessionPrefs.gameStorage(context)
val previousStorage = prefs(context).getString("storagePath", null)
if (storageSetting.isEmpty() && prefs(context).getString("storageSetting", null) == storageSetting && previousStorage != null) {
check(File(previousStorage).isDirectory) { "Previous SD library is unavailable" }
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
}
storage?.let { check(File(it.path).listFiles() != null) { "Game storage is unavailable" } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { "Added games folder is unavailable" } }
GameFiles.sync(folder, Library.launchableGames(context, strictRead = true))
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
}
}
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.content.Intent
import android.util.AtomicFile
import com.droiddeck.launcher.R
import androidx.core.util.readText
import androidx.core.util.writeBytes
import androidx.core.util.writeText
@@ -63,28 +64,28 @@ object GameFiles {
return id
}
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
exportNamed(folder, game, filename(game))
@Synchronized fun export(context: Context, folder: File, game: Library.SteamGame): File =
exportNamed(context, folder, game, filename(game))
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
require(folder.isDirectory) { "Folder is unavailable" }
private fun exportNamed(context: Context, folder: File, game: Library.SteamGame, name: String): File {
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
val text = contents(game.gameIdString)
val target = exportTarget(folder, game, name)
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
val target = exportTarget(context, folder, game, name)
require(!target.exists() || matches(target, text)) { context.getString(R.string.gfiles_name_taken, target.name) }
if (!target.exists()) write(target, text)
exportArt(folder, game)
exportArt(context, folder, game)
return target
}
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
val target = child(folder, name)
private fun exportTarget(context: Context, folder: File, game: Library.SteamGame, name: String): File {
val target = child(context, folder, name)
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
child(folder, filename(game, disambiguate = true))
child(context, folder, filename(game, disambiguate = true))
} else target
}
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
private fun exportArt(folder: File, game: Library.SteamGame) {
private fun exportArt(context: Context, folder: File, game: Library.SteamGame) {
val art = listOfNotNull(game.icon, game.art).firstOrNull {
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
} ?: return
@@ -93,19 +94,19 @@ object GameFiles {
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
if (title.isEmpty()) return
val images = child(folder, "images")
check(images.isDirectory || images.mkdir()) { "Artwork folder is unavailable" }
val target = child(images, "$title-icon.${art.extension.lowercase()}")
val images = child(context, folder, "images")
check(images.isDirectory || images.mkdir()) { context.getString(R.string.gfiles_art_unavailable) }
val target = child(context, images, "$title-icon.${art.extension.lowercase()}")
if (!target.exists()) AtomicFile(target).writeBytes(art.readBytes())
}
/** Only remove files in our inventory whose content is still exactly what we wrote. */
@Synchronized fun sync(folder: File, games: List<Library.SteamGame>) {
require(folder.isDirectory) { "Folder is unavailable" }
val inventory = child(folder, INVENTORY)
@Synchronized fun sync(context: Context, folder: File, games: List<Library.SteamGame>) {
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
val inventory = child(context, folder, INVENTORY)
val previous = if (inventory.exists()) {
val record = JSONObject(AtomicFile(inventory).readText())
require(record.optString("format") == FORMAT) { "Folder has an unrelated sync inventory" }
require(record.optString("format") == FORMAT) { context.getString(R.string.gfiles_unrelated_inventory) }
record.getJSONObject("games")
} else JSONObject()
val next = JSONObject()
@@ -113,15 +114,15 @@ object GameFiles {
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
val exports = unique.map { game ->
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
val preferred = child(folder, name)
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
else exportTarget(folder, game, name)
val preferred = child(context, folder, name)
val target = if (inventoryOwns(context, folder, previous, preferred, game.gameIdString)) preferred
else exportTarget(context, folder, game, name)
val text = contents(game.gameIdString)
val existed = target.exists()
// Keep an edited export without letting it block the rest of the library sync.
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
val owned = inventoryOwns(context, folder, previous, target, game.gameIdString)
val file = if (owned && target.exists() &&
!matches(target, text)) target else exportNamed(folder, game, target.name)
!matches(target, text)) target else exportNamed(context, folder, game, target.name)
if (!existed || owned) next.put(file.name, game.gameIdString)
file
}
@@ -129,32 +130,32 @@ object GameFiles {
for (name in previous.keys()) {
val id = previous.getString(name)
if (!next.has(name) && name.endsWith(".$EXTENSION") && GameLaunchLink.validId(id)) {
val file = child(folder, name)
val file = child(context, folder, name)
if (matches(file, contents(id))) {
// Android shared storage is often case insensitive. A case-only rename may
// still resolve to the live export, which must not be pruned as an old file.
val retained = exports.any { java.nio.file.Files.isSameFile(it.toPath(), file.toPath()) }
if (!retained) check(file.delete()) { "Could not remove $name" }
if (!retained) check(file.delete()) { context.getString(R.string.gfiles_remove_failed, name) }
}
}
}
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
}
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
private fun inventoryOwns(context: Context, folder: File, record: JSONObject, file: File, id: String): Boolean {
if (record.optString(file.name) == id) return true
if (!file.exists()) return false
return record.keys().asSequence().any { name ->
record.optString(name) == id && child(folder, name).let { old ->
record.optString(name) == id && child(context, folder, name).let { old ->
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
}
}
}
private fun child(folder: File, name: String): File {
private fun child(context: Context, folder: File, name: String): File {
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
val file = File(folder, name)
require(file.canonicalFile.parentFile == folder.canonicalFile) { "File leaves the export folder" }
require(file.canonicalFile.parentFile == folder.canonicalFile) { context.getString(R.string.gfiles_outside_folder) }
return file
}
@@ -24,15 +24,23 @@ object Library {
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
val icon: File? = null,
/** Where the game came from: [SOURCE_STEAM], a store's id (gog, epic, amazon) or [ADDED] for a folder the user added. */
val source: String = SOURCE_STEAM,
/** The store's own id for a store install; null otherwise. */
val storeId: String? = null,
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
const val ADDED = "added"
/** The [SteamGame.source] of a title Steam itself installed. */
const val SOURCE_STEAM = "steam"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
/** The shown name of [system] when it is words rather than a platform name, else 0. */
val systemRes: Int get() = if (id == "retroarch") R.string.content_system_many else 0
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
val iconRes: Int get() = when (id) {
"rpcs3" -> R.drawable.emu_rpcs3; "armsx2" -> R.drawable.emu_pcsx2; "dolphin" -> R.drawable.emu_dolphin
@@ -84,17 +92,20 @@ object Library {
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
}
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
fun protonPrefix(
context: Context, appId: Long, preferredLibrary: File? = null,
libraries: List<File> = steamLibraries(context).map { it.first },
): File? {
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
val secondary = GameStorage.effective(context)?.let { File(it.path) }
fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath)
val secondary = libraries.drop(1).mapNotNull { runCatching { it.canonicalPath }.getOrNull() }.toSet()
fun prefixRoot(root: File): File = if (runCatching { root.canonicalPath }.getOrNull() in secondary)
SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root
else root
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot)
val roots = (listOfNotNull(preferredLibrary) + libraries).map(::prefixRoot)
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
return roots.asSequence()
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
@@ -105,11 +116,12 @@ object Library {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context)
val roots = libraries.map { it.first }
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
val manifests = steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
if (strictRead && steamapps.isDirectory) check(manifests != null) { "Steam manifests are unavailable" }
if (strictRead && steamapps.isDirectory) check(manifests != null) { context.getString(R.string.game_sync_err_manifests) }
manifests?.sortedBy { it.name }?.forEach { manifest ->
val appId = manifest.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return@forEach
if (appId in NOT_GAMES || out.containsKey(appId)) return@forEach
@@ -130,7 +142,7 @@ object Library {
out[appId] = SteamGame(
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library),
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
)
@@ -140,16 +152,20 @@ object Library {
}
/** The same installed-game inventory used for links, shortcuts and file exports. */
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
)
}
}).distinctBy { it.gameId }
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
)
}
}).distinctBy { it.gameId }
}
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
@@ -26,6 +26,8 @@ object LibraryCache {
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
icon = o.file("icon"),
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
)
}
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
@@ -36,7 +38,8 @@ object LibraryCache {
for (g in games) array.put(
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.gpu
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
@@ -84,7 +85,7 @@ object DriverBundle {
unzip(context, uri, work)
val manifest = File(work, MANIFEST).takeIf { it.isFile }
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
?: throw IllegalArgumentException(context.getString(R.string.bundle_no_manifest))
val androidDir = File(work, manifest.androidPath)
val linuxDir = File(work, manifest.linuxPath)
@@ -135,7 +136,7 @@ object DriverBundle {
for (entry in generateSequence { zip.nextEntry }) {
if (entry.isDirectory) continue
val out = File(root, entry.name).canonicalFile
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException(context.getString(R.string.bundle_entry_outside, entry.name))
out.parentFile?.mkdirs()
out.outputStream().use { sink ->
val buf = ByteArray(1 shl 16)
@@ -143,7 +144,7 @@ object DriverBundle {
val r = zip.read(buf)
if (r <= 0) break
total += r
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
if (total > MAX_BYTES) throw IllegalArgumentException(context.getString(R.string.bundle_too_large, MAX_BYTES shr 20))
sink.write(buf, 0, r)
}
}
@@ -1,14 +1,16 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.os.Build
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
/**
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
* may start, but it is not "supported" just because it is a Qualcomm chip.
* support level is what the app has actually been tested on (Adreno 650, 725 and newer) - an
* Adreno 610 may start, but it is not "supported" just because it is a Qualcomm chip.
*/
data class GpuInfo(
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
@@ -33,7 +35,10 @@ data class GpuInfo(
A6XX("Adreno 6xx"),
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
ADRENO_UNKNOWN("Adreno"),
NOT_ADRENO("Not an Adreno GPU"),
NOT_ADRENO("Not an Adreno GPU");
/** [label] in the app's language: the Adreno families are names, the rest is words. */
fun label(context: Context): String = if (this == NOT_ADRENO) context.getString(R.string.gpuinfo_not_adreno) else label
}
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
@@ -41,21 +46,35 @@ data class GpuInfo(
val support: Support
get() = when {
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
family == Family.A6XX && model == 650 -> Support.TESTED
family == Family.A8XX -> Support.TESTED
family == Family.A7XX && model >= 725 -> Support.TESTED
else -> Support.UNTESTED
}
/** One line for the device card and the system check. */
/** One line for the device card and the system check, in English for the device report. */
val supportText: String
get() = when (support) {
Support.TESTED -> "Supported"
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
else "Below tested hardware (Adreno 725 and newer): it may not run"
else "Outside tested hardware (Adreno 650, 725 and newer): it may not run"
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
}
/** [supportText] in the app's language. */
fun supportText(context: Context): String = context.getString(when (support) {
Support.TESTED -> R.string.gpuinfo_supported
Support.UNTESTED -> if (family == Family.A7XX_LOW) R.string.gpuinfo_experimental else R.string.gpuinfo_below_tested
Support.UNSUPPORTED -> R.string.gpuinfo_unsupported
})
/** [name] for the screen: the stand-in for a GPU the device does not name is in the app's language. */
fun displayName(context: Context): String = if (name == UNNAMED) context.getString(R.string.gpu_this_gpu) else name
companion object {
/** [name] when the device names no GPU; English, as the device report shows it. */
private const val UNNAMED = "this GPU"
fun detect(): GpuInfo {
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
@@ -78,7 +97,7 @@ data class GpuInfo(
val family = familyOf(adreno, model)
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
return GpuInfo(
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
name = if (!adreno) Build.HARDWARE.ifBlank { UNNAMED } else if (model > 0) "Adreno $model" else "Adreno",
model = model, family = family, soc = soc,
oneUi8Gen2 = samsung && model == 740,
kgslName = raw.orEmpty(), modelSource = source,
@@ -4,6 +4,7 @@ import android.content.Context;
import android.net.Uri;
import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.FileUtils;
import org.json.JSONObject;
@@ -181,20 +182,17 @@ public class LinuxVulkanDriverManager {
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
try {
if (soName == null) {
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
throw new IllegalArgumentException(context.getString(R.string.lvd_no_library));
}
File so = new File(tmpDir, LIB_NAME);
if (!isAarch64Elf(so)) {
throw new IllegalArgumentException(soName + " is not a 64-bit AArch64 ELF shared library.");
throw new IllegalArgumentException(context.getString(R.string.lvd_not_aarch64, soName));
}
// The one check that actually separates this from the bionic builds. Both sonames
// are in the driver's .dynstr, so a byte scan is enough and needs no ELF parsing:
// glibc's is versioned ("libc.so.6"), bionic's is not ("libc.so").
if (!containsAscii(so, "libc.so.6")) {
throw new IllegalArgumentException(soName + " is not a glibc driver - it links Android's libc. "
+ "The Linux runtime needs a \"-Linux\" zip; a plain or \"-Wayland\" Turnip cannot be "
+ "loaded by the Steam client at all.");
throw new IllegalArgumentException(context.getString(R.string.lvd_not_glibc, soName));
}
String kind = zipMeta != null ? zipMeta.optString("kind", "") : "";
if (!kind.isEmpty() && !"linux-vulkan-icd".equals(kind)) {
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.gpu
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.wayland.WaylandCompositor
import org.json.JSONObject
import kotlin.math.abs
@@ -82,11 +84,15 @@ data class ScreenEffects(
* SessionPrefs.upscalerChoices mode the Look switches to.
*/
data class Look(
/** The Look's id: what the row picks by and the compositor logs. */
val name: String,
val effects: ScreenEffects,
val scalingMode: Int? = null,
/** One line under the row while this Look is picked. */
val desc: String,
/** [name] and [desc] in the app's language, for the menu. */
@StringRes val label: Int,
@StringRes val descRes: Int,
)
object ScreenEffectLooks {
@@ -96,29 +102,29 @@ object ScreenEffectLooks {
/** Index 0 is the neutral state a fresh session matches, so the row always names something. */
val LOOKS: List<Look> = listOf(
Look("Off", ScreenEffects.OFF, desc = "Nothing applied."),
Look("Off", ScreenEffects.OFF, desc = "Nothing applied.", label = R.string.frame_gen_off, descRes = R.string.look_off_desc),
Look("Game Clarity", ScreenEffects(brightness = 2, contrast = 12, saturation = 108, cas = true, casLevel = 55),
desc = "Sharpening with a touch of contrast - the everyday one."),
desc = "Sharpening with a touch of contrast - the everyday one.", label = R.string.look_game_clarity, descRes = R.string.look_game_clarity_desc),
Look("Vivid", ScreenEffects(brightness = 3, contrast = 10, gamma = 0.98f, saturation = 145, cas = true, casLevel = 30, deband = true),
desc = "Colour pushed hard. Deband on so skies don't stripe."),
desc = "Colour pushed hard. Deband on so skies don't stripe.", label = R.string.look_vivid, descRes = R.string.look_vivid_desc),
Look("Cinematic", ScreenEffects(brightness = -4, contrast = 22, gamma = 1.10f, saturation = 88, cas = true, casLevel = 20, fxaa = true, deband = true),
desc = "Deeper contrast, cooler colour, edges smoothed."),
desc = "Deeper contrast, cooler colour, edges smoothed.", label = R.string.look_cinematic, descRes = R.string.look_cinematic_desc),
Look("Competitive", ScreenEffects(brightness = 16, contrast = 18, gamma = 0.92f, saturation = 82, cas = true, casLevel = 75),
desc = "Shadows lifted and heavy sharpening - spot people first."),
desc = "Shadows lifted and heavy sharpening - spot people first.", label = R.string.look_competitive, descRes = R.string.look_competitive_desc),
Look("Adaptive Sharpen", ScreenEffects(cas = true, casLevel = 85),
desc = "Sharpening only, colour untouched."),
desc = "Sharpening only, colour untouched.", label = R.string.look_adaptive_sharpen, descRes = R.string.look_adaptive_sharpen_desc),
Look("Filmic", ScreenEffects(brightness = -2, contrast = 16, gamma = 1.14f, saturation = 92, cas = true, casLevel = 15, fxaa = true, deband = true),
desc = "Film-like curve, slightly muted."),
desc = "Film-like curve, slightly muted.", label = R.string.look_filmic, descRes = R.string.look_filmic_desc),
Look("Arcade", ScreenEffects(brightness = 6, contrast = 20, gamma = 0.95f, saturation = 170, cas = true, casLevel = 45, deband = true),
desc = "Loud and punchy."),
desc = "Loud and punchy.", label = R.string.look_arcade, descRes = R.string.look_arcade_desc),
Look("Retro CRT", ScreenEffects(brightness = 6, contrast = 14, gamma = 1.05f, saturation = 115, crt = true, ntsc = true),
desc = "The CRT tube and analogue signal passes together."),
desc = "The CRT tube and analogue signal passes together.", label = R.string.look_retro_crt, descRes = R.string.look_retro_crt_desc),
Look("Upscale Sharp", ScreenEffects(contrast = 6, saturation = 104, cas = true, casLevel = 70), scalingMode = SCALING_FSR,
desc = "For running below panel resolution."),
desc = "For running below panel resolution.", label = R.string.look_upscale_sharp, descRes = R.string.look_upscale_sharp_desc),
Look("Pixel Clean", ScreenEffects(contrast = 8, deband = true), scalingMode = SCALING_NEAREST,
desc = "Crisp pixels for 2D and old titles."),
desc = "Crisp pixels for 2D and old titles.", label = R.string.look_pixel_clean, descRes = R.string.look_pixel_clean_desc),
Look("Anime Edge", ScreenEffects(brightness = 2, contrast = 10, saturation = 125, cas = true, casLevel = 50, toon = true, deband = true),
desc = "Toon outlines plus sharpening."),
desc = "Toon outlines plus sharpening.", label = R.string.look_anime_edge, descRes = R.string.look_anime_edge_desc),
)
/**
@@ -5,6 +5,7 @@ import android.net.Uri;
import android.os.Environment;
import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.FileUtils;
import com.droiddeck.launcher.core.TarZst;
import com.droiddeck.launcher.session.SessionPrefs;
@@ -218,7 +219,7 @@ public final class TurnipDriver {
*/
String adopt(File tmpDir, String displayName) throws IOException {
String rejected = rejectionReason(tmpDir);
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
if (rejected != null) throw new IllegalArgumentException(context.getString(R.string.turnip_not_adrenotools, rejected));
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
String name = meta != null ? meta.optString("name", "") : "";
@@ -245,31 +246,31 @@ public final class TurnipDriver {
* Why a just-extracted zip is not something adrenotools can be pointed at, or null when it is.
* The kinds refused by name are the other driver lists' zips, which carry a meta.json too.
*/
private static String rejectionReason(File dir) {
private String rejectionReason(File dir) {
File metaFile = new File(dir, "meta.json");
if (!metaFile.isFile()) return "no meta.json";
if (!metaFile.isFile()) return context.getString(R.string.turnip_reject_no_meta);
String libraryName, kind;
try {
JSONObject meta = new JSONObject(FileUtils.readString(metaFile));
libraryName = meta.optString("libraryName", "");
kind = meta.optString("kind", "");
} catch (Exception e) {
return "meta.json is unreadable (" + e.getMessage() + ")";
return context.getString(R.string.turnip_reject_meta_unreadable, e.getMessage());
}
if ("linux-vulkan-icd".equals(kind))
return "this is a Linux runtime driver (import it under \"Linux runtime driver\")";
return context.getString(R.string.turnip_reject_linux_driver);
if ("wayland-game-driver".equals(kind))
return "this is a Wayland game driver, which this app has no use for";
return context.getString(R.string.turnip_reject_wayland);
if (libraryName.isEmpty())
return "meta.json names no libraryName, so nothing could be handed to AdrenoTools";
return context.getString(R.string.turnip_reject_no_library_name);
File library = new File(dir, libraryName);
if (!library.isFile())
return "meta.json names " + libraryName + ", which is not in the zip";
return context.getString(R.string.turnip_reject_library_missing, libraryName);
if (!LinuxVulkanDriverManager.isAarch64Elf(library))
return libraryName + " is not a 64-bit AArch64 ELF shared library";
return context.getString(R.string.turnip_reject_not_aarch64, libraryName);
// A glibc build that happens to carry a libraryName would load nothing in this process.
if (LinuxVulkanDriverManager.containsAscii(library, "libc.so.6"))
return libraryName + " links glibc - a \"-Linux\" Turnip, which belongs under \"Linux runtime driver\"";
return context.getString(R.string.turnip_reject_glibc, libraryName);
return null;
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import android.content.Context
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONArray
@@ -95,7 +96,7 @@ object TurnipReleases {
var lastError: Exception? = null
for (src in SOURCES) {
try {
val releases = JSONArray(get("https://api.github.com/repos/${src.repo}/releases?per_page=15"))
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=15"))
val seen = HashSet<String>()
var newest: String? = null
for (i in 0 until releases.length()) {
@@ -135,7 +136,7 @@ object TurnipReleases {
return parse(stored)
}
private fun get(url: String): String {
private fun get(context: Context, url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
c.readTimeout = 20_000
@@ -143,8 +144,8 @@ object TurnipReleases {
c.setRequestProperty("User-Agent", "DroidDeck-app")
try {
val code = c.responseCode
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
if (code != 200) throw IOException("GitHub answered HTTP $code")
if (code == 403 || code == 429) throw IOException(context.getString(R.string.turnip_rate_limited))
if (code != 200) throw IOException(context.getString(R.string.turnip_github_http, code))
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
@@ -194,7 +195,7 @@ object TurnipReleases {
/** Download an asset into the cache; the caller imports it and deletes the file. */
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
val target = File(context.cacheDir, asset.name)
val c = URL(asset.url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
@@ -202,7 +203,7 @@ object TurnipReleases {
c.setRequestProperty("User-Agent", "DroidDeck-app")
c.instanceFollowRedirects = true
try {
if (c.responseCode != 200) throw IOException("download answered HTTP ${c.responseCode}")
if (c.responseCode != 200) throw IOException(context.getString(R.string.turnip_download_http, c.responseCode))
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
c.inputStream.use { input ->
FileOutputStream(target).use { out ->
@@ -220,7 +221,7 @@ object TurnipReleases {
}
}
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
throw IOException("Checksum mismatch - the download was discarded")
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
}
return target
} catch (e: Exception) {
@@ -24,11 +24,18 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.onSignal
import com.droiddeck.launcher.ui.LocalPalette
class ControllerEditorActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private lateinit var controls: OnScreenControls
override fun onCreate(savedInstanceState: Bundle?) {
@@ -48,10 +55,10 @@ class ControllerEditorActivity : ComponentActivity() {
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
) {
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton("Reset", false) { controls.resetLayout() }
EditorButton("Cancel", false) { finish() }
EditorButton("Save", true) {
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
controls.saveLayout()
finish()
}
@@ -1,20 +1,21 @@
package com.droiddeck.launcher.input
import android.content.Context
import com.droiddeck.launcher.R
object ControllerPrefs {
const val STEAM_BLUE = 0xFF1A9FFF.toInt()
const val OFF = "off"
val tints = listOf(
STEAM_BLUE to "Steam blue",
0xFF66C0F4.toInt() to "Sky",
0xFFE8EEF4.toInt() to "White",
0xFFC77DFF.toInt() to "Violet",
0xFF3DDC84.toInt() to "Green",
0xFFFFA726.toInt() to "Amber",
0xFFFF5252.toInt() to "Red",
0xFFFF6FB5.toInt() to "Pink",
STEAM_BLUE to R.string.ctrl_tint_steam_blue,
0xFF66C0F4.toInt() to R.string.ctrl_tint_sky,
0xFFE8EEF4.toInt() to R.string.ctrl_tint_white,
0xFFC77DFF.toInt() to R.string.ctrl_tint_violet,
0xFF3DDC84.toInt() to R.string.ctrl_tint_green,
0xFFFFA726.toInt() to R.string.ctrl_tint_amber,
0xFFFF5252.toInt() to R.string.ctrl_tint_red,
0xFFFF6FB5.toInt() to R.string.ctrl_tint_pink,
)
val opacities = listOf(40, 60, 80, 100)
@@ -22,17 +23,17 @@ object ControllerPrefs {
val sizes = listOf(80, 90, 100, 110, 125)
val mappable = listOf(
"a" to "A button", "b" to "B button", "x" to "X button", "y" to "Y button",
"lb" to "Left bumper", "rb" to "Right bumper", "lt" to "Left trigger", "rt" to "Right trigger",
"select" to "View button", "start" to "Menu button",
"a" to R.string.ctrl_button_a, "b" to R.string.ctrl_button_b, "x" to R.string.ctrl_button_x, "y" to R.string.ctrl_button_y,
"lb" to R.string.ctrl_button_lb, "rb" to R.string.ctrl_button_rb, "lt" to R.string.ctrl_button_lt, "rt" to R.string.ctrl_button_rt,
"select" to R.string.ctrl_button_view, "start" to R.string.ctrl_button_menu,
)
val targets = listOf(
"a" to "A", "b" to "B", "x" to "X", "y" to "Y",
"lb" to "LB", "rb" to "RB", "lt" to "LT", "rt" to "RT",
"l3" to "L3", "r3" to "R3", "select" to "View", "start" to "Menu", "guide" to "Steam",
"up" to "D-pad up", "down" to "D-pad down", "left" to "D-pad left", "right" to "D-pad right",
OFF to "Hidden",
"a" to R.string.ctrl_target_a, "b" to R.string.ctrl_target_b, "x" to R.string.ctrl_target_x, "y" to R.string.ctrl_target_y,
"lb" to R.string.ctrl_target_lb, "rb" to R.string.ctrl_target_rb, "lt" to R.string.ctrl_target_lt, "rt" to R.string.ctrl_target_rt,
"l3" to R.string.ctrl_target_l3, "r3" to R.string.ctrl_target_r3, "select" to R.string.ctrl_target_view, "start" to R.string.ctrl_target_menu, "guide" to R.string.ctrl_target_steam,
"up" to R.string.ctrl_target_up, "down" to R.string.ctrl_target_down, "left" to R.string.ctrl_target_left, "right" to R.string.ctrl_target_right,
OFF to R.string.ctrl_target_hidden,
)
class Settings(
@@ -0,0 +1,13 @@
package com.droiddeck.launcher.input
object InputSpace {
const val WIDTH = 1920
const val HEIGHT = 1080
fun x(position: Float, viewWidth: Int): Int = scale(position, viewWidth, WIDTH)
fun y(position: Float, viewHeight: Int): Int = scale(position, viewHeight, HEIGHT)
private fun scale(position: Float, extent: Int, size: Int): Int =
if (extent <= 0) 0 else (position / extent * size).toInt().coerceIn(0, size - 1)
}
@@ -2,14 +2,16 @@ package com.droiddeck.launcher.input
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
/** The control surface shown on a secondary Android display during a Linux session. */
enum class SecondScreenMode(val id: String, val label: String) {
NONE("none", "None"),
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
TERMINAL("terminal", "Terminal"),
enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
NONE("none", R.string.second_mode_none),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
TERMINAL("terminal", R.string.second_mode_terminal),
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -5,6 +5,8 @@ import android.graphics.BitmapFactory
import android.os.StatFs
import android.system.Os
import android.util.Log
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
@@ -27,6 +29,11 @@ object AppImageManager {
val guestDir: String get() = "$GUEST_DIR/$id"
}
/** Why a file cannot be imported: [text] with its [args]. */
internal class Problem(@StringRes val text: Int, vararg val args: Any) {
fun describe(context: Context): String = context.getString(text, *args)
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
@@ -44,22 +51,22 @@ object AppImageManager {
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
internal fun problem(file: File): Problem? {
if (!file.isFile) return Problem(R.string.user_apps_file_gone)
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return Problem(R.string.appimg_too_small) }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
return Problem(R.string.appimg_unreadable, e.message.toString())
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
return Problem(R.string.appimg_not_linux)
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> Problem(R.string.appimg_wrong_arch)
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> Problem(R.string.appimg_not_appimage)
head[10].toInt() != 2 -> Problem(R.string.appimg_wrong_type, head[10].toInt())
else -> null
}
}
@@ -83,12 +90,12 @@ object AppImageManager {
fun import(
context: Context, file: File, name: String?, icon: File?, meta: Map<String, String> = emptyMap(), onProgress: (String) -> Unit,
): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
problem(file)?.let { return it.describe(context) }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
return context.getString(R.string.appimg_no_space_import, FileUtils.sizeToString(file.length() * 3))
}
val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
try {
@@ -103,17 +110,17 @@ object AppImageManager {
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
return e.message ?: context.getString(R.string.appimg_import_failed)
}
}
/** Swaps the program of imported [id] for [file], keeping its name and icon; the old one stays if this fails. */
fun replace(context: Context, id: String, file: File, meta: Map<String, String>, onProgress: (String) -> Unit): String? {
problem(file)?.let { return it }
problem(file)?.let { return it.describe(context) }
val dir = dir(context, id)
if (!File(dir, "app").isDirectory) return "That AppImage is gone"
if (!File(dir, "app").isDirectory) return context.getString(R.string.appimg_gone)
if (StatFs(dir.path).availableBytes < file.length() * 3) {
return "Not enough free space: updating needs about ${FileUtils.sizeToString(file.length() * 3)}"
return context.getString(R.string.appimg_no_space_update, FileUtils.sizeToString(file.length() * 3))
}
return try {
extract(context, dir, file, onProgress) ?: run {
@@ -123,7 +130,7 @@ object AppImageManager {
}
} catch (e: Exception) {
Log.e(TAG, "replace $id", e)
e.message ?: "Update failed"
e.message ?: context.getString(R.string.appimg_update_failed)
}
}
@@ -135,10 +142,10 @@ object AppImageManager {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
onProgress(context.getString(R.string.user_apps_copying, file.name))
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
onProgress(context.getString(R.string.appimg_extracting, file.name))
val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage"
else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }"
val out = StringBuilder()
@@ -152,7 +159,8 @@ object AppImageManager {
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
listOf("squashfs-root", "AppDir").forEach { FileUtils.delete(File(dir, it)) }
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
return out.lines().lastOrNull { it.isNotBlank() }?.let { context.getString(R.string.appimg_extract_failed_detail, it) }
?: context.getString(R.string.appimg_extract_failed)
}
copyLinkedFiles(File(dir, "app"), links)
return null
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.core.ArchivePaths
@@ -24,13 +25,17 @@ object DeckyManager {
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
/** True while a plugin's remote binary downloads: what the UI branches on, not the stage's words. */
@Volatile var downloadingBinary = false
private set
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
fun installed(context: Context): String? {
val loader = loader(context)
if (!loader.isFile || !loader.canExecute()) return null
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: "Installed"
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: context.getString(R.string.setup_installed)
}
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
@@ -102,39 +107,40 @@ object DeckyManager {
/** Downloads to a sibling temp file and atomically renames only after size, digest and ELF checks. */
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing Decky Loader"
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_loader)
val target = loader(context)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "PluginLoader.download")
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress("Downloading ${release.tag}", if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return "Decky Loader download failed" }
if (temp.length() != release.size) { temp.delete(); return "Decky Loader size did not match the release metadata" }
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return "Could not download the release checksum" }
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return "Release checksum is missing" }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return context.getString(R.string.deckymgr_checksum_mismatch) }
if (!validElf(temp, release.machine)) { temp.delete(); return context.getString(R.string.deckymgr_not_elf) }
if (SessionState.running) { temp.delete(); return context.getString(R.string.deckymgr_session_started_loader) }
if (!temp.setExecutable(true, false)) { temp.delete(); return context.getString(R.string.deckymgr_loader_not_executable) }
// Decky needs Steam's local CEF debugger only when its session supervisor is enabled.
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile() || cefMarker.isFile
}.getOrDefault(false)
if (!cefReady) { temp.delete(); return "Could not enable Steam CEF remote debugging" }
if (!cefReady) { temp.delete(); return context.getString(R.string.deckymgr_cef_failed) }
val staged = File(target.parentFile, "PluginLoader.new")
staged.delete()
if (!temp.renameTo(staged)) { temp.delete(); return "Could not stage PluginLoader" }
if (!temp.renameTo(staged)) { temp.delete(); return context.getString(R.string.deckymgr_stage_failed) }
val old = File(target.parentFile, "PluginLoader.old")
old.delete()
if (target.exists() && !target.renameTo(old)) { staged.delete(); return "Could not preserve the existing PluginLoader" }
if (target.exists() && !target.renameTo(old)) { staged.delete(); return context.getString(R.string.deckymgr_loader_preserve_failed) }
if (!staged.renameTo(target)) {
if (old.exists()) old.renameTo(target)
staged.delete()
return "Could not activate the new PluginLoader"
return context.getString(R.string.deckymgr_loader_activate_failed)
}
old.delete()
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
@@ -145,43 +151,44 @@ object DeckyManager {
/** Imports a Decky distribution ZIP into homebrew/plugins for the next Steam session. */
fun installPluginZip(context: Context, archive: File, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing a plugin"
if (installed(context) == null) return "Install Decky Loader before importing a plugin"
if (!archive.isFile || !archive.canRead()) return "Could not read the selected ZIP file"
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_plugin)
if (installed(context) == null) return context.getString(R.string.deckymgr_loader_required)
if (!archive.isFile || !archive.canRead()) return context.getString(R.string.deckymgr_zip_unreadable)
val plugins = File(LinuxRuntime.rootDir(context), "root/homebrew/plugins")
if (!plugins.exists() && !plugins.mkdirs()) return "Could not create the Decky plugins directory"
if (!plugins.exists() && !plugins.mkdirs()) return context.getString(R.string.deckymgr_plugins_dir_failed)
val staging = File(plugins, ".droiddeck-import-${UUID.randomUUID()}")
val backupSuffix = ".droiddeck-backup-${UUID.randomUUID()}"
try {
staging.mkdirs()
progress("Extracting plugin ZIP", -1)
extractPluginZip(archive, staging)
progress(context.getString(R.string.deckymgr_extracting_zip), -1)
extractPluginZip(context, archive, staging)
val roots = staging.listFiles()?.filter { it.isDirectory } ?: emptyList()
if (roots.size != 1 || staging.listFiles()?.size != 1) return "Plugin ZIP must contain one top-level plugin folder"
if (roots.size != 1 || staging.listFiles()?.size != 1) return context.getString(R.string.deckymgr_zip_one_folder)
val plugin = roots.single()
if (!File(plugin, "plugin.json").isFile || !File(plugin, "package.json").isFile || !File(plugin, "dist/index.js").isFile) {
return "Plugin ZIP is missing plugin.json, package.json, or dist/index.js"
return context.getString(R.string.deckymgr_zip_missing_files)
}
val name = JSONObject(File(plugin, "plugin.json").readText()).getString("name")
require(name.isNotBlank()) { "Plugin name is missing" }
require(name.isNotBlank()) { context.getString(R.string.deckymgr_plugin_name_missing) }
val metadata = JSONObject(File(plugin, "package.json").readText())
downloadRemoteBinaries(metadata, plugin, progress)
if (SessionState.running) return "A session started during installation; stop it before installing a plugin"
downloadRemoteBinaries(context, metadata, plugin, progress)
if (SessionState.running) return context.getString(R.string.deckymgr_session_started_plugin)
DeckyPluginInstall.activate(plugin, plugins, name,
File(plugins.parentFile, "settings/loader.json"), backupSuffix)
progress("Plugin installed · restart Steam to load it", 100)
progress(context.getString(R.string.deckymgr_plugin_installed), 100)
return null
} catch (e: Exception) {
Log.w(TAG, "plugin ZIP import", e)
return e.message?.takeIf { it.isNotBlank() } ?: "Plugin ZIP installation failed"
if (e is DeckyPluginInstall.Failure) return context.getString(e.text)
return e.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.deckymgr_plugin_install_failed)
} finally {
staging.deleteRecursively()
}
}
private fun extractPluginZip(archive: File, destination: File) {
private fun extractPluginZip(context: Context, archive: File, destination: File) {
var totalBytes = 0L
var entries = 0
val seen = HashSet<String>()
@@ -189,30 +196,30 @@ object DeckyManager {
while (true) {
val entry = zip.nextEntry ?: break
entries++
require(entries <= 10_000) { "Plugin ZIP contains too many entries" }
require(entries <= 10_000) { context.getString(R.string.deckymgr_zip_too_many) }
val relative = entry.name.replace('\\', '/')
require(relative.isNotBlank() && !relative.startsWith('/')) { "Plugin ZIP contains an invalid path" }
require(seen.add(relative)) { "Plugin ZIP contains duplicate paths" }
require(relative.isNotBlank() && !relative.startsWith('/')) { context.getString(R.string.deckymgr_zip_invalid_path) }
require(seen.add(relative)) { context.getString(R.string.deckymgr_zip_duplicate_paths) }
val output = ArchivePaths.inside(destination, relative)
?: throw IllegalArgumentException("Plugin ZIP contains an unsafe path")
?: throw IllegalArgumentException(context.getString(R.string.deckymgr_zip_unsafe_path))
if (entry.isDirectory) {
require(output.mkdirs() || output.isDirectory) { "Could not create plugin folder" }
require(output.mkdirs() || output.isDirectory) { context.getString(R.string.deckymgr_folder_failed) }
} else {
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { "Could not create plugin folder" }
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { context.getString(R.string.deckymgr_folder_failed) }
FileOutputStream(output).use { out ->
val buffer = ByteArray(64 * 1024)
while (true) {
val read = zip.read(buffer)
if (read < 0) break
totalBytes += read
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { "Plugin ZIP expands beyond the 1 GiB limit" }
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { context.getString(R.string.deckymgr_zip_too_large) }
out.write(buffer, 0, read)
}
}
// Decky distributions place plugin executables under bin/. Java's ZIP reader
// does not expose Unix mode bits, so restore execution on those payload files.
if (relative.split('/').dropLast(1).any { it == "bin" } && !output.setExecutable(true, false)) {
throw IllegalStateException("Could not mark bundled plugin binary executable")
throw IllegalStateException(context.getString(R.string.deckymgr_bundled_not_executable))
}
}
zip.closeEntry()
@@ -220,42 +227,49 @@ object DeckyManager {
}
}
private fun downloadRemoteBinaries(metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
private fun downloadRemoteBinaries(context: Context, metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
val binaries = metadata.optJSONArray("remote_binary") ?: return
val bin = File(plugin, "bin")
for (i in 0 until binaries.length()) {
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException("Invalid remote_binary entry")
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException(context.getString(R.string.deckymgr_remote_entry_invalid))
val name = binary.optString("name")
val url = binary.optString("url")
val hash = binary.optString("sha256hash")
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { "Invalid remote binary name" }
require(url.startsWith("https://")) { "Remote binary URL must use HTTPS" }
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { "Remote binary $name has an invalid SHA-256 hash" }
require(bin.mkdirs() || bin.isDirectory) { "Could not create plugin bin directory" }
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { context.getString(R.string.deckymgr_remote_name_invalid) }
require(url.startsWith("https://")) { context.getString(R.string.deckymgr_remote_url_https) }
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { context.getString(R.string.deckymgr_remote_hash_invalid, name) }
require(bin.mkdirs() || bin.isDirectory) { context.getString(R.string.deckymgr_bin_dir_failed) }
val temp = File(bin, ".$name.download")
progress("Downloading plugin binary · $name", -1)
if (!Downloader.downloadFile(url, temp, false) { fraction ->
progress("Downloading plugin binary · $name", if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}) {
val downloading = context.getString(R.string.deckymgr_downloading_binary, name)
downloadingBinary = true
progress(downloading, -1)
val downloaded = try {
Downloader.downloadFile(url, temp, false) { fraction ->
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}
} finally {
downloadingBinary = false
}
if (!downloaded) {
temp.delete()
throw IllegalStateException("Could not download plugin binary $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_download_failed, name))
}
if (!hash.equals(Hashes.sha256(temp), ignoreCase = true)) {
temp.delete()
throw IllegalStateException("Plugin binary checksum mismatch · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_checksum_mismatch, name))
}
if (!temp.setExecutable(true, false)) {
temp.delete()
throw IllegalStateException("Could not mark plugin binary executable · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_not_executable, name))
}
val target = File(bin, name)
if (target.exists() && !target.delete()) {
temp.delete()
throw IllegalStateException("Could not replace plugin binary · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_replace_failed, name))
}
if (!temp.renameTo(target)) {
temp.delete()
throw IllegalStateException("Could not install plugin binary · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_install_failed, name))
}
}
}
@@ -1,11 +1,16 @@
package com.droiddeck.launcher.runtime
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Offline equivalent of Decky's identity-based replacement and loader settings update. */
internal object DeckyPluginInstall {
/** A failure the user is told about: [text] is its string resource. */
class Failure(@StringRes val text: Int) : IllegalStateException()
fun activate(staged: File, plugins: File, name: String, settingsFile: File, backupSuffix: String) {
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
val existing = plugins.listFiles().orEmpty().filter { folder ->
@@ -14,7 +19,7 @@ internal object DeckyPluginInstall {
}.getOrDefault(false)
}
val target = File(plugins, staged.name)
require(!target.exists() || target in existing) { "Plugin folder belongs to another plugin" }
if (target.exists() && target !in existing) throw Failure(R.string.deckymgr_folder_taken)
val order = settings.optJSONArray("pluginOrder") ?: JSONArray(plugins.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.startsWith(".droiddeck-") }
.mapNotNull { runCatching { JSONObject(File(it, "plugin.json").readText()).getString("name") }.getOrNull() }
@@ -34,15 +39,15 @@ internal object DeckyPluginInstall {
pending.writeText(settings.toString(4))
for (old in existing) {
val backup = File(plugins, ".droiddeck-backup-${old.name}$backupSuffix")
check(old.renameTo(backup)) { "Could not preserve the existing plugin" }
if (!old.renameTo(backup)) throw Failure(R.string.deckymgr_preserve_failed)
backups.add(old to backup)
}
check(staged.renameTo(target)) { "Could not activate the imported plugin" }
if (!staged.renameTo(target)) throw Failure(R.string.deckymgr_activate_failed)
activated = true
check(pending.renameTo(settingsFile)) { "Could not update Decky plugin settings" }
if (!pending.renameTo(settingsFile)) throw Failure(R.string.deckymgr_settings_failed)
} catch (error: Exception) {
if (activated) target.deleteRecursively()
for ((old, backup) in backups.asReversed()) check(backup.renameTo(old)) { "Could not restore the existing plugin" }
for ((old, backup) in backups.asReversed()) if (!backup.renameTo(old)) throw Failure(R.string.deckymgr_restore_failed)
throw error
} finally {
pending.delete()
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
@@ -81,20 +82,21 @@ object DesktopCatalog {
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
// Every catalog row carries a sha256; one without is refused rather than trusted.
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
listener?.onProgress("Downloading ${entry.name}", 0)
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true) { f ->
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return "Download failed"
listener?.onProgress("Verifying", -1)
if (!ok) return context.getString(R.string.user_apps_download_failed)
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
listener?.onProgress("Installing ${entry.name}", -1)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -102,20 +104,20 @@ object DesktopCatalog {
// Some projects zip the AppImage (melonDS); the one file inside is what we want.
val placed = if (entry.url.endsWith(".zip", ignoreCase = true)) unzipAppImage(download, target)
else download.renameTo(target)
if (!placed) return "Could not place the AppImage"
if (!placed) return context.getString(R.string.deskpkg_place_failed)
target.setExecutable(true, false)
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> if (!LinuxRuntimeInstaller.extract(download, root, listener)) return "Extraction failed"
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
}
FileUtils.writeString(marker(context, entry.id), entry.version)
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: "Install failed"
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
@@ -92,9 +93,10 @@ object FlatpakManager {
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
/** [block]'s result, or [ifBusy]'s while another operation has the installation. */
private inline fun <T> exclusive(what: String, ifBusy: () -> T, block: () -> T): T {
synchronized(this) {
if (busy != null) return null
if (busy != null) return ifBusy()
busy = what
}
try { return block() } finally { busy = null }
@@ -102,8 +104,8 @@ object FlatpakManager {
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
return exclusive("setup") {
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
return exclusive("setup", { context.getString(R.string.flatpakmgr_busy) }) {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
Log.i(TAG, "setup: $line")
@@ -114,63 +116,76 @@ object FlatpakManager {
}
when {
failure != null -> failure
status != 0 -> "Flatpak setup failed (exit $status)"
!ready(context) -> "Flatpak setup did not finish"
status != 0 -> context.getString(R.string.flatpakmgr_setup_failed, status)
!ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished)
else -> null
}
} ?: "Another store task is running"
}
}
/**
* One store operation - install, uninstall or update - with its progress as a stage line and
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
*/
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
if (!ready(context)) return "Set up Flatpak first"
return exclusive("$verb:${id ?: "all"}") {
private fun transaction(
context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit, onDone: (JSONObject) -> Unit = {},
): String? {
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
return exclusive("$verb:${id ?: "all"}", { context.getString(R.string.flatpakmgr_busy) }) {
var error: String? = null
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = "Starting"
var step = context.getString(R.string.flatpakmgr_starting)
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
val status = runGuest(context, argv, fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull()
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
when (o.optString("e")) {
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
"op" -> { step = stage(context, o); onProgress(step, overall(o, 0)) }
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
"done" -> onDone(o)
}
}
error ?: if (status != 0) "Flatpak exited with status $status" else null
} ?: "Another store task is running"
error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null
}
}
private fun stage(o: JSONObject): String {
private fun stage(context: Context, o: JSONObject): String {
val ref = o.optString("ref")
val name = refLabel(ref)
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
return when (o.optString("kind")) {
"uninstall" -> "Removing $name$step"
"update" -> "Updating $name$step"
else -> "Installing $name$step"
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
if (ref.isEmpty()) return o.optString("status").ifEmpty { context.getString(R.string.setup_check_busy) }
val name = refLabel(ref) { id, args -> context.getString(id, *args) }
val n = o.optInt("n")
val kind = o.optString("kind")
return if (n > 1) context.getString(when (kind) {
"uninstall" -> R.string.flatpakmgr_removing_step
"update" -> R.string.flatpakmgr_updating_step
else -> R.string.flatpakmgr_installing_step
}, name, o.optInt("i"), n)
else context.getString(when (kind) {
"uninstall" -> R.string.flatpakmgr_removing
"update" -> R.string.flatpakmgr_updating
else -> R.string.flatpakmgr_installing
}, name)
}
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
internal fun refLabel(ref: String): String {
/**
* A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". [text]
* resolves a string resource with its arguments.
*/
internal fun refLabel(ref: String, text: (Int, Array<out Any>) -> String): String {
val parts = ref.split('/')
val id = parts.getOrNull(1) ?: return ref
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
val branch = parts.getOrNull(3)
return when {
id.endsWith(".Locale") -> "translations"
".GL." in id || ".GL32." in id -> "graphics drivers"
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
id.endsWith(".Locale") -> text(R.string.flatpakmgr_ref_translations, emptyArray())
".GL." in id || ".GL32." in id -> text(R.string.flatpakmgr_ref_graphics_drivers, emptyArray())
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> text(R.string.flatpakmgr_ref_media_codecs, emptyArray())
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
"gnome", "kde" -> vendor.uppercase()
else -> vendor.replaceFirstChar { it.uppercase() }
} + " runtime$branch"
}.let { if (branch != null) text(R.string.flatpakmgr_ref_runtime_branch, arrayOf(it, branch)) else text(R.string.flatpakmgr_ref_runtime, arrayOf(it)) }
else -> id.substringAfterLast('.')
}
}
@@ -184,13 +199,20 @@ object FlatpakManager {
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
/** Installs the .flatpak bundle at [guestPath], its runtimes from Flathub: the app's ID, and what went wrong if anything. */
fun installBundle(context: Context, guestPath: String, onProgress: (String, Int) -> Unit): Pair<String?, String?> {
var id: String? = null
val problem = transaction(context, "install-bundle", guestPath, onProgress) { id = it.optString("id").ifEmpty { null } }
return id to problem
}
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
/** Installed apps with an update on Flathub, or null when that could not be checked. */
fun updates(context: Context): Set<String>? {
if (!ready(context)) return emptySet()
var apps: Set<String>? = null
exclusive("updates") {
exclusive("updates", {}) {
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
if (o.optString("e") == "updates") {
@@ -42,6 +42,8 @@ public final class LinuxRuntime {
* session ended at once. A long host path behind it is proot's to shorten.
*/
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
private static final String SYSTEM_FONTS = "/system/fonts";
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
public static final String DEBUG_LOG_DIR = "DroidDeck";
@@ -207,6 +209,7 @@ public final class LinuxRuntime {
File shm = new File(context.getCacheDir(), "shm");
shm.mkdirs();
bind(cmd, shm.getPath() + ":/dev/shm");
bindSystemFonts(cmd, root);
// Android denies apps these; glibc, Steam and libcap read them at startup.
File fakeProc = new File(root, "etc/droiddeck/proc");
@@ -465,6 +468,15 @@ public final class LinuxRuntime {
return byType;
}
private static void bindSystemFonts(List<String> cmd, File root) {
File fonts = new File(SYSTEM_FONTS);
File target = new File(root, GUEST_SYSTEM_FONTS.substring(1));
if (!fonts.isDirectory() || !fonts.canRead() || !new File(root, "usr/local").isDirectory()) return;
if (target.isDirectory() || target.mkdirs()) {
bind(cmd, SYSTEM_FONTS + ":" + GUEST_SYSTEM_FONTS);
}
}
private static void bind(List<String> cmd, String spec) {
cmd.add("-b");
cmd.add(spec);
@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.ArchivePaths;
import com.droiddeck.launcher.core.Downloader;
import com.droiddeck.launcher.core.Hashes;
@@ -44,9 +45,17 @@ public final class LinuxRuntimeInstaller {
private static final String VERSION_FILE = ".version";
/** What a stage is doing, for code that branches on it rather than on its (translated) words. */
public enum Step { DOWNLOADING, VERIFYING, OTHER }
public interface ProgressListener {
/** {@code percent} is -1 while the size is unknown. */
void onProgress(String stage, int percent);
/** The same with its {@link Step}; a listener that branches on the step overrides this one. */
default void onProgress(Step step, String stage, int percent) {
onProgress(stage, percent);
}
}
public static final class Release {
@@ -71,11 +80,12 @@ public final class LinuxRuntimeInstaller {
private static final class Job {
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
final CountDownLatch done = new CountDownLatch(1);
volatile String stage = "Starting\u2026";
volatile String stage;
volatile Step step = Step.OTHER;
volatile int percent = -1;
volatile boolean ok;
final boolean removal;
Job(boolean removal) { this.removal = removal; }
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
}
private static final Object JOB_LOCK = new Object();
@@ -131,16 +141,23 @@ public final class LinuxRuntimeInstaller {
synchronized (JOB_LOCK) {
if (running != null && running.removal) return false;
owner = running == null;
if (owner) { running = new Job(false); removalError = null; }
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
job = running;
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
try {
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
job.ok = installOnce(context.getApplicationContext(), release, new ProgressListener() {
@Override public void onProgress(String stage, int percent) {
onProgress(Step.OTHER, stage, percent);
}
@Override public void onProgress(Step step, String stage, int percent) {
job.step = step;
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(step, stage, percent);
}
});
return job.ok;
} finally {
@@ -192,7 +209,7 @@ public final class LinuxRuntimeInstaller {
private static boolean join(Job job, ProgressListener listener) {
try {
if (listener != null) listener.onProgress(job.stage, job.percent);
if (listener != null) listener.onProgress(job.step, job.stage, job.percent);
job.done.await();
return job.ok;
} catch (InterruptedException e) {
@@ -209,13 +226,14 @@ public final class LinuxRuntimeInstaller {
// A failed/interrupted explicit removal must finish before a fresh installation.
// Its quarantined home is not user data to carry into an update.
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress("Removing runtime leftovers", -1);
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
RuntimeFileTree.delete(pendingRemoval, null);
if (listener != null) listener.onProgress("Downloading", 0);
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
if (listener != null) {
listener.onProgress("Downloading",
listener.onProgress(Step.DOWNLOADING, downloading,
fraction < 0 ? -1 : Math.round(fraction * 100f));
}
});
@@ -224,7 +242,7 @@ public final class LinuxRuntimeInstaller {
return false;
}
if (listener != null) listener.onProgress("Verifying", -1);
if (listener != null) listener.onProgress(Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1);
String actual = Hashes.sha256(archive);
if (!release.sha256.equalsIgnoreCase(actual)) {
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
@@ -239,8 +257,8 @@ public final class LinuxRuntimeInstaller {
recoverInterruptedSwap(root, staging, old);
RuntimeFileTree.delete(staging, null);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
if (!extract(archive, staging, listener)) {
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
if (!extract(context, archive, staging, listener)) {
RuntimeFileTree.delete(staging, null);
return false;
}
@@ -307,30 +325,35 @@ public final class LinuxRuntimeInstaller {
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
return beginUninstall(LinuxRuntime.rootDir(context));
return beginUninstall(context.getApplicationContext(), LinuxRuntime.rootDir(context));
}
static Removal beginUninstall(File root) {
/** {@code app} words the progress; unit tests pass null and get none. */
static Removal beginUninstall(Context app, File root) {
synchronized (JOB_LOCK) {
if (running != null) return null;
removalError = null;
running = new Job(true);
running.stage = "Removing Linux runtime";
return new Removal(root, running);
running = new Job(true, text(app, R.string.rtinst_removing));
return new Removal(app, root, running);
}
}
private static String text(Context app, int id) {
return app == null ? "" : app.getString(id);
}
public static final class Removal {
private final Context app;
private final File root;
private final Job job;
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private Removal(File root, Job job) { this.root = root; this.job = job; }
private Removal(Context app, File root, Job job) { this.app = app; this.root = root; this.job = job; }
public boolean run(ProgressListener listener) {
if (!started.compareAndSet(false, true)) return join(job, listener);
if (listener != null) job.listeners.add(listener);
try {
report("Removing Linux runtime", -1);
report(text(app, R.string.rtinst_removing), -1);
final long[] removed = {0};
final long[] lastShown = {0};
java.util.function.LongConsumer progress = ignored -> {
@@ -338,7 +361,8 @@ public final class LinuxRuntimeInstaller {
long now = System.nanoTime();
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
lastShown[0] = now;
report("Removing Linux runtime · " + removed[0] + " entries", -1);
report(app == null ? "" : app.getResources().getQuantityString(
R.plurals.rtinst_removing_entries, (int) Math.min(removed[0], Integer.MAX_VALUE), removed[0]), -1);
}
};
File pending = removalDirectory(root);
@@ -355,10 +379,10 @@ public final class LinuxRuntimeInstaller {
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
RuntimeFileTree.delete(pending, progress);
job.ok = true;
report("Linux runtime removed", 100);
report(text(app, R.string.rtinst_removed), 100);
return true;
} catch (IOException e) {
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
removalError = text(app, R.string.rtinst_removal_failed);
Log.w(TAG, "runtime removal incomplete", e);
report(removalError, -1);
return false;
@@ -380,8 +404,9 @@ public final class LinuxRuntimeInstaller {
* empty file instead of its link target is a rootfs that boots to nothing. Symlinks, hard
* links and the executable bit are all carried over here.
*/
public static boolean extract(File archive, File destination, ProgressListener listener) {
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -432,7 +457,7 @@ public final class LinuxRuntimeInstaller {
continue; // device nodes and fifos: the runtime binds the real ones
}
if (++entries % 2000 == 0 && listener != null) {
listener.onProgress("Extracting", -1);
listener.onProgress(extracting, -1);
}
}
return true;
@@ -22,12 +22,15 @@ import com.droiddeck.launcher.R
* two gets there first.
*/
class RuntimeInstallService : Service() {
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(if (removal) "Removing Linux runtime" else "Starting…", -1))
startForeground(NOTIFICATION_ID, notification(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
if (release == null && !removal) {
stopSelf(startId)
return START_NOT_STICKY
@@ -53,9 +56,9 @@ class RuntimeInstallService : Service() {
private fun notification(stage: String, percent: Int): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Linux runtime",
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while the Linux runtime is installed or removed"
description = getString(R.string.rtinst_channel_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
@@ -65,7 +68,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(if (LinuxRuntimeInstaller.isRemoving()) "Removing the Linux runtime" else "Installing the Linux runtime")
.setContentTitle(getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -69,6 +69,7 @@ object UserApps {
class AppImage(val file: File) : Source()
class GitHub(val repo: String) : Source()
class Flatpak(val id: String) : Source()
class FlatpakBundle(val file: File) : Source()
}
/** [icon]: a picked file's path or a suggested icon's URL. */
@@ -124,6 +125,7 @@ object UserApps {
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
}
}
}
@@ -422,9 +424,36 @@ object UserApps {
private fun addFlatpak(context: Context, id: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
FlatpakManager.install(context, id, onProgress)?.let { return it }
val custom = overrides(context, id)
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
if (icon != null && !saveIcon(icon, File(custom, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
customiseFlatpak(context, id, name, icon)
return null
}
private fun addFlatpakBundle(context: Context, file: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
if (!file.isFile) return context.getString(R.string.user_apps_file_gone)
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
// Shared storage is bound into the runtime at its own path; a file anywhere else is copied in.
val shared = Environment.getExternalStorageDirectory().canonicalPath
val copy = File(LinuxRuntime.rootDir(context), "tmp/droiddeck-bundle.flatpak").takeIf { !file.canonicalPath.startsWith("$shared/") }
try {
if (copy != null) {
onProgress(context.getString(R.string.user_apps_copying, file.name), -1)
file.copyTo(copy.apply { parentFile?.mkdirs() }, overwrite = true)
}
val (id, problem) = FlatpakManager.installBundle(context, copy?.let { "/tmp/${it.name}" } ?: file.canonicalPath, onProgress)
problem?.let { return it }
if (id != null) customiseFlatpak(context, id, name, icon)
return null
} catch (e: Exception) {
Log.e(TAG, "bundle ${file.path}", e)
return e.message ?: context.getString(R.string.user_apps_failed)
} finally {
copy?.delete()
}
}
private fun customiseFlatpak(context: Context, id: String, name: String?, icon: File?) {
val custom = overrides(context, id)
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
if (icon != null && !saveIcon(icon, File(custom.apply { mkdirs() }, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
@@ -248,12 +249,13 @@ object ComponentsManager {
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
}.getOrDefault("")
private fun fexVersion(dir: File): String {
/** The FEX build's version name; [unnamed] when its DLL carries none, "" when there is no FEX. */
private fun fexVersion(dir: File, unnamed: String = "FEX (build without a version name)"): String {
val dll = File(dir, FEX_FILES[0])
if (!dll.isFile) return ""
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
?: "FEX (build without a version name)"
?: unnamed
}
private fun compFiles(dir: File, comp: String): List<String> {
@@ -274,23 +276,24 @@ object ComponentsManager {
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
private fun readProfile(context: Context, f: File): JSONObject = readWcp(f) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
}
throw IllegalArgumentException("no profile.json in ${f.name}")
throw IllegalArgumentException(context.getString(R.string.cmgr_no_profile, f.name))
}
private fun packageInfo(f: File): Package {
val prof = readProfile(f)
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
private fun packageInfo(context: Context, f: File): Package {
val prof = readProfile(context, f)
val comp = TYPES[prof.optString("type")]
?: throw IllegalArgumentException(context.getString(R.string.cmgr_not_component_package, f.name, prof.optString("type")))
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
}
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?.mapNotNull { f -> runCatching { packageInfo(context, f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?: emptyList()
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
@@ -363,7 +366,7 @@ object ComponentsManager {
return versions.flatMap { v ->
COMPONENTS.mapNotNull { comp ->
val f = File(v, "$comp.wcp")
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(context, f).optString("description") }.getOrDefault(""), f.length())
}
}
}
@@ -415,24 +418,24 @@ object ComponentsManager {
val syncPacks = EsyncPacks.status(root(context))
val views = protons(context).map { p ->
val comps = COMPONENTS.associateWith { comp ->
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
val detected = if (comp == "fex") fexVersion(p.dir, context.getString(R.string.cmgr_fex_unnamed)) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") context.getString(if (File(p.dir, FEX_FILES[2]).isFile) R.string.cmgr_fex_linux_helpers else R.string.cmgr_fex_dlls_only) else ""
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
val inUseLabel = when {
active == null -> "Original"
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
active == null -> context.getString(R.string.comp_tag_original)
active.optString("protonVersion") != p.version -> context.getString(R.string.cmgr_original_proton_updated)
else -> {
val want = active.optJSONObject("files") ?: JSONObject()
val fp = fingerprint(p.dir, comp)
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
else "Changed outside Components"
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) originalLabel(context, active) ?: active.optString("label", active.optString("file"))
else context.getString(R.string.cmgr_changed_outside)
}
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { "not present" }, detail, inUseLabel,
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.optString("label"),
q?.let { originalLabel(context, it) ?: it.optString("label") },
)
}
ProtonView(p, comps, originals(context, p.id), gameRunning(context, p), reapplied[p.dir.name] ?: 0, syncPacks.tool(p.guestPath))
@@ -440,6 +443,16 @@ object ComponentsManager {
Snapshot(views, packages(context))
}
/**
* The label of a chosen or queued original, in the app's language: the stored "label" was written
* in English when it was chosen. Null for a package, whose label is its version.
*/
private fun originalLabel(context: Context, entry: JSONObject): String? = when {
entry.optString("kind") == "original" -> context.getString(R.string.cmgr_original_version, entry.optString("protonVersion"))
entry.optString("file").startsWith("original ") -> context.getString(R.string.cmgr_original_from, entry.optString("file").removePrefix("original "))
else -> null
}
// ------------------------------------------------------------------ launch-time enforcement
/**
@@ -508,17 +521,17 @@ object ComponentsManager {
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(packagesDir(context), safeName(file))
val info = packageInfo(wcp)
val info = packageInfo(context, wcp)
val state = loadState(context)
if (inUse(context, p)) {
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
saveState(context, state)
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
return context.getString(R.string.cmgr_swap_queued, LABEL[info.comp], info.version, p.name)
}
applyPackage(context, p, info, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
saveState(context, state)
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
context.getString(R.string.cmgr_swap_done, LABEL[info.comp], info.version, p.name)
}
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
@@ -534,17 +547,17 @@ object ComponentsManager {
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(wcp.isFile) { "That original bundle is not stored" }
check(wcp.isFile) { context.getString(R.string.cmgr_original_missing) }
val state = loadState(context)
if (inUse(context, p)) {
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
saveState(context, state)
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
return context.getString(R.string.cmgr_restore_queued, LABEL[comp], p.name)
}
applyOriginal(context, p, comp, protonVersion, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(comp)
saveState(context, state)
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
@@ -576,7 +589,7 @@ object ComponentsManager {
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
} else {
val wcp = File(packagesDir(context), q.optString("file"))
applyPackage(context, p, packageInfo(wcp), wcp, state)
applyPackage(context, p, packageInfo(context, wcp), wcp, state)
}
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
@@ -600,27 +613,27 @@ object ComponentsManager {
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val f = File(packagesDir(context), name)
check(f.isFile) { "$name is not stored" }
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
f.delete()
"Deleted $name."
context.getString(R.string.cmgr_deleted, name)
}
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
check(safeName(p.version) != protonVersion) { context.getString(R.string.cmgr_original_kept) }
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(f.isFile) { "Not stored" }
check(f.isFile) { context.getString(R.string.cmgr_not_stored) }
f.delete()
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
"Deleted the $protonVersion original of ${LABEL[comp]}."
context.getString(R.string.cmgr_deleted_original, protonVersion, LABEL[comp])
}
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
val info = packageInfo(source)
val info = packageInfo(context, source)
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
info.copy(file = dest.name)
@@ -742,7 +755,7 @@ object ComponentsManager {
fetch: (CatalogItem) -> Unit,
): String? {
val wcp = File(packagesDir(context), safeName(rule.file))
if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null
if (wcp.isFile && runCatching { packageInfo(context, wcp) }.isSuccess) return null
wcp.delete()
val item = lookup(rule.file, rule.release)
?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy"
@@ -764,14 +777,14 @@ object ComponentsManager {
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { context.getString(R.string.cmgr_nightlies_only) }
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
return info.copy(file = dest.name)
}
@@ -49,7 +49,6 @@ object EsyncPacks {
private const val TOOLS = "tools.tsv"
private const val FLOOR = "floor"
private const val PACK_FAILURES = "packFailures"
private const val PROGRESS_LABEL = "Fetching droiddeck-esync pack"
private const val INDEX_LIMIT = 4L shl 20
private const val SIG_LIMIT = 4096L
private const val ASSET_LIMIT = 256L shl 20
@@ -618,14 +617,15 @@ object EsyncPacks {
return false
}
try {
onProgress?.invoke(PROGRESS_LABEL, -1)
val label = context.getString(com.droiddeck.launcher.R.string.esync_fetching)
onProgress?.invoke(label, -1)
var reported = -1
val fetched = FileOutputStream(part).use { output ->
fetch(entry.asset.url, entry.asset.size, output) { done ->
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
if (percent != reported) {
reported = percent
onProgress?.invoke(PROGRESS_LABEL, percent)
onProgress?.invoke(label, percent)
}
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.Library
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
@@ -25,9 +27,12 @@ import java.util.zip.ZipOutputStream
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
*/
object GameSaves {
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
WINLATOR("Winlator zip", "drive_c/", "xuser"),
enum class Layout(val app: String, val rootPrefix: String, val user: String) {
GAMEHUB("GameHub", "/drive_c/", "steamuser"),
WINLATOR("Winlator", "drive_c/", "xuser");
/** "GameHub zip", in the app's language. */
fun label(context: Context): String = context.getString(R.string.saves_layout_zip, app)
}
/** A game, its prefix, and the save folders found in it. */
@@ -127,12 +132,12 @@ object GameSaves {
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
*/
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
fun export(context: Context, game: Game, layout: Layout, dir: File): Pair<File, Int> {
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
dir.mkdirs()
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
if (count == 0) { out.delete(); error(context.getString(R.string.saves_none_found, game.game.name)) }
return out to count
}
@@ -162,11 +167,11 @@ object GameSaves {
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
*/
fun import(game: Game, zip: File): Pair<Int, File?> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
fun import(context: Context, game: Game, zip: File): Pair<Int, File?> {
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
val driveC = File(prefix, "drive_c")
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
if (!driveC.isDirectory) error(context.getString(R.string.saves_not_launched, game.game.name))
if (!looksLikeSaveZip(zip)) error(context.getString(R.string.saves_not_save_zip, zip.name))
backupsDir().mkdirs()
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
@@ -4,7 +4,9 @@ import android.content.Context
import android.os.Environment
import android.os.StatFs
import android.os.storage.StorageManager
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.AddedGames
import java.io.File
/**
@@ -17,7 +19,8 @@ import java.io.File
* slowly (intro movies, big asset loads); internal stays the default and the dialog says so.
*/
object GameStorage {
class Option(val label: String, val path: String)
/** A library root: [label] as the menu lists it (a volume with its free space), [name] alone. */
class Option(val label: String, val path: String, val name: String = label)
/** Every removable volume, as its app folder, with the free space it has. */
fun options(context: Context): List<Option> {
@@ -26,8 +29,8 @@ object GameStorage {
val removable = try { Environment.isExternalStorageRemovable(dir) } catch (e: Exception) { false }
if (!removable) return@mapNotNull null
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: "SD card"
Option("$name · ${free(dir)} free", File(dir, "steam").absolutePath)
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: context.getString(R.string.gstore_sd_card)
Option(context.getString(R.string.gstore_volume_free, name, free(dir)), File(dir, "steam").absolutePath, name)
}
}
@@ -41,15 +44,15 @@ object GameStorage {
* Makes [path] a library root the session can bind - the folder and its steamapps/ - and proves
* it writable. Returns why it cannot be used, or null when it can.
*/
fun prepare(path: String): String? {
fun prepare(context: Context, path: String): String? {
val root = File(path)
val steamapps = File(root, "steamapps")
if (!steamapps.isDirectory && !steamapps.mkdirs()) return "cannot create folders in $path"
if (!steamapps.isDirectory && !steamapps.mkdirs()) return context.getString(R.string.gstore_cannot_create, path)
val probe = File(steamapps, ".writable")
return try {
if (!probe.createNewFile() && !probe.isFile) "cannot write in $path" else { probe.delete(); null }
if (!probe.createNewFile() && !probe.isFile) context.getString(R.string.gstore_cannot_write, path) else { probe.delete(); null }
} catch (e: Exception) {
"cannot write in $path (${e.message})"
context.getString(R.string.gstore_cannot_write_detail, path, e.message)
}
}
@@ -58,13 +61,32 @@ object GameStorage {
val pref = SessionPrefs.gameStorage(context)
return when {
pref == SessionPrefs.GAME_STORAGE_OFF -> null
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.label.substringBefore(" ·"), it.path) }
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.name, it.path) }
else -> Option(SessionPrefs.gameStorageLabel(context), pref)
}
}
class Library(val host: File, val guest: String, val label: String)
fun gamesFolderLibraries(context: Context): List<Library> {
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
val card = effective(context)?.let { canonical(File(it.path)) }
val sm = context.getSystemService(StorageManager::class.java)
return AddedGames.roots(context).mapNotNull { root ->
val host = canonical(root.host)
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) }
val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume)
Library(root.host, root.guest, label.replace('"', ' ').replace('\\', ' '))
}
}
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
/** The label the client shows for a chosen folder: its last name, or the volume's. */
fun labelFor(context: Context, path: String): String =
options(context).firstOrNull { it.path == path }?.label?.substringBefore(" ·")
?: File(path).name.ifEmpty { "Folder" }
options(context).firstOrNull { it.path == path }?.name
?: File(path).name.ifEmpty { context.getString(R.string.gstore_folder) }
}
@@ -17,9 +17,16 @@ import java.nio.charset.StandardCharsets
* progress, and lifting that out is the difference between "starting the Steam client" for three
* minutes and a percentage that moves.
*/
class LoadingState(context: Context, steam: Boolean = true) {
class LoadingState(context: Context, private val steam: Boolean = true) {
var visible by mutableStateOf(true)
var step by mutableStateOf("Starting the session…")
var step by mutableStateOf(context.getString(com.droiddeck.launcher.R.string.loadstate_starting))
private set
/** What [step] is about, so the screen picks its checklist stage without reading the (translated) words. */
var topic by mutableStateOf(Topic.SESSION)
private set
/** [step] is a download or install line worth showing as it is, not one of the script's own notes. */
var readable by mutableStateOf(false)
private set
var percent by mutableIntStateOf(-1)
var elapsed by mutableStateOf("")
var hint by mutableStateOf("")
@@ -31,15 +38,23 @@ class LoadingState(context: Context, steam: Boolean = true) {
private val hints = context.resources.getStringArray(
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
)
private val res = context.resources
private val startedAt = SystemClock.elapsedRealtime()
/** Once a second: the clock and the hint. */
fun tick() {
val seconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
elapsed = String.format(java.util.Locale.US, "%d:%02d elapsed · still working", seconds / 60, seconds % 60)
elapsed = res.getString(com.droiddeck.launcher.R.string.loadstate_elapsed, seconds / 60, seconds % 60)
hint = hints[((seconds / 8) % hints.size).toInt()]
}
/** A line the app itself writes (an install's progress, the session starting). */
fun say(line: String, topic: Topic, readable: Boolean) {
step = line
this.topic = topic
this.readable = readable
}
fun showEnded(message: String, detail: String? = null) {
visible = true
ended = true
@@ -50,12 +65,16 @@ class LoadingState(context: Context, steam: Boolean = true) {
/** Re-reads the end of the log and updates the line and the bar. */
fun update(context: Context, log: File?) {
val state = read(context, log) ?: return
step = state.first
percent = state.second
step = state.line
topic = state.topic
readable = state.readable
percent = state.percent
}
private class Line(val line: String, val percent: Int, val topic: Topic, val readable: Boolean)
/** Only the tail is read: the client alone writes megabytes an hour. */
private fun read(context: Context, log: File?): Pair<String, Int>? {
private fun read(context: Context, log: File?): Line? {
if (log == null || !log.isFile) return null
val text = try {
RandomAccessFile(log, "r").use { file ->
@@ -100,16 +119,45 @@ class LoadingState(context: Context, steam: Boolean = true) {
}
}
return when {
downloadAt > stepAt && downloadPercent >= 0 ->
Pair("Downloading the Steam client update · $downloadPercent%", downloadPercent)
clientDownloadAt == stepAt && clientDownload != null ->
Pair("Downloading the Steam client · $clientDownload", clientPercent)
stepText != null -> Pair(stepText, -1)
downloadAt > stepAt && downloadPercent >= 0 -> Line(
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_update, downloadPercent), downloadPercent,
Topic.STEAM, readable = true,
)
clientDownloadAt == stepAt && clientDownload != null -> Line(
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_client, clientDownload), clientPercent,
Topic.STEAM, readable = true,
)
stepText != null -> Line(stepText, -1, topicOf(stepText, steam), readableScriptStep(stepText))
else -> null
}
}
/** The checklist stages a loading line can move the screen to. */
enum class Topic { RUNTIME, SESSION, DESKTOP, STEAM, STEAM_STARTING, OTHER }
companion object {
/**
* A session script "== STEP" line's topic in a Steam ([steam]) or desktop session; those
* lines are English whatever the app's language.
*/
internal fun topicOf(scriptStep: String, steam: Boolean): Topic {
val t = scriptStep.lowercase()
return when {
"linux runtime" in t -> Topic.RUNTIME
"starting the session" in t -> Topic.SESSION
!steam && "desktop" in t -> Topic.DESKTOP
steam && "starting the steam client" in t -> Topic.STEAM_STARTING
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> Topic.STEAM
else -> Topic.OTHER
}
}
/** The script's install lines are worth reading as they are; its other notes are not. */
internal fun readableScriptStep(scriptStep: String): Boolean {
val t = scriptStep.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
}
private const val TAIL_BYTES = 48L * 1024
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
@@ -30,7 +30,7 @@ object OfflineMode {
private fun loginUsers(root: File) = File(root, "root/.local/share/Steam/config/loginusers.vdf")
/** Whether the client has an account it could sign in as offline. */
/** The account the client could sign in as offline: its name, "" when it recorded none, null when there is none. */
fun account(context: Context): String? {
val file = loginUsers(LinuxRuntime.rootDir(context))
val text = try {
@@ -39,7 +39,7 @@ object OfflineMode {
Log.w(TAG, "could not read loginusers.vdf", e); return null
}
if (!REMEMBER.containsMatchIn(text)) return null
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() } ?: "signed in"
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() }.orEmpty()
}
fun enabled(context: Context): Boolean = prefs(context).getBoolean(KEY, false)
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import android.content.Context
import android.os.StatFs
import android.util.Log
@@ -59,70 +60,72 @@ object ProtonExtras {
/** Downloads, verifies and registers the latest build without waiting for another session. */
@Synchronized
fun install(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
if (installInProgress) return "A compatibility tool install is already running"
if (installInProgress) return context.getString(R.string.pextra_busy)
installInProgress = true
return try { installNow(context, tool, onProgress) } finally { installInProgress = false }
}
private fun installNow(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing compatibility tools"
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
if (SessionState.running) return context.getString(R.string.pextra_stop_session)
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
val asset = findLatestAsset(tool) ?: return "Could not find an ARM64 release for ${tool.name}"
val asset = findLatestAsset(tool) ?: return context.getString(R.string.pextra_no_release, tool.name)
val downloads = File(context.filesDir, "proton-downloads").apply { mkdirs() }
val archive = File(downloads, "${tool.id}-${asset.name}")
if (asset.size > 0 && archive.length() > asset.size) archive.delete()
val missing = (asset.size - archive.length()).coerceAtLeast(0L)
if (StatFs(LinuxRuntime.rootDir(context).path).availableBytes < NEED_BYTES + missing) {
return "At least 4 GB free plus the download size is required to install ${tool.name}"
return context.getString(R.string.pextra_no_space, tool.name)
}
onProgress("Downloading ${tool.name} ${asset.tag}", 0)
val downloading = context.getString(R.string.pextra_downloading, tool.name, asset.tag)
onProgress(downloading, 0)
var downloaded = false
for (attempt in 0 until 5) {
downloaded = Downloader.downloadFile(asset.url, archive, true) { fraction ->
onProgress("Downloading ${tool.name} ${asset.tag}", if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
onProgress(downloading, if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
}
if (downloaded) break
if (attempt < 4) {
onProgress("Retrying download · attempt ${attempt + 2} of 5", -1)
onProgress(context.getString(R.string.pextra_retrying, attempt + 2, 5), -1)
try { Thread.sleep(3_000) } catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return "Download interrupted"
return context.getString(R.string.pextra_interrupted)
}
}
}
if (!downloaded) return "Download failed; the partial file is kept so a retry can resume"
if (!downloaded) return context.getString(R.string.pextra_download_failed)
if (asset.size > 0 && archive.length() != asset.size) {
if (archive.length() > asset.size) {
archive.delete()
return "Download exceeded the release size and was deleted"
return context.getString(R.string.pextra_too_big)
}
return "Download was incomplete; the partial file is kept for a resumable retry"
return context.getString(R.string.pextra_incomplete)
}
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
// release's own sha512 file. A download neither vouches for is not installed.
onProgress("Verifying download", -1)
onProgress(context.getString(R.string.pextra_verifying), -1)
val verified = when {
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
asset.sha512 != null -> {
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
?: return "Could not read the release checksum; try again"
?: return context.getString(R.string.pextra_checksum_unreadable)
expected.equals(Hashes.sha512(archive), ignoreCase = true)
}
else -> {
archive.delete()
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
return context.getString(R.string.pextra_no_checksum, tool.name, asset.tag)
}
}
if (!verified) {
archive.delete()
return "Checksum mismatch; the download was deleted"
return context.getString(R.string.pextra_checksum_mismatch)
}
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
onProgress("Installing ${tool.name}", -1)
if (SessionState.running) return context.getString(R.string.pextra_session_started)
val installing = context.getString(R.string.pextra_installing, tool.name)
onProgress(installing, -1)
return try {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
@@ -146,17 +149,17 @@ object ProtonExtras {
lines.forEach { line ->
Log.i(TAG, line)
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
onProgress("Installing ${tool.name}", -1)
onProgress(installing, -1)
}
}
}
val status = child.waitFor()
if (status != 0) "${tool.name} installation failed (exit $status)"
if (status != 0) context.getString(R.string.pextra_install_failed, tool.name, status)
else {
archive.delete()
unqueue(context, tool)
if (EsyncPacks.enabled(context)) {
onProgress("Fetching droiddeck-esync pack", -1)
onProgress(context.getString(R.string.esync_fetching), -1)
try {
EsyncPacks.fetchWanted(context, root, onProgress)
} catch (t: Throwable) {
@@ -167,7 +170,7 @@ object ProtonExtras {
}
} catch (e: Exception) {
Log.e(TAG, "install ${tool.id}", e)
e.message ?: "Could not install ${tool.name}"
e.message ?: context.getString(R.string.pextra_could_not_install, tool.name)
}
}
@@ -23,8 +23,10 @@ object SecondaryLibrary {
return File(files, "steam-libraries/$digest")
}
val CARD_GUESTS = listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd")
/** Parent binds come first; the more specific private directories override them in PRoot. */
fun binds(files: File, library: File): List<String> {
fun binds(files: File, library: File, guests: List<String> = CARD_GUESTS): List<String> {
val private = privateRoot(files, library)
val apps = File(library, "steamapps")
val tools = LinkedHashMap(bootstrapTools)
@@ -57,7 +59,7 @@ object SecondaryLibrary {
if (lower != relative) overrides.add(target to lower)
}
}
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
return guests.flatMap { guest ->
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
}
}
@@ -16,6 +16,8 @@ object SessionFiles {
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
/** Where the DirectAudio driver lives inside the runtime; the wrappers get it as BL_DIRECTAUDIO. */
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
/**
* The libraries and scripts the session runs, refreshed from the apk at every launch.
@@ -41,10 +43,13 @@ object SessionFiles {
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
"libssbs.so" to "usr/local/lib/libssbs.so",
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
@@ -55,6 +60,10 @@ object SessionFiles {
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
// Asks the app for an Epic game's sign-in code at launch, from the compat tool (StoreLaunchRequests).
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
// A game's web links to Android's browser (Wine's winebrowser starts it).
"usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url",
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
@@ -149,30 +158,37 @@ object SessionFiles {
ComponentsManager.migrateLaunchDir(context)
EsyncPacks.stageBundled(context, root)
// The DirectAudio driver for games under Proton: the glibc build of winedirectaudio, which
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO).
// Staged like the scripts, so a driver fix reaches an installed runtime without re-hosting.
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO). Two
// sets, one per Wine audio interface - the wrapper picks by what the Proton's own winepulse.so
// imports (see steam-compatibility), because both Wines call themselves 11.0 and the wrong set
// plays silence. Staged like the scripts, so a driver fix reaches an installed runtime without
// re-hosting. The sets come from the pinned DirectAudio release (tools/directaudio/release.env).
val directAudio = arrayOf(
"aarch64-unix/winedirectaudio.so",
"aarch64-windows/winedirectaudio.drv",
"i386-windows/winedirectaudio.drv",
"version.txt",
)
for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/lib/wine/$relative")
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("directaudio/linux-wine11/$relative").use { input ->
context.assets.open("directaudio/$set/$relative").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not stage DirectAudio $relative", e)
Log.w(TAG, "could not stage DirectAudio $set/$relative", e)
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "DirectAudio $relative NOT staged")
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
}
// A runtime from before the sets had names kept the driver at the directory's root; it would
// never be picked again, so it goes.
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
// What every process in the session preloads. LD_PRELOAD in the environment would not
// survive: the Steam client rebuilds it for each process it starts and appends its own
// overlay entry without a separator, which silently drops whatever was there.
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
@@ -72,7 +73,9 @@ object SessionLogShare {
// every share kept the share sheet from appearing for ten seconds or more.
val scrubbed = SessionArtifacts.scrubbedFiles(folder)
val live = liveSteamLogs(context, folder)
val total = (files + live).sumOf { it.length() }.coerceAtLeast(1L)
// The stores' own log (last 7 days, app-private, already redacted), under stores/.
val storeLogs = com.droiddeck.launcher.stores.StoreLogFiles.files(context)
val total = (files + live + storeLogs).sumOf { it.length() }.coerceAtLeast(1L)
var done = 0L
var shown = -1
fun advance(f: File) {
@@ -83,6 +86,7 @@ object SessionLogShare {
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
storeLogs.forEach { f -> addEntry(z, folder.name + "/stores/" + f.name, f, false); advance(f) }
}
return zip
}
@@ -123,6 +127,6 @@ object SessionLogShare {
.putExtra(Intent.EXTRA_SUBJECT, zip.nameWithoutExtension)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
send.clipData = ClipData.newRawUri(zip.name, uri)
return Intent.createChooser(send, "Share session logs").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return Intent.createChooser(send, context.getString(R.string.logshare_chooser)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.session
import android.content.Context
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.gpu.ScreenEffects
import org.json.JSONObject
@@ -11,6 +13,8 @@ object SessionPrefs {
const val SUSPEND_NATIVE = "native"
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
@@ -19,10 +23,12 @@ object SessionPrefs {
const val OSC_STEAM_QAM = "steam-qam"
const val OSC_NEVER = "never"
const val BACK_MENU_THEN_QAM = "1: menu 2: QAM"
const val BACK_QAM_THEN_MENU = "1: QAM 2: menu"
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
val BACK_QAM_THEN_MENU = R.string.back_qam_then_menu
fun backActionsOrder(inverted: Boolean): String =
@StringRes
fun backActionsOrder(inverted: Boolean): Int =
if (inverted) BACK_QAM_THEN_MENU else BACK_MENU_THEN_QAM
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
@@ -47,6 +53,52 @@ object SessionPrefs {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
fun setGameStoresEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
}
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
fun setGameStoresSpeedTier(context: Context, tier: String) {
prefs(context).edit().putString("gameStoresSpeedTier", tier).apply()
}
/** Which tab a signed-in store opens on when its chip is picked: [STORES_OPEN_LIBRARY] or [STORES_OPEN_STORE]. */
fun storesOpenTab(context: Context): String =
prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY
fun setStoresOpenTab(context: Context, tab: String) {
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
}
/** Mature titles (the stores' own 17+/18 ratings or adult tags) in the storefront's shelves and search; off by default. Owned games always show. */
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
fun setStoresShowMature(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("storesShowMature", show).apply()
}
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
/** The install root picked last time the Stores asked where (a StoreInstallRoot target's path); only the dialog's default, never a silent choice. */
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
fun setStoresInstallTarget(context: Context, path: String) {
prefs(context).edit().putString("storesInstallTarget", path).apply()
}
/** How many store downloads run at once, 1..3; one by default (a single download already fills the link). */
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
fun setGameStoresParallel(context: Context, count: Int) {
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
}
/**
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
@@ -154,16 +206,9 @@ object SessionPrefs {
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
}
/**
* The Steam client's own sound through the DirectAudio relay instead of the classic AAudio
* sink. Off by default: on an AYN Thor (Android 13, 20 ms bursts) the relay path stayed choppy
* where the classic sink - the one 0.1.5 shipped - was fine.
*/
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", false)
fun setClientDirectAudio(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
}
// The Steam client's own sound has one route now: DirectAudio's engine inside the PulseAudio
// daemon (PulseAudioComponent), one step from Android. The old "clientDirectAudio" boolean
// (which meant the relay route, one hop more) is no longer read.
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
@@ -588,8 +633,25 @@ object SessionPrefs {
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
private val addedGameLock = Any()
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
}
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
synchronized(addedGameLock) {
val unchanged = addedGameExe(context, folderPath) == expected
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
unchanged
}
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
val p = prefs(context)
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
}
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
@@ -658,6 +720,10 @@ object SessionPrefs {
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
/** [fpsLimitChoices] labelled in the app's language. */
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
/**
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
@@ -669,6 +735,16 @@ object SessionPrefs {
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
)
/** The [upscalerChoices] labels that are words rather than product names. */
private val upscalerLabels = mapOf(
0 to R.string.sprefs_upscaler_linear, 2 to R.string.sprefs_upscaler_nearest,
8 to R.string.sprefs_upscaler_gsr_quality, 6 to R.string.sprefs_upscaler_sharpen,
)
/** [upscalerChoices] labelled in the app's language; the English list stays for the device report. */
fun upscalerChoices(context: Context): List<Pair<Int, String>> =
upscalerChoices.map { (mode, label) -> mode to (upscalerLabels[mode]?.let(context::getString) ?: label) }
fun canonicalUpscaler(mode: Int): Int = when (mode) {
1 -> 0
5 -> 4
@@ -703,6 +779,10 @@ object SessionPrefs {
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
/** [textureAnisotropyChoices] labelled in the app's language. */
fun textureAnisotropyChoices(context: Context): List<Pair<Int, String>> =
textureAnisotropyChoices.map { (value, label) -> value to if (value == 0) context.getString(R.string.frame_gen_off) else label }
val textureLodBiasChoices = TextureFiltering.LOD_BIAS.map {
it to when (it) {
TextureFiltering.LOD_BIAS_OFF -> "Off"
@@ -711,6 +791,15 @@ object SessionPrefs {
}
}
/** [textureLodBiasChoices] labelled in the app's language. */
fun textureLodBiasChoices(context: Context): List<Pair<String, String>> = textureLodBiasChoices.map { (value, label) ->
value to when (value) {
TextureFiltering.LOD_BIAS_OFF -> context.getString(R.string.frame_gen_off)
TextureFiltering.LOD_BIAS_AUTO -> context.getString(R.string.sprefs_lod_bias_auto)
else -> label
}
}
/** Anisotropic filtering forced on DirectX 9-11 games (core/TextureFiltering); 0 = the game's own. */
fun textureAnisotropy(context: Context): Int =
prefs(context).getInt("textureAnisotropy", 0).takeIf { it in TextureFiltering.ANISOTROPY } ?: 0
@@ -749,16 +838,41 @@ object SessionPrefs {
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
// Direct games share Steam's settings but have no Steam client to prepare.
?.let { if (it == SUSPEND_NATIVE && mode != SessionService.MODE_STEAM) SUSPEND_AUTO else it }
?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it }
?: SUSPEND_MANUAL
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
val normalized = policy.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_MANUAL, SUSPEND_NEVER) ||
(it == SUSPEND_NATIVE && mode == SessionService.MODE_STEAM) }
?: SUSPEND_MANUAL
prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply()
val prefs = prefs(context)
val key = "suspendPolicy.${prefMode(mode)}"
prefs.edit().apply {
if (mode == SessionService.MODE_STEAM && prefs.getString(key, null) == LEGACY_SUSPEND_DOWNLOADS) {
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, true)
}
putString(key, normalized)
}.apply()
}
fun steamDownloadsInBackground(context: Context): Boolean {
val prefs = prefs(context)
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
prefs.getString("suspendPolicy.${SessionService.MODE_STEAM}", null) == LEGACY_SUSPEND_DOWNLOADS
}
fun setSteamDownloadsInBackground(context: Context, enabled: Boolean) {
val prefs = prefs(context)
val policyKey = "suspendPolicy.${SessionService.MODE_STEAM}"
prefs.edit().apply {
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, enabled)
if (prefs.getString(policyKey, null) == LEGACY_SUSPEND_DOWNLOADS) {
putString(policyKey, SUSPEND_AUTO)
}
}.apply()
}
// ── Game storage ────────────────────────────────────────────────────────────────────────
@@ -13,6 +13,7 @@ import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.TrafficStats
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Environment
@@ -60,6 +61,9 @@ import java.util.UUID
* The activity comes and goes on top of this; see [com.droiddeck.launcher.wayland.CompositorHost].
*/
class SessionService : Service() {
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
private var deckBinds: List<String> = emptyList()
@@ -72,7 +76,12 @@ class SessionService : Service() {
private val auxiliaryProcessesLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
private var suspendController: SessionSuspendController? = null
private val steamDownloadMonitor = SteamDownloadMonitor()
private var downloadMonitorTask: Runnable? = null
private var downloadActive = false
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
private var activityVisible = true
private var screenOn = true
private var manualPauseRequested = false
@@ -164,6 +173,7 @@ class SessionService : Service() {
ACTION_ACTIVITY_VISIBLE -> {
if (!SessionState.pipActive) pipTask = false
activityVisible = true
suspendDownloadNow = false
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
@@ -178,10 +188,18 @@ class SessionService : Service() {
ACTION_SUSPEND_POLICY_CHANGED -> {
if (!SessionState.running) return START_NOT_STICKY
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
updateDownloadMonitoring()
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_SUSPEND_NOW -> {
if (!SessionState.running) return START_NOT_STICKY
suspendDownloadNow = true
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_RESUME -> {
resumeSession()
return START_NOT_STICKY
@@ -196,9 +214,13 @@ class SessionService : Service() {
}
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
suspendDownloadNow = false
steamDownloadMonitor.reset()
downloadActive = false
steamSleepToken = null
completeNativeSleep()
suspendOperationPending = false
@@ -263,11 +285,15 @@ class SessionService : Service() {
}
private fun extraEnv(): List<String> {
val file = File(Environment.getExternalStorageDirectory(), ENV_SWITCH).takeIf { it.isFile } ?: return emptyList()
if (File(Environment.getExternalStorageDirectory(), LEGACY_ENV_SWITCH).isFile) {
Log.w(TAG, "ignoring $LEGACY_ENV_SWITCH: any app with storage access can write there; " +
"the file now lives at ${envSwitchFile(this)?.path ?: "(not available before Android 11)"}")
}
val file = envSwitchFile(this)?.takeIf { it.isFile } ?: return emptyList()
val lines = FileUtils.readString(file)?.lines().orEmpty()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') && !it.startsWith("=") }
if (lines.isNotEmpty()) Log.i(TAG, "extra environment from $ENV_SWITCH: $lines")
if (lines.isNotEmpty()) Log.i(TAG, "extra environment keys from ${file.path}: ${lines.map { it.substringBefore('=') }}")
return lines
}
@@ -298,6 +324,9 @@ class SessionService : Service() {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
// The compat tool asks for an Epic game's sign-in code at every launch, the client's Play button included.
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
@@ -397,14 +426,14 @@ class SessionService : Service() {
}
guest.add(program)
guest.addAll(SessionState.programArgs)
Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope")
Log.i(TAG, "run: $program (${SessionState.programArgs.size} arguments) under gamescope")
}
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest)
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest, sessionRoot, runtimeDir)
// The fast path is told exactly the rootfs and binds proot is given (ProotFastPath).
val fastPathKey = if (ProotFastPath.enabled(this)) {
val prootBinds = LinuxRuntime.binds(
@@ -522,6 +551,7 @@ class SessionService : Service() {
},
)
mainHandler.post { updateSuspendPolicy() }
updateDownloadMonitoring()
}
}
@@ -554,6 +584,12 @@ class SessionService : Service() {
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
// Steam's interface language: the app's (Setup's choice, or the system's). The system's
// region decides between Spain's and Latin American Spanish, which the app has one of.
guest.add("BL_STEAM_LANGUAGE=" + SteamLanguage.forLocale(
com.droiddeck.launcher.core.AppLanguage.effective(this),
com.droiddeck.launcher.core.AppLanguage.system(this).country,
))
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
// session bundle sit hours off the device's. Bannerlator carries the same line.
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
@@ -619,11 +655,15 @@ class SessionService : Service() {
// gamescope's realtime Vulkan queues (the session script turns this into
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
// the client's - whatever the experiment needs, without a build per attempt.
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
// Anything else, for a device that cannot be reached with a debugger: droiddeck-env in the
// app's own external files (envSwitchFile) holds KEY=VALUE lines that go into the session's
// environment as written, after ours, so a line here wins. Zink and Turnip tunables
// (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, the client's - whatever the experiment
// needs, without a build per attempt.
extraEnv().forEach { guest.add(it) }
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
@@ -656,16 +696,13 @@ class SessionService : Service() {
val audioLog = File(sessionDir, "audio.log")
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
// fills the relay's ring and the relay, outside proot, drives the device.
// The client's own sound: the classic AAudio sink unless the user chose the relay.
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
// The client's own sound: DirectAudio's engine inside the daemon, one step from Android (the
// daemon runs on the Android side). The relay below is for games and the microphone only.
pulse.setLogFile(audioLog)
pulse.attach(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
if (wantsDirectAudio || wantsMic) {
// After the daemon in the list, so it can wait for the pipe the daemon makes.
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
relay.setLogFile(audioLog)
@@ -679,7 +716,7 @@ class SessionService : Service() {
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
}
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
Log.i(TAG, "audio: client DirectAudio (in-process sink)" + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
return pulse
@@ -747,6 +784,8 @@ class SessionService : Service() {
fakeInputDir: File,
sessionDir: File,
guest: MutableList<String>,
sessionRoot: File,
runtimeDir: File,
): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
@@ -808,7 +847,7 @@ class SessionService : Service() {
val library = GameStorage.effective(this)
var storageDiagnosticLibrary: File? = null
if (library != null) {
val problem = GameStorage.prepare(library.path)
val problem = GameStorage.prepare(this, library.path)
if (problem == null) {
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
// Links, prefixes and Steam entries made before the rename still name the old path.
@@ -857,6 +896,15 @@ class SessionService : Service() {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
}
}
// Store games installed on a card: each card's Games root at its own fixed place, so the
// shortcuts point somewhere whether or not the card is also the Steam library.
for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRoots(this)) {
if (host.isDirectory && host.canRead()) {
File(LinuxRuntime.rootDir(this), guest.removePrefix("/")).mkdirs()
binds.add(host.path + ":" + guest)
Log.i(TAG, "store games: $host -> $guest")
}
}
// Folders of added scripts outside internal storage, where their links point.
binds.addAll(com.droiddeck.launcher.runtime.UserApps.binds(this))
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
@@ -867,6 +915,45 @@ class SessionService : Service() {
} else if (roms != null) {
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
}
val libraries = ArrayList<Pair<String, String>>()
if (storageDiagnosticLibrary != null && library != null) libraries.add(SecondaryLibrary.CARD_GUESTS[0] to library.label)
val gamesLibraries = GameStorage.gamesFolderLibraries(this)
if (gamesLibraries.isNotEmpty()) {
var room = if (ProotFastPath.enabled(this)) {
ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size
} else Int.MAX_VALUE
for (games in gamesLibraries) {
if (games.host.path + ":" + games.guest !in binds) continue
val problem = GameStorage.prepare(this, games.host.path)
if (problem != null) {
Log.w(TAG, "steam library: $problem")
continue
}
try {
val extra = SecondaryLibrary.binds(filesDir, games.host, listOf(games.guest)).drop(1)
if (extra.size > room) {
Log.w(TAG, "steam library: ${games.host} needs ${extra.size} binds, $room left for the fast path; not a library this session")
continue
}
binds.addAll(extra)
room -= extra.size
libraries.add(games.guest to games.label)
Log.i(TAG, "steam library: ${games.host} -> ${games.guest} (\"${games.label}\")")
} catch (e: Exception) {
Log.w(TAG, "steam library: ${games.host} private directories could not be prepared", e)
}
}
}
try {
val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() }
val json = org.json.JSONArray()
for ((path, label) in libraries) json.put(org.json.JSONObject().put("path", path).put("label", label.replace('"', ' ').replace('\\', ' ')))
listing.writeText(json.toString())
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
guest.add(envAt, "BL_STEAM_LIBRARIES=" + listing.path)
} catch (e: Exception) {
Log.w(TAG, "steam library: the list could not be written; the card alone this session", e)
}
return binds
}
@@ -1007,6 +1094,7 @@ class SessionService : Service() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
@@ -1178,16 +1266,19 @@ class SessionService : Service() {
}
val controller = suspendController ?: return
val hidden = !activityVisible || !screenOn
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden) {
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
else -> false
}
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
@@ -1324,6 +1415,13 @@ class SessionService : Service() {
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
com.droiddeck.launcher.agent.AgentGuest.stop()
com.droiddeck.launcher.stores.StoreLaunchRequests.stop()
// Cloud saves of a store game played this session, if the compat tool's exit request did
// not upload them (a session closed from the drawer never reaches it).
Thread({ com.droiddeck.launcher.stores.CloudSaves.uploadAllDirty(applicationContext, "session-end", running = false) }, "cloud-session-end").start()
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
releaseLocks()
if (status == 0) {
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
@@ -1371,6 +1469,7 @@ class SessionService : Service() {
if (!SessionState.running) return
SessionState.running = false
}
updateDownloadMonitoring()
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
@@ -1539,11 +1638,26 @@ class SessionService : Service() {
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification))
.setContentText(getString(
when {
SessionState.suspended -> R.string.session_notification_paused
downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
else -> R.string.session_notification
}
))
.setContentIntent(open)
.apply {
if (SessionState.suspended) {
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
} else if (downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
val suspend = PendingIntent.getService(
this@SessionService, 3,
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
PendingIntent.FLAG_IMMUTABLE,
)
addAction(Notification.Action.Builder(null, getString(R.string.suspend_now), suspend).build())
}
}
.addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build())
@@ -1557,6 +1671,40 @@ class SessionService : Service() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
}
private fun updateDownloadMonitoring() {
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
if (!enabled) {
val wasActive = downloadActive
downloadMonitorTask?.let(mainHandler::removeCallbacks)
downloadMonitorTask = null
steamDownloadMonitor.reset()
downloadActive = false
if (wasActive) refreshNotification()
return
}
if (downloadMonitorTask != null) return
val poll = object : Runnable {
override fun run() {
if (downloadMonitorTask !== this || !SessionState.running ||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
return
}
val wasActive = downloadActive
downloadActive = steamDownloadMonitor.poll(
LinuxRuntime.rootDir(this@SessionService),
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
)
if (wasActive != downloadActive) refreshNotification()
updateSuspendPolicy()
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
}
}
downloadMonitorTask = poll
mainHandler.post(poll)
}
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
private fun logControllersAtStart() {
val pads = ArrayList<String>()
@@ -1575,12 +1723,30 @@ class SessionService : Service() {
private const val TRACER_NICE = -6
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
private const val ENV_SWITCH = "Download/droiddeck-env"
/**
* Where droiddeck-env (KEY=VALUE lines added to the session environment verbatim) used to
* be. Ignored now: a line there reaches the Steam client's environment as written
* (LD_PRELOAD, VK_ICD_FILENAMES, PATH...), and any app with storage access can write to
* Download - code of its choosing inside our sandbox, next to the client's saved login.
*/
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
private const val ENV_SWITCH_NAME = "droiddeck-env"
/**
* The session's extra-environment file: Android/data/<package>/files/droiddeck-env. Only
* this app, adb and the user (through the app's own file manager) can write there - from
* Android 11 on. Before that other apps with storage access can reach Android/data too, so
* there is no such file at all.
*/
fun envSwitchFile(context: Context): File? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
context.getExternalFilesDir(null)?.let { File(it, ENV_SWITCH_NAME) }
else null
private const val CHANNEL_ID = "session"
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
const val ACTION_SUSPEND_NOW = "com.droiddeck.launcher.SUSPEND_NOW"
private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN"
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
@@ -1600,6 +1766,7 @@ class SessionService : Service() {
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val GAME_LAUNCH_RETRY_MS = 250L
private const val DOWNLOAD_POLL_MS = 2_000L
private const val STEAM_GAME_REQUEST = "steam-game"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
@@ -80,6 +80,9 @@ object SessionState {
/** Told (on the thread that changed it) whenever [deckPad] changes. */
@Volatile var deckPadListener: (() -> Unit)? = null
/** The session screen's virtual pad, for the agent bridge's pad input (AgentInput). */
@Volatile var padBridge: com.droiddeck.launcher.input.PadBridge? = null
/** What the second screen shows, and on which display: kept for the session, so a screen
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@@ -0,0 +1,138 @@
package com.droiddeck.launcher.session
import java.io.File
import java.io.RandomAccessFile
internal class SteamDownloadMonitor {
private data class ManifestState(
val bytesDownloaded: Long,
val incomplete: Boolean,
)
private var previous = emptyMap<String, ManifestState>()
private var lastProgressAt = 0L
private var contentLogOffset = 0L
private val activeContentUpdates = mutableSetOf<String>()
private var lastContentActivityAt = 0L
private var lastReceivedBytes = -1L
private var lastReceivedAt = 0L
/**
* [receivedBytes] is the app uid's received-byte counter (the guest runs as that uid), or a
* negative value when Android does not report one. Steam writes its rate line about once a
* minute and flushes manifest counters even less often, so live traffic is what bridges them.
*/
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L): Boolean {
val current = manifests(runtimeRoot).associate { it.path to read(it) }
val progressed = current.any { (path, state) ->
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
}
if (progressed) lastProgressAt = now
previous = current
val manifestActive = progressed || lastProgressAt > 0L && now - lastProgressAt <= MANIFEST_PROGRESS_GRACE_MS &&
current.values.any { it.incomplete }
if (receiving(receivedBytes, now)) lastContentActivityAt = now
return manifestActive || readContentActivity(runtimeRoot, now)
}
private fun receiving(receivedBytes: Long, now: Long): Boolean {
val previousBytes = lastReceivedBytes
val previousAt = lastReceivedAt
lastReceivedBytes = receivedBytes
lastReceivedAt = now
if (receivedBytes < 0L || previousBytes < 0L || receivedBytes < previousBytes || now <= previousAt) return false
return (receivedBytes - previousBytes) * 1000L / (now - previousAt) >= MIN_RECEIVE_BYTES_PER_SEC
}
fun reset() {
previous = emptyMap()
lastProgressAt = 0L
contentLogOffset = 0L
activeContentUpdates.clear()
lastContentActivityAt = 0L
lastReceivedBytes = -1L
lastReceivedAt = 0L
}
private fun readContentActivity(runtimeRoot: File, now: Long): Boolean {
val log = File(runtimeRoot, "root/.local/share/Steam/logs/content_log.txt")
if (!log.isFile) return false
if (log.length() < contentLogOffset) {
contentLogOffset = 0L
activeContentUpdates.clear()
lastContentActivityAt = 0L
}
runCatching {
RandomAccessFile(log, "r").use { input ->
input.seek(contentLogOffset)
while (true) {
val line = input.readLine() ?: break
when {
"Client version:" in line -> {
activeContentUpdates.clear()
lastContentActivityAt = 0L
}
CONTENT_UPDATE.find(line)?.let { match ->
val appId = match.groupValues[1]
val updateType = match.groupValues[2]
val state = match.groupValues[3]
val updateId = "$updateType:$appId"
if ("Running Update" in state && "Stopping" !in state) {
activeContentUpdates += updateId
lastContentActivityAt = now
} else {
activeContentUpdates -= updateId
}
true
} == true -> Unit
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
lastContentActivityAt = now
"Downloading " in line && " chunks for depot " in line ->
lastContentActivityAt = now
}
if (activeContentUpdates.isNotEmpty()) lastContentActivityAt = now
}
contentLogOffset = input.filePointer
}
}
return activeContentUpdates.isNotEmpty() && lastContentActivityAt > 0L &&
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
}
private fun manifests(runtimeRoot: File): List<File> {
val steamRoots = linkedSetOf(File(runtimeRoot, "root/.local/share/Steam"))
val primary = steamRoots.first()
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
runCatching {
PATH.findAll(libraryFolders.readText()).forEach { match ->
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
}
}
return steamRoots.flatMap { root ->
File(root, "steamapps").listFiles { file ->
file.isFile && file.name.startsWith("appmanifest_") && file.name.endsWith(".acf")
}.orEmpty().toList()
}
}
private fun read(file: File): ManifestState {
val text = runCatching { file.readText() }.getOrDefault("")
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
val downloaded = value(text, "BytesDownloaded")?.toLongOrNull() ?: 0L
val total = value(text, "BytesToDownload")?.toLongOrNull() ?: 0L
return ManifestState(downloaded, flags != 4L && (total <= 0L || downloaded < total))
}
private fun value(text: String, key: String): String? =
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
private companion object {
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
// Longer than Steam's ~60 s between rate lines, for when no traffic counter is available.
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
}
}
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.session
import java.util.Locale
/**
* Steam's name for a language: the API language codes the client and the Steamworks API use
* ("english", "schinese", "koreana"...), for every language Steam's interface comes in.
*/
object SteamLanguage {
const val DEFAULT = "english"
/** Every language the Steam client's interface is offered in. */
val all: Set<String> = setOf(
"arabic", "bulgarian", "schinese", "tchinese", "czech", "danish", "dutch", "english",
"finnish", "french", "german", "greek", "hungarian", "indonesian", "italian", "japanese",
"koreana", "norwegian", "polish", "portuguese", "brazilian", "romanian", "russian",
"spanish", "latam", "swedish", "thai", "turkish", "ukrainian", "vietnamese", "malay",
)
/** Spanish-speaking regions Steam serves with Latin American Spanish rather than Spain's. */
private val latinAmerica = setOf(
"419", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "MX", "NI", "PA", "PE",
"PR", "PY", "SV", "US", "UY", "VE",
)
/**
* Steam's language for [locale]. [region] stands in for the locale's own when it has none: the
* app's Spanish carries no region, and a player in Mexico should get Latin American Spanish.
*/
fun forLocale(locale: Locale, region: String? = null): String {
val country = locale.country.ifEmpty { region.orEmpty() }
return when (locale.language) {
"ar" -> "arabic"
"bg" -> "bulgarian"
"cs" -> "czech"
"da" -> "danish"
"nl" -> "dutch"
"en" -> "english"
"fi" -> "finnish"
"fr" -> "french"
"de" -> "german"
"el" -> "greek"
"hu" -> "hungarian"
"id", "in" -> "indonesian"
"it" -> "italian"
"ms" -> "malay"
"ja" -> "japanese"
"ko" -> "koreana"
"nb", "no", "nn" -> "norwegian"
"pl" -> "polish"
"pt" -> if (country == "BR") "brazilian" else "portuguese"
"ro" -> "romanian"
"ru" -> "russian"
"es" -> if (country in latinAmerica) "latam" else "spanish"
"sv" -> "swedish"
"th" -> "thai"
"tr" -> "turkish"
"uk" -> "ukrainian"
"vi" -> "vietnamese"
"zh" -> when {
locale.script == "Hant" -> "tchinese"
locale.script == "Hans" -> "schinese"
country == "TW" || country == "HK" || country == "MO" -> "tchinese"
else -> "schinese"
}
else -> DEFAULT
}
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.store
import android.util.Log
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
@@ -32,13 +34,13 @@ object FlathubApi {
)
/** A category as Flathub's search filters it, with the name shown for it. */
class Category(val id: String, val label: String)
class Category(val id: String, @StringRes val label: Int)
val categories = listOf(
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
Category("Utility", "Utilities"),
Category("Game", R.string.store_cat_games), Category("AudioVideo", R.string.store_cat_audio_video), Category("Graphics", R.string.store_cat_graphics),
Category("Network", R.string.store_cat_internet), Category("Office", R.string.store_cat_office), Category("Development", R.string.store_cat_developer),
Category("Education", R.string.store_cat_education), Category("Science", R.string.store_cat_science), Category("System", R.string.store_cat_system),
Category("Utility", R.string.store_cat_utilities),
)
/** Front-page collections: popular, trending, recently-updated, recently-added. */
@@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.FlatpakManager
/**
@@ -49,7 +50,7 @@ object StoreState {
private set
private var searchSeq = 0
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
fun refresh(context: Context) {
val app = context.applicationContext
@@ -97,7 +98,7 @@ object StoreState {
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
if (busy != null) return
val app = context.applicationContext
busy = what; stage = "Starting…"; percent = -1
busy = what; stage = app.getString(R.string.store_starting); percent = -1
Thread({
val problem = try {
work(app) { s, p -> main.post { stage = s; percent = p } }
@@ -109,12 +110,12 @@ object StoreState {
main.post {
busy = null; stage = null; percent = -1
ready = r; installed = list
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
if (problem != null) Toast.makeText(app, app.getString(R.string.store_problem, label, problem), Toast.LENGTH_LONG).show()
}
}, "store-$what").start()
}
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
fun setup(context: Context) = run(context, "setup", context.getString(R.string.store_flatpak_setup)) { c, p -> FlatpakManager.setup(c, p) }
fun install(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
@@ -135,7 +136,7 @@ object StoreState {
val u = FlatpakManager.updates(app)
main.post {
checkingUpdates = false
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, R.string.store_updates_check_failed, Toast.LENGTH_SHORT).show()
}
}, "store-updates").start()
}
@@ -0,0 +1,87 @@
package com.droiddeck.launcher.stores
import org.json.JSONObject
/**
* What a cloud sync may do, decided from three views of each save file: the local copy, the cloud
* copy, and the baseline - both as they were when this device last synced the game. Pure, so the
* rules are tested on their own:
*
* - No upload before a download has completed once on this device (no baseline): a first launch
* writes new profile files that must never replace the real saves in the cloud.
* - The first download (no baseline) lets the cloud win where the two differ - the local folder is
* backed up first.
* - A file changed on both sides since the baseline is a conflict: neither side is overwritten; the
* user picks Keep cloud or Keep local in Manage saves.
* - Otherwise the side that changed since the baseline wins; one that changed on neither is left.
*/
object CloudPlan {
/** One file as this device last saw it in sync: the local MD5, and the cloud's MD5 (when known) and time. */
data class Entry(val localMd5: String, val cloudMd5: String?, val cloudModified: Long)
class Local(val md5: String)
class Remote(val md5: String?, val modified: Long)
class Plan(val transfer: List<String>, val conflicts: List<String>, val same: List<String>, val refused: String? = null)
/** The cloud copy differs from what the baseline recorded of it. */
private fun cloudChanged(r: Remote, b: Entry?): Boolean {
if (b == null) return true
if (r.md5 != null && b.cloudMd5 != null) return !r.md5.equals(b.cloudMd5, ignoreCase = true)
// Times only (Epic's listing): the service's own clock, so a couple of seconds of slack.
return r.modified > 0 && b.cloudModified > 0 && kotlin.math.abs(r.modified - b.cloudModified) > 2000
}
private fun localChanged(l: Local, b: Entry?): Boolean = b == null || !l.md5.equals(b.localMd5, ignoreCase = true)
private fun same(l: Local, r: Remote): Boolean = r.md5 != null && r.md5.equals(l.md5, ignoreCase = true)
/** Cloud → device. [baseline] null = this device never synced the game. */
fun down(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
for ((name, r) in remote) {
val l = local[name]
val b = baseline?.get(name)
when {
l == null -> transfer += name
same(l, r) -> same += name
baseline == null -> transfer += name
cloudChanged(r, b) && localChanged(l, b) -> conflicts += name
cloudChanged(r, b) -> transfer += name
}
}
return Plan(transfer, conflicts, same)
}
/** Device → cloud. Refused outright without a baseline. */
fun up(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
if (baseline == null) return Plan(emptyList(), emptyList(), emptyList(), refused = "no-baseline")
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
for ((name, l) in local) {
val r = remote[name]
val b = baseline[name]
when {
r == null -> transfer += name
same(l, r) -> same += name
localChanged(l, b) && cloudChanged(r, b) -> conflicts += name
localChanged(l, b) -> transfer += name
}
}
return Plan(transfer, conflicts, same)
}
fun toJson(baseline: Map<String, Entry>): String = JSONObject().apply {
for ((k, e) in baseline) put(k, JSONObject().put("l", e.localMd5).put("c", e.cloudMd5 ?: "").put("t", e.cloudModified))
}.toString()
fun fromJson(text: String?): Map<String, Entry>? {
if (text == null) return null
return runCatching {
val o = JSONObject(text)
o.keys().asSequence().associateWith { k ->
val e = o.getJSONObject(k)
Entry(e.optString("l"), e.optString("c").ifEmpty { null }, e.optLong("t", -1L))
}
}.getOrNull()
}
}
@@ -0,0 +1,84 @@
/*
* Cloud-save folder resolution for GOG and Epic games, after Bannerlator's GogCloudSavePaths and
* EpicCloudSavePaths (device-proven there, August 2026): a store's save-location template expanded
* against the game's Proton prefix and its install folder.
*/
package com.droiddeck.launcher.stores
import java.io.File
/**
* Where a store keeps a game's saves on Windows, made concrete inside this game's Proton prefix.
*
* GOG gives a template per game in its public remote-config (`cloudStorage.locations[].location`):
* a leading `<?TOKEN?>` (or `%TOKEN%`) and a path, e.g. ELDERBORN's
* `<?APPLICATION_DATA_LOCAL_LOW?>/Hyperstrange/ELDERBORN`. Epic gives `CloudSaveFolder` in the
* game's catalog attributes: a leading `{Token}`, with `{EpicId}` / `{AppName}` inside, e.g.
* `{AppData}/Game/Saved/SaveGames`. Profile tokens map under the prefix's Wine user
* (`drive_c/users/steamuser`), the install tokens to the game's folder. The path is walked against
* what is on disk case-insensitively; a result outside its boundary is refused, never guessed.
*/
object CloudSavePaths {
/** The Wine user every prefix has (Proton's). */
fun profile(prefix: File): File = File(prefix, "drive_c/users/steamuser")
/** Expands a GOG location template; null for an unknown token or an escape. */
fun gog(template: String, prefix: File, install: File): File? {
val norm = template.replace('\\', '/').trim().trimStart('/')
val match = Regex("^(<\\?[^?>]+\\?>|%[^%]+%)").find(norm) ?: return null
val token = match.groupValues[1].trim('<', '>', '?', '%').uppercase()
val profile = profile(prefix)
val (base, boundary) = when (token) {
"INSTALL" -> install to install
"SAVED_GAMES" -> File(profile, "Saved Games") to profile
"DOCUMENTS" -> File(profile, "Documents") to profile
"APPLICATION_DATA_LOCAL", "LOCALAPPDATA" -> File(profile, "AppData/Local") to profile
"APPLICATION_DATA_LOCAL_LOW" -> File(profile, "AppData/LocalLow") to profile
"APPLICATION_DATA_ROAMING", "APPDATA" -> File(profile, "AppData/Roaming") to profile
"USERPROFILE" -> profile to profile
"PROGRAMDATA" -> File(prefix, "drive_c/ProgramData").let { it to it }
else -> return null
}
return walk(base, boundary, norm.substring(match.value.length), fuzzy = false)
}
/** Expands an Epic CloudSaveFolder; null for an unknown token or an escape. */
fun epic(template: String, prefix: File, install: File, accountId: String, appName: String): File? {
val norm = template.replace('\\', '/').trim().trimStart('/')
val match = Regex("^\\{([^}]+)\\}").find(norm) ?: return null
val profile = profile(prefix)
val (base, boundary) = when (match.groupValues[1].lowercase()) {
"installdir" -> install to install
"userprofile" -> profile to profile
"userdir" -> File(profile, "Documents") to profile
"usersavedgames" -> File(profile, "Saved Games") to profile
"appdata", "localappdata" -> File(profile, "AppData/Local") to profile
"roamingappdata" -> File(profile, "AppData/Roaming") to profile
else -> return null
}
val rest = norm.substring(match.value.length)
.replace("{epicid}", accountId, ignoreCase = true)
.replace("{appname}", appName, ignoreCase = true)
return walk(base, boundary, rest, fuzzy = true)
}
/**
* Each segment matched against the folder's entries case-insensitively (and, for Epic, by letters
* and digits alone when that names exactly one), else taken as written - a download creates it.
*/
private fun walk(base: File, boundary: File, rest: String, fuzzy: Boolean): File? {
var dir = base
for (seg in rest.trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }) {
if (seg == "..") { dir = dir.parentFile ?: return null; continue }
val kids = dir.listFiles()
val existing = kids?.firstOrNull { it.name.equals(seg, ignoreCase = true) }
?: if (fuzzy) kids?.filter { squash(it.name) == squash(seg) && squash(seg).isNotEmpty() }?.singleOrNull() else null
dir = existing ?: File(dir, seg)
}
val bound = runCatching { boundary.canonicalPath }.getOrNull() ?: return null
val dest = runCatching { dir.canonicalPath }.getOrNull() ?: return null
return if (dest == bound || dest.startsWith(bound + File.separator)) dir else null
}
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
}
@@ -0,0 +1,387 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.stores.epic.EpicApiClient
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicPrefs
import com.droiddeck.launcher.stores.gog.GogAuth
import com.droiddeck.launcher.stores.gog.GogPrefs
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.json.JSONObject
/**
* GOG and Epic cloud saves for an installed store game: before a launch the cloud copy is brought
* down where it is newer, after the game exits what changed goes up - newest wins, file by file.
* Before a download overwrites anything, the folder is copied to the app's storage (the last
* [BACKUPS] per game). Both launch paths reach it: the compat tool's request from
* droiddeck-store-launch ([StoreLaunchRequests]) for every launch, and the Games tab's Manage saves.
*
* The save folder: GOG's remote-config location template (by the game's Galaxy client id) or
* Epic's CloudSaveFolder catalog attribute, cached per game, made concrete by [CloudSavePaths]
* inside the game's Proton prefix. A game whose store gives none has no cloud saves.
*/
object CloudSaves {
private const val TAG = "CloudSaves"
private const val BACKUPS = 3
private const val PARALLEL = 6
/** What a sync did, for the log and the Manage saves row. */
/**
* What a sync did, for the log and the Manage saves row. [result]: ok, skipped (nothing to do or
* not possible: [reason] says why), deferred (no prefix yet; it runs once Proton makes one),
* failed. [conflicts]: files changed on both sides, left alone.
*/
class Result(val result: String, val files: Int, val bytes: Long, val reason: String, val conflicts: Int = 0) {
val ok: Boolean get() = result != "failed"
val line: String get() = "result=$result files=$files bytes=$bytes reason=$reason" + if (conflicts > 0) " conflicts=$conflicts" else ""
}
/** A game's cloud state for the Manage saves row. */
class Status(val supported: Boolean, val folder: File?, val lastSync: Long, val reason: String, val conflicts: List<String> = emptyList())
private fun game(context: Context, store: Store, id: String): Pair<File, StoreGameSidecar>? =
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
StoreGameSidecar.read(f)?.takeIf { it.store == store && it.id == id && it.isInstalled }?.let { f to it }
}
/** The game's prefix: compatdata/<its shortcut appid>/pfx, wherever that library is. */
fun prefix(context: Context, folder: File): File? {
val appId = AddedGames.scan(context).firstOrNull { it.folder.absolutePath == folder.absolutePath }?.appId ?: return null
return Library.protonPrefix(context, appId and 0xFFFFFFFFL)
}
private fun prefs(context: Context, store: Store) = if (store == Store.GOG) GogPrefs.get(context) else EpicPrefs.get(context)
fun lastSync(context: Context, store: Store, id: String): Long = prefs(context, store).getLong("cloud_sync_$id", 0L)
private fun markSynced(context: Context, store: Store, id: String) = prefs(context, store).edit().putLong("cloud_sync_$id", System.currentTimeMillis()).apply()
// ---- the save folder ------------------------------------------------------------------------
/** The store's template for this game: cached, else fetched; "" when the store gives none, null when it could not be asked. */
fun template(context: Context, store: Store, id: String, folder: File, sidecar: StoreGameSidecar): String? {
val p = prefs(context, store)
if (p.contains("cloud_template_$id")) return p.getString("cloud_template_$id", "")
val fetched = when (store) {
Store.GOG -> gogTemplate(context, id, folder)
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
val ns = sidecar.extra["namespace"].orEmpty()
val item = sidecar.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
}
Store.AMAZON -> ""
} ?: return null
p.edit().putString("cloud_template_$id", fetched).apply()
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
return fetched
}
private fun gogClientId(context: Context, id: String, folder: File): String? =
GogPrefs.get(context).getString("client_id_$id", null)?.ifEmpty { null }
?: runCatching { JSONObject(File(folder, "goggame-$id.info").readText()).optString("clientId").ifEmpty { null } }.getOrNull()
/** GOG's public remote-config: `content.Windows.cloudStorage.locations`, the one named "saves" first. */
private fun gogTemplate(context: Context, id: String, folder: File): String? {
val clientId = gogClientId(context, id, folder) ?: return ""
return try {
val c = URL("https://remote-config.gog.com/components/galaxy_client/clients/$clientId?component_version=2.0.45").openConnection() as HttpURLConnection
c.connectTimeout = 15_000; c.readTimeout = 15_000
c.setRequestProperty("User-Agent", "GOG Galaxy")
if (c.responseCode !in 200..299) { c.disconnect(); return null }
val cloud = JSONObject(c.inputStream.bufferedReader().readText()).optJSONObject("content")?.optJSONObject("Windows")?.optJSONObject("cloudStorage")
c.disconnect()
if (cloud?.optBoolean("enabled", false) != true) return ""
val locations = cloud.optJSONArray("locations") ?: return ""
val all = (0 until locations.length()).mapNotNull { locations.optJSONObject(it) }
(all.firstOrNull { it.optString("name").equals("saves", true) } ?: all.firstOrNull())?.optString("location").orEmpty()
} catch (e: Exception) {
Log.w(TAG, "gog remote-config: ${e.javaClass.simpleName}")
null
}
}
/** The concrete save folder in the prefix, or null with why. */
private fun saveFolder(context: Context, store: Store, id: String): Pair<File?, String> {
val (folder, sidecar) = game(context, store, id) ?: return null to "not-installed"
val template = template(context, store, id, folder, sidecar) ?: return null to "store-unreachable"
if (template.isEmpty()) return null to "no-cloud-saves"
val pfx = prefix(context, folder) ?: return null to "no-prefix"
val dir = when (store) {
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
Store.AMAZON -> null
}
return dir to (if (dir == null) "unresolved" else "ok")
}
fun status(context: Context, store: Store, id: String): Status {
val (dir, reason) = saveFolder(context, store, id)
return Status(reason != "no-cloud-saves" && reason != "not-installed", dir, lastSync(context, store, id), reason, conflicts(context, store, id))
}
// ---- transport ------------------------------------------------------------------------------
private fun transport(context: Context, store: Store, id: String, folder: File): CloudTransport? = when (store) {
Store.GOG -> {
val account = StoreAccounts.read(context, Store.GOG)
val userId = account?.optString("user_id").orEmpty()
val galaxy = GogAuth.validToken(context)
val clientId = gogClientId(context, id, folder)
if (userId.isEmpty() || galaxy == null || clientId == null) null
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId, account?.optString("refresh_token").orEmpty()) ?: galaxy)
}
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context)
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
}
Store.AMAZON -> null
}
/** GOG cloud storage wants a token issued to the game's own client: the Galaxy refresh token, exchanged with the game's id and secret. */
private fun gogScopedToken(context: Context, id: String, clientId: String, refresh: String): String? {
val secret = GogPrefs.get(context).getString("client_secret_$id", null)?.ifEmpty { null } ?: return null
if (refresh.isEmpty()) return null
return try {
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
val c = URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(secret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}").openConnection() as HttpURLConnection
c.connectTimeout = 15_000; c.readTimeout = 15_000
c.setRequestProperty("User-Agent", "GOG Galaxy")
if (c.responseCode != 200) { c.disconnect(); return null }
JSONObject(c.inputStream.bufferedReader().readText()).optString("access_token").ifEmpty { null }.also { c.disconnect() }
} catch (e: Exception) { null }
}
// ---- sync -----------------------------------------------------------------------------------
private fun baseline(context: Context, store: Store, id: String): Map<String, CloudPlan.Entry>? =
CloudPlan.fromJson(prefs(context, store).getString("cloud_baseline_$id", null))
private fun saveBaseline(context: Context, store: Store, id: String, b: Map<String, CloudPlan.Entry>) =
prefs(context, store).edit().putString("cloud_baseline_$id", CloudPlan.toJson(b)).apply()
/** Files changed on both sides since the last sync, waiting for Keep cloud / Keep local. */
fun conflicts(context: Context, store: Store, id: String): List<String> =
prefs(context, store).getString("cloud_conflicts_$id", "").orEmpty().split('\n').filter { it.isNotEmpty() }
private fun saveConflicts(context: Context, store: Store, id: String, names: Collection<String>) =
prefs(context, store).edit().putString("cloud_conflicts_$id", names.joinToString("\n")).apply()
/** Cloud → device where the cloud copy changed (the first time: wherever it differs). [force]: conflicts too (Keep cloud). Blocking. */
fun download(context: Context, store: Store, id: String, force: Boolean = false): Result =
sync(context, store, id, "down") { dir, cloud -> downWith(context, store, id, dir, cloud, force) }
/** Device → cloud where the local copy changed, once a download has set the baseline. [force]: conflicts too (Keep local). Blocking. */
fun upload(context: Context, store: Store, id: String, force: Boolean = false): Result =
sync(context, store, id, "up") { dir, cloud -> upWith(context, store, id, dir, cloud, force) }
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
/**
* The pre-launch download, or - when Proton has not made the game's prefix yet (a first launch)
* - a deferred one: answered at once, then run in the background as soon as the prefix's user
* folder appears (within [PREFIX_WAIT_MS]), so the launch is not held up for it.
*/
fun downloadOrDefer(context: Context, store: Store, id: String): Result {
val app = context.applicationContext
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
Thread({
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
while (System.currentTimeMillis() < end) {
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
Thread.sleep(2000)
}
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
}, "cloud-deferred").start()
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
}
private const val PREFIX_WAIT_MS = 60_000L
private fun sync(context: Context, store: Store, id: String, way: String, body: (File, CloudTransport) -> Result): Result {
val result = try {
val g = game(context, store, id)
when {
g == null -> Result("skipped", 0, 0, "not-installed")
!g.second.cloud -> Result("skipped", 0, 0, "off")
else -> {
val (dir, reason) = saveFolder(context, store, id)
val cloud = if (dir != null) transport(context, store, id, g.first) else null
when {
dir == null -> Result("skipped", 0, 0, reason)
cloud == null -> Result("skipped", 0, 0, "not-signed-in")
else -> body(dir, cloud).also { if (it.result == "ok") markSynced(context, store, id) }
}
}
}
} catch (e: NoCloudSaves) {
prefs(context, store).edit().putString("cloud_template_$id", "").apply()
Result("skipped", 0, 0, "no-cloud-saves")
} catch (e: Exception) {
Result("failed", 0, 0, e.javaClass.simpleName)
}
return done(store, id, way, result)
}
private fun done(store: Store, id: String, way: String, r: Result): Result {
Log.i(TAG, "cloud ${store.id} $id $way ${r.line}")
StoresState.logLine("cloud ${store.id} $id $way ${r.line}")
return r
}
private fun localFiles(dir: File): Map<String, File> =
if (!dir.isDirectory) emptyMap()
else dir.walkTopDown().filter { it.isFile }.associateBy { it.relativeTo(dir).path.replace(File.separatorChar, '/') }
internal fun md5(f: File): String = md5(f.readBytes())
internal fun md5(data: ByteArray): String = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) }
/** The cloud listing, with MD5 and time filled in (one HEAD each where the listing lacks them, in parallel). */
private fun remote(cloud: CloudTransport, wanted: (String) -> Boolean): Map<String, CloudFile> {
val files = cloud.list()
val pool = Executors.newFixedThreadPool(PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("cloud"))
for (f in files) if (wanted(f.name)) pool.execute { runCatching { cloud.details(f) } }
pool.shutdown(); pool.awaitTermination(2, TimeUnit.MINUTES)
return files.associateBy { it.name }
}
internal fun downWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
val files = localFiles(dir)
val remote = remote(cloud) { it in files }
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
val base = baseline(context, store, id)
val plan = CloudPlan.down(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
val take = if (force) plan.transfer + plan.conflicts else plan.transfer
if (take.isNotEmpty() && files.isNotEmpty()) backup(context, store, id, dir, "local")
val next = HashMap(base.orEmpty())
var bytes = 0L
for (name in take) {
val f = remote[name] ?: continue
val (data, modified) = cloud.get(f)
val dest = File(dir, name)
if (!dest.canonicalPath.startsWith(dir.canonicalPath + File.separator)) continue
dest.parentFile?.mkdirs()
dest.writeBytes(data)
if (modified > 0) dest.setLastModified(modified)
bytes += data.size
next[name] = CloudPlan.Entry(md5(data), f.md5, if (modified > 0) modified else f.modifiedMs)
}
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
// A download that completed sets the baseline even when the cloud was empty: uploads may follow.
saveBaseline(context, store, id, next)
val left = if (force) emptyList() else plan.conflicts
saveConflicts(context, store, id, left)
return Result("ok", take.size, bytes, if (take.isEmpty()) "up-to-date" else "downloaded", left.size)
}
internal fun upWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
val base = baseline(context, store, id) ?: return Result("skipped", 0, 0, "no-baseline")
val files = localFiles(dir)
if (files.isEmpty()) return Result("skipped", 0, 0, "nothing-local")
val remote = remote(cloud) { it in files }
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
val plan = CloudPlan.up(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
val send = if (force) plan.transfer + plan.conflicts else plan.transfer
val next = HashMap(base)
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
if (send.isNotEmpty()) {
// The cloud copies about to be replaced are kept first, as a download keeps the local ones.
backupCloud(context, store, id, cloud, send.mapNotNull { remote[it] })
val data = send.associateWith { files.getValue(it).readBytes() }
val stamped = cloud.put(data)
for ((name, t) in stamped) if (t > 0) files[name]?.setLastModified(t)
val after = remote(cloud) { it in data }
for (name in data.keys) {
val r = after[name]
next[name] = CloudPlan.Entry(md5(data.getValue(name)), r?.md5, r?.modifiedMs ?: stamped[name] ?: -1L)
}
}
saveBaseline(context, store, id, next)
val left = if (force) emptyList() else plan.conflicts
saveConflicts(context, store, id, left)
return Result("ok", send.size, send.sumOf { files.getValue(it).length() }, if (send.isEmpty()) "up-to-date" else "uploaded", left.size)
}
// ---- uploads that do not depend on one hook ---------------------------------------------------
/**
* A game launched with cloud saves is marked dirty in the app's storage when its pre-launch step
* runs. The upload happens on the first of: the compat tool's exit request (`exit`), the end of
* the session (`session-end`), or the app's next start for a mark a killed app left (`recovery`)
* - so a session closed from the drawer, a game Steam killed, or a crash still uploads. The mark
* goes only after an upload that went through, or one skipped on purpose with its reason logged.
*/
private fun marks(context: Context) = context.applicationContext.getSharedPreferences("stores.cloud", Context.MODE_PRIVATE)
fun markDirty(context: Context, store: Store, id: String) {
marks(context).edit().putLong("dirty:${store.id}:$id", System.currentTimeMillis()).apply()
}
fun dirty(context: Context): List<Pair<Store, String>> = marks(context).all.keys.mapNotNull { k ->
val parts = k.split(':', limit = 3)
if (parts.size == 3 && parts[0] == "dirty") Store.byId(parts[1])?.let { it to parts[2] } else null
}
private val markLock = Any()
/** Why a skip is final: retrying cannot change it, so the mark goes. */
private val FINAL_SKIPS = setOf("off", "no-cloud-saves", "no-baseline", "not-installed", "nothing-local")
/** Uploads [id] if it is marked dirty; null when it is not (another trigger got there first). */
fun uploadDirty(context: Context, store: Store, id: String, trigger: String): Result? = synchronized(markLock) {
val key = "dirty:${store.id}:$id"
if (!marks(context).contains(key)) return null
val r = sync(context, store, id, "up trigger=$trigger") { dir, cloud -> upWith(context, store, id, dir, cloud, force = false) }
if (r.result == "ok" || (r.result == "skipped" && r.reason in FINAL_SKIPS)) marks(context).edit().remove(key).apply()
r
}
/**
* Every dirty game, uploaded; [running] says a game session is up, in which case `recovery` waits
* - a marked game may be the one running.
*/
fun uploadAllDirty(context: Context, trigger: String, running: Boolean = com.droiddeck.launcher.session.SessionState.running) {
if (trigger == "recovery" && running) return
for ((store, id) in dirty(context)) uploadDirty(context, store, id, trigger)
}
private fun backupRoot(context: Context, store: Store, id: String) =
File(context.filesDir, "stores/cloud-backups/${store.id}-${id.replace(Regex("[^A-Za-z0-9._-]"), "_")}")
/** The local folder as it is, before a download changes it; the last [BACKUPS] of each kind are kept. */
private fun backup(context: Context, store: Store, id: String, dir: File, kind: String) {
val root = backupRoot(context, store, id)
runCatching { dir.copyRecursively(File(root, "$kind-${System.currentTimeMillis()}"), overwrite = true) }
prune(root, kind)
}
private fun backupCloud(context: Context, store: Store, id: String, cloud: CloudTransport, files: List<CloudFile>) {
if (files.isEmpty()) return
val root = backupRoot(context, store, id)
val target = File(root, "cloud-${System.currentTimeMillis()}")
for (f in files) {
val dest = File(target, f.name)
if (!dest.canonicalPath.startsWith(target.canonicalPath + File.separator)) continue
dest.parentFile?.mkdirs()
dest.writeBytes(cloud.get(f).first)
}
prune(root, "cloud")
}
private fun prune(root: File, kind: String) {
root.listFiles { f -> f.name.startsWith("$kind-") }?.sortedByDescending { it.name }?.drop(BACKUPS)?.forEach { it.deleteRecursively() }
}
}
@@ -0,0 +1,191 @@
/*
* The two stores' cloud-save services, after Bannerlator's GogCloudSaveManager and
* EpicCloudSaveManager (device-proven there, August 2026): GOG's cloudstorage (one object per
* file, Last-Modified and an MD5 ETag on a HEAD) and Epic's savesync data storage (a listing with
* lastModified and signed read links; signed write links asked for per upload).
*/
package com.droiddeck.launcher.stores
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import org.json.JSONArray
import org.json.JSONObject
/** One file in a game's cloud: its key (a path with '/'), when it was written (-1 unknown) and its MD5 when known. */
class CloudFile(val name: String, var modifiedMs: Long, var md5: String? = null, val readLink: String? = null)
/** A store's cloud for one game. Every call is blocking network. */
interface CloudTransport {
fun list(): List<CloudFile>
/** Fills in [CloudFile.modifiedMs] / [CloudFile.md5] where the listing left them out. */
fun details(file: CloudFile) {}
/** The file's bytes and its cloud time (-1 unknown). */
fun get(file: CloudFile): Pair<ByteArray, Long>
/** Writes [files] (key → bytes); the cloud times assigned, where the service says (-1 unknown). */
fun put(files: Map<String, ByteArray>): Map<String, Long>
}
private const val TIMEOUT = 30_000
private fun open(url: String, method: String, token: String?, ua: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = TIMEOUT
readTimeout = TIMEOUT
setRequestProperty("User-Agent", ua)
if (token != null) setRequestProperty("Authorization", "Bearer $token")
}
private fun HttpURLConnection.body(): ByteArray = inputStream.use { input ->
ByteArrayOutputStream().also { input.copyTo(it) }.toByteArray()
}
private fun httpDate(s: String?): Long = try {
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { timeZone = TimeZone.getTimeZone("GMT") }.parse(s!!.trim())?.time ?: -1L
} catch (e: Exception) { -1L }
internal fun isoDate(s: String?): Long {
if (s == null || s.length < 19) return -1L
return try {
java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
clear()
set(s.substring(0, 4).toInt(), s.substring(5, 7).toInt() - 1, s.substring(8, 10).toInt(),
s.substring(11, 13).toInt(), s.substring(14, 16).toInt(), s.substring(17, 19).toInt())
}.timeInMillis
} catch (e: Exception) { -1L }
}
/** Thrown when the store says the game has no cloud saves for this client. */
class NoCloudSaves : Exception("no cloud saves")
/** GOG cloudstorage: `/v1/<userId>/<clientId>[/<key>]`. */
class GogCloud(private val userId: String, private val clientId: String, private val token: String) : CloudTransport {
private val base = "https://cloudstorage.gog.com/v1/$userId/$clientId"
private val ua = "GOG Galaxy"
/** A path segment: '+' from a space is wrong in a path (it stores a second, '+'-named copy). */
private fun key(name: String) = URLEncoder.encode(name, "UTF-8").replace("+", "%20")
override fun list(): List<CloudFile> {
val c = open(base, "GET", token, ua)
val code = c.responseCode
if (code == 404) { c.disconnect(); return emptyList() }
if (code !in 200..299) {
val err = runCatching { c.errorStream?.readBytes()?.decodeToString().orEmpty() }.getOrDefault("")
c.disconnect()
if ("not_enabled_for_client" in err || "disabled" in err) throw NoCloudSaves()
throw java.io.IOException("GOG cloud list HTTP $code")
}
val text = c.body().decodeToString().trim()
c.disconnect()
if (text.isEmpty() || text == "[]") return emptyList()
// A plain newline-separated list of keys (checked on device); a JSON array is read too.
if (text.startsWith("[")) {
val a = JSONArray(text)
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull { o ->
val n = o.optString("name").ifEmpty { return@mapNotNull null }
val lm = o.optLong("last_modified", 0L)
CloudFile(n, if (lm > 1_000_000_000_000L) lm else if (lm > 0) lm * 1000 else -1L)
}
}
return text.lines().map { it.trim() }.filter { it.isNotEmpty() }.map { CloudFile(it, -1L) }
}
override fun details(file: CloudFile) {
if (file.modifiedMs > 0 && file.md5 != null) return
val c = open("$base/${key(file.name)}", "HEAD", token, ua)
if (c.responseCode in 200..299) {
httpDate(c.getHeaderField("Last-Modified")).takeIf { it > 0 }?.let { file.modifiedMs = it }
c.getHeaderField("ETag")?.replace("\"", "")?.trim()?.ifEmpty { null }?.let { file.md5 = it }
}
c.disconnect()
}
override fun get(file: CloudFile): Pair<ByteArray, Long> {
val c = open("$base/${key(file.name)}", "GET", token, ua)
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("GOG cloud get HTTP $code") }
val modified = httpDate(c.getHeaderField("Last-Modified"))
return c.body().also { c.disconnect() } to modified
}
override fun put(files: Map<String, ByteArray>): Map<String, Long> = files.mapValues { (name, data) ->
val c = open("$base/${key(name)}", "PUT", token, ua)
c.doOutput = true
c.setRequestProperty("Content-Type", "application/octet-stream")
c.setFixedLengthStreamingMode(data.size)
c.outputStream.use { it.write(data) }
val code = c.responseCode
c.disconnect()
if (code !in 200..299) throw java.io.IOException("GOG cloud put HTTP $code")
// The server stamps the object itself; that time, so the next sync sees the two as equal.
CloudFile(name, -1L).also { details(it) }.modifiedMs
}
}
/** Epic savesync: `/api/v1/access/egstore/savesync/<accountId>/<appName>/`. */
class EpicCloud(private val accountId: String, private val appName: String, private val token: String) : CloudTransport {
private val base = "https://datastorage-public-service-liveegs.live.use1a.on.epicgames.com/api/v1/access/egstore/savesync/$accountId/$appName/"
private val ua = "EpicGamesLauncher/15.17.1-22692490"
override fun list(): List<CloudFile> {
val c = open(base, "GET", token, ua)
val code = c.responseCode
if (code == 404) { c.disconnect(); return emptyList() }
if (code !in 200..299) { c.disconnect(); throw java.io.IOException("Epic cloud list HTTP $code") }
val root = JSONObject(c.body().decodeToString().ifEmpty { "{}" })
c.disconnect()
val files = root.optJSONObject("files") ?: return emptyList()
return files.keys().asSequence().mapNotNull { k ->
val e = files.optJSONObject(k) ?: return@mapNotNull null
CloudFile(strip(k), isoDate(e.optString("lastModified", null)), e.optString("hash", "").ifEmpty { null }, e.optString("readLink", "").ifEmpty { null })
}.toList()
}
/** The listing names a file `<...>/<appName>/<key>`; the key is what is under the save folder. */
private fun strip(key: String): String {
val marker = "/$appName/"
val i = key.indexOf(marker)
if (i >= 0) return key.substring(i + marker.length)
return key.removePrefix("$appName/")
}
override fun get(file: CloudFile): Pair<ByteArray, Long> {
val link = file.readLink ?: throw java.io.IOException("Epic cloud: no read link")
val c = open(link, "GET", null, ua)
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("Epic cloud get HTTP $code") }
return c.body().also { c.disconnect() } to file.modifiedMs
}
override fun put(files: Map<String, ByteArray>): Map<String, Long> {
if (files.isEmpty()) return emptyMap()
val c = open(base, "POST", token, ua)
c.doOutput = true
c.setRequestProperty("Content-Type", "application/json")
val req = JSONObject().put("files", JSONArray(files.keys.toList())).toString().toByteArray()
c.outputStream.use { it.write(req) }
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("Epic cloud write links HTTP $code") }
val links = JSONObject(c.body().decodeToString()).optJSONObject("files") ?: JSONObject()
c.disconnect()
val out = HashMap<String, Long>()
for (k in links.keys()) {
val link = links.optJSONObject(k)?.optString("writeLink", "")?.ifEmpty { null } ?: continue
val name = strip(k)
val data = files[name] ?: files[k] ?: continue
val p = open(link, "PUT", null, ua)
p.doOutput = true
p.setRequestProperty("Content-Type", "application/octet-stream")
p.setFixedLengthStreamingMode(data.size)
p.outputStream.use { it.write(data) }
val code = p.responseCode
p.disconnect()
if (code !in 200..299) throw java.io.IOException("Epic cloud put HTTP $code")
out[name] = -1L
}
return out
}
}
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.stores
import android.net.Uri
import android.util.Log
import android.webkit.WebView
import com.droiddeck.launcher.stores.gog.GogAuth
/** The three stores' sign-in flows for [StoreLoginActivity]. */
internal object LoginFlows {
private const val TAG = "StoreLogin"
fun forStore(store: Store, state: String): StoreLoginActivity.Flow? = when (store) {
Store.GOG -> GogFlow(state)
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
}
/** Set by the Epic and Amazon backends when they are in the build; null keeps the chip's card honest. */
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
/**
* GOG's implicit grant: the token arrives in the redirect URL's fragment. The CSRF state is
* checked only when GOG echoes one back - its social-login path returns none, and treating
* that as a mismatch threw away good tokens.
*/
private class GogFlow(private val state: String) : StoreLoginActivity.Flow {
override val startUrl: String get() = GogAuth.authUrl(state)
override val userAgent: String get() = GogAuth.GALAXY_UA
override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX)
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val fragment = Uri.parse(url).fragment ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
val frag = Uri.parse("x://x?$fragment")
val returned = frag.getQueryParameter("state")
if (returned != null && returned != state) {
Log.w(TAG, "gog: state mismatch; redirect rejected")
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
}
val access = frag.getQueryParameter("access_token")
if (access.isNullOrEmpty()) {
// The keys only, never the values: the fragment carries live tokens.
Log.w(TAG, "gog: redirect without access_token; keys=${frag.queryParameterNames}")
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
}
return try {
GogAuth.saveLogin(activity, access, frag.getQueryParameter("refresh_token"), frag.getQueryParameter("user_id"))
null
} catch (e: Exception) {
Log.w(TAG, "gog: saving the sign-in failed: ${e.javaClass.simpleName}")
activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
}
}
}
}
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.stores
import java.io.File
import org.json.JSONObject
/**
* Game sizes as found, kept per game AND the version they were found for, in
* `filesDir/stores/<store>/sizes.json`: a size is never fetched twice for one version, and a
* new version (a library refresh brings it) simply misses and is looked up again. The file carries
* its format; one in another format is discarded, not read.
*/
class SizeCache(private val file: File) {
private val sizes = HashMap<String, Pair<String, Long>>()
init {
runCatching {
val o = JSONObject(file.readText())
if (o.optInt("format") == FORMAT) {
val s = o.getJSONObject("sizes")
for (k in s.keys()) s.optJSONObject(k)?.let { e -> sizes[k] = e.optString("v") to e.optLong("b") }
}
}
}
@Synchronized
fun get(id: String, version: String): Long? = sizes[id]?.takeIf { it.first == version && it.second > 0 }?.second
@Synchronized
fun put(id: String, version: String, bytes: Long) {
if (bytes <= 0) return
sizes[id] = version to bytes
runCatching {
file.parentFile?.mkdirs()
val s = JSONObject()
for ((k, v) in sizes) s.put(k, JSONObject().put("v", v.first).put("b", v.second))
val tmp = File(file.path + ".tmp")
tmp.writeText(JSONObject().put("format", FORMAT).put("sizes", s).toString())
tmp.renameTo(file)
}
}
companion object {
const val FORMAT = 1
fun forStore(filesDir: File, store: Store) = SizeCache(File(filesDir, "stores/${store.id}/sizes.json"))
}
}
@@ -0,0 +1,187 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.security.SecureRandom
/**
* Adds and removes a store game's shortcut in a Steam client that is running right now, so it
* shows without waiting for the next client start. The runtime starts the client with its
* DevTools port open (droiddeck-session writes it to session/agent/cdp-port) and the client's own
* JavaScript API has `SteamClient.Apps.AddShortcut`; this speaks the small part of the DevTools
* protocol that one `Runtime.evaluate` needs, over a plain socket, nothing else.
*
* Best-effort throughout: no session, no port, no SharedJSContext, or an API that moved and the
* shortcut simply arrives with the shortcuts writer at the next start. The appid Steam derives
* (CRC of the quoted exe plus the name) is the one the writer computes, so the two never disagree.
*/
object SteamLiveShortcuts {
private const val TAG = "SteamLive"
private const val TAG_NAME = "droiddeck-app"
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
/** Adds every store game in [games] the client does not list yet. Blocking; worker thread. */
fun sync(context: Context, games: List<AddedGames.Game>) {
val wanted = games.filter { it.source != Library.ADDED }
if (wanted.isEmpty() || !clientRunning()) return
val list = JSONArray()
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir))
val js = """
(async () => {
const want = ${list};
const have = await SteamClient.Apps.GetAllShortcuts();
const strip = s => (s || "").replace(/^"|"$/g, "");
let added = 0;
for (const w of want) {
if (have.some(a => a.data && strip(a.data.strExePath) === w.exe && a.data.strAppName === w.name)) continue;
const id = await SteamClient.Apps.AddShortcut(w.name, w.exe, w.dir, "");
if (!id) continue;
try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {}
try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {}
added++;
}
return added;
})()
""".trimIndent()
val result = evaluate(context, js) ?: return
Log.i(TAG, "live shortcuts: $result added")
if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client")
}
/** Removes the shortcut with [appId] from the running client. Blocking; worker thread. */
fun remove(context: Context, appId: Long) {
if (!clientRunning()) return
val result = evaluate(context, "(async () => { await SteamClient.Apps.RemoveShortcut(${appId and 0xFFFFFFFFL}); return 1; })()")
if (result != null) Log.i(TAG, "live shortcuts: removed $appId")
}
private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested
/** `Runtime.evaluate` of [expression] in the client's SharedJSContext; the value as text, or null. */
private fun evaluate(context: Context, expression: String): String? {
val portFile = File(LinuxRuntime.sessionRoot(context), "agent/cdp-port")
val port = runCatching { portFile.readText().trim().toInt() }.getOrNull() ?: return null
val targets = runCatching { JSONArray(get("http://127.0.0.1:$port/json")) }.getOrNull() ?: return null
var wsUrl: String? = null
for (i in 0 until targets.length()) {
val t = targets.optJSONObject(i) ?: continue
if (t.optString("title").contains("SharedJSContext") || t.optString("url").contains("SharedJSContext")) { wsUrl = t.optString("webSocketDebuggerUrl").ifEmpty { null }; break }
}
if (wsUrl == null) { Log.w(TAG, "no SharedJSContext target"); return null }
return try {
WebSocket(wsUrl, 8000).use { ws ->
ws.sendText(JSONObject().put("id", 1).put("method", "Runtime.evaluate").put("params", JSONObject()
.put("expression", expression).put("returnByValue", true).put("awaitPromise", true).put("userGesture", true)).toString())
val deadline = System.currentTimeMillis() + 15_000
while (System.currentTimeMillis() < deadline) {
val message = JSONObject(ws.receiveText() ?: return null)
if (message.optInt("id") != 1) continue
if (message.has("error")) { Log.w(TAG, "cdp: ${message.optJSONObject("error")}"); return null }
val result = message.optJSONObject("result") ?: return null
result.optJSONObject("exceptionDetails")?.let { Log.w(TAG, "js: ${it.optJSONObject("exception")?.optString("description") ?: it.optString("text")}"); return null }
return result.optJSONObject("result")?.opt("value")?.toString()
}
null
}
} catch (e: Exception) {
Log.w(TAG, "cdp: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
private fun get(url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 3000; c.readTimeout = 3000
try { return c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
}
/** The least of RFC 6455 a localhost DevTools talk needs: upgrade, masked text out, text in. */
private class WebSocket(url: String, timeoutMs: Int) : AutoCloseable {
private val socket: Socket
private val out: OutputStream
private val input: DataInputStream
init {
val u = URL(url.replaceFirst("ws://", "http://"))
socket = Socket(u.host, if (u.port > 0) u.port else 80).apply { soTimeout = timeoutMs }
out = socket.getOutputStream()
input = DataInputStream(socket.getInputStream())
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
val path = u.path.ifEmpty { "/" } + (u.query?.let { "?$it" } ?: "")
out.write(("GET $path HTTP/1.1\r\nHost: ${u.host}:${u.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Key: ${Base64.encodeToString(key, Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
out.flush()
val status = readLine(input) ?: throw java.io.IOException("no upgrade response")
if (!status.contains(" 101 ")) throw java.io.IOException("upgrade refused: $status")
while (true) { val line = readLine(input) ?: break; if (line.isEmpty()) break }
}
private fun readLine(s: InputStream): String? {
val buf = ByteArrayOutputStream()
while (true) {
val b = s.read()
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
buf.write(b)
}
}
fun sendText(text: String) {
val payload = text.toByteArray()
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
val frame = ByteArrayOutputStream()
frame.write(0x81)
when {
payload.size < 126 -> frame.write(0x80 or payload.size)
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
else -> { frame.write(0x80 or 127); for (i in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * i)) and 0xFF).toInt()) }
}
frame.write(mask)
for (i in payload.indices) frame.write((payload[i].toInt() xor mask[i % 4].toInt()) and 0xFF)
out.write(frame.toByteArray()); out.flush()
}
/** The next text message (fragments joined); null on close. */
fun receiveText(): String? {
val message = ByteArrayOutputStream()
while (true) {
val b0 = input.read(); if (b0 < 0) return null
val b1 = input.read(); if (b1 < 0) return null
val fin = b0 and 0x80 != 0
val opcode = b0 and 0x0F
var len = (b1 and 0x7F).toLong()
if (len == 126L) len = input.readUnsignedShort().toLong()
else if (len == 127L) len = input.readLong()
val masked = b1 and 0x80 != 0
val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null
if (len > 16L * 1024 * 1024) throw java.io.IOException("frame too large")
val data = ByteArray(len.toInt()).also { input.readFully(it) }
if (mask != null) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i % 4].toInt()).toByte()
when (opcode) {
0x8 -> return null
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
0x1, 0x0 -> { message.write(data); if (fin) return message.toString("UTF-8") }
else -> {}
}
}
}
override fun close() { runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }; runCatching { socket.close() } }
}
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.stores
/**
* The three storefronts the Stores section talks to. [id] is what the sidecar, the prefs file
* names and the Games badge carry; it never changes once a game is installed under it.
*/
enum class Store(val id: String, val label: String, val shortLabel: String) {
GOG("gog", "GOG", "GOG"),
EPIC("epic", "Epic Games", "Epic"),
AMAZON("amazon", "Amazon Games", "Amazon");
companion object {
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
}
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.File
/**
* Each store's sign-in, kept in the app's private files: `filesDir/stores/<store>/credentials.json`.
* Tokens never go to shared storage and never into a log; the file holds whatever the store's auth
* flow handed back (access and refresh tokens, expiry, the account's display name, and for Amazon
* the device registration), as one JSON object the store's own client reads and writes.
*/
object StoreAccounts {
private const val TAG = "StoreAccounts"
private const val FILE = "credentials.json"
/** The store's private folder: credentials, caches, the engine's scratch. */
fun dir(context: Context, store: Store): File = File(File(context.filesDir, "stores"), store.id).apply { mkdirs() }
fun credentialsFile(context: Context, store: Store): File = File(dir(context, store), FILE)
/** The stored credentials, or null when signed out or unreadable (never logged: the text is the secret). */
fun read(context: Context, store: Store): JSONObject? {
val f = credentialsFile(context, store)
if (!f.isFile) return null
return try { JSONObject(f.readText()) } catch (e: Exception) { Log.w(TAG, "${store.id}: credentials unreadable (${e.javaClass.simpleName})"); null }
}
fun write(context: Context, store: Store, json: JSONObject) {
val f = credentialsFile(context, store)
val tmp = File(f.path + ".tmp")
tmp.writeText(json.toString())
tmp.setReadable(false, false); tmp.setReadable(true, true)
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
fun clear(context: Context, store: Store) {
credentialsFile(context, store).delete()
}
/** The account's display name when signed in (the store's own name for it, or a stand-in), else null. */
fun signedInAs(context: Context, store: Store): String? {
val json = read(context, store) ?: return null
if (json.optString("access_token", "").isEmpty()) return null
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.stores
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.util.Log
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* The store's art, placed in the game's folder under the names AddedGameArt already looks for -
* `cover.jpg` (the portrait capsule), `hero.jpg` and `header.jpg` (the wide art) - so a store game
* shows real art in the Games tab and the session's shortcuts writer copies it into the client's
* grid. Each image is decoded and re-encoded as an opaque JPEG over a dark ground: a store's PNG
* with an alpha fade would otherwise end in white, which is what the washed-out cards were.
*/
object StoreArt {
private const val TAG = "StoreArt"
/** Fetches what the sidecar names; a missing or unreadable image is skipped, never an error. Blocking. */
fun fetchInto(folder: File, sidecar: StoreGameSidecar) {
sidecar.cover?.let { place(it, File(folder, "cover.jpg")) }
val wide = sidecar.hero ?: return
if (place(wide, File(folder, "hero.jpg"))) {
val header = File(folder, "header.jpg")
if (!header.isFile) runCatching { File(folder, "hero.jpg").copyTo(header, overwrite = true) }
}
}
private fun place(url: String, dst: File): Boolean {
if (dst.isFile && dst.length() > 0) return true
val bytes = get(url) ?: return false
val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return false
val opaque = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
Canvas(opaque).apply { drawColor(Color.rgb(0x12, 0x14, 0x17)); drawBitmap(bitmap, 0f, 0f, null) }
val tmp = File(dst.path + ".tmp")
return try {
tmp.outputStream().use { opaque.compress(Bitmap.CompressFormat.JPEG, 90, it) }
tmp.renameTo(dst)
} catch (e: Exception) {
Log.w(TAG, "${dst.name}: ${e.javaClass.simpleName}")
tmp.delete()
false
} finally {
bitmap.recycle(); opaque.recycle()
}
}
private fun get(url: String): ByteArray? {
var c: HttpURLConnection? = null
return try {
c = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = 10_000; readTimeout = 20_000; setRequestProperty("User-Agent", StoreNet.BROWSER_UA) }
if (c.responseCode != 200) null else c.inputStream.use { it.readBytes() }
} catch (e: Exception) {
Log.w(TAG, "${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
c?.disconnect()
}
}
}
@@ -0,0 +1,135 @@
package com.droiddeck.launcher.stores
/**
* One title as the Stores page draws it, whichever store produced it: a card on a shelf, a tile
* in a grid, the hero of a game page. Library games and storefront results both project into
* this, so one card composable serves every tab.
*/
data class CatalogItem(
val store: Store,
/** Store-native id: GOG product id, Epic catalog item id (offer id for storefront results), Amazon product id. */
val id: String,
val title: String,
/** Wide (16:9-ish) art for cards and heroes. */
val imageUrl: String?,
/** Tall (2:3) box art when the store publishes one; falls back to [imageUrl]. */
val tallImageUrl: String? = null,
/** Comma-joined genre / tag line. */
val tags: String = "",
val isFree: Boolean = false,
/** False when the endpoint gave no price at all: the price row then draws nothing. */
val hasPrice: Boolean = false,
/** Pre-formatted by the store ("$9.99"). */
val finalPrice: String = "",
val originalPrice: String = "",
val discountPercent: Int = 0,
/** The web product page: where "Get for free" and "View on <store>" land. */
val storeUrl: String = "",
val developer: String = "",
val releaseDate: String = "",
val description: String = "",
/** True for a title the signed-in account owns (a library entry). */
val owned: Boolean = false,
/** The install size in bytes when known, else 0. */
val sizeBytes: Long = 0L,
/** Per-store identifiers the install needs (Epic namespace / catalog id / app name, Amazon entitlement / sku). */
val extra: Map<String, String> = emptyMap(),
/** Rated or tagged for adults by the store's own data; hidden from the storefront unless Show mature content is on. */
val mature: Boolean = false,
) {
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
/** Stable key across stores ("gog:1207658924"). */
val key: String get() = "${store.id}:$id"
}
/** A store's storefront shelves: what the Store tab shows above the account's own library. */
class StoreShelves(
val whatsNew: List<CatalogItem> = emptyList(),
val deals: List<CatalogItem> = emptyList(),
val free: List<CatalogItem> = emptyList(),
val trending: List<CatalogItem> = emptyList(),
) {
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
}
/** A game a store installed, as found on disk through its sidecar. */
/** Store tags that mark adult content, by slug or name, lowercased. */
private val MATURE_TAGS = setOf("mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content")
/** Whether a store's own tags mark a title as adult. */
fun matureByTags(tags: Collection<String>): Boolean = tags.any { it.trim().lowercase() in MATURE_TAGS }
/**
* Whether a store's own age ratings mark a title as adult: ESRB M (17) or AO (18), PEGI / USK / the
* store's own rating 18, or any rating system's 17+. Ratings are ages, as GOG gives them.
*/
fun matureByAge(ages: Collection<Int>): Boolean = ages.any { it >= 17 }
/**
* The Installed tab's cards for [store], from the installs on disk - the same source as its count:
* each joined to its library item by store id (by title when the ids differ), else a card made
* from the sidecar's own title and art, so an install shows even before the library has loaded.
*/
fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: List<CatalogItem>): List<CatalogItem> {
val byId = library.associateBy { it.id }
val byTitle = library.associateBy { it.title.lowercase() }
return installed.filter { it.sidecar.store == store }.map { game ->
val s = game.sidecar
byId[s.id] ?: byTitle[s.title.lowercase()]
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true)
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
}
/**
* A refreshed list with every unchanged item kept as the very object already shown, so a refresh
* that lands while the grid is on screen recomposes only the cards that changed - no reset, the
* scroll position and the pad's focus stay.
*/
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
if (old.isNullOrEmpty()) return new
val before = old.associateBy { it.key }
val merged = new.map { n -> before[n.key]?.takeIf { it == n } ?: n }
return if (merged.size == old.size && merged.indices.all { merged[it] === old[it] }) old else merged
}
class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) {
val key: String get() = "${sidecar.store.id}:${sidecar.id}"
}
private val TEMPLATE_KEY = Regex("product_(description|feature)_\\d+")
private val HTML_TAG = Regex("<[^>]+>")
private val ENTITY = Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);")
private val NAMED_ENTITIES = mapOf("amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'", "nbsp" to " ", "ndash" to "–", "mdash" to "—", "hellip" to "…", "trade" to "™", "reg" to "®", "copy" to "©")
/**
* Store copy as plain text: GOG's account endpoints return template keys
* (`product_description_2015545325`) for some products instead of words, and every store's
* descriptions carry HTML. Those keys go, tags go, entities are decoded, whitespace is collapsed;
* "" when nothing readable is left, so a page shows nothing rather than a placeholder.
*/
fun cleanStoreText(raw: String?): String {
if (raw.isNullOrBlank()) return ""
var s = raw.replace(TEMPLATE_KEY, " ")
s = s.replace(Regex("(?i)<br\\s*/?>|</p>|</li>|</div>"), "\n").replace(HTML_TAG, " ")
s = ENTITY.replace(s) { m ->
val e = m.groupValues[1]
when {
e.startsWith("#x") -> e.substring(2).toIntOrNull(16)?.toChar()?.toString() ?: " "
e.startsWith("#") -> e.substring(1).toIntOrNull()?.toChar()?.toString() ?: " "
else -> NAMED_ENTITIES[e.lowercase()] ?: " "
}
}
return s.lines().map { it.replace(Regex("[ \\t\\u00A0]+"), " ").trim() }.filter { it.isNotEmpty() }.joinToString("\n")
}
/** Human sizes the way the Downloads page and the cards print them ("810.2 MB", "3.9 GB"). */
fun formatBytes(bytes: Long): String = when {
bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0)
bytes >= 1_048_576L -> "%.1f MB".format(bytes / 1_048_576.0)
bytes >= 1024L -> "%.0f KB".format(bytes / 1024.0)
bytes >= 0L -> "$bytes B"
else -> ""
}
fun formatSpeed(bytesPerSec: Long): String = if (bytesPerSec <= 0L) "" else "${formatBytes(bytesPerSec)}/s"
@@ -0,0 +1,44 @@
package com.droiddeck.launcher.stores
import java.io.File
/**
* Which .exe in an install folder is the game. The store's own hint (a manifest's launch
* executable) wins when the file exists; else the candidates, helpers and installers left out,
* the one named most like the title first, then the shallowest. Relative to the folder, forward
* slashes; "" when the folder has none.
*/
object StoreExe {
private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config")
fun pick(folder: File, hint: String?, title: String): String {
val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) }
if (hinted != null && hinted.isFile) return relative(folder, hinted)
val candidates = ArrayList<File>()
collect(folder, candidates, 0)
if (candidates.isEmpty()) return ""
val key = title.lowercase().replace(Regex("[^a-z0-9]"), "")
val best = candidates.firstOrNull { f ->
val n = f.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "")
n.isNotEmpty() && (n == key || (key.length >= 4 && key.startsWith(n)) || (n.length >= 4 && n.startsWith(key)))
} ?: candidates.sortedWith(compareBy<File> { depth(folder, it) }.thenByDescending { it.length() }).first()
return relative(folder, best)
}
private fun collect(dir: File, out: MutableList<File>, depth: Int) {
if (depth > 4) return
val files = dir.listFiles() ?: return
for (f in files) {
if (f.isFile && f.name.endsWith(".exe", ignoreCase = true)) {
val lower = f.name.lowercase()
if (SKIP.none { lower.contains(it) }) out.add(f)
}
}
for (f in files) if (f.isDirectory && !f.name.startsWith(".")) collect(f, out, depth + 1)
}
private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' }
fun relative(root: File, f: File): String =
f.absolutePath.removePrefix(root.absolutePath).trimStart('/').replace('\\', '/')
}
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff. Show more