Compare commits

...
74 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
141 changed files with 25539 additions and 73 deletions

No files matched your search

+79
View File
@@ -405,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:
@@ -424,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'
+3
View File
@@ -15,6 +15,9 @@ 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).
+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.
+33
View File
@@ -188,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"
@@ -29,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)
}
}
@@ -164,6 +164,12 @@ class MainActivity : ComponentActivity() {
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)
@@ -437,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.
@@ -523,6 +530,10 @@ 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 {
@@ -576,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() },
@@ -586,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) },
@@ -702,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
@@ -903,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 ->
@@ -960,6 +988,8 @@ class MainActivity : ComponentActivity() {
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
@@ -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
}
@@ -99,9 +116,14 @@ object AddedGames {
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
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,13 +147,19 @@ 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 picked = SessionPrefs.addedGameExe(context, folder.path)
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val found = chosen ?: candidates.firstOrNull() ?: return
val foundGuest = guestPath(context, found)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
val name = folder.name
// 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(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
@@ -143,10 +171,11 @@ object AddedGames {
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
val exe = adopted ?: found
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
val 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))
}
}
@@ -24,12 +24,18 @@ 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>) {
@@ -155,6 +161,7 @@ object Library {
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 }
@@ -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")
@@ -60,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.
@@ -73,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) {
@@ -84,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
}
@@ -53,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
@@ -325,6 +325,8 @@ class SessionService : Service() {
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")
@@ -894,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) }
@@ -1405,6 +1416,10 @@ class SessionService : Service() {
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()
@@ -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('\\', '/')
}
@@ -0,0 +1,165 @@
package com.droiddeck.launcher.stores
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* What a store install leaves beside the game so the rest of the app can treat the folder like
* any added game: `.droiddeck-store.json` in the game's folder.
*
* [AddedGames.scanGame] reads it to pick the exe (instead of guessing from the folder), to name
* the shortcut after the store's title and to tag the game with its [store]. [launcher] is the
* small .bat the install writes when the game needs arguments or environment at launch (Epic's
* identity arguments, Amazon's FuelPump variables): a Steam shortcut can only carry a fixed Exe,
* so the launcher is what the shortcut runs and [exe] is what it starts. Every store install is a
* Steam shortcut; there is no switch for it (an `addToSteam` field in an older sidecar is ignored).
*
* Paths are relative to the game folder with forward slashes, so the folder can move between
* storage volumes and stay valid.
*
* An install writes the sidecar twice: at its start with [state] `installing` (store, id, title -
* no exe yet), and at its end with the exe, the launcher and `installed`. A folder whose sidecar
* still says `installing` is an unfinished install, never a game: the scan leaves it out and the
* Stores page offers to resume it. A sidecar without the field (an earlier build) is installed.
*/
class StoreGameSidecar(
val store: Store,
val id: String,
val title: String,
val exe: String,
val launcher: String? = null,
val args: List<String> = emptyList(),
val env: Map<String, String> = emptyMap(),
val installVersion: String = "",
val installedAt: Long = 0L,
val cover: String? = null,
val hero: String? = null,
/** Per-store identifiers the launcher needs again (Epic namespace / catalog id, Amazon entitlement). */
val extra: Map<String, String> = emptyMap(),
val state: String = STATE_INSTALLED,
/** An Epic game's launch choices (the Games tab's Epic card); both launch paths read them from here. */
val epic: EpicOptions = EpicOptions(),
/** GOG / Epic cloud saves for this game: down before a launch, up after it (the game card's switch). */
val cloud: Boolean = true,
) {
val isInstalled: Boolean get() = state == STATE_INSTALLED
fun exeFile(folder: File): File = File(folder, exe)
fun launcherFile(folder: File): File? = launcher?.takeIf { it.isNotEmpty() }?.let { File(folder, it) }
fun copy(
exe: String = this.exe, launcher: String? = this.launcher, args: List<String> = this.args,
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero,
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud)
fun toJson(): JSONObject = JSONObject().apply {
put("version", VERSION)
put("store", store.id)
put("id", id)
put("title", title)
if (exe.isNotEmpty()) put("exe", exe)
if (state != STATE_INSTALLED) put("state", state)
if (store == Store.EPIC) put("epic", JSONObject().put("eos", epic.eos).put("offline", epic.offline))
if (store != Store.AMAZON) put("cloud", cloud)
if (!launcher.isNullOrEmpty()) put("launcher", launcher)
if (args.isNotEmpty()) put("args", JSONArray(args))
if (env.isNotEmpty()) put("env", JSONObject(env as Map<*, *>))
if (installVersion.isNotEmpty()) put("installVersion", installVersion)
if (installedAt > 0L) put("installedAt", installedAt)
if (!cover.isNullOrEmpty()) put("cover", cover)
if (!hero.isNullOrEmpty()) put("hero", hero)
if (extra.isNotEmpty()) put("extra", JSONObject(extra as Map<*, *>))
}
fun write(folder: File) {
folder.mkdirs()
val file = File(folder, FILE_NAME)
val tmp = File(folder, "$FILE_NAME.tmp")
tmp.writeText(toJson().toString(2))
if (!tmp.renameTo(file)) { file.delete(); tmp.renameTo(file) }
}
companion object {
const val FILE_NAME = ".droiddeck-store.json"
const val VERSION = 1
const val STATE_INSTALLED = "installed"
const val STATE_INSTALLING = "installing"
fun file(folder: File): File = File(folder, FILE_NAME)
/**
* Changes an Epic game's launch choices on disk: reads the sidecar fresh (never a copy held
* by a screen), applies [change], writes it, and reads it back. The written sidecar, or null
* when there is none or the write did not take. Blocking.
*/
fun updateEpic(folder: File, change: (EpicOptions) -> EpicOptions): StoreGameSidecar? {
val current = read(folder) ?: return null
val next = current.copy(epic = change(current.epic))
next.write(folder)
return read(folder)?.takeIf { it.epic == next.epic }
}
/** As [updateEpic], for the cloud-saves switch. */
fun updateCloud(folder: File, on: Boolean): StoreGameSidecar? {
val current = read(folder) ?: return null
current.copy(cloud = on).write(folder)
return read(folder)?.takeIf { it.cloud == on }
}
/** The sidecar in [folder], or null when there is none or it cannot be read. */
fun read(folder: File): StoreGameSidecar? {
val f = file(folder)
if (!f.isFile) return null
return runCatching { parse(f.readText()) }.getOrNull()
}
/** Pure parse; null for anything that is not a usable sidecar (so a scan falls back to guessing). */
fun parse(text: String): StoreGameSidecar? {
val o = runCatching { JSONObject(text) }.getOrNull() ?: return null
val store = Store.byId(o.optString("store")) ?: return null
val id = o.optString("id", "")
val title = o.optString("title", "")
val exe = o.optString("exe", "").replace('\\', '/')
val state = o.optString("state", STATE_INSTALLED).ifEmpty { STATE_INSTALLED }
if (id.isEmpty() || title.isEmpty()) return null
// An unfinished install has no exe yet; a finished one must name one.
if (exe.isEmpty() && state == STATE_INSTALLED) return null
// A relative path only: a sidecar that points outside its folder is not trusted.
if (exe.isNotEmpty() && !relativeInside(exe)) return null
val launcher = o.optString("launcher", "").replace('\\', '/').takeIf { it.isNotEmpty() && relativeInside(it) }
val args = o.optJSONArray("args")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } } ?: emptyList()
val env = LinkedHashMap<String, String>()
o.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.optString(k) } }
val extra = LinkedHashMap<String, String>()
o.optJSONObject("extra")?.let { e -> e.keys().forEach { k -> extra[k] = e.optString(k) } }
return StoreGameSidecar(
store, id, title, exe, launcher, args, env,
installVersion = o.optString("installVersion", ""),
installedAt = o.optLong("installedAt", 0L),
cover = o.optString("cover", "").ifEmpty { null },
hero = o.optString("hero", "").ifEmpty { null },
extra = extra,
state = state,
// An "overlay" (or "v") field from an earlier build is read past: there is no overlay.
epic = o.optJSONObject("epic")?.let { e -> EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false)) } ?: EpicOptions(),
cloud = o.optBoolean("cloud", true),
)
}
/** Relative, no drive letter, no step up: an absolute or escaping path is refused, not trimmed into shape. */
private fun relativeInside(path: String): Boolean =
path.isNotEmpty() && !path.startsWith("/") && !path.contains(":") && path.split('/').none { it == ".." || it.isEmpty() }
}
}
/**
* An Epic game's launch choices. [eos]: pass a fresh exchange code (Epic sign-in). [offline]: start
* with the Epic identity arguments only, no code - for a game that will not start when sign-in fails.
*/
data class EpicOptions(val eos: Boolean = true, val offline: Boolean = false) {
/** Whether a launch asks for a code at all. */
val wantsCode: Boolean get() = eos && !offline
}
@@ -0,0 +1,158 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.os.StatFs
import android.os.storage.StorageManager
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import java.io.File
import java.util.zip.CRC32
/**
* Where store games are installed: `<root>/<Store>/<title>`.
*
* The default root is the app's own internal storage - the runtime's tree, where the folder sits at
* /root/Games/Stores and needs no bind - whatever the session's Game storage setting says. An SD
* card the app may write (its app folder, `GameStorage.options`) is offered as a second root at
* Install time and never chosen silently. Every root is scanned, so a game stays in the library
* wherever it went. A card that is the Steam library is already bound (`/mnt/droiddeck-sd`); any
* other card's root is bound at a fixed place of its own (`/mnt/droiddeck-stores/<volume>`), so a
* shortcut's path - and with it its appid - stays the same from one session to the next.
*/
object StoreInstallRoot {
private const val INTERNAL = "root/Games/Stores"
private const val ON_VOLUME = "Games"
const val GUEST_EXTERNAL = "/mnt/droiddeck-stores"
/** One place a game can be installed: the folder, its name for the dialog, and whether it is a card. */
class Target(val root: File, val label: String, val removable: Boolean) {
val freeBytes: Long get() = try { StatFs((if (root.isDirectory) root else root.parentFile ?: root).path).availableBytes } catch (e: Exception) { 0L }
}
/** The internal root: always there, always the default. */
fun internalRoot(context: Context): File = File(LinuxRuntime.rootDir(context), INTERNAL)
/** The roots a game can go to right now: internal first, then every writable card. */
fun targets(context: Context): List<Target> {
val internal = Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false)
val cards = GameStorage.options(context).map { Target(File(it.path, ON_VOLUME), it.name, removable = true) }
return listOf(internal) + cards
}
/** The default root, when nothing asks: internal storage. */
fun installRoot(context: Context): File = internalRoot(context)
/** Every root that may hold store games: the internal one, every card's, and the Steam library's (older installs). */
fun roots(context: Context): List<File> {
val cards = GameStorage.options(context).map { File(it.path, ON_VOLUME) }
val library = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) }
return (listOf(internalRoot(context)) + cards + listOfNotNull(library)).distinctBy { it.absolutePath }
}
/**
* The card roots that need a bind of their own, with where the session sees them. A root under
* the Steam library is left out: the library's bind already covers it, and the games there keep
* the guest path (and appid) they were registered with.
*/
fun externalRoots(context: Context): List<Pair<File, String>> {
val library = GameStorage.effective(context)?.path
return roots(context)
.filter { !isInternal(context, it) && (library == null || !it.absolutePath.startsWith("$library/")) }
.map { it to guestFor(context, it) }
}
/** A card root's guest path, keyed on the volume (its UUID, else a hash of the path), so it is the same across sessions. */
private fun guestFor(context: Context, root: File): String {
val sm = context.getSystemService(StorageManager::class.java)
val uuid = runCatching { sm?.getStorageVolume(root)?.uuid }.getOrNull()?.takeIf { it.isNotBlank() }?.replace(Regex("[^A-Za-z0-9-]"), "")
val key = uuid ?: "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
return "$GUEST_EXTERNAL/$key"
}
fun isInternal(context: Context, root: File): Boolean = root.absolutePath == internalRoot(context).absolutePath
/** Where a host path under a card root appears inside the session, or null when it is not under one. */
fun externalGuestPath(context: Context, host: File): String? {
val path = host.absolutePath
for ((root, guest) in externalRoots(context)) {
val dir = root.absolutePath
if (path == dir) return guest
if (path.startsWith("$dir/")) return guest + "/" + path.removePrefix("$dir/")
}
return null
}
/** The target a game folder sits under, for the page and the Downloads row; internal when unknown. */
fun labelFor(context: Context, folder: File): String {
val path = folder.absolutePath
for (t in targets(context)) if (path.startsWith(t.root.absolutePath + "/")) return t.label
return context.getString(com.droiddeck.launcher.R.string.games_internal)
}
fun isRemovable(context: Context, folder: File): Boolean = !folder.absolutePath.startsWith(internalRoot(context).absolutePath + "/")
fun storeDir(root: File, store: Store): File = File(root, store.shortLabel)
/** The game folders under every root, by store: what AddedGames scans in addition to the user's folders. */
fun gameFolders(context: Context): List<File> = roots(context).flatMap { root ->
Store.entries.flatMap { store ->
storeDir(root, store).listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()
}
}
/**
* The folder a game installs into under [root]. An existing folder wins over a fresh one, so a
* repair, an update or a resume lands on what is already there: one with this game's sidecar
* anywhere, else one under any root with this game's folder name and no finished sidecar (an
* install an earlier build left without one, or that stopped before writing it).
*/
fun folderFor(context: Context, store: Store, id: String, title: String, root: File = installRoot(context)): File =
existingFolder(context, store, id, title) ?: File(storeDir(root, store), folderName(title, id))
/** The folder an earlier install of this game left, finished or not; null when there is none. */
fun existingFolder(context: Context, store: Store, id: String, title: String): File? {
val all = roots(context).flatMap { r -> storeDir(r, store).listFiles { f -> f.isDirectory }?.toList().orEmpty() }
all.firstOrNull { folder -> StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id } == true }?.let { return it }
val name = folderName(title, id)
return all.firstOrNull { it.name == name && StoreGameSidecar.read(it)?.isInstalled != true }
}
/** An unfinished install: [id] from its `installing` sidecar, null when it has none (matched by folder name then). */
class Unfinished(val store: Store, val id: String?, val folder: File)
/**
* A folder under a store root is a game only once its sidecar says installed. Without one it is
* an install that never finished (stopped, killed, or from a build that wrote the sidecar last):
* not a Custom game, and not listed in Steam.
*/
fun isFinished(folder: File): Boolean = StoreGameSidecar.read(folder)?.isInstalled == true
/** Every unfinished install under every root, for the Stores page's "Resume install". */
fun unfinished(context: Context): List<Unfinished> = roots(context).flatMap { root ->
Store.entries.flatMap { store ->
storeDir(root, store).listFiles { f -> f.isDirectory }.orEmpty().mapNotNull { folder ->
val sidecar = StoreGameSidecar.read(folder)
when {
sidecar == null -> Unfinished(store, null, folder)
!sidecar.isInstalled -> Unfinished(store, sidecar.id, folder)
else -> null
}
}
}
}
/** A scratch folder in the app's cache for an install's in-flight pieces, when the install itself is on a card. */
fun scratchDir(context: Context, store: Store, id: String): File =
File(File(File(context.cacheDir, "stores"), store.id), id.replace(Regex("[^A-Za-z0-9._-]"), "_"))
/**
* A folder name from a store title: letters, digits and a few safe marks, single spaces, at most
* 60 characters; the store id when nothing printable is left. The name is the shortcut's folder
* for ever, so it is chosen once at install and read back from the sidecar afterwards.
*/
fun folderName(title: String, id: String): String {
val cleaned = title.replace(Regex("[^A-Za-z0-9 ._'()\\-]"), " ").replace(Regex("\\s+"), " ").trim().trimEnd('.')
val cut = if (cleaned.length > 60) cleaned.take(60).trimEnd().trimEnd('.') else cleaned
return cut.ifEmpty { id.replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "game" } }
}
}
@@ -0,0 +1,110 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.LibraryCache
import com.droiddeck.launcher.frontend.Library
import java.io.File
/**
* What happens around a store install so the rest of the app sees the game: the sidecar is
* written, the launcher .bat beside it when the game needs one, and Steam is told.
*
* Telling Steam is the same path an added folder takes: the game is one more folder the
* [AddedGames] scan finds, the scan's listing (`session/added-games.json`) is what the runtime's
* shortcuts writer reads at the client's next start, and here the listing is rewritten at once so
* a Steam client that restarts inside the running session already has it. When the client is up,
* [SteamLiveShortcuts] adds the shortcut over the client's DevTools port as well, so the game shows
* without a restart; that part is best-effort.
*/
object StoreInstalls {
private const val TAG = "StoreInstalls"
/**
* Marks [folder] as an install under way before anything is fetched: a sidecar in state
* `installing` with the store, id and title. Whatever stops the install from here on - a
* failure, a cancel, the process killed - leaves a folder the scan skips and the Stores page
* offers to resume, never a half-written Custom game. A finished install being repaired or
* updated keeps its finished sidecar, so the game stays in Steam meanwhile.
*/
fun begin(folder: File, store: Store, id: String, title: String, cover: String?, hero: String?) {
if (StoreGameSidecar.read(folder)?.isInstalled == true) return
StoreGameSidecar(store, id, title, exe = "", cover = cover, hero = hero, state = StoreGameSidecar.STATE_INSTALLING).write(folder)
}
/**
* Finishes an install: writes the launcher and the finished sidecar, then the art, then
* registers the game. Runs on the caller's worker thread. The launcher and sidecar writes
* throw, so a failure there fails the download with its message; the art and the Steam side
* are best-effort and only logged.
*/
fun complete(context: Context, folder: File, sidecar: StoreGameSidecar, onStep: ((Int, Int) -> Unit)? = null) {
val app = context.applicationContext
onStep?.invoke(0, STEPS)
// The user's launch choices survive an update or a repair.
val before = StoreGameSidecar.read(folder)
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud))
finished.write(folder)
if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}")
onStep?.invoke(1, STEPS)
// The art before the listing, so the listing already carries it for the client's grid.
try { StoreArt.fetchInto(folder, finished) } catch (e: Exception) { Log.w(TAG, "art for ${folder.name}: ${e.message}") }
onStep?.invoke(2, STEPS)
register(app)
onStep?.invoke(STEPS, STEPS)
}
/** The finishing steps [complete] reports: the record and launcher, the art, Steam. */
private const val STEPS = 3
/** Rewrites the session's listing from a fresh scan and, with a client running, adds live. */
fun register(context: Context) {
val app = context.applicationContext
try {
val games = AddedGames.scan(app)
AddedGames.writeListing(app, games)
LibraryCache.save(app, Library.launchableGames(app))
SteamLiveShortcuts.sync(app, games)
} catch (e: Exception) {
Log.w(TAG, "registration: ${e.message}")
}
}
/**
* Removes a store game: its folder with the sidecar, then the shortcut (the listing without
* it makes the writer drop the entry; the live client is asked too). Blocking.
*/
fun uninstall(context: Context, game: InstalledStoreGame) {
val app = context.applicationContext
val appId = AddedGames.scan(app).firstOrNull { it.folder.absolutePath == game.folder.absolutePath }?.appId
deleteTree(game.folder)
// The scratch cache an install on a card may have left in the app's cache.
deleteTree(StoreInstallRoot.scratchDir(app, game.sidecar.store, game.sidecar.id))
register(app)
if (appId != null) SteamLiveShortcuts.remove(app, appId)
StoresState.logLine("uninstalled \"${game.sidecar.title}\"")
}
/**
* What Cancel does: everything the install wrote goes - the game folder with its sidecar and
* the caches in [caches] - unless the folder holds a finished install being repaired or updated;
* then only the caches go and the working game stays. Off the caller's thread; the library
* follows when it is done, so the game reads Install again.
*/
fun discard(context: Context, folder: File, caches: List<File>) {
val app = context.applicationContext
Thread({
val keep = StoreGameSidecar.read(folder)?.isInstalled == true
caches.forEach { deleteTree(it) }
if (!keep) deleteTree(folder)
StoresState.logLine(if (keep) "removed the partial update of ${folder.name}" else "removed ${folder.name}")
StoresState.notifyLibraryChanged(app)
}, "store-cancel-clean").start()
}
fun deleteTree(dir: File) {
dir.listFiles()?.forEach { if (it.isDirectory) deleteTree(it) else it.delete() }
dir.delete()
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.Library
import java.io.File
/**
* How a store game is started inside the session. The shortcut Steam gets can only name an exe,
* so when a game needs arguments or environment the install writes a small `.droiddeck-launch.bat`
* beside it and the shortcut runs that: Proton's steam.exe shim hands a .bat to Wine's cmd, the
* script sets the variables, steps into the exe's folder and starts it, and waits for it as a
* batch file does, so Steam sees the game running.
*
* Epic's online sign-in is a fresh exchange code per launch (it expires in minutes), which no fixed
* command line can carry: [prepare] mints one right before a launch and leaves it in
* `.droiddeck-epic-code`; the script reads the file, deletes it and passes the AUTH arguments. With
* no file the game starts in its offline identity mode, which most EOS titles accept.
*/
object StoreLaunch {
private const val TAG = "StoreLaunch"
const val LAUNCHER = ".droiddeck-launch.bat"
const val EPIC_CODE = ".droiddeck-epic-code"
/**
* Writes the launcher for [sidecar] when it needs one (arguments, environment, or an Epic
* sign-in), removes a stale one otherwise, and returns the sidecar with [StoreGameSidecar.launcher] set.
*/
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
val file = File(folder, LAUNCHER)
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
file.writeText(launcherText(sidecar))
return sidecar.copy(launcher = LAUNCHER)
}
/** The batch file's text; pure, so it can be checked without a folder. CRLF line ends, as cmd expects. */
fun launcherText(sidecar: StoreGameSidecar): String {
val exe = sidecar.exe.replace('/', '\\')
val dir = exe.substringBeforeLast('\\', "")
val lines = ArrayList<String>()
lines += "@echo off"
lines += "setlocal"
lines += "rem Written by DroidDeck for ${sidecar.store.label}: ${sidecar.title}. Steam's shortcut runs this file."
lines += "cd /d \"%~dp0$dir\""
for ((k, v) in sidecar.env) if (ENV_NAME.matches(k)) lines += "set \"$k=${v.replace("\"", "")}\""
val args = sidecar.args.joinToString(" ") { quoteArg(it) }
val run = "\"%~dp0$exe\"" + (if (args.isNotEmpty()) " $args" else "")
if (sidecar.store == Store.EPIC) {
// The one-shot code reaches the game on its command line only. cmd expands %DD_X% when it
// parses the last line, so the variable is cleared on that same line before the game
// starts: nothing of it is left in the environment the game inherits (and dumps).
lines += "set \"$CODE_VAR=\""
lines += "if exist \"%~dp0$EPIC_CODE\" set /p $CODE_VAR=<\"%~dp0$EPIC_CODE\""
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
lines += "if defined $CODE_VAR goto signed"
lines += run
lines += "goto :eof"
lines += ":signed"
lines += "set \"$CODE_VAR=\" & $run -AUTH_LOGIN=unused -AUTH_PASSWORD=%$CODE_VAR% -AUTH_TYPE=exchangecode"
} else {
lines += run
}
return lines.joinToString("\r\n") + "\r\n"
}
private val ENV_NAME = Regex("[A-Za-z_][A-Za-z0-9_]*")
/** An argument on cmd's line: quoted when it has a space and is not already quoted; control characters dropped. */
fun quoteArg(arg: String): String {
val clean = arg.filter { it >= ' ' && it != '\u007f' }
return if (' ' in clean && !clean.contains('"')) "\"$clean\"" else clean
}
/**
* Everything a launch needs done first, then [onReady] on the main thread: for an Epic game a
* fresh exchange code written beside its launcher (a few seconds at most; the launch goes
* ahead without one when the store cannot be reached). Other stores need nothing.
*/
/** The launcher's variable for the one-shot code; a plain name, cleared before the game starts. */
private const val CODE_VAR = "DD_X"
/**
* Rewrites a game's launcher when its text is not what this build writes - an install from an
* earlier build keeps its old .bat otherwise. Called before every Epic launch.
*/
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
if (sidecar.launcher != LAUNCHER) return
val file = File(folder, LAUNCHER)
val text = launcherText(sidecar)
if (runCatching { file.readText() }.getOrNull() != text) {
file.writeText(text)
Log.i(TAG, "launcher rewritten for ${sidecar.id}")
}
}
fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) {
if (store != Store.EPIC) { onReady(); return }
val app = context.applicationContext
Thread({
epicCode(app, id)
StoresState.post(onReady)
}, "stores-launch-prep").start()
}
/** What [epicCode] did, for the guest's request and the log: whether a code was written, and why not. */
class CodeResult(val written: Boolean, val reason: String)
/**
* Mints an Epic exchange code for the installed game [id] and writes it as [EPIC_CODE] into the
* very folder its sidecar and launcher are in, where the launcher .bat reads and deletes it.
* Blocking (network). Logs `epic launch id=<id> code=yes|no reason=...` - never the code. Every
* launch path comes here: the Games tab and Stores before they launch, and the compat tool for
* any launch, the Steam client's own Play button included ([StoreLaunchRequests]).
*/
fun epicCode(context: Context, id: String): CodeResult {
val app = context.applicationContext
val result = try {
val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f -> StoreGameSidecar.read(f)?.let { it.store == Store.EPIC && it.id == id } == true }
val sidecar = folder?.let { StoreGameSidecar.read(it) }
sidecar?.let { refreshLauncher(folder, it) }
val options = sidecar?.epic
if (folder == null) CodeResult(false, "not-installed")
else if (options != null && !options.wantsCode) {
File(folder, EPIC_CODE).delete()
CodeResult(false, if (options.offline) "offline" else "eos-off")
} else {
val file = File(folder, EPIC_CODE)
file.delete()
val support = StoresState.backend(Store.EPIC) as? EpicLaunchSupport
val code = support?.exchangeCode(app)
when {
support == null -> CodeResult(false, "no-backend")
code != null -> { file.writeText(code); CodeResult(true, "ok") }
StoreAccounts.signedInAs(app, Store.EPIC) == null -> CodeResult(false, "signed-out")
support.signInExpired(app) -> { StoresState.markSignInExpired(Store.EPIC); CodeResult(false, "sign-in-expired") }
else -> CodeResult(false, "exchange-failed")
}
}
} catch (e: Exception) {
CodeResult(false, "error-" + e.javaClass.simpleName)
}
Log.i(TAG, "epic launch id=$id code=${if (result.written) "yes" else "no"} reason=${result.reason}")
return result
}
fun prepare(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
val store = Store.byId(game.source)
val id = game.storeId
if (store == null || id == null) { onReady(); return }
prepare(context, store, id, onReady)
}
}
/** What the Epic backend adds for launches: a short-lived exchange code for the signed-in account. */
interface EpicLaunchSupport {
fun exchangeCode(context: Context): String?
/** True when the stored sign-in can no longer be refreshed: the user has to sign in again. */
fun signInExpired(context: Context): Boolean
}
@@ -0,0 +1,108 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.os.FileObserver
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import org.json.JSONObject
/**
* The app's half of droiddeck-store-launch: the compat tool asks, before Proton starts a store
* game, for what the store needs at launch, through files under the session root's `stores/`
* (`req/<id>.json` in, `resp/<id>.json` out, both written tmp + rename). Today that is an Epic
* game's exchange code, which [StoreLaunch.epicCode] writes into the game's own folder; the answer
* says only whether it did and why not. Watched while a session runs.
*/
object StoreLaunchRequests {
private const val TAG = "StoreLaunchRequests"
private val lock = Any()
private var observer: FileObserver? = null
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "stores")
/** Before each session: nothing of the last one's requests; then watch for new ones. */
fun start(context: Context) {
val app = context.applicationContext
synchronized(lock) {
observer?.stopWatching()
val root = dir(app)
root.deleteRecursively()
val req = File(root, "req").apply { mkdirs() }
File(root, "resp").mkdirs()
@Suppress("DEPRECATION")
observer = object : FileObserver(req.path, MOVED_TO or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
if (path == null || !path.endsWith(".json")) return
val file = File(req, path)
Thread({ answer(app, file) }, "store-launch-request").start()
}
}.also { it.startWatching() }
}
}
fun stop() {
synchronized(lock) {
observer?.stopWatching()
observer = null
}
}
private fun answer(app: Context, file: File) {
val request = try {
JSONObject(file.readText()).also { file.delete() }
} catch (e: Exception) {
return // withdrawn already, or not complete yet (the rename brings it again)
}
val reply = when (request.optString("op")) {
"epic-code" -> {
val id = request.optString("id")
if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
else {
val code = StoreLaunch.epicCode(app, id)
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
}
}
// Cloud saves: down before Proton starts (the wrapper waits a bounded time), up after the
// game exits (answered at once; the upload runs on without holding Steam).
"cloud-down" -> {
val store = Store.byId(request.optString("store"))
val id = request.optString("id")
if (store == null || id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-game")
else {
// Marked before the game runs: whatever ends it, an upload follows (CloudSaves.uploadDirty).
CloudSaves.markDirty(app, store, id)
CloudSaves.downloadOrDefer(app, store, id).let { JSONObject().put("ok", it.ok).put("result", it.result).put("files", it.files).put("reason", it.reason) }
}
}
"cloud-up" -> {
val store = Store.byId(request.optString("store"))
val id = request.optString("id")
if (store != null && id.isNotEmpty()) Thread({ CloudSaves.uploadDirty(app, store, id, "exit") }, "cloud-up").start()
JSONObject().put("ok", true)
}
// A browser a game opens inside the session (an EOS device sign-in, a store page):
// http(s) only, handed to Android's browser.
"open-url" -> {
val url = request.optString("url")
if (!(url.startsWith("https://") || url.startsWith("http://"))) JSONObject().put("ok", false).put("reason", "not-http")
else try {
app.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
Log.i(TAG, "open-url: " + StoreLog.redactUrl(url))
JSONObject().put("ok", true)
} catch (e: Exception) {
JSONObject().put("ok", false).put("reason", e.javaClass.simpleName)
}
}
else -> JSONObject().put("ok", false).put("reason", "unknown-op")
}
try {
val resp = File(dir(app), "resp").apply { mkdirs() }
val tmp = File(resp, file.name + ".tmp")
tmp.writeText(reply.toString())
tmp.renameTo(File(resp, file.name))
} catch (e: Exception) {
Log.w(TAG, "could not answer ${file.name}: ${e.message}")
}
}
}
@@ -0,0 +1,79 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* The stores' log on disk, app-private: `filesDir/logs/stores/stores-<yyyy-MM-dd>.log` (beside the
* session folders, where every log lives), one file a
* day, the last [KEEP_DAYS] kept, each capped near [MAX_BYTES] (the full one moves to `.1`). Lines
* arrive already redacted ([StoreLog.redactLine]). Written on one background thread, in order. The
* session's Share logs zip carries these files, so they can be attached to a report when asked.
*/
object StoreLogFiles {
private const val TAG = "StoreLogFiles"
private const val KEEP_DAYS = 7
private const val MAX_BYTES = 2L * 1024 * 1024
private val DAY = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val NAME = Regex("""stores-(\d{4}-\d{2}-\d{2})\.log(\.1)?""")
private val writer = Executors.newSingleThreadExecutor { r -> Thread(r, "store-log").apply { isDaemon = true } }
fun dir(context: Context): File = File(context.applicationContext.filesDir, "logs/stores")
private val pending = java.util.concurrent.ConcurrentLinkedQueue<String>()
private val flushQueued = java.util.concurrent.atomic.AtomicBoolean(false)
private val flusher = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r ->
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "store-log-flush").apply { isDaemon = true }
}
/**
* Queues one stamped line for today's file. Any thread; nothing touches the disk here. Lines are
* written in batches, at most every half second, on [writer] in order.
*/
fun append(context: Context, line: String) {
val app = context.applicationContext
pending.add(line)
if (flushQueued.compareAndSet(false, true)) {
flusher.schedule({ flushQueued.set(false); writer.execute { flush(app) } }, 500, java.util.concurrent.TimeUnit.MILLISECONDS)
}
}
private fun flush(app: Context) {
if (pending.isEmpty()) return
val batch = StringBuilder()
while (true) { val l = pending.poll() ?: break; batch.append(l).append('\n') }
try {
val dir = dir(app).apply { mkdirs() }
val file = File(dir, "stores-${synchronized(DAY) { DAY.format(Date()) }}.log")
if (file.length() > MAX_BYTES) {
val old = File(dir, file.name + ".1")
old.delete()
file.renameTo(old)
}
file.appendText(batch.toString())
} catch (e: Exception) {
Log.w(TAG, "could not write the stores log: ${e.message}")
}
}
/** Drops the files older than [KEEP_DAYS] days. Called as the app starts. */
fun prune(context: Context) {
val app = context.applicationContext
writer.execute {
val cutoff = synchronized(DAY) { DAY.format(Date(System.currentTimeMillis() - (KEEP_DAYS - 1) * 86_400_000L)) }
dir(app).listFiles()?.forEach { f ->
val day = NAME.matchEntire(f.name)?.groupValues?.get(1) ?: return@forEach
if (day < cutoff) f.delete()
}
}
}
/** The files there are, oldest first, for a log share. */
fun files(context: Context): List<File> =
dir(context).listFiles { f -> f.isFile && NAME.matches(f.name) }?.sortedBy { it.name }.orEmpty()
}
@@ -0,0 +1,308 @@
package com.droiddeck.launcher.stores
import android.annotation.SuppressLint
import android.graphics.Color
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
import android.os.Message
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.gog.GogAuth
import java.util.concurrent.atomic.AtomicBoolean
/**
* A store's own sign-in page in a WebView, one activity for the three stores: GOG's OAuth page
* (the token comes back in the redirect's fragment), Epic's web login (an authorization code on
* a JSON page), Amazon's device sign-in (a PKCE code on the return URL). Plain views, as the
* proven GOG flow was: the page is rendered with the store's own client identity, and a social
* sign-in hop to Google / Facebook / Apple runs under a real browser identity in a popup, since
* those providers refuse an embedded browser.
*
* Nothing from the page is logged but redacted URLs and key names; the tokens go straight to
* StoreAccounts' private file and the activity finishes.
*/
class StoreLoginActivity : ComponentActivity() {
companion object {
private const val TAG = "StoreLogin"
const val EXTRA_STORE = "store"
private const val KEY_STATE = "oauth_state"
private const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
fun generateState(): String {
val bytes = ByteArray(24)
java.security.SecureRandom().nextBytes(bytes)
return android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
}
}
/** What one store's flow decides: the page to open, the identity to open it with, and the redirect that ends it. */
internal interface Flow {
val startUrl: String
val userAgent: String
/** True when [url] is the flow's end; the activity then calls [finish]. */
fun isRedirect(url: String): Boolean
/** Runs off the main thread with the redirect; returns null on success, else a message to show. */
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
/** Whether a JSON page at the redirect must be read out of the document instead of the URL (Epic). */
val readsPage: Boolean get() = false
}
private var store: Store? = null
private var flow: Flow? = null
private var webViewRef: WebView? = null
private var popupWebView: WebView? = null
private lateinit var contentHost: FrameLayout
private lateinit var popupHost: FrameLayout
private lateinit var progressBar: ProgressBar
private lateinit var errorPanel: LinearLayout
private lateinit var errorText: TextView
private lateinit var titleText: TextView
private var oauthState: String? = null
private val captured = AtomicBoolean(false)
private fun dp(value: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
store = Store.byId(intent.getStringExtra(EXTRA_STORE))
oauthState = savedInstanceState?.getString(KEY_STATE) ?: generateState()
flow = store?.let { LoginFlows.forStore(it, oauthState!!) }
if (flow == null) { finish(); return }
setContentView(buildChrome())
val webView = newWebView(flow!!.userAgent, isPopup = false)
webViewRef = webView
contentHost.addView(webView, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
popupWebView != null -> dismissPopup()
webViewRef?.canGoBack() == true -> webViewRef?.goBack()
else -> finish()
}
}
})
webView.loadUrl(flow!!.startUrl)
}
private fun buildChrome(): View {
val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(0xFF0A0B0D.toInt()) }
val bar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL; setBackgroundColor(0xFF121417.toInt())
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))
}
titleText = TextView(this).apply {
text = getString(R.string.stores_login_title, store?.label ?: "")
setTextColor(0xFFF2F4F7.toInt()); textSize = 16f; setPadding(dp(16), 0, dp(8), 0)
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
val reload = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_rotate); setBackgroundColor(Color.TRANSPARENT)
contentDescription = getString(R.string.stores_login_reload); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
setOnClickListener { reloadLogin() }
}
val close = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_close_clear_cancel); setBackgroundColor(Color.TRANSPARENT)
contentDescription = getString(R.string.stores_login_close); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
setOnClickListener { finish() }
}
bar.addView(titleText); bar.addView(reload); bar.addView(close)
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
max = 100; isIndeterminate = false
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3))
}
contentHost = FrameLayout(this).apply { setBackgroundColor(0xFF0A0B0D.toInt()); layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) }
popupHost = FrameLayout(this).apply { setBackgroundColor(0xFF0A0B0D.toInt()); visibility = View.GONE }
contentHost.addView(popupHost, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
errorPanel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER; setBackgroundColor(0xFF0A0B0D.toInt())
setPadding(dp(24), dp(24), dp(24), dp(24)); visibility = View.GONE
}
errorText = TextView(this).apply { setTextColor(0xFFF2F4F7.toInt()); textSize = 15f; gravity = Gravity.CENTER }
val retry = Button(this).apply { text = getString(R.string.stores_login_retry); setOnClickListener { reloadLogin() } }
errorPanel.addView(errorText)
errorPanel.addView(retry, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(16) })
contentHost.addView(errorPanel, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
root.addView(bar); root.addView(progressBar); root.addView(contentHost)
return root
}
private fun showError(message: String) {
runOnUiThread { errorText.text = message; errorPanel.visibility = View.VISIBLE; errorPanel.bringToFront(); progressBar.visibility = View.GONE }
}
private fun clearError() { runOnUiThread { errorPanel.visibility = View.GONE } }
private fun reloadLogin() {
dismissPopup(); clearError()
captured.set(false)
val fresh = generateState()
oauthState = fresh
flow = store?.let { LoginFlows.forStore(it, fresh) } ?: return
webViewRef?.let { it.settings.userAgentString = flow!!.userAgent; it.loadUrl(flow!!.startUrl) }
}
@SuppressLint("SetJavaScriptEnabled")
private fun newWebView(userAgent: String, isPopup: Boolean): WebView = WebView(this).apply {
setBackgroundColor(0xFF0A0B0D.toInt())
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.userAgentString = userAgent
// The stores' social-login buttons are window.open() popups; without these the call is dropped silently.
settings.setSupportMultipleWindows(true)
settings.javaScriptCanOpenWindowsAutomatically = true
if (isPopup) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
webViewClient = Client(isPopup)
webChromeClient = Chrome(isPopup)
}
private inner class Chrome(private val isPopup: Boolean) : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progressBar.progress = newProgress
progressBar.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
}
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean {
if (resultMsg == null) return false
dismissPopup()
val child = newWebView(CHROME_UA, isPopup = true)
popupWebView = child
popupHost.addView(child, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
popupHost.visibility = View.VISIBLE
popupHost.bringToFront()
titleText.text = getString(R.string.stores_login_provider_title)
(resultMsg.obj as WebView.WebViewTransport).webView = child
resultMsg.sendToTarget()
return true
}
override fun onCloseWindow(window: WebView?) { if (isPopup || window === popupWebView) dismissPopup() }
}
/** Deferred: destroying a WebView from inside one of its own callbacks crashes chromium. */
private fun dismissPopup() {
val child = popupWebView ?: return
popupWebView = null
popupHost.post { destroyPopupView(child) }
}
private fun destroyPopupView(child: WebView) {
child.stopLoading(); child.webChromeClient = null; child.loadUrl("about:blank")
popupHost.removeView(child); child.destroy()
if (popupWebView == null) {
popupHost.visibility = View.GONE
if (!isFinishing) titleText.text = getString(R.string.stores_login_title, store?.label ?: "")
}
}
private inner class Client(private val isPopup: Boolean) : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
Log.d(TAG, "page[popup=$isPopup]: ${StoreLog.redactUrl(url)}")
// Amazon's return URL lands as a page load rather than a navigation we can intercept.
if (url != null && view != null && flow?.isRedirect(url) == true && flow?.readsPage != true) complete(view, url)
}
override fun onPageFinished(view: WebView?, url: String?) {
// Epic's redirect is a JSON page: the code is in the document, read once it is loaded.
if (url != null && view != null && flow?.readsPage == true && flow?.isRedirect(url) == true) complete(view, url)
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
if (request?.isForMainFrame == true) showError(getString(R.string.stores_login_error_network, error?.description ?: ""))
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
val status = errorResponse?.statusCode ?: 0
if (request?.isForMainFrame != true) return
if (GogAuth.isIdentityProviderHost(request.url?.host) && (status == 403 || status == 400)) showError(getString(R.string.stores_login_error_idp_blocked))
else showError(getString(R.string.stores_login_error_http, status))
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.cancel()
showError(getString(R.string.stores_login_error_ssl))
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = route(view, request.url)
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = route(view, Uri.parse(url))
private fun route(view: WebView, uri: Uri): Boolean {
val f = flow ?: return false
val url = uri.toString()
if (f.isRedirect(url) && !f.readsPage) { complete(view, url); return true }
// A social sign-in hop that stays in the main view: the provider gets a browser identity,
// and the store gets its own identity back afterwards (a plain Chrome UA makes GOG's
// page serve a form that never renders in a WebView).
if (!isPopup && store == Store.GOG) {
if (GogAuth.isGogHost(uri.host) && view.settings.userAgentString != f.userAgent) { view.settings.userAgentString = f.userAgent; return false }
if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != CHROME_UA) {
view.settings.userAgentString = CHROME_UA
CookieManager.getInstance().setAcceptThirdPartyCookies(view, true)
view.loadUrl(url)
return true
}
}
return false
}
}
private fun complete(view: WebView, url: String) {
if (!captured.compareAndSet(false, true)) return
val f = flow ?: return
dismissPopup()
runOnUiThread {
clearError()
titleText.text = getString(R.string.stores_login_finishing)
if (!f.readsPage) view.stopLoading()
}
if (f.readsPage) {
// The document's text, then the exchange off the main thread.
view.evaluateJavascript("(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()") { json ->
Thread({ settle(f.finish(this, view, json ?: "")) }, "store-login").start()
}
} else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
}
private fun settle(problem: String?) {
runOnUiThread {
if (problem == null) { StoresState.refresh(this); finish(); return@runOnUiThread }
captured.set(false)
if (!isFinishing && !isDestroyed) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
reloadLogin()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
oauthState?.let { outState.putString(KEY_STATE, it) }
}
override fun onDestroy() {
popupWebView?.let { popupWebView = null; destroyPopupView(it) }
webViewRef?.let { it.webChromeClient = null; (it.parent as? ViewGroup)?.removeView(it); it.destroy() }
webViewRef = null
super.onDestroy()
}
}
@@ -0,0 +1,164 @@
package com.droiddeck.launcher.stores
import android.util.Log
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
/**
* The small HTTP the storefront catalogs and account calls need: a GET or a JSON POST with the
* right User-Agent, a bearer token when the call is signed in, and a body or null. Store URLs
* carry their authorization in the query string or the path, so a URL is only ever logged
* through [StoreLog.redactUrl]. Plain HttpURLConnection, like the rest of the app.
*/
internal object StoreNet {
private const val TAG = "StoreNet"
/** A desktop browser: GOG's catalog and Epic's store GraphQL answer it; Epic's edge refuses the bare Java UA. */
const val BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
fun get(url: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 20_000, headers: Map<String, String> = emptyMap()): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
instanceFollowRedirects = true
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Accept", "application/json, text/plain, */*")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
headers.forEach { (k, v) -> setRequestProperty(k, v) }
}
val code = conn.responseCode
if (code !in 200..299) { Log.w(TAG, "GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
} catch (e: Exception) {
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = "POST"
doOutput = true
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
}
OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body) }
val code = conn.responseCode
if (code !in 200..299) { Log.w(TAG, "POST $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
} catch (e: Exception) {
Log.w(TAG, "POST failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/**
* A 600x900 poster for [title] from SteamGridDB by name, or "" - the lookup the GOG library and
* the Amazon poster backfill use, since those stores publish no tall art of their own.
*/
fun sgdbPoster(title: String): String = try {
val enc = java.net.URLEncoder.encode(title, "UTF-8")
val search = get("https://www.steamgriddb.com/api/v2/search/autocomplete/$enc", bearer = SGDB_KEY)
val data = search?.let { org.json.JSONObject(it).optJSONArray("data") }
if (data == null || data.length() == 0) "" else {
val gameId = data.getJSONObject(0).getInt("id")
val grids = get("https://www.steamgriddb.com/api/v2/grids/game/$gameId?dimensions=600x900&mimes=image/jpeg,image/png&limit=1", bearer = SGDB_KEY)
?.let { org.json.JSONObject(it).optJSONArray("data") }
if (grids == null || grids.length() == 0) "" else grids.getJSONObject(0).optString("url", "")
}
} catch (_: Exception) { "" }
private const val SGDB_KEY = "cf89227f12c773bb1117b6b109ae1659"
private fun readAll(conn: HttpURLConnection): String {
val sb = StringBuilder()
BufferedReader(InputStreamReader(conn.inputStream, "UTF-8")).use { br ->
val buf = CharArray(8192)
var n: Int
while (br.read(buf).also { n = it } > 0) sb.append(buf, 0, n)
}
return sb.toString()
}
}
/**
* Keeps credentials out of the logs. A store's CDN, manifest and token URLs carry their
* authorization in the query string (signed, time-limited tokens, `client_secret`,
* `refresh_token`) or in the authority's userinfo; one such line in logcat or a shared log hands a
* reader a live download. Every store URL that is written anywhere goes through [redactUrl] first.
*/
object StoreLog {
/** `scheme://host/path` only: query, fragment and `user:pass@` dropped; "[url]" when it cannot be parsed. */
@JvmStatic
fun redactUrl(url: String?): String? {
if (url == null) return null
return try {
var out: String = url
val hash = out.indexOf('#')
if (hash >= 0) out = out.substring(0, hash)
val q = out.indexOf('?')
if (q >= 0) out = out.substring(0, q)
val scheme = out.indexOf("://")
if (scheme >= 0) {
val authStart = scheme + 3
val pathStart = out.indexOf('/', authStart)
val authority = if (pathStart >= 0) out.substring(authStart, pathStart) else out.substring(authStart)
val at = authority.lastIndexOf('@')
if (at >= 0) out = out.substring(0, authStart) + authority.substring(at + 1) + (if (pathStart >= 0) out.substring(pathStart) else "")
}
out.ifEmpty { "[url]" }
} catch (e: Exception) {
"[url]"
}
}
private val URL_PATTERN = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
private val SECRET = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'}]+""")
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code|exchange_code|authorizationCode)(["']?\s*=\s*["']?)[^\s&;,"'}]+""")
private val AUTH_HEADER = Regex("""(?i)(authorization|cookie)(\s*[:=]\s*).*""")
/**
* What any store line may say, for the Downloads log and logcat alike: every URL cut to
* `scheme://host/first-segment/…` (the segment kept only when it is a plain name - a GOG secure
* link carries its token in the path), no query, fragment or userinfo; and outside URLs, the
* values of token-like keys and of Authorization / Cookie headers blanked.
*/
@JvmStatic
fun redactLine(text: String): String {
var out = URL_PATTERN.replace(text) { m -> shortUrl(m.value) }
out = SECRET.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
out = CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
out = AUTH_HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
return out
}
private fun shortUrl(url: String): String {
val schemeEnd = url.indexOf("://")
val scheme = url.substring(0, schemeEnd)
var rest = url.substring(schemeEnd + 3)
rest = rest.substringBefore('#').substringBefore('?')
val slash = rest.indexOf('/')
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
if (slash < 0) return "$scheme://$authority"
val path = rest.substring(slash + 1)
val first = path.substringBefore('/')
val more = path.length > first.length
if (first.isEmpty()) return "$scheme://$authority/"
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
return "$scheme://$authority/$shown" + if (more && shown != "…") "/…" else ""
}
}
@@ -0,0 +1,96 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.stores.amazon.AmazonApiClient
import com.droiddeck.launcher.stores.amazon.AmazonCredentialStore
import com.droiddeck.launcher.stores.amazon.AmazonManifest
import com.droiddeck.launcher.stores.amazon.AmazonPrefs
import com.droiddeck.launcher.stores.epic.EpicApiClient
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
import com.droiddeck.launcher.stores.epic.EpicInstallTags
import com.droiddeck.launcher.stores.epic.EpicPrefs
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
* Install sizes for owned Epic and Amazon games, whose library answers carry none: Epic's from the
* game's build manifest (the files this device installs), Amazon's from its download manifest.
* Asked for a card as it comes on screen, three at a time in the background, once per game - the
* answer is kept in the store's prefs (`size_<id>`, where the library reads it too). Until it is
* known the card shows nothing.
*/
object StoreSizes {
private const val TAG = "StoreSizes"
/** Sizes found this run, by card key; the cards read them. */
val known = mutableStateMapOf<String, Long>()
private val asked = ConcurrentHashMap.newKeySet<String>()
private val pool = Executors.newFixedThreadPool(3, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("sizes"))
fun size(item: CatalogItem): Long = known[item.key] ?: item.sizeBytes
private val caches = ConcurrentHashMap<Store, SizeCache>()
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
/** A card for [item] is on screen (or about to be): its size from the cache for this version, else looked up. */
fun request(context: Context, item: CatalogItem) {
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG) return
if (known.containsKey(item.key) || !asked.add(item.key)) return
val app = context.applicationContext
val version = item.extra["version"].orEmpty()
pool.execute {
val cached = cache(app, item.store).get(item.id, version)
if (cached != null) { StoresState.post { known[item.key] = cached }; return@execute }
val bytes = runCatching { fetch(app, item) }.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }.getOrNull() ?: 0L
if (bytes > 0) {
cache(app, item.store).put(item.id, version, bytes)
when (item.store) {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.GOG -> {}
}
StoresState.post { known[item.key] = bytes }
}
}
}
/** An installed game's size on disk, by card key; worked out once per install. */
val onDisk = mutableStateMapOf<String, Long>()
fun requestDisk(context: Context, key: String, game: InstalledStoreGame) {
if (onDisk.containsKey(key) || !asked.add("disk:$key")) return
val app = context.applicationContext
val version = "disk:${game.sidecar.installedAt}:${game.sidecar.installVersion}"
pool.execute {
val c = cache(app, game.sidecar.store)
val bytes = c.get("disk:${game.sidecar.id}", version)
?: game.folder.walkTopDown().filter { it.isFile && !it.name.startsWith(".droiddeck") }.sumOf { it.length() }.also { c.put("disk:${game.sidecar.id}", version, it) }
if (bytes > 0) StoresState.post { onDisk[key] = bytes }
}
}
private fun fetch(context: Context, item: CatalogItem): Long { return when (item.store) {
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return 0L
val ns = item.extra["namespace"].orEmpty()
val catalogItemId = item.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || catalogItemId.isEmpty()) return 0L
val json = EpicApiClient.getManifestApiJson(token, ns, catalogItemId, item.id) ?: return 0L
val bytes = EpicDownloadManager.downloadManifest(json, EpicDownloadManager.parseCdnUrls(json)) ?: return 0L
val manifest = EpicDownloadManager.parseManifest(bytes) ?: return 0L
EpicDownloadManager.resolveInstallFiles(manifest, EpicInstallTags.tagsForDevice()).sumOf { it.fileSize() }
}
Store.AMAZON -> {
val token = AmazonCredentialStore.getValidAccessToken(context) ?: return 0L
val entitlement = item.extra["entitlementId"].orEmpty().ifEmpty { return 0L }
val spec = AmazonApiClient.getGameDownload(token, entitlement) ?: return 0L
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
AmazonManifest.parse(bytes).totalInstallSize
}
Store.GOG -> 0L
} }
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.stores
import android.util.Log
/**
* The native download engine for the three stores: `libdroiddeckstores.so`, loaded the first time
* a store needs it, never at process start. The app has to run without it (a build without the
* Rust side, or an ABI the library was not built for), so [available] is a plain false then and
* Setup says "Stores engine not built"; the store managers fall back to their Java fetch loops.
*/
object StoresNative {
private const val TAG = "StoresNative"
private const val LIBRARY = "droiddeckstores"
@Volatile private var loaded: Boolean? = null
@Volatile private var versionText: String? = null
@Volatile private var failure: String? = null
/** True once the library is loaded and its version symbol binds; false when it cannot be. Cached. */
val available: Boolean get() = ensureLoaded()
/** The engine's own version string, or null when the library is not there. */
val version: String? get() { ensureLoaded(); return versionText }
/** Why the library did not load, for the Setup row; null when it did. */
val problem: String? get() { ensureLoaded(); return failure }
@Synchronized
fun ensureLoaded(): Boolean {
loaded?.let { return it }
val ok = try {
System.loadLibrary(LIBRARY)
versionText = nativeVersion()
Log.i(TAG, "engine ${versionText ?: "?"} loaded")
true
} catch (t: Throwable) {
// UnsatisfiedLinkError when the .so is missing or a symbol is not there; anything else is
// a surprise worth the same treatment: the Java paths carry on.
failure = t.javaClass.simpleName + (t.message?.let { ": $it" } ?: "")
Log.w(TAG, "engine unavailable: $failure")
false
}
loaded = ok
return ok
}
/** Resets the cached answer, so a probe after an update tries the library again. */
@Synchronized
fun forget() { loaded = null }
/** The crate's version string; only after [ensureLoaded] said yes. */
@JvmStatic
external fun nativeVersion(): String
}
@@ -0,0 +1,271 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.download.DownloadEntry
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The Stores section's state for the life of the process (like the Flathub store's StoreState):
* who is signed in where, each store's library and shelves, what is installed on disk and what is
* being downloaded, so a download carries on - and its progress shows - while the user moves
* around the launcher or plays something meanwhile.
*
* Everything here is read on the main thread by Compose; the workers post their results back.
* The stores themselves plug in as [StoreBackend]s; a store with no backend in this build shows
* its sign-in card and nothing else.
*/
object StoresState {
private const val TAG = "Stores"
private val main = Handler(Looper.getMainLooper())
/** The signed-in account name per store; absent = signed out. */
val accounts = mutableStateMapOf<Store, String>()
/** Each store's owned games, once synced; absent until then. */
val library = mutableStateMapOf<Store, List<CatalogItem>>()
/** Each store's storefront shelves, once fetched. */
val shelves = mutableStateMapOf<Store, StoreShelves>()
/** What a store is doing for the user right now ("Syncing 12 games…"); absent when idle. */
val status = mutableStateMapOf<Store, String>()
/** Why the last sync or shelf fetch failed, per store; cleared by the next success. */
val problems = mutableStateMapOf<Store, String>()
/** Games the stores installed, as their sidecars say; rebuilt by [refresh]. */
var installed by mutableStateOf<List<InstalledStoreGame>>(emptyList())
private set
/**
* The download queue as the Downloads page lists it, newest first. Replaced only when a row is
* added, removed, reordered or changes state - not on progress - so whatever iterates it does
* not recompose on every byte. A row's progress is read through [download].
*/
var downloads by mutableStateOf<List<DownloadEntry>>(emptyList())
private set
/** Downloads queued, running or paused across the stores (the rail badge); changes only when the count does. */
var activeDownloads by mutableStateOf(0)
private set
private val rows = HashMap<String, androidx.compose.runtime.MutableState<DownloadEntry?>>()
/** One row's live entry: a composable reading it recomposes for that row's progress only. Main thread. */
fun download(key: String): DownloadEntry? = rows.getOrPut(key) { mutableStateOf(null) }.value
/** Main thread: each row's holder set (equal entries change nothing), the list and the count only when they change. */
/** The newest list as published, progress included; for the notification, which is not composed. */
@Volatile var latestDownloads: List<DownloadEntry> = emptyList()
private set
private var notifiedAt = 0L
internal fun publishDownloads(list: List<DownloadEntry>) {
latestDownloads = list
// The notification follows at most once a second.
val now = android.os.SystemClock.uptimeMillis()
if (now - notifiedAt >= 1000) { notifiedAt = now; com.droiddeck.launcher.stores.download.StoreDownloadService.tick() }
val keys = list.mapTo(HashSet()) { it.key }
for (e in list) rows.getOrPut(e.key) { mutableStateOf(null) }.value = e
for ((k, holder) in rows) if (k !in keys) holder.value = null
val shape = { l: List<DownloadEntry> -> l.map { it.key to it.state } }
if (shape(list) != shape(downloads)) downloads = list
activeDownloads = list.count { it.isActive }
}
/** The native engine's version once probed, "" when the probe said it is missing, null before. */
var engine by mutableStateOf<String?>(null)
private set
private val backends = HashMap<Store, StoreBackend>()
/** A store's implementation, registered once as the app starts; null when this build lacks it. */
fun backend(store: Store): StoreBackend? = backends[store]
fun register(backend: StoreBackend) { backends[backend.store] = backend }
/** Reads what is on disk for every store: sign-ins, installed games, the engine probe. Off the main thread. */
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } }
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) }
}
val partial = StoreInstallRoot.unfinished(app)
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
main.post {
accounts.clear()
found.forEach { (store, name) -> accounts[store] = name }
installed = onDisk
unfinished = partial
engine = version
}
}, "stores-refresh").start()
}
fun isSignedIn(store: Store): Boolean = accounts.containsKey(store)
/** Stores whose sign-in has run out (a launch could not get a code): their chip dims until the user signs in again. */
val expired = mutableStateMapOf<Store, Boolean>()
fun markSignInExpired(store: Store) = post { expired[store] = true }
/** Installs that stopped before they finished; their Install button reads "Resume install". */
var unfinished by mutableStateOf<List<StoreInstallRoot.Unfinished>>(emptyList())
internal set
fun isUnfinished(item: CatalogItem): Boolean = unfinished.any {
it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id)))
}
/** Deletes an unfinished install's folder and caches, as Cancel would; drops its row if there is one. */
fun clearUnfinished(context: Context, item: CatalogItem) {
val app = context.applicationContext
val key = "${item.store.id}:${item.id}"
if (com.droiddeck.launcher.stores.download.DownloadQueue.entry(key) != null) { com.droiddeck.launcher.stores.download.DownloadQueue.clear(app, key); return }
val folder = unfinished.firstOrNull {
it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id)))
}?.folder ?: return
StoreInstalls.discard(app, folder, listOf(java.io.File(folder, ".chunks"), java.io.File(folder, ".gog_chunks"), StoreInstallRoot.scratchDir(app, item.store, item.id)))
}
fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id }
/** The user's library and the shelves for [store], from the cache first and the network when stale. */
fun open(context: Context, store: Store, force: Boolean = false) {
val backend = backends[store] ?: return
val app = context.applicationContext
if (!isSignedIn(store)) return
if (force || !library.containsKey(store)) backend.syncLibrary(app, force)
if (force || !shelves.containsKey(store)) backend.loadShelves(app, force)
}
fun signIn(context: Context, store: Store) {
expired.remove(store)
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
}
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
Thread({
backends[store]?.signOut(app)
StoreAccounts.clear(app, store)
main.post {
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
}
}, "stores-signout").start()
}
/** A notification asked for the Downloads chip; the Stores page takes it and clears it. */
var openDownloads by mutableStateOf(false)
/**
* Android 13+: notifications need permission. Asked once, the first time a download starts from
* a screen; refused, downloads still run, without their notification.
*/
private fun askForNotifications(context: Context) {
val activity = context as? android.app.Activity ?: return
if (android.os.Build.VERSION.SDK_INT < 33) return
if (activity.checkSelfPermission("android.permission.POST_NOTIFICATIONS") == android.content.pm.PackageManager.PERMISSION_GRANTED) return
val p = activity.getSharedPreferences("stores", Context.MODE_PRIVATE)
if (p.getBoolean("asked_notifications", false)) return
p.edit().putBoolean("asked_notifications", true).apply()
runCatching { activity.requestPermissions(arrayOf("android.permission.POST_NOTIFICATIONS"), 7102) }
}
/** An install waiting for the user to say where: the page shows the choice while this is set. */
var pendingInstall by mutableStateOf<CatalogItem?>(null)
/**
* The way a page starts an install: with a card in the device the user is asked where (the
* dialog's default is last time's pick, never a silent choice); with none it goes to internal
* storage at once.
*/
fun requestInstall(context: Context, item: CatalogItem) {
askForNotifications(context)
// A resume goes where the files already are; there is nothing to choose.
if (isUnfinished(item)) { install(context, item); return }
val targets = StoreInstallRoot.targets(context)
if (targets.size > 1) pendingInstall = item else install(context, item, targets.first().root)
}
/** Starts an install into [root] (the internal root when null). */
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
val app = context.applicationContext
val target = root ?: StoreInstallRoot.installRoot(app)
backends[item.store]?.install(app, item, target) ?: logLine("${item.store.label}: this build cannot install yet")
}
/** Removes the game's folder and its shortcut, then [onDone] on the main thread. */
fun uninstall(context: Context, game: InstalledStoreGame, onDone: () -> Unit = {}) {
val app = context.applicationContext
Thread({
runCatching { backends[game.sidecar.store]?.uninstall(app, game) }
StoreInstalls.uninstall(app, game)
main.post(onDone)
}, "stores-uninstall").start()
}
/** Told on the main thread when an install or removal changed what is on disk; the launcher rebuilds its Games list. */
@Volatile var libraryListener: (() -> Unit)? = null
/** A game landed or went: the installed set is re-read and the launcher told. Any thread. */
fun notifyLibraryChanged(context: Context) {
refresh(context)
main.post { libraryListener?.invoke() }
}
/** Appends to the engine log with a clock, keeping the last 80 lines. Any thread. */
fun logLine(raw: String) {
// Every store line passes here, the engines' included: no token or full URL is kept.
val text = StoreLog.redactLine(raw)
Log.i(TAG, text)
val app = appContext ?: return
StoreLogFiles.append(app, "${synchronized(CLOCK) { CLOCK.format(Date()) }} $text")
}
private var appContext: Context? = null
/** Called once as the app starts: where the log file goes, and the old days pruned. */
fun init(context: Context) {
appContext = context.applicationContext
StoreLogFiles.prune(context)
// The EOS overlay an earlier build downloaded (656 MB) is gone with the feature.
val overlay = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.local/share/droiddeck/epic-overlay")
if (overlay.exists()) Thread({ overlay.deleteRecursively(); Log.i(TAG, "removed the old EOS overlay download") }, "epic-overlay-cleanup").start()
// Cloud saves a killed app never uploaded; not while a session (and maybe that game) runs.
val app = context.applicationContext
Thread({ CloudSaves.uploadAllDirty(app, "recovery") }, "cloud-recovery").start()
}
internal fun post(block: () -> Unit) = main.post(block)
private val CLOCK = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
}
/** What one store contributes: sign-in, its library, its shelves, installs and removals. */
interface StoreBackend {
val store: Store
fun signIn(context: Context)
fun signOut(context: Context)
fun syncLibrary(context: Context, force: Boolean)
fun loadShelves(context: Context, force: Boolean)
/** Queues an install of [item] under [root] (a StoreInstallRoot target). */
fun install(context: Context, item: CatalogItem, root: java.io.File)
/** The store's own records of the install, before the folder goes (the queue row, cached ids). */
fun uninstall(context: Context, game: InstalledStoreGame)
}
@@ -0,0 +1,206 @@
package com.droiddeck.launcher.stores.amazon;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Amazon's distribution API, as the launcher calls it: every gaming call carries `X-Amz-Target`,
* `x-amzn-token`, the launcher's User-Agent and `Content-Encoding: amz-1.0`.
*/
public final class AmazonApiClient {
private static final String TAG = "AmazonApi";
private static final String ENTITLEMENTS_URL = "https://gaming.amazon.com/api/distribution/entitlements";
private static final String DISTRIBUTION_URL = "https://gaming.amazon.com/api/distribution/v2/public";
private static final String GAMING_USER_AGENT = "com.amazon.agslauncher.win/3.0.9202.1";
private static final String DOWNLOAD_USER_AGENT = "nile/0.1 Amazon";
private static final String KEY_ID = "d5dc8b8b-86c8-4fc4-ae93-18c0def5314d";
private AmazonApiClient() {}
/** The owned games, every page, one per productId. */
public static List<AmazonGame> getEntitlements(String accessToken, String deviceSerial) {
Map<String, AmazonGame> seen = new HashMap<>();
String nextToken = null;
String hardwareHash;
try {
hardwareHash = AmazonPkce.sha256Upper(deviceSerial);
} catch (Exception e) {
Log.e(TAG, "hardware hash failed", e);
return new ArrayList<>();
}
do {
try {
JSONObject body = new JSONObject();
body.put("Operation", "GetEntitlements");
body.put("clientId", "Sonic");
body.put("syncPoint", JSONObject.NULL);
body.put("nextToken", nextToken != null ? nextToken : JSONObject.NULL);
body.put("maxResults", 50);
body.put("productIdFilter", JSONObject.NULL);
body.put("keyId", KEY_ID);
body.put("hardwareHash", hardwareHash);
String resp = postGaming(ENTITLEMENTS_URL, "com.amazon.animusdistributionservice.entitlement.AnimusEntitlementsService.GetEntitlements", accessToken, body.toString());
if (resp == null) break;
JSONObject json = new JSONObject(resp);
JSONArray entitlements = json.optJSONArray("entitlements");
if (entitlements == null) break;
for (int i = 0; i < entitlements.length(); i++) {
AmazonGame game = parseEntitlement(entitlements.getJSONObject(i));
if (game != null && !game.productId.isEmpty()) seen.put(game.productId, game);
}
nextToken = json.optString("nextToken", null);
if (nextToken != null && nextToken.isEmpty()) nextToken = null;
} catch (Exception e) {
Log.e(TAG, "entitlements page failed: " + e.getClass().getSimpleName());
break;
}
} while (nextToken != null);
return new ArrayList<>(seen.values());
}
static AmazonGame parseEntitlement(JSONObject e) {
try {
JSONObject product = e.optJSONObject("product");
if (product == null) return null;
AmazonGame game = new AmazonGame();
game.entitlementId = e.optString("id", "");
game.productId = product.optString("id", "");
game.title = product.optString("title", "Unknown");
game.productSku = product.optString("sku", "");
String productType = product.optString("productType", "");
if (productType.isEmpty()) productType = product.optString("type", "");
String parentId = product.optString("parentProductId", "");
if (parentId.isEmpty()) parentId = product.optString("baseProductId", "");
if (parentId.isEmpty()) parentId = product.optString("parentId", "");
JSONObject detail = product.optJSONObject("productDetail");
if (detail != null) {
// The opaque images only: the square icon, and for the wide card the second
// background (a plain key art) before the first (often faded) or the crown image.
game.artUrl = detail.optString("iconUrl", "");
JSONObject details = detail.optJSONObject("details");
if (details != null) {
if (game.artUrl.isEmpty()) game.artUrl = details.optString("pgCrownImageUrl", "");
game.heroUrl = details.optString("backgroundUrl2", "");
if (game.heroUrl.isEmpty()) game.heroUrl = details.optString("backgroundUrl1", "");
if (game.heroUrl.isEmpty()) game.heroUrl = details.optString("pgCrownImageUrl", "");
game.developer = details.optString("developer", "");
game.publisher = details.optString("publisher", "");
if (productType.isEmpty()) productType = details.optString("productType", "");
if (parentId.isEmpty()) parentId = details.optString("parentProductId", "");
}
}
boolean typeIsDlc = !productType.isEmpty() && !productType.equalsIgnoreCase("GAME");
if (typeIsDlc || !parentId.isEmpty()) { game.isDLC = true; game.parentProductId = parentId; }
return game;
} catch (Exception ex) {
Log.e(TAG, "entitlement parse failed: " + ex.getClass().getSimpleName());
return null;
}
}
public static final class GameDownloadSpec {
public String downloadUrl;
public String versionId;
}
/** The download root and version for a game; takes the entitlement id, not the product id. */
public static GameDownloadSpec getGameDownload(String accessToken, String entitlementId) {
try {
JSONObject body = new JSONObject().put("entitlementId", entitlementId).put("Operation", "GetGameDownload");
String resp = postGaming(DISTRIBUTION_URL, "com.amazon.animusdistributionservice.external.AnimusDistributionService.GetGameDownload", accessToken, body.toString());
if (resp == null) return null;
JSONObject json = new JSONObject(resp);
GameDownloadSpec spec = new GameDownloadSpec();
spec.downloadUrl = json.optString("downloadUrl", "");
spec.versionId = json.optString("versionId", "");
return spec.downloadUrl.isEmpty() ? null : spec;
} catch (Exception e) {
Log.e(TAG, "GetGameDownload failed: " + e.getClass().getSimpleName());
return null;
}
}
/** The live version id of a product, for update checks. */
public static String getLiveVersionId(String accessToken, String productId) {
try {
JSONObject body = new JSONObject().put("adgProductIds", new JSONArray().put(productId)).put("Operation", "GetLiveVersionIds");
String resp = postGaming(DISTRIBUTION_URL, "com.amazon.animusdistributionservice.external.AnimusDistributionService.GetLiveVersionIds", accessToken, body.toString());
if (resp == null) return null;
JSONObject versions = new JSONObject(resp).optJSONObject("versionIds");
return versions == null ? null : versions.optString(productId, null);
} catch (Exception e) {
Log.e(TAG, "GetLiveVersionIds failed: " + e.getClass().getSimpleName());
return null;
}
}
/** `baseUrl/segment`, keeping a query string on the base where it belongs. */
public static String appendPath(String baseUrl, String segment) {
int q = baseUrl.indexOf('?');
if (q >= 0) return baseUrl.substring(0, q) + "/" + segment + baseUrl.substring(q);
return baseUrl + "/" + segment;
}
static String postGaming(String urlStr, String target, String accessToken, String body) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setDoOutput(true);
conn.setRequestProperty("X-Amz-Target", target);
conn.setRequestProperty("x-amzn-token", accessToken);
conn.setRequestProperty("User-Agent", GAMING_USER_AGENT);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Content-Encoding", "amz-1.0");
try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }
int code = conn.getResponseCode();
String resp = AmazonAuthClient.readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return resp;
} catch (Exception e) {
Log.e(TAG, "POST failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
/** Raw bytes (the manifest). */
public static byte[] getBytes(String urlStr, String accessToken) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(120000);
if (accessToken != null) conn.setRequestProperty("x-amzn-token", accessToken);
conn.setRequestProperty("User-Agent", DOWNLOAD_USER_AGENT);
int code = conn.getResponseCode();
if (code < 200 || code >= 300) { Log.e(TAG, "bytes HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); conn.disconnect(); return null; }
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = conn.getInputStream()) {
byte[] buf = new byte[131072];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
}
conn.disconnect();
return out.toByteArray();
} catch (Exception e) {
Log.e(TAG, "bytes failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
}
@@ -0,0 +1,174 @@
package com.droiddeck.launcher.stores.amazon;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* Amazon's device-auth endpoints, as the Amazon Games launcher registers itself:
* POST api.amazon.com/auth/register (the PKCE code for bearer tokens, with a device registration)
* POST api.amazon.com/auth/token (access token renewal; the refresh token stays)
* POST api.amazon.com/auth/deregister (sign-out, best-effort)
*/
public final class AmazonAuthClient {
private static final String TAG = "AmazonAuth";
static final String REGISTER_URL = "https://api.amazon.com/auth/register";
static final String REFRESH_URL = "https://api.amazon.com/auth/token";
static final String DEREGISTER_URL = "https://api.amazon.com/auth/deregister";
public static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0";
static final String APP_NAME = "AGSLauncher for Windows";
static final String APP_VERSION = "1.0.0";
static final String DEVICE_TYPE = "A2UMVHOX7UP4V7";
static final String OS_VERSION = "10.0.19044.0";
public static final class RegisterResult {
public String accessToken;
public String refreshToken;
public long expiresIn;
public String name;
}
private AmazonAuthClient() {}
/** Exchanges the PKCE authorization code for bearer tokens; null on failure. */
public static RegisterResult registerDevice(String authCode, String codeVerifier, String deviceSerial, String clientId) {
try {
JSONObject authData = new JSONObject();
authData.put("authorization_code", authCode);
authData.put("client_domain", "DeviceLegacy");
authData.put("client_id", clientId);
authData.put("code_algorithm", "SHA-256");
authData.put("code_verifier", codeVerifier);
authData.put("use_global_authentication", false);
JSONObject regData = new JSONObject();
regData.put("app_name", APP_NAME);
regData.put("app_version", APP_VERSION);
regData.put("device_model", "Windows");
regData.put("device_name", JSONObject.NULL);
regData.put("device_serial", deviceSerial);
regData.put("device_type", DEVICE_TYPE);
regData.put("domain", "Device");
regData.put("os_version", OS_VERSION);
JSONArray tokenTypes = new JSONArray().put("bearer").put("mac_dms");
JSONArray extensions = new JSONArray().put("customer_info").put("device_info");
JSONObject body = new JSONObject();
body.put("auth_data", authData);
body.put("registration_data", regData);
body.put("requested_extensions", extensions);
body.put("requested_token_type", tokenTypes);
body.put("user_context_map", new JSONObject());
String response = postJson(REGISTER_URL, body.toString(), null, null, null);
if (response == null) return null;
JSONObject success = new JSONObject(response).getJSONObject("response").getJSONObject("success");
JSONObject bearer = success.getJSONObject("tokens").getJSONObject("bearer");
RegisterResult result = new RegisterResult();
result.accessToken = bearer.getString("access_token");
result.refreshToken = bearer.getString("refresh_token");
result.expiresIn = bearer.optLong("expires_in", 3600L);
JSONObject customer = success.optJSONObject("extensions") != null ? success.getJSONObject("extensions").optJSONObject("customer_info") : null;
result.name = customer != null ? customer.optString("name", "") : "";
return result;
} catch (Exception e) {
Log.e(TAG, "registerDevice failed: " + e.getClass().getSimpleName());
return null;
}
}
/** A new access token; the refresh token is not returned and stays as it was. */
public static RegisterResult refreshAccessToken(String refreshToken) {
try {
JSONObject body = new JSONObject();
body.put("source_token", refreshToken);
body.put("source_token_type", "refresh_token");
body.put("requested_token_type", "access_token");
body.put("app_name", APP_NAME);
body.put("app_version", APP_VERSION);
String response = postJson(REFRESH_URL, body.toString(), null, "x-amzn-identity-auth-domain", "api.amazon.com");
if (response == null) return null;
JSONObject json = new JSONObject(response);
RegisterResult result = new RegisterResult();
result.accessToken = json.getString("access_token");
result.refreshToken = refreshToken;
result.expiresIn = json.optLong("expires_in", 3600L);
return result;
} catch (Exception e) {
Log.e(TAG, "refresh failed: " + e.getClass().getSimpleName());
return null;
}
}
public static void deregisterDevice(String accessToken) {
try {
JSONObject body = new JSONObject().put("requested_extensions", new JSONArray().put("device_info").put("customer_info"));
postJson(DEREGISTER_URL, body.toString(), accessToken, null, null);
} catch (Exception e) {
Log.w(TAG, "deregister failed (ignored): " + e.getClass().getSimpleName());
}
}
static String postJson(String urlStr, String body, String bearerToken, String extraKey, String extraVal) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setDoOutput(true);
conn.setRequestProperty("User-Agent", USER_AGENT);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
if (bearerToken != null) conn.setRequestProperty("Authorization", "Bearer " + bearerToken);
if (extraKey != null) conn.setRequestProperty(extraKey, extraVal);
try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }
int code = conn.getResponseCode();
String resp = readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return resp;
} catch (Exception e) {
Log.e(TAG, "POST failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
static String getRequest(String urlStr, String token, String tokenHeader, String extraKey, String extraVal) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(120000);
if (token != null && tokenHeader != null) conn.setRequestProperty(tokenHeader, token);
if (extraKey != null) conn.setRequestProperty(extraKey, extraVal);
int code = conn.getResponseCode();
String resp = readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "GET HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return resp;
} catch (Exception e) {
Log.e(TAG, "GET failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
static String readStream(InputStream is) throws IOException {
if (is == null) return "";
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) sb.append(line);
}
return sb.toString();
}
}
@@ -0,0 +1,163 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebView
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.LoginFlows
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoreShelves
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/**
* Amazon Games in the Stores section. Amazon has no browsable catalog for third-party clients -
* Prime Gaming's claims live on gaming.amazon.com, signed in - so the Store tab's shelves are cut
* from the account's own library: owned-not-installed as Trending, installed as Your library.
*/
object AmazonBackend : StoreBackend {
private const val TAG = "AmazonBackend"
override val store: Store = Store.AMAZON
init { LoginFlows.amazonFlow = { LoginFlow() } }
override fun signIn(context: Context) {
context.startActivity(Intent(context, StoreLoginActivity::class.java).putExtra(StoreLoginActivity.EXTRA_STORE, store.id).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun signOut(context: Context) {
AmazonCredentialStore.load(context)?.let { Thread({ AmazonAuthClient.deregisterDevice(it.accessToken) }, "amazon-deregister").start() }
AmazonPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
val cached = AmazonLibrary.cached(app)
if (cached.isNotEmpty()) StoresState.post { publish(app, cached) }
StoresState.post { StoresState.status[store] = "Fetching your library…" }
Thread({
val result = AmazonLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
is AmazonLibrary.SyncResult.Ok -> { publish(app, result.games); StoresState.problems.remove(store) }
is AmazonLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
AmazonLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "Amazon session expired: sign in again."
AmazonLibrary.SyncResult.Throttled -> publish(app, AmazonLibrary.cached(app))
AmazonLibrary.SyncResult.Busy -> {}
}
}
}, "amazon-sync").start()
}
/** The library and, from it, the shelves. */
private fun publish(app: Context, games: List<AmazonGame>) {
val items = games.map { AmazonLibrary.toCatalogItem(app, it) }
StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], items)
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
StoresState.shelves[store] = StoreShelves(trending = items.filter { it.id !in installed }, free = emptyList(), deals = emptyList(), whatsNew = emptyList())
}
override fun loadShelves(context: Context, force: Boolean) {
// Nothing public to fetch; the shelves follow the library sync.
if (!StoresState.library.containsKey(store)) syncLibrary(context, force)
}
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
val entitlementId = item.extra["entitlementId"] ?: ""
if (entitlementId.isEmpty()) { StoresState.logLine("Amazon: \"${item.title}\" has no entitlement to download with"); return }
val game = AmazonGame().apply { productId = item.id; this.entitlementId = entitlementId; title = item.title; productSku = item.extra["sku"] ?: ""; heroUrl = item.imageUrl ?: ""; artUrl = item.tallImageUrl ?: "" }
val folder = StoreInstallRoot.folderFor(app, Store.AMAZON, game.productId, game.title, root)
DownloadQueue.enqueue(app, DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))) { InstallJob(app, game, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
private class InstallJob(val app: Context, val game: AmazonGame, val item: CatalogItem, val folder: File) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, "Checking sign-in…")
val token = AmazonCredentialStore.getValidAccessToken(app) ?: throw AmazonDownloadManager.InstallException("Not signed in to Amazon Games")
StoreInstalls.begin(folder, Store.AMAZON, game.productId, game.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl)
var downloading = false
val result = AmazonDownloadManager.install(app, game, token, folder, cancelled, object : AmazonDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {
when {
message.startsWith("Finishing") -> handle.stage(DownloadStage.INSTALL, message)
message.startsWith("Downloading") && !message.startsWith("Downloading manifest") -> { if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message) }
else -> handle.stage(DownloadStage.MANIFEST, message)
}
}
override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) }
override fun onLog(line: String) { handle.log(line) }
override fun onSizes(downloadBytes: Long, diskBytes: Long) { handle.diskSize(diskBytes) }
}) ?: return null
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL, "Registering with Steam…")
AmazonLibrary.rememberSize(app, game.productId, result.bytes)
AmazonPrefs.get(app).edit().putString("version_${game.productId}", result.versionId).apply()
val spec = AmazonLaunchHelper.buildLaunchSpec(folder, game.title)
val exe = spec.exeRelative.ifEmpty { StoreExe.pick(folder, null, game.title) }
if (exe.isEmpty()) handle.log("amazon: no exe found in ${folder.name}; pick one in Steam settings › Added games")
val env = LinkedHashMap<String, String>()
for (kv in AmazonLaunchHelper.buildFuelEnv(game.entitlementId, game.productSku)) env[kv.substringBefore('=')] = kv.substringAfter('=')
val sidecar = StoreGameSidecar(
Store.AMAZON, game.productId, game.title, exe = exe.ifEmpty { "game.exe" }, args = spec.args, env = env,
installVersion = result.versionId ?: "", installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
extra = mapOf("entitlementId" to game.entitlementId, "sku" to game.productSku),
)
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = sidecar)
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
}
}
/** Amazon's device sign-in: a PKCE challenge on the sign-in page, the code on the return URL, a device registration for tokens. */
private class LoginFlow : StoreLoginActivity.Flow {
private val serial = AmazonPkce.generateDeviceSerial()
private val clientId = AmazonPkce.generateClientId(serial)
private val verifier = AmazonPkce.generateCodeVerifier()
override val startUrl: String = "https://www.amazon.com/ap/signin" +
"?openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0" +
"&openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select" +
"&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select" +
"&openid.mode=checkid_setup&openid.oa2.scope=device_auth_access" +
"&openid.ns.oa2=http%3A%2F%2Fwww.amazon.com%2Fap%2Fext%2Foauth%2F2&openid.oa2.response_type=code" +
"&openid.oa2.code_challenge_method=S256&openid.oa2.client_id=device%3A$clientId" +
"&language=en_US&marketPlaceId=ATVPDKIKX0DER&openid.return_to=https%3A%2F%2Fwww.amazon.com" +
"&openid.pape.max_auth_age=0&openid.ns.pape=http%3A%2F%2Fspecs.openid.net%2Fextensions%2Fpape%2F1.0" +
"&openid.assoc_handle=amzn_sonic_games_launcher&pageId=amzn_sonic_games_launcher" +
"&openid.oa2.code_challenge=${AmazonPkce.generateCodeChallenge(verifier)}"
override val userAgent: String get() = AmazonAuthClient.USER_AGENT
override fun isRedirect(url: String): Boolean = url.contains("openid.oa2.authorization_code=")
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val code = Uri.parse(url).getQueryParameter("openid.oa2.authorization_code")
if (code.isNullOrEmpty()) { Log.w(TAG, "login: return URL without a code"); return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification) }
val result = AmazonAuthClient.registerDevice(code, verifier, serial, clientId) ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
AmazonCredentialStore.save(activity, result.accessToken, result.refreshToken, serial, clientId, System.currentTimeMillis() + result.expiresIn * 1000L, result.name)
Log.i(TAG, "signed in")
return null
}
}
}
@@ -0,0 +1,52 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONObject
/**
* The Amazon sign-in in StoreAccounts' private file: the bearer tokens, the device serial and
* client id the registration was made with (the entitlements call hashes the serial), the expiry.
*/
object AmazonCredentialStore {
private const val TAG = "AmazonAuth"
class Credentials(val accessToken: String, val refreshToken: String?, val deviceSerial: String, val clientId: String, val expiresAt: Long)
@JvmStatic
fun save(context: Context, accessToken: String, refreshToken: String?, deviceSerial: String, clientId: String, expiresAt: Long, displayName: String?) {
val json = StoreAccounts.read(context, Store.AMAZON) ?: JSONObject()
json.put("access_token", accessToken).put("device_serial", deviceSerial).put("client_id", clientId).put("expires_at", expiresAt)
if (!refreshToken.isNullOrEmpty()) json.put("refresh_token", refreshToken)
if (!displayName.isNullOrEmpty()) json.put("display_name", displayName)
StoreAccounts.write(context, Store.AMAZON, json)
}
@JvmStatic
fun load(context: Context): Credentials? {
val json = StoreAccounts.read(context, Store.AMAZON) ?: return null
val token = json.optString("access_token", "").ifEmpty { return null }
return Credentials(token, json.optString("refresh_token", "").ifEmpty { null }, json.optString("device_serial", ""), json.optString("client_id", ""), json.optLong("expires_at", 0L))
}
@JvmStatic
fun isLoggedIn(context: Context): Boolean = load(context) != null
/** A valid access token, refreshed within five minutes of expiry; null when signed out or the refresh failed. */
@JvmStatic
fun getValidAccessToken(context: Context): String? {
val creds = load(context) ?: return null
if (creds.expiresAt - System.currentTimeMillis() < 5L * 60L * 1000L && creds.refreshToken != null) {
val result = AmazonAuthClient.refreshAccessToken(creds.refreshToken)
if (result != null) {
save(context, result.accessToken, creds.refreshToken, creds.deviceSerial, creds.clientId, System.currentTimeMillis() + result.expiresIn * 1000L, null)
Log.i(TAG, "token refreshed")
return result.accessToken
}
Log.w(TAG, "token refresh failed; using the stored token")
}
return creds.accessToken
}
}
@@ -0,0 +1,288 @@
package com.droiddeck.launcher.stores.amazon;
import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import com.droiddeck.launcher.stores.download.StoreDownloadTier;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* The Amazon Games install pipeline, ported from Bannerlator: GetGameDownload for the download
* root and version, `manifest.proto`, then every file fetched (the native engine when it is
* there, eight Java threads otherwise), SHA-256 checked, written as `.tmp` and renamed. A
* complete file on disk is skipped by size, so a rerun resumes. Blocking; the queue runs it.
*/
public final class AmazonDownloadManager {
private static final String TAG = "AmazonDownload";
private static final int MAX_PARALLEL = 8;
private static final int MAX_RETRIES = 3;
private static final long PROGRESS_INTERVAL = 512L * 1024L;
private static final String DOWNLOAD_USER_AGENT = "nile/0.1 Amazon";
private static final String IN_PROGRESS_MARKER = ".amazon_download_in_progress";
private static final String COMPLETE_MARKER = ".amazon_download_complete";
public interface Callback {
void onProgress(String message, int pct);
default void onBytes(long done, long total, long speedBps) {}
default void onLog(String line) {}
/** What this run fetches and what the game takes on disk. */
default void onSizes(long downloadBytes, long diskBytes) {}
}
public static final class Result {
public final String versionId;
public final long bytes;
Result(String versionId, long bytes) { this.versionId = versionId; this.bytes = bytes; }
}
public static final class InstallException extends Exception {
InstallException(String message) { super(message); }
}
private AmazonDownloadManager() {}
/** Installs the game into {@code installDir}; null when cancelled, an exception when it fails. */
public static Result install(Context ctx, AmazonGame game, String accessToken, File installDir, AtomicBoolean cancel, Callback cb) throws InstallException {
if (game.entitlementId == null || game.entitlementId.isEmpty()) throw new InstallException("This game has no entitlement to download with");
try {
installDir.mkdirs();
new File(installDir, IN_PROGRESS_MARKER).createNewFile();
cb.onProgress("Asking Amazon for the download…", 0);
AmazonApiClient.GameDownloadSpec spec = AmazonApiClient.getGameDownload(accessToken, game.entitlementId);
if (spec == null) throw new InstallException("Amazon gave no download for this game");
cb.onLog("amazon: version " + spec.versionId);
cb.onProgress("Downloading manifest…", 1);
byte[] manifestBytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), accessToken);
if (manifestBytes == null) throw new InstallException("The manifest could not be downloaded");
AmazonManifest.ParsedManifest manifest = AmazonManifest.parse(manifestBytes);
cb.onLog("amazon: " + manifest.allFiles.size() + " files, " + fmt(manifest.totalInstallSize));
long usable = installDir.getUsableSpace();
if (manifest.totalInstallSize > 0 && usable > 0 && manifest.totalInstallSize > usable)
throw new InstallException("Not enough free space: need " + fmt(manifest.totalInstallSize) + ", only " + fmt(usable) + " free");
cb.onSizes(manifest.totalInstallSize, manifest.totalInstallSize);
cb.onBytes(0, manifest.totalInstallSize, 0);
if (cancel.get()) return null;
AtomicLong downloaded = new AtomicLong(0);
AtomicLong lastEmit = new AtomicLong(0);
AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
AtomicLong lastSpeedBytes = new AtomicLong(0);
AtomicLong speedBps = new AtomicLong(0);
List<AmazonManifest.ManifestFile> files = manifest.allFiles;
final long total = manifest.totalInstallSize;
if (AmazonNative.isAvailable()) {
if (!downloadAllNative(ctx, spec.downloadUrl, files, total, installDir, downloaded, lastEmit, lastSpeedMs, lastSpeedBytes, speedBps, cancel, cb)) {
if (cancel.get()) return null;
throw new InstallException("A file failed to download");
}
} else {
cb.onLog("amazon: engine=built-in (" + MAX_PARALLEL + " threads)");
ExecutorService pool = Executors.newFixedThreadPool(MAX_PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("amazon-dl"));
List<Future<Boolean>> futures = new ArrayList<>();
for (AmazonManifest.ManifestFile file : files) {
final String dlUrl = spec.downloadUrl;
futures.add(pool.submit(() -> downloadFileWithRetry(file, dlUrl, installDir, downloaded, lastEmit, total, lastSpeedMs, lastSpeedBytes, speedBps, cb, cancel)));
}
pool.shutdown();
try {
for (Future<Boolean> f : futures) {
if (!f.get()) {
pool.shutdownNow();
if (cancel.get()) return null;
throw new InstallException("A file failed to download");
}
}
} catch (InstallException e) {
throw e;
} catch (Exception e) {
pool.shutdownNow();
throw new InstallException("download pool error: " + e.getClass().getSimpleName());
}
}
if (cancel.get()) return null;
cb.onProgress("Finishing…", 98);
new File(installDir, IN_PROGRESS_MARKER).delete();
new File(installDir, COMPLETE_MARKER).createNewFile();
return new Result(spec.versionId, total);
} catch (InstallException e) {
throw e;
} catch (Exception e) {
Log.w(TAG, "install failed", e);
throw new InstallException(e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : ""));
}
}
/** One entry per manifest file with the exact URL the Java loop would open and the SHA-256 when it would check it. */
static String buildPlan(List<AmazonManifest.ManifestFile> files, String baseUrl) {
JSONArray arr = new JSONArray();
try {
for (AmazonManifest.ManifestFile file : files) {
String hashHex = file.hashHex();
arr.put(new JSONObject().put("relPath", file.unixPath()).put("url", AmazonApiClient.appendPath(baseUrl, "files/" + hashHex))
.put("size", file.size).put("sha256hex", (file.hashAlgorithm == 0 && file.hashBytes.length > 0) ? hashHex : ""));
}
} catch (Exception e) {
Log.e(TAG, "plan failed", e);
return "";
}
return arr.toString();
}
private static boolean downloadAllNative(Context ctx, String baseUrl, List<AmazonManifest.ManifestFile> files, long totalSize, File installDir,
AtomicLong totalDownloaded, AtomicLong lastEmit, AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps,
AtomicBoolean cancel, Callback cb) {
String plan = buildPlan(files, baseUrl);
if (plan.isEmpty()) return false;
StoreDownloadTier tier = StoreDownloadTier.Companion.current(ctx);
int maxWorkers = Math.max(1, Math.min(128, tier.getNetworkWindow()));
int processWorkers = Math.max(Math.max(1, Runtime.getRuntime().availableProcessors() / 2), tier.getProcessWorkers());
cb.onLog("amazon: engine=native workers=" + maxWorkers + " process_workers=" + processWorkers + " tier=" + tier.getId());
AmazonNative.Listener listener = new AmazonNative.Listener() {
@Override public void onProgress(long bytesDone, long bytesTotal, long filesDone, long filesTotal) {
totalDownloaded.set(bytesDone);
long emit = lastEmit.get();
if (bytesDone - emit >= PROGRESS_INTERVAL && lastEmit.compareAndSet(emit, bytesDone)) {
sampleSpeed(bytesDone, lastSpeedMs, lastSpeedBytes, speedBps);
cb.onProgress("Downloading (" + filesDone + "/" + filesTotal + " files)" + speedSuffix(speedBps.get()), (int) (bytesDone * 95L / Math.max(1, totalSize)));
cb.onBytes(bytesDone, totalSize, speedBps.get());
}
}
@Override public void onLog(String line) { cb.onLog(line); }
@Override public void onComplete(boolean success, String error, long bytesWritten) {
cb.onLog("amazon: complete success=" + success + " bytes=" + bytesWritten + (error.isEmpty() ? "" : " error=" + error));
}
};
AmazonNative.RunResult result = AmazonNative.runBlocking(plan, installDir.getAbsolutePath(), "", maxWorkers, processWorkers, cancel::get, listener);
if (result.cancelled) return false;
if (!result.success) { cb.onLog("amazon: engine failed: " + result.error); return false; }
return true;
}
private static boolean downloadFileWithRetry(AmazonManifest.ManifestFile file, String baseUrl, File installDir, AtomicLong totalDownloaded, AtomicLong lastEmit, long totalSize,
AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps, Callback cb, AtomicBoolean cancel) {
File destFile = new File(installDir, file.unixPath());
File tmpFile = new File(installDir, file.unixPath() + ".tmp");
if (destFile.exists() && destFile.length() == file.size) { totalDownloaded.addAndGet(file.size); return true; }
String fileUrl = AmazonApiClient.appendPath(baseUrl, "files/" + file.hashHex());
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
destFile.getParentFile().mkdirs();
try {
if (!downloadFile(fileUrl, tmpFile, totalDownloaded, lastEmit, totalSize, lastSpeedMs, lastSpeedBytes, speedBps, cb, cancel)) { tmpFile.delete(); return false; }
if (file.hashAlgorithm == 0 && file.hashBytes.length > 0 && !Arrays.equals(sha256(tmpFile), file.hashBytes)) {
cb.onLog("amazon: SHA-256 mismatch for " + file.unixPath());
tmpFile.delete();
if (attempt < MAX_RETRIES) { Thread.sleep(1000L << (attempt - 1)); continue; }
return false;
}
if (destFile.exists()) destFile.delete();
return tmpFile.renameTo(destFile);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
tmpFile.delete();
return false;
} catch (Exception e) {
cb.onLog("amazon: attempt " + attempt + " failed for " + file.unixPath() + ": " + e.getClass().getSimpleName());
tmpFile.delete();
if (attempt < MAX_RETRIES) { try { Thread.sleep(1000L << (attempt - 1)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return false; } }
}
}
return false;
}
/** Streams one file; false = cancelled. */
private static boolean downloadFile(String urlStr, File tmpFile, AtomicLong totalDownloaded, AtomicLong lastEmit, long totalSize,
AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps, Callback cb, AtomicBoolean cancel) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(30000);
conn.setReadTimeout(120000);
conn.setRequestProperty("User-Agent", DOWNLOAD_USER_AGENT);
int code = conn.getResponseCode();
if (code < 200 || code >= 300) { conn.disconnect(); throw new IOException("HTTP " + code + " " + StoreLog.redactUrl(urlStr)); }
try (InputStream in = conn.getInputStream(); FileOutputStream out = new FileOutputStream(tmpFile)) {
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) >= 0) {
if (cancel.get()) { conn.disconnect(); return false; }
out.write(buf, 0, n);
long dl = totalDownloaded.addAndGet(n);
long emit = lastEmit.get();
if (dl - emit >= PROGRESS_INTERVAL && lastEmit.compareAndSet(emit, dl)) {
sampleSpeed(dl, lastSpeedMs, lastSpeedBytes, speedBps);
cb.onProgress("Downloading: " + tmpFile.getName().replace(".tmp", "") + speedSuffix(speedBps.get()), (int) (dl * 95L / Math.max(1, totalSize)));
cb.onBytes(dl, totalSize, speedBps.get());
}
}
} finally {
conn.disconnect();
}
return true;
}
/** The install size from the manifest alone, or -1. Blocking. */
public static long fetchInstallSizeBytes(String accessToken, String entitlementId) {
try {
AmazonApiClient.GameDownloadSpec spec = AmazonApiClient.getGameDownload(accessToken, entitlementId);
if (spec == null) return -1;
byte[] bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), accessToken);
if (bytes == null) return -1;
AmazonManifest.ParsedManifest m = AmazonManifest.parse(bytes);
return m.totalInstallSize > 0 ? m.totalInstallSize : -1;
} catch (Exception e) {
return -1;
}
}
public static boolean isInstalled(File installDir) { return new File(installDir, COMPLETE_MARKER).exists(); }
private static byte[] sha256(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (FileInputStream fis = new FileInputStream(file)) {
byte[] buf = new byte[65536];
int n;
while ((n = fis.read(buf)) >= 0) digest.update(buf, 0, n);
}
return digest.digest();
}
private static void sampleSpeed(long done, AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps) {
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
long prevB = lastSpeedBytes.getAndSet(done);
long dt = nowMs - prevMs;
if (dt > 0) speedBps.set((done - prevB) * 1000L / dt);
}
}
private static String speedSuffix(long bps) {
if (bps <= 0) return "";
return " " + (bps >= 1048576 ? String.format("%.1f MB/s", bps / 1048576.0) : (bps / 1024) + " KB/s");
}
private static String fmt(long bytes) {
if (bytes >= 1073741824L) return String.format("%.1f GB", bytes / 1073741824.0);
if (bytes >= 1048576L) return String.format("%.1f MB", bytes / 1048576.0);
return String.format("%.0f KB", bytes / 1024.0);
}
}
@@ -0,0 +1,28 @@
package com.droiddeck.launcher.stores.amazon;
/**
* One Amazon Games library entry. {@code productId} ("amzn1.adg.product.…") names the game,
* {@code entitlementId} (the top-level UUID of GetEntitlements) is what GetGameDownload takes,
* {@code productSku} goes to the game as AMAZON_GAMES_FUEL_PRODUCT_SKU.
*/
public class AmazonGame {
public String productId = "";
public String entitlementId = "";
public String title = "";
/** iconUrl: square art. */
public String artUrl = "";
/** backgroundUrl1: the wide background. */
public String heroUrl = "";
public String developer = "";
public String publisher = "";
public String productSku = "";
public String versionId = "";
public boolean isDLC = false;
public String parentProductId = "";
/** The id's last segment, for display when there is no title. */
public String shortId() {
int dot = productId.lastIndexOf('.');
return (dot >= 0 && dot < productId.length() - 1) ? productId.substring(dot + 1) : productId;
}
}
@@ -0,0 +1,140 @@
package com.droiddeck.launcher.stores.amazon;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* How an installed Amazon game is started: its `fuel.json` names the exe, a working subfolder and
* arguments when the publisher shipped one; otherwise the exe is scored (Unreal shipping binaries
* first, the title's name, never crash handlers or installers). The FuelPump variables the Amazon
* Games Services SDK reads go into the game's environment.
*/
public final class AmazonLaunchHelper {
private static final String TAG = "AmazonLaunch";
public static final class LaunchSpec {
/** The exe relative to the install folder, forward slashes. */
public String exeRelative = "";
/** The arguments fuel.json names, in order. */
public List<String> args = new ArrayList<>();
}
private AmazonLaunchHelper() {}
public static LaunchSpec buildLaunchSpec(File installDir, String gameTitle) {
LaunchSpec spec = new LaunchSpec();
String fuelCommand = null;
File fuelFile = new File(installDir, "fuel.json");
if (fuelFile.exists()) {
try {
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(new FileReader(fuelFile))) {
String line;
while ((line = br.readLine()) != null) sb.append(line);
}
JSONObject main = new JSONObject(sb.toString()).optJSONObject("Main");
if (main != null) {
String cmd = main.optString("Command", "").trim();
if (!cmd.isEmpty()) fuelCommand = cmd;
JSONArray argsArr = main.optJSONArray("Args");
if (argsArr != null) for (int i = 0; i < argsArr.length(); i++) {
String a = argsArr.optString(i);
if (a != null && !a.isEmpty()) spec.args.add(a);
}
}
} catch (Exception e) {
Log.w(TAG, "fuel.json unreadable; choosing the exe by name");
}
}
if (fuelCommand != null && new File(installDir, fuelCommand.replace('\\', '/')).isFile()) {
spec.exeRelative = fuelCommand.replace('\\', '/');
} else {
File chosen = choosePrimaryExe(installDir, gameTitle);
if (chosen != null) spec.exeRelative = relativePath(installDir, chosen);
}
return spec;
}
/** The FuelPump variables, as KEY=VALUE, for the game's environment. */
public static String[] buildFuelEnv(String entitlementId, String productSku) {
return new String[]{
"FUEL_DIR=C:\\ProgramData\\Amazon Games Services\\Legacy",
"AMAZON_GAMES_SDK_PATH=C:\\ProgramData\\Amazon Games Services\\AmazonGamesSDK",
"AMAZON_GAMES_FUEL_ENTITLEMENT_ID=" + entitlementId,
"AMAZON_GAMES_FUEL_PRODUCT_SKU=" + productSku,
"AMAZON_GAMES_FUEL_DISPLAY_NAME=Player",
};
}
private static final Pattern UE_SHIPPING = Pattern.compile(".*-win(32|64)(-shipping)?\\.exe$", Pattern.CASE_INSENSITIVE);
private static final Pattern UE_BINARIES = Pattern.compile(".*/binaries/win(32|64)/.*\\.exe$", Pattern.CASE_INSENSITIVE);
private static final Pattern GENERIC_NAME = Pattern.compile("^[a-z]\\d{1,3}\\.exe$", Pattern.CASE_INSENSITIVE);
private static final String[] NEGATIVE_KEYWORDS = { "crash", "handler", "viewer", "compiler", "tool", "setup", "unins", "eac", "launcher", "steam" };
private static boolean isLikelyStub(File f) {
String n = f.getName().toLowerCase();
if (GENERIC_NAME.matcher(n).matches()) return true;
if (f.length() < 1_000_000L) return true;
for (String kw : NEGATIVE_KEYWORDS) if (n.contains(kw)) return true;
return false;
}
static int scoreExe(File f, String gameNameLower) {
int score = 50;
String path = f.getAbsolutePath().replace('\\', '/').toLowerCase();
if (UE_SHIPPING.matcher(path).matches()) score += 300;
if (UE_BINARIES.matcher(path).find()) score += 250;
String fn = f.getName().toLowerCase();
String cleanGame = gameNameLower.replaceAll("[^a-z]", "");
String cleanFile = fn.replaceAll("[^a-z]", "");
boolean nameMatch = path.contains(gameNameLower) || (cleanGame.length() >= 5 && cleanFile.length() >= 5 && cleanGame.substring(0, 5).equals(cleanFile.substring(0, 5)));
if (nameMatch) score += 100;
for (String kw : NEGATIVE_KEYWORDS) if (path.contains(kw)) { score -= 150; break; }
if (GENERIC_NAME.matcher(fn).matches()) score -= 200;
return score;
}
static File choosePrimaryExe(File installDir, String gameTitle) {
if (!installDir.isDirectory()) return null;
List<File> all = new ArrayList<>();
collectExe(installDir, all);
if (all.isEmpty()) return null;
List<File> pool = new ArrayList<>();
for (File f : all) if (!isLikelyStub(f)) pool.add(f);
if (pool.isEmpty()) pool = all;
String lowerTitle = gameTitle.toLowerCase();
File best = null;
int bestScore = Integer.MIN_VALUE;
for (File f : pool) {
int s = scoreExe(f, lowerTitle);
if (best == null || s > bestScore || (s == bestScore && f.length() > best.length())) { best = f; bestScore = s; }
}
return best;
}
private static void collectExe(File dir, List<File> out) {
File[] entries = dir.listFiles();
if (entries == null) return;
for (File f : entries) {
if (f.isDirectory()) collectExe(f, out);
else if (f.getName().toLowerCase().endsWith(".exe")) out.add(f);
}
}
private static String relativePath(File base, File absolute) {
String b = base.getAbsolutePath();
if (!b.endsWith("/")) b += "/";
String a = absolute.getAbsolutePath();
return a.startsWith(b) ? a.substring(b.length()) : absolute.getName();
}
}
@@ -0,0 +1,128 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* The signed-in account's Amazon Games library: the entitlements, DLC folded under their base
* game, cached so the tab paints at once, re-synced when forced or 15 minutes stale. Amazon
* publishes a square icon and a wide background only, so a 2:3 poster per title is backfilled
* from SteamGridDB (a few per pass). Ported from Bannerlator's AmazonLibraryRepo.
*/
object AmazonLibrary {
private const val TAG = "AmazonLibrary"
private const val CACHE_KEY = "library_cache"
private const val LAST_SYNC_KEY = "library_synced_at"
private const val THROTTLE_MS = 6L * 60L * 60L * 1000L
private const val POSTER_BUDGET = 40
private val syncing = AtomicBoolean(false)
sealed interface SyncResult {
class Ok(val games: List<AmazonGame>) : SyncResult
class Failed(val message: String) : SyncResult
object NotLoggedIn : SyncResult
object Busy : SyncResult
object Throttled : SyncResult
}
fun cached(context: Context): List<AmazonGame> {
val json = AmazonPrefs.get(context).getString(CACHE_KEY, null) ?: return emptyList()
return runCatching {
val arr = JSONArray(json)
val out = ArrayList<AmazonGame>(arr.length())
for (i in 0 until arr.length()) {
val j = arr.optJSONObject(i) ?: continue
val g = AmazonGame()
g.productId = j.optString("productId", ""); g.entitlementId = j.optString("entitlementId", ""); g.title = j.optString("title", "")
g.artUrl = j.optString("artUrl", ""); g.heroUrl = j.optString("heroUrl", ""); g.developer = j.optString("developer", "")
g.publisher = j.optString("publisher", ""); g.productSku = j.optString("productSku", ""); g.versionId = j.optString("versionId", "")
if (g.productId.isNotEmpty()) out.add(g)
}
out.distinctBy { it.productId }.sortedBy { it.title.lowercase() }
}.getOrDefault(emptyList())
}
fun poster(context: Context, productId: String): String? = AmazonPrefs.get(context).getString("vcover_$productId", null)?.ifBlank { null }
fun rememberSize(context: Context, productId: String, bytes: Long) {
if (bytes > 0) AmazonPrefs.get(context).edit().putLong("size_$productId", bytes).apply()
}
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult {
if (!syncing.compareAndSet(false, true)) return SyncResult.Busy
try {
val app = context.applicationContext
val p = AmazonPrefs.get(app)
val cachedList = cached(app)
if (!force && cachedList.isNotEmpty() && System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) < THROTTLE_MS) {
backfillPosters(p, cachedList, onStatus)
return SyncResult.Throttled
}
onStatus("Checking sign-in…")
val creds = AmazonCredentialStore.load(app) ?: return SyncResult.NotLoggedIn
val token = AmazonCredentialStore.getValidAccessToken(app) ?: return SyncResult.Failed("Amazon session expired: sign in again.")
onStatus("Fetching game list…")
val all = AmazonApiClient.getEntitlements(token, creds.deviceSerial)
if (all.isNullOrEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Amazon library") else SyncResult.Ok(cachedList)
val games = all.filter { !(it.isDLC && it.parentProductId.isNotEmpty()) }.ifEmpty { all }
.sortedWith { a, b -> a.title.compareTo(b.title, ignoreCase = true) }
for (fresh in games) cachedList.firstOrNull { it.productId == fresh.productId }?.let { fresh.versionId = it.versionId }
saveCache(p, games)
p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
backfillPosters(p, games, onStatus)
Log.i(TAG, "sync: entitlements=${all.size} games=${games.size}")
return SyncResult.Ok(games)
} catch (e: Exception) {
Log.w(TAG, "sync failed: ${e.message}")
return SyncResult.Failed(e.message ?: "Sync failed")
} finally {
syncing.set(false)
}
}
private fun backfillPosters(p: SharedPreferences, games: List<AmazonGame>, onStatus: (String) -> Unit) {
val missing = games.filter { it.productId.isNotEmpty() && !p.contains("vcover_${it.productId}") }.take(POSTER_BUDGET)
if (missing.isEmpty()) return
onStatus("Fetching cover art…")
val pool = Executors.newFixedThreadPool(4)
val futures = missing.map { g -> pool.submit(Callable { g.productId to StoreNet.sgdbPoster(g.title) }) }
pool.shutdown()
val ed = p.edit()
for (f in futures) {
val (pid, url) = runCatching { f.get() }.getOrNull() ?: continue
// A miss is recorded as "" so it is not asked again every pass.
ed.putString("vcover_$pid", url)
}
ed.apply()
}
private fun saveCache(p: SharedPreferences, games: List<AmazonGame>) {
runCatching {
val arr = JSONArray()
for (g in games) arr.put(JSONObject().put("productId", g.productId).put("entitlementId", g.entitlementId).put("title", g.title).put("artUrl", g.artUrl)
.put("heroUrl", g.heroUrl).put("developer", g.developer).put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId))
p.edit().putString(CACHE_KEY, arr.toString()).apply()
}
}
fun toCatalogItem(context: Context, g: AmazonGame): CatalogItem = CatalogItem(
store = Store.AMAZON, id = g.productId, title = g.title.ifBlank { g.shortId() },
imageUrl = g.heroUrl.ifBlank { null } ?: g.artUrl.ifBlank { null },
tallImageUrl = poster(context, g.productId) ?: g.artUrl.ifBlank { null } ?: g.heroUrl.ifBlank { null },
tags = listOfNotNull(g.developer.ifBlank { null }, g.publisher.ifBlank { null }.takeIf { it != g.developer }).joinToString(", "),
developer = g.developer, owned = true,
sizeBytes = AmazonPrefs.get(context).getLong("size_${g.productId}", 0L),
storeUrl = "https://gaming.amazon.com/home",
extra = mapOf("entitlementId" to g.entitlementId, "sku" to g.productSku, "version" to (g.versionId ?: "")),
)
}
@@ -0,0 +1,158 @@
package com.droiddeck.launcher.stores.amazon;
import org.tukaani.xz.LZMAInputStream;
import org.tukaani.xz.XZInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Amazon's `manifest.proto`: a big-endian header length, a ManifestHeader protobuf naming the
* compression, then an LZMA (or XZ) body holding the Manifest protobuf - packages of files, each
* with a Windows path, a size and a SHA-256. Read with a tiny protobuf walker; the XZ library the
* app already carries does the decompression.
*/
public final class AmazonManifest {
public static final class ManifestFile {
public final String path;
public final long size;
/** 0 = SHA-256. */
public final int hashAlgorithm;
public final byte[] hashBytes;
ManifestFile(String path, long size, int hashAlgorithm, byte[] hashBytes) {
this.path = path; this.size = size; this.hashAlgorithm = hashAlgorithm; this.hashBytes = hashBytes;
}
public String unixPath() { return path.replace('\\', '/'); }
public String hashHex() {
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) sb.append(String.format("%02x", b & 0xFF));
return sb.toString();
}
}
public static final class ParsedManifest {
public final List<ManifestFile> allFiles;
public final long totalInstallSize;
ParsedManifest(List<ManifestFile> files) {
this.allFiles = files;
long total = 0;
for (ManifestFile f : files) total += f.size;
this.totalInstallSize = total;
}
}
private AmazonManifest() {}
public static ParsedManifest parse(byte[] data) throws IOException {
if (data == null || data.length < 4) throw new IOException("Manifest too short");
int headerSize = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.BIG_ENDIAN).getInt();
if (headerSize < 0 || 4 + headerSize > data.length) throw new IOException("Invalid header size " + headerSize);
byte[] body = Arrays.copyOfRange(data, 4 + headerSize, data.length);
return parseManifest(decompress(body));
}
private static byte[] decompress(byte[] body) throws IOException {
if (body.length < 2) return body;
boolean isXz = (body[0] & 0xFF) == 0xFD && (body[1] & 0xFF) == 0x37;
try (InputStream in = isXz ? new XZInputStream(new ByteArrayInputStream(body), -1) : new LZMAInputStream(new ByteArrayInputStream(body), -1)) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) >= 0) bos.write(buf, 0, n);
return bos.toByteArray();
}
}
private static ParsedManifest parseManifest(byte[] bytes) {
List<ManifestFile> files = new ArrayList<>();
ProtoReader r = new ProtoReader(bytes);
while (r.hasMore()) {
int tag = (int) r.readVarint();
if ((tag >>> 3) == 1 && (tag & 7) == 2) parsePackage(r.readBytes(), files); else r.skip(tag & 7);
}
return new ParsedManifest(files);
}
private static void parsePackage(byte[] bytes, List<ManifestFile> out) {
ProtoReader r = new ProtoReader(bytes);
while (r.hasMore()) {
int tag = (int) r.readVarint();
int field = tag >>> 3, wire = tag & 7;
if (field == 2 && wire == 2) { ManifestFile f = parseFile(r.readBytes()); if (f != null) out.add(f); }
else r.skip(wire);
}
}
private static ManifestFile parseFile(byte[] bytes) {
String path = "";
long size = 0L;
int hashAlgo = 0;
byte[] hashValue = new byte[0];
ProtoReader r = new ProtoReader(bytes);
while (r.hasMore()) {
int tag = (int) r.readVarint();
int field = tag >>> 3, wire = tag & 7;
if (field == 1 && wire == 2) path = r.readString();
else if (field == 3 && wire == 0) size = r.readVarint();
else if (field == 5 && wire == 2) {
ProtoReader hr = new ProtoReader(r.readBytes());
while (hr.hasMore()) {
int ht = (int) hr.readVarint();
int hf = ht >>> 3, hw = ht & 7;
if (hf == 1 && hw == 0) hashAlgo = (int) hr.readVarint();
else if (hf == 2 && hw == 2) hashValue = hr.readBytes();
else hr.skip(hw);
}
} else r.skip(wire);
}
if (path.isEmpty()) return null;
return new ManifestFile(path, size, hashAlgo, hashValue);
}
private static final class ProtoReader {
private final byte[] buf;
private int pos;
ProtoReader(byte[] buf) { this.buf = buf; }
boolean hasMore() { return pos < buf.length; }
long readVarint() {
long result = 0;
int shift = 0;
while (pos < buf.length) {
int b = buf[pos++] & 0xFF;
result |= (long) (b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
return result;
}
byte[] readBytes() {
int len = (int) readVarint();
if (len < 0 || pos + len > buf.length) return new byte[0];
byte[] out = Arrays.copyOfRange(buf, pos, pos + len);
pos += len;
return out;
}
String readString() { return new String(readBytes(), java.nio.charset.StandardCharsets.UTF_8); }
void skip(int wireType) {
switch (wireType) {
case 0: readVarint(); break;
case 1: pos += 8; break;
case 2: readBytes(); break;
case 5: pos += 4; break;
default: pos = buf.length; break;
}
}
}
}
@@ -0,0 +1,84 @@
// JNI symbols depend on this package path and class name (app/src/main/rust/stores/JNI.md).
package com.droiddeck.launcher.stores.amazon
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* The native Amazon file fetcher in `libdroiddeckstores.so`: one streamed GET per manifest file,
* SHA-256 checked, written as `<dest>.tmp` and renamed. The manager keeps manifest, auth, markers
* and post-install; [runBlocking] mirrors the Java loop's shape and polls the cancel probe.
*/
object AmazonNative {
const val TAG = "AmazonNative"
private const val CANCEL_POLL_MS = 100L
/** Callbacks from the engine; every method is called on a native thread. */
interface Listener {
/** Once up front with the resume-skipped credit, then after each committed file; [bytesDone] includes skipped files. */
fun onProgress(bytesDone: Long, bytesTotal: Long, filesDone: Long, filesTotal: Long)
/** One diagnostic line; the engine itself does not write to logcat. */
fun onLog(line: String)
/** Exactly once, when the run succeeds, fails or is cancelled. */
fun onComplete(success: Boolean, error: String, bytesWritten: Long)
}
fun interface CancelCheck { fun isCancelled(): Boolean }
class RunResult(@JvmField val success: Boolean, @JvmField val cancelled: Boolean, @JvmField val error: String, @JvmField val bytesWritten: Long)
@JvmStatic
fun isAvailable(): Boolean = StoresNative.available
/**
* Runs one download to completion. [planJson]: `[{relPath, url, size, sha256hex}]`, every
* manifest file (the engine skips complete ones itself). [maxWorkers] <= 0 = the engine's default.
*/
@JvmStatic
fun runBlocking(planJson: String, installDir: String, caBundlePath: String, maxWorkers: Int, processWorkers: Int, isCancelled: CancelCheck?, listener: Listener): RunResult {
if (!StoresNative.ensureLoaded()) return RunResult(false, false, "engine not built", 0L)
val latch = CountDownLatch(1)
var outcome: RunResult? = null
val bridge = object : Listener {
override fun onProgress(bytesDone: Long, bytesTotal: Long, filesDone: Long, filesTotal: Long) = listener.onProgress(bytesDone, bytesTotal, filesDone, filesTotal)
override fun onLog(line: String) = listener.onLog(line)
override fun onComplete(success: Boolean, error: String, bytesWritten: Long) {
outcome = RunResult(success, false, error, bytesWritten)
listener.onComplete(success, error, bytesWritten)
latch.countDown()
}
}
val handle = try {
nativeStart(planJson, installDir, caBundlePath, maxWorkers, processWorkers, bridge)
} catch (t: Throwable) {
Log.w(TAG, "nativeStart threw: ${t.javaClass.simpleName}: ${t.message}")
return RunResult(false, false, "nativeStart: ${t.javaClass.simpleName}", 0L)
}
if (handle == 0L) return outcome ?: RunResult(false, false, "native start failed", 0L)
var cancelSent = false
try {
while (!latch.await(CANCEL_POLL_MS, TimeUnit.MILLISECONDS)) {
if (!cancelSent && isCancelled?.isCancelled() == true) { nativeCancel(handle); cancelSent = true }
}
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
if (!cancelSent) { nativeCancel(handle); cancelSent = true }
try { latch.await(30, TimeUnit.SECONDS) } catch (_: InterruptedException) {}
} finally {
nativeRelease(handle)
}
val r = outcome ?: RunResult(false, cancelSent, "no completion", 0L)
return RunResult(r.success && !cancelSent, cancelSent, r.error, r.bytesWritten)
}
@JvmStatic
private external fun nativeStart(planJson: String, installDir: String, caBundlePath: String, maxWorkers: Int, processWorkers: Int, listener: Listener): Long
@JvmStatic
private external fun nativeCancel(handle: Long)
@JvmStatic
private external fun nativeRelease(handle: Long)
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.stores.amazon;
import android.util.Base64;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.UUID;
/** The PKCE values Amazon's device sign-in takes, generated the way the Amazon Games launcher does. */
public final class AmazonPkce {
private static final String DEVICE_TYPE = "A2UMVHOX7UP4V7";
private AmazonPkce() {}
/** One per sign-in: a UUID as uppercase hex. */
public static String generateDeviceSerial() { return UUID.randomUUID().toString().replace("-", "").toUpperCase(); }
/** hex("serial#DEVICE_TYPE"). */
public static String generateClientId(String serial) {
byte[] bytes = (serial + "#" + DEVICE_TYPE).getBytes(StandardCharsets.UTF_8);
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b & 0xFF));
return sb.toString();
}
public static String generateCodeVerifier() {
byte[] random = new byte[32];
new SecureRandom().nextBytes(random);
return Base64.encodeToString(random, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP);
}
public static String generateCodeChallenge(String verifier) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.UTF_8));
return Base64.encodeToString(hash, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP);
} catch (Exception e) {
throw new RuntimeException("SHA-256 unavailable", e);
}
}
/** SHA-256 of the input as uppercase hex: the entitlements call's hardwareHash. */
public static String sha256Upper(String input) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) sb.append(String.format("%02x", b & 0xFF));
return sb.toString().toUpperCase();
} catch (Exception e) {
throw new RuntimeException("SHA-256 unavailable", e);
}
}
}
@@ -0,0 +1,10 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.content.SharedPreferences
/** Amazon's own records that are not credentials: the library cache, posters, versions. Public for the Java clients. */
object AmazonPrefs {
@JvmStatic
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences("stores.amazon", Context.MODE_PRIVATE)
}
@@ -0,0 +1,75 @@
package com.droiddeck.launcher.stores.download
import com.droiddeck.launcher.stores.Store
/**
* The store-agnostic shape every store's download is projected into, so one queue and one
* Downloads page serve all three. Ported from Bannerlator's download registry, trimmed to what
* this app shows: a stage, one byte pair, speed and ETA, and the handles that drive it.
*/
/** Where a download is. The names are what the Downloads page prints over its stage strip. */
enum class DownloadStage { MANIFEST, DOWNLOAD, VERIFY, INSTALL, DONE }
/**
* QUEUED / RUNNING / PAUSED are the active states (in memory only). INSTALLED is the terminal
* success; FAILED and CANCELLED stay listed for the session so the page can offer a retry or a
* dismissal.
*/
enum class DownloadState { QUEUED, RUNNING, PAUSED, INSTALLED, FAILED, CANCELLED }
data class DownloadEntry(
val store: Store,
val id: String,
val name: String,
val cover: String? = null,
val state: DownloadState = DownloadState.QUEUED,
val stage: DownloadStage = DownloadStage.MANIFEST,
/** Bytes landed and the total, as the engine reports them (install size). */
val bytesDone: Long = 0L,
val bytesTotal: Long = 0L,
/** Smoothed speed in bytes/s (0 = unknown) and seconds left (-1 = unknown). */
val speedBps: Long = 0L,
val etaSeconds: Long = -1L,
/** The engine's current line ("Downloading: data.pak"), shown under the bar. */
val detail: String = "",
/** Where it was installed (INSTALLED) or why it failed (FAILED). */
val installPath: String? = null,
val error: String? = null,
/** 1-based place in the queue while QUEUED, else 0. */
val queuePosition: Int = 0,
/** When the entry reached a terminal state, for ordering and ageing out. */
val finishedAt: Long = 0L,
val startedAt: Long = 0L,
/** Where it is going: the install target's name ("Internal storage", the card's name). */
val location: String = "",
/**
* The active stage's own progress outside Download (which runs on [bytesDone]/[bytesTotal]):
* an amount ([stageDone] of [stageTotal], bytes or items) and, when it counts files, [stageItems]
* of [stageItemsTotal]. Reset when the stage changes.
*/
val stageDone: Long = 0L,
val stageTotal: Long = 0L,
val stageItems: Int = 0,
val stageItemsTotal: Int = 0,
/** What the game takes on disk, when the store has said (the download itself is [bytesTotal], often compressed). */
val diskBytes: Long = 0L,
/** Stages already passed, one bit per [DownloadStage] ordinal; stages can come back (Epic checks files before it fetches). */
val stagesPassed: Int = 0,
) {
fun passed(s: DownloadStage): Boolean = state == DownloadState.INSTALLED || (stagesPassed and (1 shl s.ordinal)) != 0
val key: String get() = "${store.id}:$id"
val isActive: Boolean get() = state == DownloadState.QUEUED || state == DownloadState.RUNNING || state == DownloadState.PAUSED
/** The active stage's progress, 0..1, or -1 while it has nothing to count yet. */
val stageFraction: Float get() = when {
state == DownloadState.INSTALLED || stage == DownloadStage.DONE -> 1f
stage == DownloadStage.DOWNLOAD -> if (bytesTotal > 0L) (bytesDone.toDouble() / bytesTotal).toFloat().coerceIn(0f, 1f) else -1f
stageTotal > 0L -> (stageDone.toDouble() / stageTotal).toFloat().coerceIn(0f, 1f)
stageItemsTotal > 0 -> (stageItems.toFloat() / stageItemsTotal).coerceIn(0f, 1f)
else -> -1f
}
/** 0..1 for a single bar (a card, the game page): the active stage's progress. */
val fraction: Float get() = stageFraction.coerceAtLeast(0f)
val percent: Int get() = (fraction * 100).toInt()
}
@@ -0,0 +1,379 @@
package com.droiddeck.launcher.stores.download
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoresState
import java.util.concurrent.atomic.AtomicBoolean
/**
* One queue for every store's downloads, [parallel] of them running at a time (1..3, Setup and
* the Downloads page), the rest waiting as data until a slot frees. A store hands in a
* [DownloadJob] - its whole install: manifest, files, verify, post-install - and reports back
* through the job's [JobHandle]; the queue owns the states, the registry row the UI draws and the
* foreground service that keeps the process alive while anything runs.
*
* Pause for these engines is "stop now, keep the files": every store's install skips files that
* are already complete and verified, so resuming is simply running the job again. Cancel removes
* what was downloaded (the job's own cleanup) and the row.
*
* Thread-safe: one [lock] guards the table; jobs run on their own threads, outside it.
*/
object DownloadQueue {
private const val TAG = "StoreDownloads"
/** The work for one download. [run] blocks until it is over; [cancel] asks a running job to stop. */
interface DownloadJob {
/** Runs the install; returns the install folder's path on success, throws or returns null on failure. */
fun run(handle: JobHandle): String?
/** Stops a running job; [deleteFiles] false keeps what was fetched for a later resume. */
fun cancel(deleteFiles: Boolean)
}
/** What a running job reports with. */
class JobHandle internal constructor(val key: String, internal val cancelled: AtomicBoolean) {
val isCancelled: Boolean get() = cancelled.get()
/** Enters [stage] (a repeat of the current one only updates the line); a new stage starts its own count from zero. */
fun stage(stage: DownloadStage, detail: String = "") = enter(key, stage, stripSpeed(detail))
/**
* The active stage's own count outside Download: an amount and, when it counts files, the
* files. [bytes] = the amount is bytes written, so the stage gets its own rate and ETA.
*/
fun stageProgress(done: Long, total: Long, items: Int = 0, itemsTotal: Int = 0, bytes: Boolean = false) = stageCount(key, done, total, items, itemsTotal, bytes)
/** The game's size on disk, once the store has said. */
fun diskSize(bytes: Long) = update(key) { if (bytes > 0) it.copy(diskBytes = bytes) else it }
/**
* Bytes so far (negative = unchanged), the total (<= 0 = unchanged), a line. [speedBps] is the
* engine's own figure: a burst rate at the network side, which jumps while the write side
* paces the bar, so it is not what the page shows - the queue measures the install rate
* from the byte deltas itself ([measure]); the engine's figure stays in its log lines.
*/
fun progress(bytesDone: Long, bytesTotal: Long, detail: String? = null, @Suppress("UNUSED_PARAMETER") speedBps: Long = -1L) = measure(key, bytesDone, bytesTotal, detail?.let(::stripSpeed))
fun log(line: String) = StoresState.logLine(line)
}
/** "Downloading: data.pak 12.3 MB/s" → "Downloading: data.pak": the engine's burst rate is not shown twice. */
private fun stripSpeed(s: String): String = s.replace(Regex("\\s+\\S+ [KMG]B/s\\s*$"), "").trimEnd()
private fun enter(key: String, stage: DownloadStage, detail: String) {
synchronized(lock) {
val item = items[key] ?: return
val moved = item.entry.stage != stage || item.entry.state != DownloadState.RUNNING
transitionLocked(item, stage)
item.entry = item.entry.copy(detail = detail.ifEmpty { item.entry.detail }, state = DownloadState.RUNNING)
// A new stage shows at once; a new line within the same stage (one per file) waits its turn.
publishLocked(progressOnly = !moved)
}
}
/**
* Moves an item to [stage] when it is elsewhere: the old stage is marked passed, the new one
* counts from zero, and the measured speed is cleared - the next stage writes or checks, it
* does not fetch, and a stale rate there would read as a download. Caller holds [lock].
*/
private fun transitionLocked(item: Item, stage: DownloadStage) {
val e = item.entry
if (e.stage == stage) return
item.speedAt = 0L; item.speedBytes = 0L; item.speedEwma = 0.0
item.entry = e.copy(
stage = stage, stagesPassed = e.stagesPassed or (1 shl e.stage.ordinal), speedBps = 0, etaSeconds = -1,
stageDone = 0, stageTotal = 0, stageItems = 0, stageItemsTotal = 0,
)
}
/** How far back the shown speed looks: a few seconds, so the figure settles instead of following each file. */
private const val SPEED_WINDOW_MS = 3000.0
private fun stageCount(key: String, done: Long, total: Long, files: Int, filesTotal: Int, bytes: Boolean) {
synchronized(lock) {
val item = items[key] ?: return
val e = item.entry
var speed = e.speedBps
if (bytes) speed = sample(item, done)
val eta = if (bytes && speed > 0 && total > done) (total - done) / speed else -1L
item.entry = e.copy(stageDone = done, stageTotal = total, stageItems = files, stageItemsTotal = filesTotal, speedBps = if (bytes) speed else 0, etaSeconds = eta)
publishLocked(progressOnly = true)
}
}
/** One sample of the rate behind [done] (bytes so far in this stage), smoothed over [SPEED_WINDOW_MS]. Caller holds [lock]. */
private fun sample(item: Item, done: Long): Long {
val now = System.currentTimeMillis()
if (item.speedAt == 0L) { item.speedAt = now; item.speedBytes = done; return item.speedEwma.toLong() }
val dt = now - item.speedAt
// Sampled no faster than every quarter second, as an exponential average over
// SPEED_WINDOW_MS: one big file landing moves it, it does not define it.
if (dt >= 250) {
val inst = (done - item.speedBytes).coerceAtLeast(0) * 1000.0 / dt
val alpha = 1.0 - Math.exp(-dt / SPEED_WINDOW_MS)
item.speedEwma = if (item.speedEwma <= 0.0) inst else item.speedEwma + (inst - item.speedEwma) * alpha
item.speedAt = now; item.speedBytes = done
}
return item.speedEwma.toLong()
}
private fun measure(key: String, bytesDone: Long, bytesTotal: Long, detail: String?) {
synchronized(lock) {
val item = items[key] ?: return
transitionLocked(item, DownloadStage.DOWNLOAD)
val e = item.entry
val done = if (bytesDone >= 0) bytesDone else e.bytesDone
val total = if (bytesTotal > 0) bytesTotal else e.bytesTotal
if (bytesDone >= 0) sample(item, done)
val speed = item.speedEwma.toLong()
val eta = if (speed > 0 && total > done) (total - done) / speed else -1L
val moved = e.stage != DownloadStage.DOWNLOAD || e.state != DownloadState.RUNNING
item.entry = e.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = done, bytesTotal = total, detail = detail ?: e.detail, speedBps = speed, etaSeconds = eta)
publishLocked(progressOnly = !moved)
}
}
private class Item(var entry: DownloadEntry, val factory: () -> DownloadJob, var job: DownloadJob? = null, val cancelled: AtomicBoolean = AtomicBoolean(false), var pauseRequested: Boolean = false) {
/** The shown speed's state: the last sample's clock and bytes, and the running average (bytes/s). */
var speedAt = 0L
var speedBytes = 0L
var speedEwma = 0.0
}
private val lock = Any()
private val items = LinkedHashMap<String, Item>()
private var running = 0
fun parallel(context: Context): Int = SessionPrefs.gameStoresParallel(context)
/** Threads for a store's download pool: background priority, so the UI and its render thread come first. */
@JvmStatic
fun workerFactory(name: String): java.util.concurrent.ThreadFactory {
val n = java.util.concurrent.atomic.AtomicInteger()
return java.util.concurrent.ThreadFactory { r ->
Thread({
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
r.run()
}, "$name-${n.incrementAndGet()}")
}
}
fun setParallel(context: Context, count: Int) {
SessionPrefs.setGameStoresParallel(context, count)
advance(context.applicationContext)
}
fun entry(key: String): DownloadEntry? = synchronized(lock) { items[key]?.entry }
fun isActive(key: String): Boolean = entry(key)?.isActive == true
/**
* Queues a download, or starts it when a slot is free. A key already active is left alone; a
* finished or failed row with the same key is replaced.
*/
fun enqueue(context: Context, entry: DownloadEntry, factory: () -> DownloadJob) {
val app = context.applicationContext
synchronized(lock) {
items[entry.key]?.let { if (it.entry.isActive) { Log.i(TAG, "${entry.key}: already queued"); return } }
items.remove(entry.key)
items[entry.key] = Item(entry.copy(state = DownloadState.QUEUED, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory)
renumber()
}
StoresState.logLine("${entry.store.label}: queued \"${entry.name}\"" + if (entry.bytesTotal > 0) " (${com.droiddeck.launcher.stores.formatBytes(entry.bytesTotal)})" else "")
publish(app)
advance(app)
}
fun pause(key: String) {
val item = synchronized(lock) { items[key] } ?: return
synchronized(lock) {
if (item.entry.state == DownloadState.QUEUED) { item.entry = item.entry.copy(state = DownloadState.PAUSED, queuePosition = 0); renumber(); publishLocked(); return }
if (item.entry.state != DownloadState.RUNNING) return
item.pauseRequested = true
}
item.cancelled.set(true)
item.job?.cancel(deleteFiles = false)
StoresState.logLine("paused \"${item.entry.name}\"")
}
fun resume(context: Context, key: String) {
val app = context.applicationContext
synchronized(lock) {
val item = items[key] ?: return
if (item.entry.state != DownloadState.PAUSED && item.entry.state != DownloadState.FAILED) return
items.remove(key)
items[key] = Item(item.entry.copy(state = DownloadState.QUEUED, speedBps = 0, etaSeconds = -1, error = null, startedAt = System.currentTimeMillis()), item.factory)
renumber()
publishLocked()
}
StoresState.logLine("resumed \"${entry(key)?.name}\"")
advance(app)
}
fun retry(context: Context, key: String) = resume(context, key)
fun cancel(context: Context, key: String) {
val app = context.applicationContext
val item: Item
val wasRunning: Boolean
synchronized(lock) {
item = items[key] ?: return
wasRunning = item.entry.state == DownloadState.RUNNING
if (!wasRunning) {
item.entry = item.entry.copy(state = DownloadState.CANCELLED, queuePosition = 0, finishedAt = System.currentTimeMillis())
renumber()
publishLocked()
}
}
if (wasRunning) {
item.cancelled.set(true)
item.job?.cancel(deleteFiles = true)
} else {
// Never started: nothing is on disk, but the job knows where it would have gone.
Thread({ runCatching { item.factory().cancel(deleteFiles = true) } }, "store-dl-cancel").start()
}
StoresState.logLine("cancelled \"${item.entry.name}\"")
StoreDownloadService.finish(app, key)
advance(app)
// The row says Cancelled for a moment, then goes; the game reads Install again.
Thread({ Thread.sleep(3000); synchronized(lock) { if (items[key]?.entry?.state == DownloadState.CANCELLED) { items.remove(key); publishLocked() } } }, "store-dl-cancelled").start()
}
/**
* Clear on a failed or paused row: deletes what it kept exactly as Cancel does (the job's own
* cleanup), then drops the row. An installed or cancelled row is only dropped.
*/
fun clear(context: Context, key: String) {
val item = synchronized(lock) { items[key] } ?: return
if (item.entry.state == DownloadState.RUNNING || item.entry.state == DownloadState.QUEUED) return
if (item.entry.state == DownloadState.FAILED || item.entry.state == DownloadState.PAUSED) {
Thread({ runCatching { item.factory().cancel(deleteFiles = true) } }, "store-dl-clear").start()
}
synchronized(lock) { items.remove(key); publishLocked() }
StoreDownloadService.finish(context.applicationContext, key)
}
/** Drops every finished, failed or cancelled row; files a failed one kept stay (its game page's Clear removes them). */
fun dismissFinished() {
synchronized(lock) {
items.entries.removeAll { !it.value.entry.isActive }
publishLocked()
}
}
/** Drops a finished, failed or cancelled row from the list. */
fun dismiss(key: String) {
synchronized(lock) {
val item = items[key] ?: return
if (item.entry.isActive) return
items.remove(key)
publishLocked()
}
}
private fun renumber() {
var pos = 1
for (item in items.values) if (item.entry.state == DownloadState.QUEUED) item.entry = item.entry.copy(queuePosition = pos++)
}
private fun update(key: String, transform: (DownloadEntry) -> DownloadEntry) {
synchronized(lock) {
val item = items[key] ?: return
item.entry = transform(item.entry)
publishLocked()
}
}
private fun publish(context: Context) { synchronized(lock) { publishLocked() }; StoreDownloadService.sync(context) }
/** At most this often a progress-only change reaches the UI; a state or stage change goes at once. */
private const val PROGRESS_PUBLISH_MS = 250L
private var lastPublishAt = 0L
private var publishPending = false
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/**
* Newest first: what was started last sits on top, as the preview lists them. Caller holds
* [lock]. Engines report per chunk or per file - hundreds of times a second at full speed - so
* [progressOnly] changes are coalesced to [PROGRESS_PUBLISH_MS]; the UI then recomposes a few
* times a second, not on every callback.
*/
private fun publishLocked(progressOnly: Boolean = false) {
val now = android.os.SystemClock.uptimeMillis()
if (progressOnly) {
val wait = lastPublishAt + PROGRESS_PUBLISH_MS - now
if (wait > 0) {
if (!publishPending) {
publishPending = true
main.postDelayed({ synchronized(lock) { publishPending = false; publishLocked() } }, wait)
}
return
}
}
lastPublishAt = now
val list = items.values.map { it.entry }.sortedByDescending { it.startedAt }
StoresState.post { StoresState.publishDownloads(list) }
}
/** Starts queued items while slots are free. */
private fun advance(context: Context) {
while (true) {
val next: Item
synchronized(lock) {
if (running >= parallel(context)) return
next = items.values.firstOrNull { it.entry.state == DownloadState.QUEUED } ?: return
running++
next.entry = next.entry.copy(state = DownloadState.RUNNING, stage = DownloadStage.MANIFEST, queuePosition = 0)
renumber()
publishLocked()
}
StoreDownloadService.start(context)
Thread({ runItem(context, next) }, "store-dl-${next.entry.key}").start()
}
}
private fun runItem(context: Context, item: Item) {
// Downloads run beside the UI, never ahead of it: background priority for this thread and
// (through [workerFactory]) every pool a store's manager starts.
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
val handle = JobHandle(item.entry.key, item.cancelled)
var result: String? = null
var error: String? = null
try {
val job = item.factory()
synchronized(lock) { item.job = job }
StoresState.logLine("${item.entry.store.label}: fetching manifest for \"${item.entry.name}\"")
result = job.run(handle)
if (result == null && !item.cancelled.get()) error = "install failed"
} catch (t: Throwable) {
error = t.message ?: t.javaClass.simpleName
Log.w(TAG, "${item.entry.key} failed", t)
}
synchronized(lock) {
running--
val now = System.currentTimeMillis()
item.entry = when {
item.cancelled.get() && item.pauseRequested -> item.entry.copy(state = DownloadState.PAUSED, speedBps = 0, etaSeconds = -1)
item.cancelled.get() -> item.entry.copy(state = DownloadState.CANCELLED, finishedAt = now, speedBps = 0, etaSeconds = -1)
result != null -> item.entry.copy(state = DownloadState.INSTALLED, stage = DownloadStage.DONE, installPath = result, bytesDone = item.entry.bytesTotal, speedBps = 0, etaSeconds = -1, finishedAt = now)
else -> item.entry.copy(state = DownloadState.FAILED, error = error, speedBps = 0, etaSeconds = -1, finishedAt = now)
}
item.job = null
publishLocked()
}
when (item.entry.state) {
DownloadState.INSTALLED -> {
StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
// The folder is on disk and registered: the Installed tab and the Games list follow.
StoresState.notifyLibraryChanged(context)
val games = runCatching {
com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
}.getOrNull()
StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games)
}
DownloadState.FAILED -> {
StoresState.logLine("${item.entry.store.label}: \"${item.entry.name}\" failed: $error")
StoreDownloadService.ended(context, item.entry, installed = false, gamesKey = null)
}
DownloadState.CANCELLED -> StoresState.notifyLibraryChanged(context)
else -> {}
}
StoreDownloadService.finish(context, item.entry.key)
advance(context)
}
}
@@ -0,0 +1,299 @@
package com.droiddeck.launcher.stores.download
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.formatBytes
import com.droiddeck.launcher.stores.formatSpeed
import java.util.concurrent.ConcurrentHashMap
/**
* Keeps store downloads going with the screen off or the app in the background: a foreground
* service (dataSync) with a notification showing the active download's progress, Pause / Resume
* and a two-step Cancel; a Wi-Fi lock and a partial wake lock while anything is running, so the
* radio and the CPU do not sleep under a multi-gigabyte install; and a notification when a game
* has installed or failed.
*
* [start] is called when a download begins and [finish] when one ends; the service stops itself when
* nothing is running.
*/
class StoreDownloadService : Service() {
private var wifi: WifiManager.WifiLock? = null
private var wake: PowerManager.WakeLock? = null
private var wakeAt = 0L
/** The download whose Cancel was tapped once: its actions read Delete / Keep until [confirmUntil]. */
private var confirmKey: String? = null
private var confirmUntil = 0L
private val main = Handler(Looper.getMainLooper())
override fun onCreate() {
super.onCreate()
instance = this
channels(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
instance = this
startForegroundCompat(build())
val key = intent?.getStringExtra(EXTRA_KEY)
when (intent?.action) {
ACTION_PAUSE -> key?.let {
DownloadQueue.pause(it)
// The progress notification goes with the last running download; this one stays to resume from.
pausedNote(this, it)
}
ACTION_RESUME -> key?.let {
runCatching { getSystemService(NotificationManager::class.java)?.cancel(pausedId(it)) }
DownloadQueue.resume(this, it)
}
ACTION_CANCEL -> key?.let {
confirmKey = it; confirmUntil = System.currentTimeMillis() + CONFIRM_MS
main.postDelayed({ if (System.currentTimeMillis() >= confirmUntil) { confirmKey = null; refresh() } }, CONFIRM_MS + 100)
}
ACTION_DELETE -> key?.let { confirmKey = null; DownloadQueue.cancel(this, it) }
ACTION_KEEP -> confirmKey = null
}
refresh()
if (active.isEmpty() && StoresState.latestDownloads.none { it.state == DownloadState.RUNNING }) stopNow()
return START_NOT_STICKY
}
override fun onDestroy() {
releaseLocks()
if (instance === this) instance = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun pending(action: String, key: String, code: Int): PendingIntent {
val i = Intent(this, StoreDownloadService::class.java).setAction(action).putExtra(EXTRA_KEY, key)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) PendingIntent.getForegroundService(this, code, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
else PendingIntent.getService(this, code, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun build(): Notification {
val rows = StoresState.latestDownloads.filter { it.isActive }
val first = rows.firstOrNull { it.state == DownloadState.RUNNING } ?: rows.firstOrNull()
val title = if (rows.size > 1) getString(R.string.stores_notification_many, rows.size) else first?.name ?: getString(R.string.stores_notification_one)
val b = Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle(title)
.setOngoing(true).setOnlyAlertOnce(true)
.setContentIntent(openDownloads(this, 1))
if (first == null) return b.setContentText(getString(R.string.stores_notification_waiting)).setProgress(0, 0, true).build()
b.setContentText(DownloadNotificationText.line(first, stageName(first.stage), getString(R.string.stores_dl_paused), getString(R.string.stores_dl_queued)))
val fraction = first.stageFraction
if (fraction >= 0f) b.setProgress(1000, (fraction * 1000).toInt(), false) else b.setProgress(0, 0, true)
val code = first.key.hashCode()
if (confirmKey == first.key && System.currentTimeMillis() < confirmUntil) {
b.addAction(Notification.Action.Builder(null, getString(R.string.stores_notification_delete), pending(ACTION_DELETE, first.key, code + 3)).build())
b.addAction(Notification.Action.Builder(null, getString(R.string.stores_notification_keep), pending(ACTION_KEEP, first.key, code + 4)).build())
} else {
if (first.state == DownloadState.PAUSED) b.addAction(Notification.Action.Builder(null, getString(R.string.stores_dl_resume), pending(ACTION_RESUME, first.key, code + 1)).build())
else b.addAction(Notification.Action.Builder(null, getString(R.string.stores_dl_pause), pending(ACTION_PAUSE, first.key, code)).build())
b.addAction(Notification.Action.Builder(null, getString(R.string.stores_dl_cancel), pending(ACTION_CANCEL, first.key, code + 2)).build())
}
return b.build()
}
private fun stageName(stage: DownloadStage): String = getString(when (stage) {
DownloadStage.MANIFEST -> R.string.stores_stage_manifest
DownloadStage.DOWNLOAD -> R.string.stores_stage_downloading
DownloadStage.VERIFY -> R.string.stores_stage_verifying
DownloadStage.INSTALL -> R.string.stores_stage_installing
DownloadStage.DONE -> R.string.stores_stage_done
})
private fun refresh() {
locks()
runCatching { (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(ID, build()) }
}
/**
* While a download runs: a Wi-Fi lock (high-performance before Android 14, low-latency from it,
* where the old mode is deprecated) and a partial wake lock, taken with a timeout and renewed
* as progress comes in rather than held indefinitely. Released as soon as nothing runs.
*/
@SuppressLint("WakelockTimeout")
private fun locks() {
val running = StoresState.latestDownloads.any { it.state == DownloadState.RUNNING }
if (!running) { releaseLocks(); return }
if (wifi == null) {
runCatching {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION")
val mode = if (Build.VERSION.SDK_INT >= 34) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF
wifi = wm.createWifiLock(mode, "DroidDeck:store-download").apply { setReferenceCounted(false); acquire() }
Log.i(TAG, "wifi lock held")
}
}
val now = System.currentTimeMillis()
if (wake == null) {
wake = (getSystemService(POWER_SERVICE) as PowerManager).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DroidDeck:store-download").apply { setReferenceCounted(false) }
Log.i(TAG, "wake lock held")
}
if (now - wakeAt > WAKE_RENEW_MS) {
wake?.acquire(WAKE_TIMEOUT_MS)
wakeAt = now
}
}
private fun releaseLocks() {
wifi?.let { runCatching { if (it.isHeld) it.release() }; Log.i(TAG, "wifi lock released") }
wifi = null
wake?.let { runCatching { if (it.isHeld) it.release() }; Log.i(TAG, "wake lock released") }
wake = null
wakeAt = 0L
}
private fun startForegroundCompat(n: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(ID, n, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else startForeground(ID, n)
}
private fun stopNow() {
releaseLocks()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
companion object {
private const val TAG = "StoreDownloadService"
private const val CHANNEL = "store_downloads"
private const val DONE_CHANNEL = "store_downloads_done"
private const val ID = 9102
private const val CONFIRM_MS = 10_000L
private const val WAKE_TIMEOUT_MS = 10 * 60_000L
private const val WAKE_RENEW_MS = 5 * 60_000L
private const val EXTRA_KEY = "key"
private const val ACTION_PAUSE = "droiddeck.stores.PAUSE"
private const val ACTION_RESUME = "droiddeck.stores.RESUME"
private const val ACTION_CANCEL = "droiddeck.stores.CANCEL"
private const val ACTION_DELETE = "droiddeck.stores.DELETE"
private const val ACTION_KEEP = "droiddeck.stores.KEEP"
/** MainActivity: which page to open ("stores:downloads", or a Games selection such as "app:<id>"). */
const val EXTRA_NAV = "droiddeck.nav"
private val active = ConcurrentHashMap.newKeySet<String>()
@Volatile private var instance: StoreDownloadService? = null
private fun channels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL) == null) {
nm.createNotificationChannel(NotificationChannel(CHANNEL, context.getString(R.string.stores_notification_channel), NotificationManager.IMPORTANCE_LOW).apply {
description = context.getString(R.string.stores_notification_channel_hint)
setShowBadge(false)
})
}
if (nm.getNotificationChannel(DONE_CHANNEL) == null) {
nm.createNotificationChannel(NotificationChannel(DONE_CHANNEL, context.getString(R.string.stores_notification_done_channel), NotificationManager.IMPORTANCE_DEFAULT))
}
}
private fun openPage(context: Context, nav: String, code: Int): PendingIntent =
PendingIntent.getActivity(context, code, Intent(context, MainActivity::class.java).putExtra(EXTRA_NAV, nav)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
private fun openDownloads(context: Context, code: Int) = openPage(context, "stores:downloads", code)
/** A download started: the service is up (idempotent). */
fun start(context: Context) {
StoresState.latestDownloads.filter { it.state == DownloadState.RUNNING }.forEach { active.add(it.key) }
try {
val app = context.applicationContext
val intent = Intent(app, StoreDownloadService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) app.startForegroundService(intent) else app.startService(intent)
} catch (e: Exception) {
Log.w(TAG, "could not start: ${e.message}")
}
}
/** Progress moved: redraw the notification (StoresState calls it at most once a second). */
fun tick() {
instance?.refresh()
}
/** The queue changed: redraw the notification. */
fun sync(context: Context) {
instance?.refresh()
}
/** A download ended: drop it; the service stops with the last one. */
fun finish(context: Context, key: String) {
active.remove(key)
val inst = instance ?: return
if (active.isEmpty() && StoresState.latestDownloads.none { it.state == DownloadState.RUNNING }) inst.stopNow() else inst.refresh()
}
private fun pausedId(key: String) = ID + 0x20000 + (key.hashCode() and 0xFFFF)
/** "<game> paused" with Resume, once the progress notification has gone with the service. */
private fun pausedNote(context: Context, key: String) {
val entry = StoresState.latestDownloads.firstOrNull { it.key == key } ?: return
val i = Intent(context, StoreDownloadService::class.java).setAction(ACTION_RESUME).putExtra(EXTRA_KEY, key)
val resume = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) PendingIntent.getForegroundService(context, key.hashCode() + 5, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
else PendingIntent.getService(context, key.hashCode() + 5, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val n = Notification.Builder(context, DONE_CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle(entry.name).setContentText(context.getString(R.string.stores_dl_paused))
.setContentIntent(resume).setAutoCancel(true)
.addAction(Notification.Action.Builder(null, context.getString(R.string.stores_dl_resume), resume).build())
.build()
runCatching { context.getSystemService(NotificationManager::class.java)?.notify(pausedId(key), n) }
}
/** "<game> installed" (opens it in Games) or "<game> download failed" (opens Downloads); dismissible. */
fun ended(context: Context, entry: DownloadEntry, installed: Boolean, gamesKey: String?) {
val app = context.applicationContext
channels(app)
val code = entry.key.hashCode()
val n = Notification.Builder(app, DONE_CHANNEL)
.setSmallIcon(if (installed) android.R.drawable.stat_sys_download_done else android.R.drawable.stat_notify_error)
.setContentTitle(app.getString(if (installed) R.string.stores_notification_installed else R.string.stores_notification_failed, entry.name))
.setAutoCancel(true)
.setContentIntent(if (installed && gamesKey != null) openPage(app, gamesKey, code + 10) else openDownloads(app, code + 11))
.build()
runCatching { app.getSystemService(NotificationManager::class.java)?.notify(ID + 1 + (code and 0xFFFF), n) }
}
}
}
/** The notification's one line for a download. Pure, for the test. */
object DownloadNotificationText {
fun line(d: DownloadEntry, stageName: String, paused: String, queued: String): String {
val parts = ArrayList<String>()
parts += d.name
when {
d.state == DownloadState.PAUSED -> parts += paused
d.state == DownloadState.QUEUED -> parts += queued
d.stage == DownloadStage.DOWNLOAD -> {
if (d.bytesTotal > 0) { parts += "${d.percent}%"; parts += "${formatBytes(d.bytesDone)}/${formatBytes(d.bytesTotal)}" }
if (d.speedBps > 0) parts += formatSpeed(d.speedBps)
if (d.etaSeconds >= 0) parts += eta(d.etaSeconds)
}
else -> parts += if (d.stageFraction >= 0f) "$stageName ${d.percent}%" else stageName
}
return parts.joinToString(" · ")
}
fun eta(seconds: Long): String = when {
seconds < 60 -> "<1 min"
seconds < 3600 -> "${seconds / 60} min"
else -> "${seconds / 3600} h ${(seconds % 3600) / 60} min"
}
}
@@ -0,0 +1,33 @@
package com.droiddeck.launcher.stores.download
import android.content.Context
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
/**
* How hard the native download engine may pull: the ceiling of chunk requests it keeps in flight,
* and the inflate / hash / write threads behind them. Three tiers, one app-wide setting (Setup ›
* Stores and the Downloads page), read when a download starts so a change applies to the next one.
*
* The windows are the ones the engine was tuned with (16 / 32 / 96 requests): it ramps towards the
* ceiling only while throughput keeps rising, so a slow link settles well below any of them.
*/
enum class StoreDownloadTier(val id: String, @StringRes val label: Int, val networkWindow: Int, val decompressPerCore: Double) {
BALANCED("balanced", R.string.stores_tier_balanced, 16, 0.4),
FAST("fast", R.string.stores_tier_fast, 32, 0.5),
MAX("max", R.string.stores_tier_max, 96, 0.8);
/** Inflate / hash / write workers for this device, at least two so a fetch never waits on a single writer. */
/** Inflate / hash / write threads: by the tier, but two cores always left for the UI (at least 2 workers). */
val processWorkers: Int get() {
val cores = Runtime.getRuntime().availableProcessors()
return (cores * decompressPerCore).toInt().coerceIn(2, maxOf(2, minOf(32, cores - 2)))
}
companion object {
val ALL: List<StoreDownloadTier> = entries
fun byId(id: String?): StoreDownloadTier = entries.firstOrNull { it.id == id } ?: FAST
fun current(context: Context): StoreDownloadTier = byId(SessionPrefs.gameStoresSpeedTier(context))
}
}
@@ -0,0 +1,196 @@
package com.droiddeck.launcher.stores.epic;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
/**
* Epic's library, catalog and launcher-assets services, as the launcher calls them:
* library-service.live.use1a.on.epicgames.com/library/api/public/items (owned items, paginated)
* catalog-public-service-prod06.ol.epicgames.com/catalog/api/shared/namespace/{ns}/bulk/items
* launcher-public-service-prod06.ol.epicgames.com/launcher/api/public/assets/v2/platform/Windows/...
* All with a bearer token.
*/
public final class EpicApiClient {
private static final String TAG = "EpicApi";
private static final String LIBRARY_URL = "https://library-service.live.use1a.on.epicgames.com/library/api/public/items?includeMetadata=true";
private static final String CATALOG_BASE = "https://catalog-public-service-prod06.ol.epicgames.com/catalog/api/shared/namespace";
private static final String MANIFEST_BASE = "https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/public/assets/v2/platform/Windows/namespace";
private static final String LEGENDARY_UA = "Legendary/0.1.0 (GameNative)";
private EpicApiClient() {}
/** The owned games, every page; Unreal assets, private sandboxes and non-Windows items left out. */
public static List<EpicGame> getLibraryItems(String accessToken) {
List<EpicGame> result = new ArrayList<>();
String cursor = null;
do {
try {
String url = LIBRARY_URL + (cursor != null ? "&cursor=" + cursor : "");
String resp = EpicAuthClient.getRequest(url, accessToken);
if (resp == null) break;
JSONObject json = new JSONObject(resp);
JSONArray records = json.optJSONArray("records");
if (records == null) break;
for (int i = 0; i < records.length(); i++) {
JSONObject rec = records.getJSONObject(i);
String appName = rec.optString("appName", "");
if (appName.isEmpty() || appName.equals("1")) continue;
String namespace = rec.optString("namespace", "");
if ("ue".equals(namespace) || "89efe5924d3d467c839449ab6ab52e7f".equals(namespace)) continue;
if ("PRIVATE".equals(rec.optString("sandboxType", ""))) continue;
JSONArray platforms = rec.optJSONArray("platform");
if (platforms != null && platforms.length() > 0) {
boolean windows = false;
for (int j = 0; j < platforms.length(); j++) {
String p = platforms.optString(j, "");
if ("Windows".equals(p) || "Win32".equals(p)) { windows = true; break; }
}
if (!windows) continue;
}
EpicGame game = new EpicGame();
game.appName = appName;
game.namespace = namespace;
game.catalogItemId = rec.optString("catalogItemId", "");
result.add(game);
}
JSONObject meta = json.optJSONObject("responseMetadata");
String next = meta != null ? meta.optString("nextCursor", null) : null;
cursor = (next == null || next.isEmpty() || next.equals(cursor)) ? null : next;
} catch (Exception e) {
Log.e(TAG, "library page failed: " + e.getClass().getSimpleName());
break;
}
} while (cursor != null);
return result;
}
/** Fills title, developer, art, description and the DLC flag from the catalog; true on success. */
public static boolean enrichFromCatalog(String accessToken, EpicGame game) {
if (game.namespace.isEmpty() || game.catalogItemId.isEmpty()) return false;
try {
String url = CATALOG_BASE + "/" + game.namespace + "/bulk/items?id=" + game.catalogItemId + "&includeDLCDetails=true&includeMainGameDetails=true&country=US";
String resp = getWithLegendaryUA(url, accessToken);
if (resp == null) return false;
JSONObject root = new JSONObject(resp);
JSONObject item = root.optJSONObject(game.catalogItemId);
if (item == null && root.length() > 0) item = root.optJSONObject(root.keys().next());
if (item == null) return false;
game.title = item.optString("title", game.title.isEmpty() ? game.appName : game.title);
game.developer = item.optString("developer", "");
game.description = item.optString("description", "");
game.isDLC = item.has("mainGameItem");
if (game.isDLC) {
JSONObject main = item.optJSONObject("mainGameItem");
if (main != null) game.baseGameCatalogItemId = main.optString("id", "");
}
JSONArray keyImages = item.optJSONArray("keyImages");
if (keyImages != null) for (int i = 0; i < keyImages.length(); i++) {
JSONObject img = keyImages.getJSONObject(i);
String type = img.optString("type", ""), imgUrl = img.optString("url", "");
if (imgUrl.isEmpty()) continue;
if ("DieselGameBoxTall".equals(type)) game.artCover = imgUrl;
else if (("DieselGameBox".equals(type) || "Thumbnail".equals(type)) && game.artSquare.isEmpty()) game.artSquare = imgUrl;
}
JSONObject attrs = item.optJSONObject("customAttributes");
if (attrs != null) {
JSONObject offline = attrs.optJSONObject("CanRunOffline");
if (offline != null) game.canRunOffline = !"false".equalsIgnoreCase(offline.optString("value", "true"));
}
String rd = item.optString("viewableDate", "");
if (rd.isEmpty()) rd = item.optString("effectiveDate", "");
if (!rd.isEmpty()) game.releaseDate = rd;
return true;
} catch (Exception e) {
Log.e(TAG, "catalog enrich failed for " + game.appName + ": " + e.getClass().getSimpleName());
return false;
}
}
/**
* The manifest API answer for a game, flattened to {"manifests": [...], "versionId": "..."} as
* the download manager reads it. {@code appName} is the library record's, not the catalog's.
*/
public static String getManifestApiJson(String accessToken, String namespace, String catalogItemId, String appName) {
try {
String url = MANIFEST_BASE + "/" + namespace + "/catalogItem/" + catalogItemId + "/app/" + appName + "/label/Live";
String resp = getWithLegendaryUA(url, accessToken);
if (resp == null) return null;
JSONObject root = new JSONObject(resp);
JSONArray elements = root.optJSONArray("elements");
if (elements == null || elements.length() == 0) return null;
JSONObject first = elements.getJSONObject(0);
JSONObject wrapper = new JSONObject();
wrapper.put("manifests", first.optJSONArray("manifests"));
String ver = first.optString("buildVersion", "");
if (ver.isEmpty()) ver = first.optString("versionId", "");
wrapper.put("versionId", ver);
// The sidecar's EOS deployment id, when the app has one.
JSONObject sidecar = first.optJSONObject("sidecar");
if (sidecar != null) {
String config = sidecar.optString("config", "");
if (!config.isEmpty()) {
try { wrapper.put("deploymentId", new JSONObject(config).optString("deploymentId", "")); } catch (Exception ignored) {}
}
}
return wrapper.toString();
} catch (Exception e) {
Log.e(TAG, "manifest API failed for " + appName + ": " + e.getClass().getSimpleName());
return null;
}
}
/** `customAttributes.AdditionalCommandLine.value` for the game, "" when it has none, null when the call failed. */
public static String getAdditionalCommandLine(String accessToken, String namespace, String catalogItemId) {
return getCustomAttribute(accessToken, namespace, catalogItemId, "AdditionalCommandLine");
}
/** `customAttributes.<name>.value` for the game (CloudSaveFolder, AdditionalCommandLine), "" when it has none, null when the call failed. */
public static String getCustomAttribute(String accessToken, String namespace, String catalogItemId, String name) {
try {
String url = CATALOG_BASE + "/" + namespace + "/bulk/items?id=" + catalogItemId + "&country=US";
String resp = getWithLegendaryUA(url, accessToken);
if (resp == null) return null;
JSONObject root = new JSONObject(resp);
JSONObject item = root.optJSONObject(catalogItemId);
if (item == null && root.length() > 0) item = root.optJSONObject(root.keys().next());
if (item == null) return "";
JSONObject attrs = item.optJSONObject("customAttributes");
if (attrs == null) return "";
JSONObject attr = attrs.optJSONObject(name);
return attr == null ? "" : attr.optString("value", "");
} catch (Exception e) {
Log.w(TAG, "catalog attribute " + name + ": " + e.getClass().getSimpleName());
return null;
}
}
static String getWithLegendaryUA(String urlStr, String accessToken) {
java.net.HttpURLConnection conn = null;
try {
conn = (java.net.HttpURLConnection) new java.net.URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setRequestProperty("User-Agent", LEGENDARY_UA);
int code = conn.getResponseCode();
java.io.InputStream is = (code < 400) ? conn.getInputStream() : conn.getErrorStream();
String body = EpicAuthClient.readStream(is);
if (code < 200 || code >= 300) { Log.e(TAG, "GET HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return body;
} catch (Exception e) {
Log.e(TAG, "GET failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
}
@@ -0,0 +1,181 @@
package com.droiddeck.launcher.stores.epic;
import android.util.Base64;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* Epic's OAuth2 token endpoint, with the launcher's public client credentials (the ones every
* third-party Epic client uses):
* POST account-public-service-prod03.ol.epicgames.com/account/api/oauth/token
* Basic base64(clientId:clientSecret), form grant_type=authorization_code|refresh_token, token_type=eg1
* GET .../account/api/oauth/exchange with a bearer token: a short-lived exchange code for a launch.
* Error bodies are never logged: they carry correlation ids and account context.
*/
public final class EpicAuthClient {
private static final String TAG = "EpicAuth";
static final String CLIENT_ID = "34a02cf8f4414e29b15921876da36f9a";
static final String CLIENT_SECRET = "daafbccc737745039dffe53d94fc76cf";
private static final String TOKEN_URL = "https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token";
private static final String EXCHANGE_URL = "https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/exchange";
public static final String USER_AGENT = "UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit";
public static final class TokenResult {
public String accessToken;
public String refreshToken;
public String accountId;
public String displayName;
public long expiresAt;
}
private EpicAuthClient() {}
public static TokenResult exchangeCode(String authCode) {
return postToken("grant_type=authorization_code&code=" + authCode + "&token_type=eg1");
}
public static TokenResult refreshToken(String refreshToken) {
return postToken("grant_type=refresh_token&refresh_token=" + refreshToken + "&token_type=eg1");
}
private static TokenResult postToken(String formBody) {
try {
String creds64 = Base64.encodeToString((CLIENT_ID + ":" + CLIENT_SECRET).getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP);
HttpURLConnection conn = (HttpURLConnection) new URL(TOKEN_URL).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setDoOutput(true);
conn.setRequestProperty("Authorization", "Basic " + creds64);
conn.setRequestProperty("User-Agent", USER_AGENT);
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
byte[] bodyBytes = formBody.getBytes(StandardCharsets.UTF_8);
conn.setRequestProperty("Content-Length", String.valueOf(bodyBytes.length));
try (OutputStream os = conn.getOutputStream()) { os.write(bodyBytes); }
int code = conn.getResponseCode();
InputStream is = (code < 400) ? conn.getInputStream() : conn.getErrorStream();
String resp = readStream(is);
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "token HTTP " + code); return null; }
JSONObject json = new JSONObject(resp);
TokenResult result = new TokenResult();
result.accessToken = json.optString("access_token", null);
result.refreshToken = json.optString("refresh_token", null);
result.accountId = json.optString("account_id", "");
result.displayName = json.optString("displayName", "");
long expiresIn = json.optLong("expires_in", 7200L);
String expiresAtStr = json.optString("expires_at", null);
long expiresAtMs;
if (expiresAtStr != null && !expiresAtStr.isEmpty()) {
try { expiresAtMs = parseIso8601(expiresAtStr); } catch (Exception e) { expiresAtMs = System.currentTimeMillis() + expiresIn * 1000L; }
} else expiresAtMs = System.currentTimeMillis() + expiresIn * 1000L;
result.expiresAt = expiresAtMs;
return result.accessToken != null ? result : null;
} catch (Exception e) {
Log.e(TAG, "token request failed: " + e.getClass().getSimpleName());
return null;
}
}
/** A short-lived exchange code for a game launch (-AUTH_PASSWORD); null on any failure. */
public static String getExchangeCode(String accessToken) {
try {
String resp = getRequest(EXCHANGE_URL, accessToken);
// getRequest has logged the HTTP status; the code itself is never logged.
if (resp == null) { Log.i(TAG, "exchange code: no answer"); return null; }
return new JSONObject(resp).optString("code", null);
} catch (Exception e) {
Log.e(TAG, "exchange code failed: " + e.getClass().getSimpleName());
return null;
}
}
/** Authorized GET; the body or null. */
public static String getRequest(String urlStr, String accessToken) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setRequestProperty("User-Agent", USER_AGENT);
int code = conn.getResponseCode();
InputStream is = (code < 400) ? conn.getInputStream() : conn.getErrorStream();
String resp = readStream(is);
if (code < 200 || code >= 300) { Log.e(TAG, "GET HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return resp;
} catch (Exception e) {
Log.e(TAG, "GET failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/** Authorized GET of raw bytes (the manifest binary). */
public static byte[] getBytes(String urlStr, String accessToken) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(120000);
if (accessToken != null) conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setRequestProperty("User-Agent", USER_AGENT);
int code = conn.getResponseCode();
if (code < 200 || code >= 300) { Log.e(TAG, "bytes HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return readAllBytes(conn.getInputStream());
} catch (Exception e) {
Log.e(TAG, "bytes failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
static byte[] readAllBytes(InputStream in) throws IOException {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[131072];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
return out.toByteArray();
}
static String readStream(InputStream is) throws IOException {
if (is == null) return "";
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) sb.append(line);
}
return sb.toString();
}
/** "2026-03-29T12:00:00.000Z" to epoch millis, without java.time. */
private static long parseIso8601(String s) {
int year = Integer.parseInt(s.substring(0, 4));
int month = Integer.parseInt(s.substring(5, 7));
int day = Integer.parseInt(s.substring(8, 10));
int hour = Integer.parseInt(s.substring(11, 13));
int minute = Integer.parseInt(s.substring(14, 16));
int second = Integer.parseInt(s.substring(17, 19));
long jd = 367L * year - (7 * (year + (month + 9) / 12)) / 4 + (275 * month) / 9 + day + 1721013L;
long epochDays = jd - 2440588L;
return epochDays * 86400000L + hour * 3600000L + minute * 60000L + second * 1000L;
}
}
@@ -0,0 +1,191 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import android.content.Intent
import android.util.Log
import android.webkit.WebView
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.EpicLaunchSupport
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.LoginFlows
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoreShelves
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/** Epic Games in the Stores section: sign-in, the library, the public store, installs and the per-launch sign-in code. */
object EpicBackend : StoreBackend, EpicLaunchSupport {
private const val TAG = "EpicBackend"
override val store: Store = Store.EPIC
init { LoginFlows.epicFlow = { LoginFlow } }
override fun signIn(context: Context) {
context.startActivity(Intent(context, StoreLoginActivity::class.java).putExtra(StoreLoginActivity.EXTRA_STORE, store.id).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun signOut(context: Context) {
EpicPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
val cached = EpicLibrary.cached(app)
if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { EpicLibrary.toCatalogItem(app, it) }) }
StoresState.post { StoresState.status[store] = "Fetching your library…" }
Thread({
val result = EpicLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
is EpicLibrary.SyncResult.Ok -> {
StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { EpicLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store)
}
is EpicLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "Epic session expired: sign in again."
EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { EpicLibrary.toCatalogItem(app, it) })
EpicLibrary.SyncResult.Busy -> {}
}
}
}, "epic-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
EpicStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = markOwned(it) } }
Thread({
val shelves = EpicStoreCatalog.featured(app, force)
StoresState.post { if (shelves != null) StoresState.shelves[store] = markOwned(shelves) }
}, "epic-shelves").start()
}
/** Store offers whose catalog item is in the library are owned: their cards offer Install under the library's id. */
private fun markOwned(s: StoreShelves): StoreShelves {
val owned = StoresState.library[store] ?: return s
val byCatalogItem = owned.associateBy { it.extra["catalogItemId"] ?: "" }
fun mark(l: List<CatalogItem>) = l.map { i ->
val match = i.extra["items"]?.split(',')?.firstNotNullOfOrNull { byCatalogItem[it] }
if (match != null) match.copy(imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl) else i
}
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
}
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
val namespace = item.extra["namespace"] ?: ""
val catalogItemId = item.extra["catalogItemId"] ?: ""
if (namespace.isEmpty() || catalogItemId.isEmpty()) { StoresState.logLine("Epic: \"${item.title}\" is not in your library"); return }
val folder = StoreInstallRoot.folderFor(app, Store.EPIC, item.id, item.title, root)
val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
DownloadQueue.enqueue(app, entry) { InstallJob(app, item, namespace, catalogItemId, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
/** A fresh exchange code for the signed-in account; null when signed out or offline. Blocking. */
override fun exchangeCode(context: Context): String? {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
return EpicAuthClient.getExchangeCode(token)
}
/** The access token is past its time and the refresh just failed: only a new sign-in helps. */
override fun signInExpired(context: Context): Boolean = EpicCredentialStore.expired(context)
private class InstallJob(val app: Context, val item: CatalogItem, val namespace: String, val catalogItemId: String, val folder: File) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
// On a card the in-flight chunks go to the app's cache instead: the card's write rate paces
// the whole install, and chunks are read back once at assembly. Internal installs keep the
// cache beside the game. A resume keeps whichever cache the stopped run filled: a
// `.chunks` already beside the game is reused, not fetched again into the scratch folder.
private val scratch: File? = if (StoreInstallRoot.isRemovable(app, folder) && !hasChunks(File(folder, ".chunks"))) StoreInstallRoot.scratchDir(app, Store.EPIC, item.id) else null
private fun hasChunks(dir: File): Boolean = dir.isDirectory && (dir.list()?.isNotEmpty() == true)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, "Checking sign-in…")
val token = EpicCredentialStore.getValidAccessToken(app) ?: throw EpicDownloadManager.InstallException("Not signed in to Epic Games")
StoreInstalls.begin(folder, Store.EPIC, item.id, item.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl)
if (scratch == null && hasChunks(File(folder, ".chunks"))) handle.log("epic: resuming \"${item.title}\" with the chunks already beside it")
val manifestJson = EpicApiClient.getManifestApiJson(token, namespace, catalogItemId, item.id)
?: throw EpicDownloadManager.InstallException("The manifest could not be fetched")
val tags = EpicInstallTags.tagsForDevice()
var downloading = false
val result = EpicDownloadManager.install(app, manifestJson, folder.path, scratch?.path ?: "", tags, cancelled, object : EpicDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {
when {
message.startsWith("Writing") || message.startsWith("Complete") -> handle.stage(DownloadStage.INSTALL, message)
message.startsWith("Downloading chunks") -> { if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message) }
message.startsWith("Checking") -> handle.stage(DownloadStage.MANIFEST, message)
else -> handle.stage(DownloadStage.MANIFEST, message)
}
}
override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) }
override fun onLog(line: String) { handle.log(line) }
override fun onSizes(downloadBytes: Long, diskBytes: Long) {
handle.diskSize(diskBytes)
EpicLibrary.rememberSize(app, item.id, diskBytes)
}
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL })
handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install")
}
}) ?: return null
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL, "Registering with Steam…")
EpicLibrary.rememberSize(app, item.id, result.bytes)
val deploymentId = runCatching { JSONObject(manifestJson).optString("deploymentId", "") }.getOrDefault("")
val exe = StoreExe.pick(folder, result.launchExe, item.title)
if (exe.isEmpty()) handle.log("epic: no exe found in ${folder.name}; pick one in Steam settings › Added games")
val args = EpicLaunchData.arguments(app, token, item.id, namespace, catalogItemId, deploymentId)
val sidecar = StoreGameSidecar(
Store.EPIC, item.id, item.title, exe = exe.ifEmpty { "game.exe" }, args = args,
installVersion = result.buildVersion, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId),
)
StoreInstalls.complete(app, folder, sidecar)
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, listOfNotNull(File(folder, ".chunks"), scratch, StoreInstallRoot.scratchDir(app, Store.EPIC, item.id)))
}
}
/**
* Epic's web sign-in: the login page redirects to a JSON page carrying an authorization code,
* read out of the document and exchanged for tokens.
*/
private object LoginFlow : StoreLoginActivity.Flow {
private const val REDIRECT = "https://www.epicgames.com/id/api/redirect"
override val startUrl: String = "https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect%3FclientId%3D${EpicAuthClient.CLIENT_ID}%26responseType%3Dcode"
override val userAgent: String get() = EpicAuthClient.USER_AGENT
override val readsPage: Boolean get() = true
override fun isRedirect(url: String): Boolean = url.startsWith(REDIRECT)
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
// The document's text comes JSON-quoted from evaluateJavascript.
var text = url
if (text.startsWith("\"")) text = text.substring(1, text.length - 1)
text = text.replace("\\n", "").replace("\\r", "").replace("\\\"", "\"")
val code = Regex("\"authorizationCode\":\"([^\"]+)\"").find(text)?.groupValues?.get(1)
if (code.isNullOrEmpty()) { Log.w(TAG, "login: no authorization code on the redirect page"); return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification) }
val result = EpicAuthClient.exchangeCode(code) ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
EpicCredentialStore.save(activity, result)
Log.i(TAG, "signed in")
return null
}
}
}
@@ -0,0 +1,61 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONObject
/**
* The Epic sign-in in StoreAccounts' private file: access and refresh tokens, the account id and
* display name (the launch arguments carry both), the expiry. Refreshed when within five minutes
* of expiring.
*/
object EpicCredentialStore {
private const val TAG = "EpicAuth"
class Credentials(val accessToken: String, val refreshToken: String?, val accountId: String, val displayName: String, val expiresAt: Long)
@JvmStatic
fun save(context: Context, result: EpicAuthClient.TokenResult) {
val json = JSONObject().put("access_token", result.accessToken).put("account_id", result.accountId ?: "")
.put("display_name", result.displayName ?: "").put("expires_at", result.expiresAt)
if (!result.refreshToken.isNullOrEmpty()) json.put("refresh_token", result.refreshToken)
StoreAccounts.write(context, Store.EPIC, json)
}
@JvmStatic
fun load(context: Context): Credentials? {
val json = StoreAccounts.read(context, Store.EPIC) ?: return null
val token = json.optString("access_token", "").ifEmpty { return null }
return Credentials(token, json.optString("refresh_token", "").ifEmpty { null }, json.optString("account_id", ""), json.optString("display_name", ""), json.optLong("expires_at", 0L))
}
@JvmStatic
fun isLoggedIn(context: Context): Boolean = load(context) != null
/** A valid access token, refreshed when within five minutes of expiry; null when signed out or the refresh failed. */
@JvmStatic
/** The stored access token is past its expiry (a refresh, if one was tried, did not replace it). */
fun expired(context: Context): Boolean {
val creds = load(context) ?: return false
return creds.expiresAt in 1 until System.currentTimeMillis()
}
fun getValidAccessToken(context: Context): String? {
val creds = load(context) ?: return null
if (creds.expiresAt - System.currentTimeMillis() < 5L * 60L * 1000L && creds.refreshToken != null) {
val result = EpicAuthClient.refreshToken(creds.refreshToken)
if (result != null) {
if (result.refreshToken.isNullOrEmpty()) result.refreshToken = creds.refreshToken
if (result.accountId.isNullOrEmpty()) result.accountId = creds.accountId
if (result.displayName.isNullOrEmpty()) result.displayName = creds.displayName
save(context, result)
Log.i(TAG, "token refreshed")
return result.accessToken
}
Log.w(TAG, "token refresh failed; using the stored token")
}
return creds.accessToken
}
}
@@ -0,0 +1,810 @@
package com.droiddeck.launcher.stores.epic;
import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import com.droiddeck.launcher.stores.download.StoreDownloadTier;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.Inflater;
/**
* The Epic install pipeline, ported from Bannerlator's EpicDownloadManager: the manifest API's CDN
* list, the manifest binary (ChunksV4, or the older JSON form), the files narrowed to the base
* set plus the device's language, a delta pass that skips files already on disk with the right
* size and SHA-1, the chunk fetch into `.chunks/<GUID>` (the native engine when it is there, the
* Java pool otherwise), and the assembly of files from those chunks.
*
* Chunk URLs carry no auth tokens; the subfolder is the decimal group number; the Cloudflare
* mirror is skipped (it refuses chunks). Blocking; the download queue runs it on its own thread.
*/
public final class EpicDownloadManager {
private static final String TAG = "EpicDownload";
private static final String UA = EpicAuthClient.USER_AGENT;
public interface Callback {
void onProgress(String message, int pct);
default void onBytes(long done, long total, long speedBps) {}
default void onLog(String line) {}
/**
* The active stage's own count: {@code stage} is "check" (the files already there, before
* anything is fetched), "verify" or "install" (bytes written), {@code done} of
* {@code total} an amount (bytes, or items when there are no bytes), {@code items} of
* {@code itemsTotal} the files, when it counts files.
*/
default void onStage(String stage, long done, long total, int items, int itemsTotal) {}
/** What this run fetches (compressed) and what the game takes on disk. */
default void onSizes(long downloadBytes, long diskBytes) {}
}
private EpicDownloadManager() {}
public static final class CdnUrl {
public final String baseUrl, cloudDir, authParams;
CdnUrl(String baseUrl, String cloudDir, String authParams) { this.baseUrl = baseUrl; this.cloudDir = cloudDir; this.authParams = authParams; }
}
public static final class ChunkInfo {
public int[] guid = new int[4];
public long hash;
public byte[] sha1;
public int groupNum;
public int windowSize;
public long fileSize;
public String guidStr() { return String.format("%08X%08X%08X%08X", guid[0], guid[1], guid[2], guid[3]); }
public String getPath(String chunkDir) { return chunkDir + "/" + String.format("%02d", groupNum) + "/" + String.format("%016X", hash) + "_" + guidStr() + ".chunk"; }
}
public static final class ChunkPart {
public int[] guid = new int[4];
public int offset, size;
public String guidStr() { return String.format("%08X%08X%08X%08X", guid[0], guid[1], guid[2], guid[3]); }
}
public static final class FileInfo {
public String filename = "";
public List<ChunkPart> parts = new ArrayList<>();
public List<String> installTags = new ArrayList<>();
public byte[] sha1 = null;
public long fileSize() { long t = 0; for (ChunkPart p : parts) t += (p.size & 0xFFFFFFFFL); return t; }
}
/** The parsed manifest: its chunks, files, and from the meta block the exe the launcher starts. */
public static final class Manifest {
public String chunkDir = "ChunksV4";
public List<ChunkInfo> uniqueChunks = new ArrayList<>();
public List<FileInfo> files = new ArrayList<>();
public String launchExe = "";
public String launchCommand = "";
public String buildVersion = "";
public List<CdnUrl> cdnUrls = new ArrayList<>();
}
/** What an install leaves for the sidecar. */
public static final class Result {
public final String launchExe;
public final String buildVersion;
public final long bytes;
Result(String launchExe, String buildVersion, long bytes) { this.launchExe = launchExe; this.buildVersion = buildVersion; this.bytes = bytes; }
}
public static final class InstallException extends Exception {
InstallException(String message) { super(message); }
}
/**
* Installs the app the manifest API JSON describes into {@code installDir}. {@code installTags}
* null = every file. {@code chunkCacheDirPath} "" keeps the chunk cache beside the game
* ({@code <installDir>/.chunks}); a path puts it there instead - the app's cache when the game
* goes to a card. Returns null when cancelled; throws when it fails.
*/
public static Result install(Context ctx, String manifestApiJson, String installDirPath, String chunkCacheDirPath, List<String> installTags, AtomicBoolean cancel, Callback cb) throws InstallException {
try {
if (cancel.get()) return null;
cb.onProgress("Reading CDN list…", 0);
List<CdnUrl> cdnUrls = parseCdnUrls(manifestApiJson);
if (cdnUrls.isEmpty()) throw new InstallException("No CDN in the manifest answer");
for (CdnUrl c : cdnUrls) cb.onLog("epic: CDN " + c.baseUrl + " auth=" + (c.authParams.isEmpty() ? "no" : "yes"));
cb.onProgress("Downloading manifest…", 0);
byte[] manifestBytes = downloadManifest(manifestApiJson, cdnUrls);
if (manifestBytes == null) throw new InstallException("The manifest could not be downloaded");
if (cancel.get()) return null;
cb.onProgress("Parsing manifest…", 0);
Manifest manifest = parseManifest(manifestBytes);
if (manifest == null) throw new InstallException("The manifest could not be parsed");
manifest.cdnUrls = cdnUrls;
cb.onLog("epic: chunkDir=" + manifest.chunkDir + " chunks=" + manifest.uniqueChunks.size() + " files=" + manifest.files.size() + " exe=" + manifest.launchExe);
File installDir = new File(installDirPath);
installDir.mkdirs();
// "" = the cache beside the game; a path = the scratch cache (the app's own storage when
// the game goes to a card). The engine and the Java loops read the same value.
final String cachePath = chunkCacheDirPath == null ? "" : chunkCacheDirPath;
final File chunkCacheDir = cachePath.isEmpty() ? new File(installDir, ".chunks") : new File(cachePath);
chunkCacheDir.mkdirs();
List<FileInfo> selected = resolveInstallFiles(manifest, installTags);
cb.onLog("epic: " + selected.size() + "/" + manifest.files.size() + " files for tags " + (installTags == null ? "(all)" : installTags.toString()));
long installBytes = 0;
for (FileInfo f : selected) installBytes += f.fileSize();
final long planned = installBytes;
// Free space is checked after the delta pass, against what is still missing (below).
cb.onProgress("Checking files…", 0);
List<FileInfo> pending = new ArrayList<>(selected.size());
int checked = 0, good = 0;
long checkedBytes = 0;
cb.onStage("check", 0, installBytes, 0, selected.size());
for (FileInfo f : selected) {
if (cancel.get()) return null;
File out = new File(installDir, f.filename.replace("\\", "/"));
if (fileExistsWithCorrectHash(out, f.fileSize(), f.sha1)) good++; else pending.add(f);
checked++;
checkedBytes += f.fileSize();
if ((checked & 15) == 0 || checked == selected.size()) cb.onStage("check", checkedBytes, installBytes, checked, selected.size());
if ((checked & 63) == 0) cb.onProgress("Checking files… (" + checked + "/" + selected.size() + ")", 0);
}
cb.onLog("epic: delta " + good + " up to date, " + pending.size() + " to download");
if (pending.isEmpty()) {
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
}
List<ChunkInfo> needed = uniqueChunksForFiles(manifest, pending);
// The cache holds whole chunk windows (~1 MiB each), and a window is shared with files
// this device does not install (other tags, other builds' data): the cache is often
// larger than the game itself (Metalstorm: 9.1 GB of chunks for 4.4 GB of files). Both
// have to fit at once until assembly, so the check counts both, on their own volumes.
long cacheMissing = 0, pendingBytes = 0;
for (ChunkInfo c : needed) if (!new File(chunkCacheDir, c.guidStr()).isFile()) cacheMissing += Math.max(c.windowSize, 1);
for (FileInfo f : pending) pendingBytes += f.fileSize();
if (cachePath.isEmpty()) {
long free = installDir.getUsableSpace();
if (free > 0 && cacheMissing + pendingBytes > free) throw new InstallException("Not enough free space: need " + fmt(cacheMissing + pendingBytes) + " (" + fmt(pendingBytes) + " of game files + " + fmt(cacheMissing) + " of download cache), only " + fmt(free) + " free");
} else {
long freeCache = chunkCacheDir.getUsableSpace(), freeGame = installDir.getUsableSpace();
if (freeCache > 0 && cacheMissing > freeCache) throw new InstallException("Not enough internal space for the download cache: need " + fmt(cacheMissing) + ", only " + fmt(freeCache) + " free");
if (freeGame > 0 && pendingBytes > freeGame) throw new InstallException("Not enough free space: need " + fmt(pendingBytes) + ", only " + fmt(freeGame) + " free");
}
cb.onLog("epic: cache " + (cachePath.isEmpty() ? "beside the game" : "scratch") + ", " + fmt(cacheMissing) + " to fetch for " + fmt(pendingBytes) + " of files");
long totalBytes = 0;
for (ChunkInfo c : needed) totalBytes += Math.max(c.fileSize, 1);
final long fTotalBytes = totalBytes;
final int totalChunks = needed.size();
final AtomicLong completedBytes = new AtomicLong(0);
final AtomicInteger completedCount = new AtomicInteger(0);
final AtomicInteger failCount = new AtomicInteger(0);
final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
final AtomicLong lastSpeedBytes = new AtomicLong(0);
final AtomicLong speedBps = new AtomicLong(0);
cb.onSizes(fTotalBytes, planned);
cb.onBytes(0, fTotalBytes, 0);
cb.onLog("epic: " + fmt(fTotalBytes) + " in " + totalChunks + " chunks");
boolean javaPool = true;
final int[] pendingIdx = pendingIndices(manifest, pending);
boolean engine = com.droiddeck.launcher.stores.StoresNative.INSTANCE.getAvailable() && pendingIdx != null;
if (engine) {
NativeOutcome r = runNativePool(ctx, manifestBytes, pendingIdx, needed, fTotalBytes, installDirPath, cachePath, cdnUrls, cancel, cb,
completedBytes, completedCount, lastSpeedMs, lastSpeedBytes, speedBps);
if (r.started) {
javaPool = false;
if (r.cancelled) return null;
if (!r.success) { cb.onLog("epic: engine failed: " + r.error); failCount.incrementAndGet(); }
} else cb.onLog("epic: engine not started (" + r.error + "); using the built-in pool");
}
if (javaPool) {
cb.onLog("epic: engine=built-in (8 threads)");
ExecutorService pool = Executors.newFixedThreadPool(8, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("epic-dl"));
for (ChunkInfo chunk : needed) {
final ChunkInfo fc = chunk;
pool.submit(() -> {
if (cancel.get()) return;
File cached = new File(chunkCacheDir, fc.guidStr());
if (!cached.exists() && !downloadChunkStreaming(fc, manifest.chunkDir, cdnUrls, cached)) {
cb.onLog("FAIL chunk=" + fc.guidStr());
failCount.incrementAndGet();
return;
}
long done = completedBytes.addAndGet(Math.max(fc.fileSize, 1));
int cnt = completedCount.incrementAndGet();
sampleSpeed(done, lastSpeedMs, lastSpeedBytes, speedBps);
cb.onProgress("Downloading chunks (" + cnt + "/" + totalChunks + ")" + speedSuffix(speedBps.get()), (int) (done * 80L / Math.max(1, fTotalBytes)));
cb.onBytes(done, fTotalBytes, speedBps.get());
});
}
pool.shutdown();
try {
while (!pool.awaitTermination(250, TimeUnit.MILLISECONDS)) {
if (cancel.get()) { pool.shutdownNow(); pool.awaitTermination(5, TimeUnit.SECONDS); return null; }
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
pool.shutdownNow();
throw new InstallException("interrupted");
}
}
if (cancel.get()) return null;
if (failCount.get() > 0) throw new InstallException(failCount.get() + " chunk(s) failed to download");
// The engine's assembler when it starts (it drops each chunk after its last use and the
// cache at the end); the loop below when it does not.
if (engine) {
NativeOutcome r = runNativeAssembly(manifestBytes, pendingIdx, pending.size(), installDirPath, cachePath, cancel, cb);
if (r.started) {
if (r.cancelled) return null;
if (!r.success) throw new InstallException(r.error.isEmpty() ? "assembly failed" : r.error);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
}
cb.onLog("epic: assembler not started (" + r.error + "); writing the files here");
}
int totalFiles = pending.size(), doneFiles = 0;
long writtenBytes = 0, writeTotal = 0;
for (FileInfo f : pending) writeTotal += f.fileSize();
for (FileInfo file : pending) {
if (cancel.get()) return null;
String relPath = file.filename.replace("\\", "/");
File outFile = new File(installDir, relPath);
File parent = outFile.getParentFile();
if (parent != null) parent.mkdirs();
cb.onProgress("Writing: " + (relPath.contains("/") ? relPath.substring(relPath.lastIndexOf('/') + 1) : relPath), 80 + (int) (doneFiles * 20L / totalFiles));
try (FileOutputStream fos = new FileOutputStream(outFile); BufferedOutputStream bos = new BufferedOutputStream(fos, 65536)) {
for (ChunkPart part : file.parts) {
File cachedChunk = new File(chunkCacheDir, part.guidStr());
if (!cachedChunk.exists()) throw new InstallException("Missing chunk " + part.guidStr() + " for " + relPath);
byte[] data = readFile(cachedChunk);
bos.write(data, part.offset, part.size);
}
}
doneFiles++;
writtenBytes += file.fileSize();
cb.onStage("install", writtenBytes, writeTotal, doneFiles, totalFiles);
}
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
} catch (InstallException e) {
throw e;
} catch (Exception e) {
Log.w(TAG, "install failed", e);
throw new InstallException(e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : ""));
}
}
private static final class NativeOutcome { boolean started, success, cancelled; String error = ""; }
/** The pending files as indices into the manifest's file list, what both native runs take; null when one is not in it. */
private static int[] pendingIndices(Manifest manifest, List<FileInfo> pending) {
java.util.IdentityHashMap<FileInfo, Integer> index = new java.util.IdentityHashMap<>();
for (int i = 0; i < manifest.files.size(); i++) index.put(manifest.files.get(i), i);
int[] pendingIdx = new int[pending.size()];
for (int i = 0; i < pending.size(); i++) {
Integer k = index.get(pending.get(i));
if (k == null) return null;
pendingIdx[i] = k;
}
return pendingIdx;
}
/** The native chunk pool on the same counters; never throws. */
private static NativeOutcome runNativePool(Context ctx, byte[] manifestBytes, int[] pendingIdx, List<ChunkInfo> needed, long totalBytes,
String installDirPath, String cachePath, List<CdnUrl> cdnUrls, AtomicBoolean cancel, Callback cb,
AtomicLong completedBytes, AtomicInteger completedCount, AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps) {
NativeOutcome r = new NativeOutcome();
try {
String[] prefixes = new String[cdnUrls.size()];
for (int i = 0; i < cdnUrls.size(); i++) prefixes[i] = cdnUrls.get(i).baseUrl + cdnUrls.get(i).cloudDir;
final int totalChunks = needed.size();
StoreDownloadTier tier = StoreDownloadTier.Companion.current(ctx);
int workers = Math.max(1, Math.min(128, tier.getNetworkWindow()));
int process = Math.max(2, tier.getProcessWorkers());
cb.onLog("epic: engine=native workers=" + workers + " process_workers=" + process + " tier=" + tier.getId() + (cachePath.isEmpty() ? "" : " cache=scratch"));
EpicNative.Result res = EpicNative.run(manifestBytes, installDirPath, cachePath, prefixes, pendingIdx, totalChunks, totalBytes, "", workers, process, cancel,
new EpicNative.Listener() {
@Override public void onPlan(int chunksTotal, long bytesTotal, String chunkDir) { cb.onLog("epic: plan chunks=" + chunksTotal + " bytes=" + bytesTotal + " dir=" + chunkDir); }
@Override public void onProgress(long bytesDone, long bytesTotal, int chunksDone, int chunksTotal) {
completedBytes.set(bytesDone);
completedCount.set(chunksDone);
sampleSpeed(bytesDone, lastSpeedMs, lastSpeedBytes, speedBps);
cb.onProgress("Downloading chunks (" + chunksDone + "/" + totalChunks + ")" + speedSuffix(speedBps.get()), (int) (bytesDone * 80L / Math.max(1, totalBytes)));
cb.onBytes(bytesDone, totalBytes, speedBps.get());
}
@Override public void onLog(String line) { cb.onLog(line); }
@Override public void onComplete(boolean success, String error, long bytesCredited) {}
});
r.started = res.started; r.success = res.success; r.cancelled = res.cancelled; r.error = res.error == null ? "" : res.error;
} catch (Throwable t) {
r.started = false;
r.error = t.getClass().getSimpleName() + ": " + t.getMessage();
}
return r;
}
/** The native assembler: the pending files written from the cache, the cache removed on success; never throws. */
private static NativeOutcome runNativeAssembly(byte[] manifestBytes, int[] pendingIdx, int totalFiles, String installDirPath, String cachePath, AtomicBoolean cancel, Callback cb) {
NativeOutcome r = new NativeOutcome();
try {
cb.onLog("epic: assembler=native files=" + totalFiles + (cachePath.isEmpty() ? "" : " cache=scratch"));
EpicNative.Result res = EpicNative.assemble(manifestBytes, installDirPath, cachePath, pendingIdx, cancel, new EpicNative.Listener() {
@Override public void onPlan(int chunksTotal, long bytesTotal, String chunkDir) {}
@Override public void onProgress(long bytesDone, long bytesTotal, int done, int total) {
cb.onStage("install", bytesDone, bytesTotal, done, Math.max(total, totalFiles));
cb.onProgress("Writing files (" + done + "/" + Math.max(total, totalFiles) + ")", 80 + (int) (done * 20L / Math.max(1, Math.max(total, totalFiles))));
}
@Override public void onLog(String line) { cb.onLog(line); }
@Override public void onComplete(boolean success, String error, long bytes) {}
});
r.started = res.started; r.success = res.success; r.cancelled = res.cancelled; r.error = res.error == null ? "" : res.error;
} catch (Throwable t) {
r.started = false;
r.error = t.getClass().getSimpleName() + ": " + t.getMessage();
}
return r;
}
// ---- CDN list and manifest download ---------------------------------------------------------
public static List<CdnUrl> parseCdnUrls(String json) {
List<CdnUrl> result = new ArrayList<>();
try {
JSONArray manifests = new JSONObject(json).optJSONArray("manifests");
if (manifests == null) return result;
for (int i = 0; i < manifests.length(); i++) {
JSONObject m = manifests.optJSONObject(i);
if (m == null) continue;
String uri = m.optString("uri", "");
int buildsIdx = uri.indexOf("/Builds");
if (buildsIdx < 0) continue;
String baseUrl = uri.substring(0, buildsIdx);
if (!baseUrl.startsWith("http") || baseUrl.contains("cloudflare.epicgamescdn.com")) continue;
String afterBase = uri.substring(buildsIdx);
int q = afterBase.indexOf('?');
if (q >= 0) afterBase = afterBase.substring(0, q);
int lastSlash = afterBase.lastIndexOf('/');
if (lastSlash < 0) continue;
String cloudDir = afterBase.substring(0, lastSlash);
StringBuilder auth = new StringBuilder();
JSONArray params = m.optJSONArray("queryParams");
if (params != null) for (int j = 0; j < params.length(); j++) {
JSONObject p = params.optJSONObject(j);
if (p == null) continue;
auth.append(auth.length() == 0 ? "?" : "&").append(p.optString("name")).append('=').append(p.optString("value"));
}
result.add(new CdnUrl(baseUrl, cloudDir, auth.toString()));
}
} catch (Exception e) {
Log.e(TAG, "CDN parse: " + e.getClass().getSimpleName());
}
return result;
}
/** The manifest binary from the first CDN that serves it; the auth query goes on this URL only. */
public static byte[] downloadManifest(String json, List<CdnUrl> cdnUrls) {
try {
JSONArray manifests = new JSONObject(json).optJSONArray("manifests");
if (manifests == null || manifests.length() == 0) return null;
String firstUri = manifests.getJSONObject(0).optString("uri", "");
String uriPath = firstUri.contains("?") ? firstUri.substring(0, firstUri.indexOf('?')) : firstUri;
int lastSlash = uriPath.lastIndexOf('/');
if (lastSlash < 0) return null;
String manifestFilename = uriPath.substring(lastSlash + 1);
for (CdnUrl cdn : cdnUrls) {
byte[] bytes = downloadBytes(cdn.baseUrl + cdn.cloudDir + "/" + manifestFilename + cdn.authParams);
if (bytes != null && bytes.length > 4) return bytes;
}
} catch (Exception e) {
Log.e(TAG, "manifest download: " + e.getClass().getSimpleName());
}
return null;
}
// ---- manifest parsing -----------------------------------------------------------------------
public static Manifest parseManifest(byte[] bytes) {
try {
ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
int magic = buf.getInt();
if (magic != 0x44BEC00C) return parseJsonManifest(bytes);
int headerSize = buf.getInt();
int sizeUncompressed = buf.getInt();
buf.getInt();
buf.position(buf.position() + 20);
int storedAs = buf.get() & 0xFF;
int version = buf.getInt();
String chunkDir = version >= 15 ? "ChunksV4" : version >= 6 ? "ChunksV3" : version >= 3 ? "ChunksV2" : "Chunks";
buf.position(headerSize);
byte[] body = new byte[buf.remaining()];
buf.get(body);
if ((storedAs & 1) != 0) {
Inflater inflater = new Inflater();
inflater.setInput(body);
byte[] decomp = new byte[sizeUncompressed];
int got = inflater.inflate(decomp);
inflater.end();
if (got != sizeUncompressed) { Log.e(TAG, "manifest inflate size mismatch"); return null; }
body = decomp;
}
ByteBuffer b = ByteBuffer.wrap(body).order(ByteOrder.LITTLE_ENDIAN);
Manifest m = new Manifest();
m.chunkDir = chunkDir;
// ManifestMeta: the launch exe and build version live here; the rest is skipped by size.
int metaStart = b.position();
int metaSize = b.getInt();
try {
b.get(); // data version
b.getInt(); // feature level
b.get(); // isFileData
b.getInt(); // app id
readFString(b); // app name
m.buildVersion = readFString(b);
m.launchExe = readFString(b).replace('\\', '/');
m.launchCommand = readFString(b);
} catch (Exception e) {
Log.w(TAG, "manifest meta: " + e.getClass().getSimpleName());
}
b.position(metaStart + metaSize);
int cdlStart = b.position();
int cdlSize = b.getInt();
b.get();
int chunkCount = b.getInt();
List<ChunkInfo> chunks = new ArrayList<>(chunkCount);
for (int i = 0; i < chunkCount; i++) chunks.add(new ChunkInfo());
for (ChunkInfo c : chunks) { c.guid[0] = b.getInt(); c.guid[1] = b.getInt(); c.guid[2] = b.getInt(); c.guid[3] = b.getInt(); }
for (ChunkInfo c : chunks) c.hash = b.getLong();
for (ChunkInfo c : chunks) { c.sha1 = new byte[20]; b.get(c.sha1); }
for (ChunkInfo c : chunks) c.groupNum = b.get() & 0xFF;
for (ChunkInfo c : chunks) c.windowSize = b.getInt();
for (ChunkInfo c : chunks) c.fileSize = b.getLong();
b.position(cdlStart + cdlSize);
int fmlStart = b.position();
int fmlSize = b.getInt();
b.get();
int fileCount = b.getInt();
List<FileInfo> files = new ArrayList<>(fileCount);
for (int i = 0; i < fileCount; i++) files.add(new FileInfo());
for (FileInfo f : files) f.filename = readFString(b);
for (int i = 0; i < fileCount; i++) readFString(b);
for (FileInfo f : files) { f.sha1 = new byte[20]; b.get(f.sha1); }
b.position(b.position() + fileCount);
for (int i = 0; i < fileCount; i++) {
int tagCount = b.getInt();
for (int j = 0; j < tagCount; j++) { String tag = readFString(b); if (tag != null && !tag.isEmpty()) files.get(i).installTags.add(tag); }
}
for (FileInfo f : files) {
int partCount = b.getInt();
for (int j = 0; j < partCount; j++) {
int partStart = b.position();
int partStructSize = b.getInt();
ChunkPart part = new ChunkPart();
part.guid[0] = b.getInt(); part.guid[1] = b.getInt(); part.guid[2] = b.getInt(); part.guid[3] = b.getInt();
part.offset = b.getInt(); part.size = b.getInt();
f.parts.add(part);
b.position(partStart + partStructSize);
}
}
b.position(fmlStart + fmlSize);
Map<String, ChunkInfo> seen = new LinkedHashMap<>(chunkCount * 2);
for (ChunkInfo c : chunks) seen.put(c.guidStr(), c);
m.uniqueChunks = new ArrayList<>(seen.values());
m.files = files;
return m;
} catch (Exception e) {
Log.e(TAG, "manifest parse: " + e.getClass().getSimpleName());
return null;
}
}
private static Manifest parseJsonManifest(byte[] bytes) {
try {
JSONObject root = new JSONObject(new String(bytes, StandardCharsets.UTF_8));
int manifestVersion = 0;
try { manifestVersion = Integer.parseInt(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {}
Manifest m = new Manifest();
m.chunkDir = manifestVersion >= 15 ? "ChunksV4" : manifestVersion >= 6 ? "ChunksV3" : manifestVersion >= 3 ? "ChunksV2" : "ChunksV4";
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
m.launchCommand = root.optString("LaunchCommand", "");
m.buildVersion = root.optString("BuildVersionString", "");
JSONObject chunkHashList = root.optJSONObject("ChunkHashList");
JSONObject dataGroupList = root.optJSONObject("DataGroupList");
JSONObject chunkFilesizeList = root.optJSONObject("ChunkFilesizeList");
if (chunkHashList == null) return null;
Map<String, ChunkInfo> chunkMap = new LinkedHashMap<>();
Iterator<String> keys = chunkHashList.keys();
while (keys.hasNext()) {
String guidHex = keys.next();
if (guidHex.length() < 32) continue;
ChunkInfo c = new ChunkInfo();
for (int k = 0; k < 4; k++) c.guid[k] = (int) Long.parseLong(guidHex.substring(k * 8, k * 8 + 8), 16);
String hashHex = chunkHashList.getString(guidHex);
if (hashHex != null && hashHex.length() >= 16) { try { c.hash = Long.parseUnsignedLong(hashHex.substring(0, 16), 16); } catch (Exception ignored) {} }
if (dataGroupList != null) { try { c.groupNum = Integer.parseInt(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
if (chunkFilesizeList != null) { try { c.fileSize = Long.parseLong(chunkFilesizeList.optString(guidHex, "0"), 16); } catch (NumberFormatException ignored) {} }
chunkMap.put(guidHex, c);
}
JSONArray fileList = root.optJSONArray("FileManifestList");
if (fileList == null) return null;
List<FileInfo> files = new ArrayList<>(fileList.length());
for (int i = 0; i < fileList.length(); i++) {
JSONObject fo = fileList.getJSONObject(i);
FileInfo fi = new FileInfo();
fi.filename = fo.optString("Filename", "");
JSONArray tags = fo.optJSONArray("InstallTags");
if (tags != null) for (int t = 0; t < tags.length(); t++) { String tag = tags.optString(t, ""); if (!tag.isEmpty()) fi.installTags.add(tag); }
JSONArray parts = fo.optJSONArray("FileChunkParts");
if (parts != null) for (int j = 0; j < parts.length(); j++) {
JSONObject po = parts.getJSONObject(j);
ChunkPart part = new ChunkPart();
String g = po.optString("Guid", "");
if (g.length() >= 32) for (int k = 0; k < 4; k++) part.guid[k] = (int) Long.parseLong(g.substring(k * 8, k * 8 + 8), 16);
try { part.offset = Integer.parseInt(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {}
try { part.size = Integer.parseInt(po.optString("Size", "0")); } catch (NumberFormatException ignored) {}
fi.parts.add(part);
}
files.add(fi);
}
m.uniqueChunks = new ArrayList<>(chunkMap.values());
m.files = files;
return m;
} catch (Exception e) {
Log.e(TAG, "json manifest parse: " + e.getClass().getSimpleName());
return null;
}
}
public static String readFString(ByteBuffer buf) {
int length = buf.getInt();
if (length == 0) return "";
if (length < 0) {
int chars = (-length) - 1;
byte[] bytes = new byte[chars * 2];
buf.get(bytes);
buf.getShort();
return new String(bytes, StandardCharsets.UTF_16LE);
}
byte[] bytes = new byte[length - 1];
buf.get(bytes);
buf.get();
return new String(bytes, StandardCharsets.US_ASCII);
}
// ---- chunk fetch (the built-in pool) --------------------------------------------------------
public static byte[] downloadBytes(String urlStr) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(30000);
conn.setReadTimeout(60000);
conn.setRequestProperty("User-Agent", UA);
if (conn.getResponseCode() != 200) { Log.w(TAG, "HTTP " + conn.getResponseCode() + " for " + StoreLog.redactUrl(urlStr)); return null; }
return EpicAuthClient.readAllBytes(conn.getInputStream());
} catch (Exception e) {
Log.w(TAG, "download error [" + StoreLog.redactUrl(urlStr) + "]: " + e.getClass().getSimpleName());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/** One chunk streamed from the first CDN that serves it, SHA-1 checked, published by rename. */
private static boolean downloadChunkStreaming(ChunkInfo chunk, String chunkDir, List<CdnUrl> cdnUrls, File outFile) {
String chunkPath = chunk.getPath(chunkDir);
File tmp = new File(outFile.getPath() + ".part");
tmp.delete();
for (CdnUrl cdn : cdnUrls) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(cdn.baseUrl + cdn.cloudDir + "/" + chunkPath).openConnection();
conn.setConnectTimeout(30000);
conn.setReadTimeout(60000);
conn.setRequestProperty("User-Agent", UA);
if (conn.getResponseCode() != 200) { conn.disconnect(); continue; }
MessageDigest sha = null;
if (chunk.sha1 != null && chunk.sha1.length == 20) {
boolean allZero = true;
for (byte x : chunk.sha1) if (x != 0) { allZero = false; break; }
if (!allZero) { try { sha = MessageDigest.getInstance("SHA-1"); } catch (Exception ignored) {} }
}
try (InputStream in = conn.getInputStream(); FileOutputStream fos = new FileOutputStream(tmp)) {
byte[] hdrBuf = new byte[41];
readFully(in, hdrBuf);
ByteBuffer hdr = ByteBuffer.wrap(hdrBuf).order(ByteOrder.LITTLE_ENDIAN);
if (hdr.getInt() != 0xB1FE3AA2) { Log.w(TAG, "bad chunk magic"); continue; }
hdr.getInt();
int headerSize = hdr.getInt();
int compressedSize = hdr.getInt();
hdr.position(hdr.position() + 24);
int storedAs = hdr.get() & 0xFF;
if (headerSize > 41) skipFully(in, headerSize - 41);
byte[] iobuf = new byte[131072];
if ((storedAs & 1) != 0) {
Inflater inflater = new Inflater();
byte[] obuf = new byte[131072];
int remaining = compressedSize;
try {
while (remaining > 0 && !inflater.finished()) {
if (inflater.needsInput()) {
int n = in.read(iobuf, 0, Math.min(iobuf.length, remaining));
if (n <= 0) break;
remaining -= n;
inflater.setInput(iobuf, 0, n);
}
int out = inflater.inflate(obuf);
if (out > 0) { fos.write(obuf, 0, out); if (sha != null) sha.update(obuf, 0, out); }
}
int out;
while ((out = inflater.inflate(obuf)) > 0) { fos.write(obuf, 0, out); if (sha != null) sha.update(obuf, 0, out); }
} finally {
inflater.end();
}
} else {
int remaining = compressedSize;
while (remaining > 0) {
int n = in.read(iobuf, 0, Math.min(iobuf.length, remaining));
if (n <= 0) break;
fos.write(iobuf, 0, n);
if (sha != null) sha.update(iobuf, 0, n);
remaining -= n;
}
}
}
if (sha != null && !MessageDigest.isEqual(sha.digest(), chunk.sha1)) { tmp.delete(); conn.disconnect(); continue; }
if (!tmp.renameTo(outFile)) { tmp.delete(); conn.disconnect(); continue; }
conn.disconnect();
return true;
} catch (Exception e) {
tmp.delete();
if (conn != null) conn.disconnect();
}
}
tmp.delete();
Log.e(TAG, "every CDN failed for chunk " + chunk.guidStr());
return false;
}
private static void readFully(InputStream in, byte[] buf) throws IOException {
int off = 0;
while (off < buf.length) { int n = in.read(buf, off, buf.length - off); if (n < 0) throw new IOException("stream ended"); off += n; }
}
private static void skipFully(InputStream in, int count) throws IOException {
byte[] skip = new byte[Math.min(count, 4096)];
int remaining = count;
while (remaining > 0) { int n = in.read(skip, 0, Math.min(skip.length, remaining)); if (n < 0) throw new IOException("stream ended"); remaining -= n; }
}
// ---- selection and verification -------------------------------------------------------------
public static List<FileInfo> getRequiredInstallFiles(Manifest manifest) {
List<FileInfo> required = new ArrayList<>();
for (FileInfo f : manifest.files) if (f.installTags.isEmpty()) required.add(f);
return required.isEmpty() ? new ArrayList<>(manifest.files) : required;
}
/** Base files plus any file carrying one of the tags; null tags = every file; nothing matched = base only. */
public static List<FileInfo> resolveInstallFiles(Manifest manifest, List<String> selectedTags) {
if (selectedTags == null) return new ArrayList<>(manifest.files);
if (selectedTags.isEmpty()) return getRequiredInstallFiles(manifest);
Set<String> want = new LinkedHashSet<>(selectedTags);
List<FileInfo> out = new ArrayList<>();
for (FileInfo f : manifest.files) {
if (f.installTags.isEmpty()) { out.add(f); continue; }
for (String t : f.installTags) if (want.contains(t)) { out.add(f); break; }
}
return out.isEmpty() ? getRequiredInstallFiles(manifest) : out;
}
public static List<ChunkInfo> uniqueChunksForFiles(Manifest manifest, List<FileInfo> files) {
Map<String, ChunkInfo> byGuid = new LinkedHashMap<>();
for (ChunkInfo c : manifest.uniqueChunks) byGuid.put(c.guidStr(), c);
Set<String> seen = new LinkedHashSet<>();
List<ChunkInfo> chunks = new ArrayList<>();
for (FileInfo f : files) for (ChunkPart p : f.parts) { String g = p.guidStr(); if (seen.add(g)) { ChunkInfo c = byGuid.get(g); if (c != null) chunks.add(c); } }
return chunks;
}
public static boolean fileExistsWithCorrectHash(File outputFile, long expectedSize, byte[] expectedHash) {
if (outputFile == null || !outputFile.exists() || outputFile.length() != expectedSize) return false;
if (expectedHash == null || expectedHash.length != 20) return false;
boolean allZero = true;
for (byte x : expectedHash) if (x != 0) { allZero = false; break; }
if (allZero) return false;
try (FileInputStream fis = new FileInputStream(outputFile)) {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] buf = new byte[64 * 1024];
int n;
while ((n = fis.read(buf)) != -1) digest.update(buf, 0, n);
return MessageDigest.isEqual(digest.digest(), expectedHash);
} catch (Exception e) {
return false;
}
}
/** The install size for the tags (null = every file), from the manifest alone; -1 on failure. Blocking. */
public static long fetchInstallSizeBytes(String manifestApiJson, List<String> installTags) {
try {
List<CdnUrl> cdnUrls = parseCdnUrls(manifestApiJson);
if (cdnUrls.isEmpty()) return -1;
byte[] bytes = downloadManifest(manifestApiJson, cdnUrls);
if (bytes == null) return -1;
Manifest m = parseManifest(bytes);
if (m == null) return -1;
long total = 0;
for (FileInfo f : resolveInstallFiles(m, installTags)) total += Math.max(f.fileSize(), 0);
return total > 0 ? total : -1;
} catch (Exception e) {
return -1;
}
}
// ---- helpers --------------------------------------------------------------------------------
static byte[] readFile(File f) throws IOException {
try (FileInputStream fis = new FileInputStream(f)) {
byte[] data = new byte[(int) f.length()];
int off = 0;
while (off < data.length) { int r = fis.read(data, off, data.length - off); if (r < 0) break; off += r; }
return data;
}
}
static void deleteDir(File dir) {
if (!dir.exists()) return;
File[] files = dir.listFiles();
if (files != null) for (File f : files) { if (f.isDirectory()) deleteDir(f); else f.delete(); }
dir.delete();
}
private static void sampleSpeed(long done, AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps) {
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
long prevB = lastSpeedBytes.getAndSet(done);
long dt = nowMs - prevMs;
if (dt > 0) speedBps.set((done - prevB) * 1000L / dt);
}
}
private static String speedSuffix(long bps) {
if (bps <= 0) return "";
return " " + (bps >= 1048576 ? String.format("%.1f MB/s", bps / 1048576.0) : (bps / 1024) + " KB/s");
}
private static String fmt(long bytes) {
if (bytes >= 1073741824L) return String.format("%.1f GB", bytes / 1073741824.0);
if (bytes >= 1048576L) return String.format("%.1f MB", bytes / 1048576.0);
return String.format("%.0f KB", bytes / 1024.0);
}
}
@@ -0,0 +1,25 @@
package com.droiddeck.launcher.stores.epic;
/**
* One Epic Games library entry. {@code appName} is the launcher's app slug (the manifest URL and
* the launch arguments take it), {@code namespace} the product's sandbox, {@code catalogItemId}
* the catalog UUID; the three together address every Epic API call for the game.
*/
public class EpicGame {
public String appName = "";
public String namespace = "";
public String catalogItemId = "";
public String title = "";
public String developer = "";
public String description = "";
/** DieselGameBoxTall: the tall portrait art. */
public String artCover = "";
/** DieselGameBox or Thumbnail. */
public String artSquare = "";
public String version = "";
public long installSize = 0L;
public boolean canRunOffline = true;
public boolean isDLC = false;
public String baseGameCatalogItemId = "";
public String releaseDate = "";
}
@@ -0,0 +1,44 @@
package com.droiddeck.launcher.stores.epic
import java.util.Locale
/**
* Epic tags a manifest's files by language so a client installs the base files plus one language
* instead of every language. The table (the launcher's tag names per language) is Legendary's;
* the language is the device's locale, English when it is not one Epic ships.
*/
object EpicInstallTags {
private val LANGUAGE_TO_TAGS: Map<String, List<String>> = mapOf(
"ar" to listOf("Arabic", "ar"), "bg" to listOf("Bulgarian", "bg-BG", "bg"),
"zh-Hans" to listOf("Chinese", "ChineseSimplified", "zh-Hans", "zh_Hans", "zh"), "zh-Hant" to listOf("ChineseTraditional", "zh-Hant", "zh_Hant"),
"cs" to listOf("Czech", "cs-CZ", "cs"), "da" to listOf("Danish", "da-DK", "da"), "nl" to listOf("Dutch", "nl-NL", "nl"),
"en" to listOf("English", "en-US", "en"), "fi" to listOf("Finnish", "fi-FI", "fi"), "fr" to listOf("French", "fr-FR", "fr"),
"de" to listOf("German", "de-DE", "de"), "el" to listOf("Greek", "el-GR", "el"), "hu" to listOf("Hungarian", "hu-HU", "hu"),
"it" to listOf("Italian", "it-IT", "it"), "ja" to listOf("Japanese", "ja-JP", "ja"), "ko" to listOf("Korean", "ko-KR", "ko"),
"no" to listOf("Norwegian", "nb-NO", "no"), "pl" to listOf("Polish", "pl-PL", "pl"), "pt" to listOf("Portuguese", "pt-PT", "pt"),
"pt-BR" to listOf("PortugueseBrazilian", "Brazilian", "pt-BR", "br"), "ro" to listOf("Romanian", "ro-RO", "ro"),
"ru" to listOf("Russian", "ru-RU", "ru"), "es" to listOf("Spanish", "es-ES", "es"), "es-419" to listOf("SpanishLatinAmerica", "Latam", "es-MX", "es_mx"),
"sv" to listOf("Swedish", "sv-SE", "sv"), "th" to listOf("Thai", "th-TH", "th"), "tr" to listOf("Turkish", "tr-TR", "tr"),
"uk" to listOf("Ukrainian", "uk-UA", "uk"), "vi" to listOf("Vietnamese", "vi-VN", "vi"),
)
private fun key(locale: Locale): String? {
val l = locale.language.lowercase(Locale.ROOT)
val r = locale.country.uppercase(Locale.ROOT)
return when (l) {
"zh" -> if (r == "TW" || r == "HK" || r == "MO") "zh-Hant" else "zh-Hans"
"pt" -> if (r == "BR") "pt-BR" else "pt"
"es" -> if (r == "ES" || r.isEmpty()) "es" else "es-419"
"nb", "nn" -> "no"
else -> if (LANGUAGE_TO_TAGS.containsKey(l)) l else null
}
}
/** The install tags for the device's language; English when Epic has no tags for it. Never empty. */
@JvmStatic
fun tagsForDevice(): List<String> = LANGUAGE_TO_TAGS[key(Locale.getDefault()) ?: "en"] ?: LANGUAGE_TO_TAGS.getValue("en")
/** The two-letter code handed to the game as -epiclocale; "en" when the language is not one Epic knows. */
@JvmStatic
fun localeCodeForDevice(): String = key(Locale.getDefault())?.substringBefore('-') ?: "en"
}
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.StoreGameSidecar
/**
* What an Epic game's launch line needs beyond the exe, built once at install time into the
* sidecar's arguments: the portal and identity arguments every EOS title expects
* (`-EpicPortal -epicusername -epicuserid -epicsandboxid -epiclocale -epicapp -epicenv=Prod`), the
* deployment id when the app has an EOS sidecar, and the extra flags Epic ships for the title
* (`AdditionalCommandLine`). The sign-in triple (`-AUTH_LOGIN/-AUTH_PASSWORD/-AUTH_TYPE`) is not
* here: the exchange code lives minutes, so the launcher .bat takes it from a file the app writes
* right before a launch (StoreLaunch.prepare).
*
* The EOS overlay is never provisioned: nothing points the game's EOS SDK at an overlay component.
*/
object EpicLaunchData {
private const val TAG = "EpicLaunch"
/** The static launch arguments for [appName] in [namespace]. Blocking: one catalog call for the extra flags. */
fun arguments(context: Context, token: String?, appName: String, namespace: String, catalogItemId: String, deploymentId: String): List<String> {
val creds = EpicCredentialStore.load(context)
val displayName = creds?.displayName?.takeIf { it.isNotEmpty() } ?: "EpicUser"
val accountId = creds?.accountId?.takeIf { it.isNotEmpty() } ?: "0"
val args = ArrayList<String>()
args += "-EpicPortal"
args += "-epicusername=\"${sanitize(displayName).replace("\"", "")}\""
args += "-epicuserid=${sanitize(accountId)}"
args += "-epicsandboxid=${sanitize(namespace)}"
args += "-epiclocale=${EpicInstallTags.localeCodeForDevice()}"
args += "-epicapp=${sanitize(appName)}"
args += "-epicenv=Prod"
if (deploymentId.isNotEmpty()) args += "-epicdeploymentid=${sanitize(deploymentId)}"
if (token != null && catalogItemId.isNotEmpty()) {
val extra = EpicApiClient.getAdditionalCommandLine(token, namespace, catalogItemId)
if (!extra.isNullOrBlank()) {
val clean = extra.filter { it >= ' ' && it != '\u007f' }.trim()
if (clean.isNotEmpty()) { args += clean; Log.i(TAG, "$appName: extra command line present") }
}
}
return args
}
/** The sidecar's identifiers the launcher reads back. */
fun extras(appName: String, namespace: String, catalogItemId: String, deploymentId: String): Map<String, String> = buildMap {
put("appName", appName); put("namespace", namespace); put("catalogItemId", catalogItemId)
if (deploymentId.isNotEmpty()) put("deploymentId", deploymentId)
}
/** True when [sidecar] is an Epic install that can take a sign-in code. */
fun wantsExchangeCode(sidecar: StoreGameSidecar): Boolean = sidecar.store == com.droiddeck.launcher.stores.Store.EPIC
private fun sanitize(s: String): String = s.replace("\n", "").replace("\r", "").replace("\u0000", "")
}
@@ -0,0 +1,119 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicBoolean
/**
* The signed-in account's Epic library: the library service's records, each enriched from the
* catalog (title, art, DLC flag), cached so the tab paints at once and re-synced when forced or
* 15 minutes stale. DLC entries are folded away under their base game. Ported from Bannerlator's
* EpicLibraryRepo.
*/
object EpicLibrary {
private const val TAG = "EpicLibrary"
private const val CACHE_KEY = "library_cache"
private const val LAST_SYNC_KEY = "library_synced_at"
private const val THROTTLE_MS = 6L * 60L * 60L * 1000L
private val syncing = AtomicBoolean(false)
sealed interface SyncResult {
class Ok(val games: List<EpicGame>) : SyncResult
class Failed(val message: String) : SyncResult
object NotLoggedIn : SyncResult
object Busy : SyncResult
object Throttled : SyncResult
}
fun cached(context: Context): List<EpicGame> {
val json = EpicPrefs.get(context).getString(CACHE_KEY, null) ?: return emptyList()
return runCatching {
val arr = JSONArray(json)
val list = ArrayList<EpicGame>(arr.length())
for (i in 0 until arr.length()) {
val j = arr.optJSONObject(i) ?: continue
val g = EpicGame()
g.appName = j.optString("appName", ""); g.namespace = j.optString("namespace", ""); g.catalogItemId = j.optString("catalogItemId", "")
g.title = j.optString("title", ""); g.artCover = j.optString("artCover", ""); g.artSquare = j.optString("artSquare", "")
g.developer = j.optString("developer", ""); g.description = j.optString("description", ""); g.version = j.optString("version", "")
g.installSize = j.optLong("installSize", 0L).let { if (it > 1_099_511_627_776L) 0L else it }
g.canRunOffline = j.optBoolean("canRunOffline", true)
if (g.appName.isNotEmpty()) list.add(g)
}
list.distinctBy { it.appName }.sortedBy { it.title.lowercase() }
}.getOrDefault(emptyList())
}
/** Remembers a game's install size once the manifest said it, for the Install button. */
fun rememberSize(context: Context, appName: String, bytes: Long) {
if (bytes <= 0) return
val p = EpicPrefs.get(context)
p.edit().putLong("size_$appName", bytes).apply()
}
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult {
if (!syncing.compareAndSet(false, true)) return SyncResult.Busy
try {
val app = context.applicationContext
val p = EpicPrefs.get(app)
val cachedList = cached(app)
val lastSync = p.getLong(LAST_SYNC_KEY, 0L)
if (!force && cachedList.isNotEmpty() && System.currentTimeMillis() - lastSync < THROTTLE_MS) return SyncResult.Throttled
onStatus("Checking sign-in…")
val token = EpicCredentialStore.getValidAccessToken(app) ?: return SyncResult.NotLoggedIn
onStatus("Fetching game list…")
val raw = EpicApiClient.getLibraryItems(token)
if (raw.isNullOrEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Epic library") else SyncResult.Ok(cachedList)
val total = raw.size
var done = 0
for (game in raw) {
EpicApiClient.enrichFromCatalog(token, game)
if (game.releaseDate.isNotEmpty()) p.edit().putString("release_${game.appName}", game.releaseDate).apply()
done++
if (done % 5 == 0) onStatus("Loading game details… ($done/$total)")
}
val main = raw.filter { !it.isDLC }
val display = (if (main.isEmpty()) raw else main).distinctBy { it.appName }.sortedWith { a, b -> a.title.compareTo(b.title, ignoreCase = true) }
for (fresh in display) {
val old = cachedList.firstOrNull { it.appName == fresh.appName } ?: continue
fresh.version = old.version
fresh.installSize = old.installSize
}
saveCache(p, display)
p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
Log.i(TAG, "sync: raw=${raw.size} shown=${display.size}")
return SyncResult.Ok(display)
} catch (e: Exception) {
Log.w(TAG, "sync failed: ${e.message}")
return SyncResult.Failed(e.message ?: "Sync failed")
} finally {
syncing.set(false)
}
}
private fun saveCache(prefs: SharedPreferences, games: List<EpicGame>) {
runCatching {
val arr = JSONArray()
for (g in games) arr.put(JSONObject().put("appName", g.appName).put("namespace", g.namespace).put("catalogItemId", g.catalogItemId)
.put("title", g.title).put("artCover", g.artCover).put("artSquare", g.artSquare).put("developer", g.developer)
.put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline))
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
}
}
fun toCatalogItem(context: Context, g: EpicGame): CatalogItem = CatalogItem(
store = Store.EPIC, id = g.appName, title = g.title.ifBlank { g.appName },
imageUrl = g.artSquare.ifBlank { null } ?: g.artCover.ifBlank { null },
tallImageUrl = g.artCover.ifBlank { null } ?: g.artSquare.ifBlank { null },
developer = g.developer, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true,
sizeBytes = EpicPrefs.get(context).getLong("size_${g.appName}", g.installSize),
storeUrl = "https://store.epicgames.com/en-US/browse?q=${java.net.URLEncoder.encode(g.title, "UTF-8")}",
extra = mapOf("namespace" to g.namespace, "catalogItemId" to g.catalogItemId, "version" to (g.version ?: "")),
)
}
@@ -0,0 +1,117 @@
// JNI symbols depend on this package path and class name (app/src/main/rust/stores/JNI.md).
package com.droiddeck.launcher.stores.epic
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* The native half of an Epic install in `libdroiddeckstores.so`: [run] fills the chunk cache with
* verified, decompressed chunks for the files the manager says are pending, [assemble] writes those
* files from the cache. Everything around them (manifest, install tags, delta pass, post-install)
* stays in the manager.
*
* The cache is `<installDir>/.chunks` when `chunkCacheDir` is `""`, else the scratch folder given -
* the app's own cache when the game installs to a card, where the assembler drops each chunk right
* after its last use and the folder at the end. Both calls take the same value for one install.
*
* Both block like the Java loops they replace and poll [AtomicBoolean] `cancel` every 250 ms: on
* cancel they flip the native flag, wait up to 5 s for the run to wind down, then return.
*/
object EpicNative {
private const val TAG = "EpicNative"
/** Callbacks from a native run; every method is called on a native thread. One interface serves both runs. */
interface Listener {
/** Fetch only, once before any fetch: the plan the engine derived (the manager cross-checks it). */
fun onPlan(chunksTotal: Int, bytesTotal: Long, chunkDir: String)
/** Fetch: per accounted chunk (cached-skip or fetched), done/total = chunks. Assembly: per file written, done/total = files. */
fun onProgress(bytesDone: Long, bytesTotal: Long, done: Int, total: Int)
/** Engine log line (already in logcat under `EpicNative`). */
fun onLog(line: String)
/** Terminal. Fetch: bytes credited; assembly: bytes written. */
fun onComplete(success: Boolean, error: String, bytes: Long)
}
/** Outcome of a run. `started == false`: nothing happened on disk, the caller runs its own loop. */
class Result(
@JvmField val started: Boolean, @JvmField val success: Boolean, @JvmField val cancelled: Boolean,
@JvmField val error: String, @JvmField val bytes: Long, @JvmField val done: Int, @JvmField val total: Int,
)
private class Completion(val success: Boolean, val error: String, val bytes: Long)
/** The fetch: chunks for [pendingFileIdx] into the cache. */
@JvmStatic
fun run(
manifest: ByteArray, installDir: String, chunkCacheDir: String, cdnPrefixes: Array<String>, pendingFileIdx: IntArray, expectedChunks: Int, expectedBytes: Long,
caBundlePath: String, maxWorkers: Int, processWorkers: Int, cancel: AtomicBoolean?, listener: Listener,
): Result = drive("nativeStart", cancel, listener) { inner ->
nativeStart(manifest, installDir, chunkCacheDir, cdnPrefixes, pendingFileIdx, expectedChunks, expectedBytes, caBundlePath, maxWorkers, processWorkers, inner)
}
/** The assembly: the files [fileIdx] written from the cache, the cache removed on success. */
@JvmStatic
fun assemble(manifest: ByteArray, installDir: String, chunkCacheDir: String, fileIdx: IntArray, cancel: AtomicBoolean?, listener: Listener): Result =
drive("nativeAssemble", cancel, listener) { inner -> nativeAssemble(manifest, installDir, chunkCacheDir, fileIdx, inner) }
private fun drive(what: String, cancel: AtomicBoolean?, listener: Listener, start: (Listener) -> Long): Result {
if (!StoresNative.ensureLoaded()) return Result(false, false, false, "engine not built", 0L, 0, 0)
val latch = CountDownLatch(1)
val completion = AtomicReference<Completion?>(null)
val doneRef = AtomicReference(0)
val totalRef = AtomicReference(0)
val inner = object : Listener {
override fun onPlan(chunksTotal: Int, bytesTotal: Long, chunkDir: String) { totalRef.set(chunksTotal); listener.onPlan(chunksTotal, bytesTotal, chunkDir) }
override fun onProgress(bytesDone: Long, bytesTotal: Long, done: Int, total: Int) { doneRef.set(done); totalRef.set(total); listener.onProgress(bytesDone, bytesTotal, done, total) }
override fun onLog(line: String) = listener.onLog(line)
override fun onComplete(success: Boolean, error: String, bytes: Long) {
completion.set(Completion(success, error, bytes))
latch.countDown()
runCatching { listener.onComplete(success, error, bytes) }
}
}
val handle: Long = try {
start(inner)
} catch (t: Throwable) {
Log.w(TAG, "engine unavailable: ${t.javaClass.simpleName}: ${t.message}")
return Result(false, false, false, "$what: ${t.javaClass.simpleName}", 0L, 0, 0)
}
if (handle == 0L) return Result(false, false, false, completion.get()?.error ?: "not started", 0L, 0, totalRef.get())
var cancelSent = false
try {
while (!latch.await(250, TimeUnit.MILLISECONDS)) {
if (cancel != null && cancel.get()) {
nativeCancel(handle)
cancelSent = true
latch.await(5, TimeUnit.SECONDS)
break
}
}
} finally {
nativeRelease(handle)
}
val c = completion.get()
val success = !cancelSent && c != null && c.success
val error = when { cancelSent -> "cancelled"; c == null -> "no completion"; else -> c.error }
return Result(true, success, cancelSent, error, c?.bytes ?: 0L, doneRef.get(), totalRef.get())
}
@JvmStatic
private external fun nativeStart(
manifest: ByteArray, installDir: String, chunkCacheDir: String, cdnPrefixes: Array<String>, pendingFileIdx: IntArray, expectedChunks: Int, expectedBytes: Long,
caBundlePath: String, maxWorkers: Int, processWorkers: Int, listener: Listener,
): Long
@JvmStatic
private external fun nativeAssemble(manifest: ByteArray, installDir: String, chunkCacheDir: String, fileIdx: IntArray, listener: Listener): Long
@JvmStatic
private external fun nativeCancel(handle: Long)
@JvmStatic
private external fun nativeRelease(handle: Long)
}
@@ -0,0 +1,14 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import android.content.SharedPreferences
/**
* Epic's own records that are not credentials: the library cache, each game's release date, the
* cached deployment id and extra command line a launch needs. App-private preferences; the sign-in
* lives in StoreAccounts. Public: the Java clients read it too.
*/
object EpicPrefs {
@JvmStatic
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences("stores.epic", Context.MODE_PRIVATE)
}
@@ -0,0 +1,225 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.ConcurrentHashMap
/**
* The Epic Games Store catalog through the GraphQL endpoint the store website uses
* (`store.epicgames.com/graphql`, which wants a browser User-Agent): `Catalog.searchStore` with
* the base-game category, sorted by price / release date / relevance, on sale or free. Elements
* carry the offer id, the namespace, the catalog item ids (what the library records, so ownership
* is matched on those), key images and pre-formatted prices. "Free this week" comes from the
* static `freeGamesPromotions` feed.
*/
object EpicStoreCatalog {
private const val TAG = "EpicStore"
private const val GRAPHQL = "https://store.epicgames.com/graphql"
private const val PROMOS = "https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions"
private const val FEATURED_TTL_MS = 3 * 60 * 60 * 1000L
private const val SEARCH_TTL_MS = 5 * 60 * 1000L
private const val KEY_FEATURED = "store_featured"
private const val ELEMENT_FIELDS =
"title id namespace description effectiveDate releaseDate offerType productSlug urlSlug " +
"items{id namespace} keyImages{type url} seller{name} tags{name} " +
"catalogNs{mappings(pageType:\"productHome\"){pageSlug pageType}} " +
"price(country:\$country){totalPrice{discountPrice originalPrice discount currencyCode " +
"fmtPrice(locale:\$locale){originalPrice discountPrice}}}"
private class Cached<T>(val value: T, val at: Long)
private var featuredCache: Cached<StoreShelves>? = null
private val searchCache = ConcurrentHashMap<String, Cached<List<CatalogItem>>>()
private fun country(): String = Locale.getDefault().country.takeIf { it.length == 2 }?.uppercase(Locale.ROOT) ?: "US"
private fun nowIso(): String {
val f = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
f.timeZone = TimeZone.getTimeZone("UTC")
return f.format(Date())
}
private fun searchStore(count: Int, keywords: String? = null, sortBy: String = "relevancy", sortDir: String = "DESC", onSale: Boolean? = null, freeGame: Boolean? = null, releasedOnly: Boolean = false): List<CatalogItem> {
val args = StringBuilder("category:\"games/edition/base\",count:\$count,country:\$country,locale:\$locale,sortBy:\$sortBy,sortDir:\$sortDir,allowCountries:\$country")
if (keywords != null) args.append(",keywords:\$keywords")
if (onSale != null) args.append(",onSale:$onSale")
if (freeGame != null) args.append(",freeGame:$freeGame")
if (releasedOnly) args.append(",releaseDate:\"[,${nowIso()}]\"")
val query = "query q(\$count:Int,\$country:String!,\$locale:String,\$sortBy:String,\$sortDir:String" +
(if (keywords != null) ",\$keywords:String" else "") + "){Catalog{searchStore($args){elements{$ELEMENT_FIELDS} paging{total}}}}"
val vars = JSONObject().put("count", count).put("country", country()).put("locale", "en-US").put("sortBy", sortBy).put("sortDir", sortDir)
if (keywords != null) vars.put("keywords", keywords)
val resp = StoreNet.postJson(GRAPHQL, JSONObject().put("query", query).put("variables", vars).toString()) ?: return emptyList()
val elements = runCatching { JSONObject(resp).optJSONObject("data")?.optJSONObject("Catalog")?.optJSONObject("searchStore")?.optJSONArray("elements") }
.onFailure { Log.w(TAG, "searchStore parse failed: ${it.message}") }.getOrNull() ?: return emptyList()
return parseElements(elements)
}
private fun parseElements(arr: JSONArray): List<CatalogItem> {
val out = ArrayList<CatalogItem>(arr.length())
for (i in 0 until arr.length()) arr.optJSONObject(i)?.let { e -> parseElement(e)?.let { out.add(it) } }
return out.distinctBy { it.id }
}
fun parseElement(e: JSONObject): CatalogItem? {
val id = e.optString("id", "")
val title = e.optString("title", "")
if (id.isEmpty() || title.isEmpty()) return null
val ns = e.optString("namespace", "")
var wide: String? = null; var tall: String? = null; var thumb: String? = null
e.optJSONArray("keyImages")?.let { imgs ->
for (k in 0 until imgs.length()) {
val img = imgs.optJSONObject(k) ?: continue
val url = img.optString("url", "")
if (url.isBlank()) continue
// The opaque box images first; the logo variants are transparent and never used.
when (img.optString("type", "")) {
"DieselGameBox" -> wide = url
"OfferImageWide", "DieselStoreFrontWide" -> if (wide == null) wide = url
"DieselGameBoxTall" -> tall = url
"OfferImageTall", "DieselStoreFrontTall" -> if (tall == null) tall = url
"Thumbnail" -> if (thumb == null) thumb = url
}
}
}
val price = e.optJSONObject("price")?.optJSONObject("totalPrice")
val original = price?.optLong("originalPrice", -1L) ?: -1L
val discounted = price?.optLong("discountPrice", -1L) ?: -1L
val fmt = price?.optJSONObject("fmtPrice")
val fmtOriginal = fmt?.optString("originalPrice", "").orEmpty()
val fmtDiscount = fmt?.optString("discountPrice", "").orEmpty()
val hasPrice = original >= 0 && discounted >= 0
val isFree = hasPrice && discounted == 0L
val discountPct = if (hasPrice && original > 0 && discounted < original) ((original - discounted) * 100 / original).toInt() else 0
val finalPrice = when { isFree -> "Free"; fmtDiscount.isNotBlank() && fmtDiscount != "0" -> fmtDiscount; else -> fmtOriginal }
val tagLine = buildList {
e.optJSONArray("tags")?.let { tags -> for (t in 0 until tags.length()) { val n = tags.optJSONObject(t)?.optString("name").orEmpty(); if (n.isNotBlank() && n != "Windows" && size < 3) add(n) } }
}.joinToString(", ")
// Epic's public store GraphQL has no age rating on an offer (no ageGatings/ratings field on
// CatalogOffer or StoreConfig); only a content tag, if Epic sets one, can mark it.
val allTags = buildList { e.optJSONArray("tags")?.let { t -> for (k in 0 until t.length()) t.optJSONObject(k)?.optString("name")?.let { add(it) } } }
val itemIds = buildList {
e.optJSONArray("items")?.let { items -> for (j in 0 until items.length()) items.optJSONObject(j)?.optString("id")?.takeIf { it.isNotBlank() }?.let { add(it) } }
}
val slug = pageSlugOf(e)
val storeUrl = if (slug.isNotBlank()) "https://store.epicgames.com/en-US/p/$slug" else "https://store.epicgames.com/en-US/browse?q=${java.net.URLEncoder.encode(title, "UTF-8")}"
return CatalogItem(
store = Store.EPIC, id = id, title = title, imageUrl = wide ?: thumb, tallImageUrl = tall ?: thumb, tags = tagLine,
isFree = isFree, hasPrice = hasPrice, finalPrice = finalPrice, originalPrice = if (discountPct > 0) fmtOriginal else "", discountPercent = discountPct,
storeUrl = storeUrl, developer = e.optJSONObject("seller")?.optString("name", "").orEmpty(),
releaseDate = e.optString("releaseDate", e.optString("effectiveDate", "")), description = com.droiddeck.launcher.stores.cleanStoreText(e.optString("description", "")),
extra = buildMap { put("namespace", ns); if (itemIds.isNotEmpty()) put("items", itemIds.joinToString(",")); if (slug.isNotBlank()) put("slug", slug) },
mature = com.droiddeck.launcher.stores.matureByTags(allTags),
)
}
private fun pageSlugOf(e: JSONObject): String {
e.optJSONObject("catalogNs")?.optJSONArray("mappings")?.let { m ->
for (i in 0 until m.length()) { val map = m.optJSONObject(i) ?: continue; if (map.optString("pageType") == "productHome") { val s = map.optString("pageSlug", ""); if (s.isNotBlank()) return s } }
}
var slug = e.optString("productSlug", "")
if (slug == "null") slug = ""
return slug.removeSuffix("/home")
}
/** The Store tab's shelves: this week's giveaways as Free, sales as Deals, newest releases, trending = free-to-play picks. Blocking. */
fun featured(context: Context, force: Boolean = false): StoreShelves? {
val now = System.currentTimeMillis()
featuredCache?.let { if (!force && now - it.at < FEATURED_TTL_MS) return it.value }
// Shelves saved on an earlier run are as good while they are young: shown, not fetched again.
if (!force && now - EpicPrefs.get(context).getLong("store_featured_at", 0L) < FEATURED_TTL_MS) cachedFeatured(context)?.let { return it }
val freeNow = fetchPromos()
val onSale = searchStore(24, sortBy = "currentPrice", sortDir = "ASC", onSale = true).filter { it.discountPercent > 0 }
val newest = searchStore(24, sortBy = "releaseDate", sortDir = "DESC", releasedOnly = true)
val freeToPlay = searchStore(24, sortBy = "relevancy", sortDir = "DESC", freeGame = true)
val result = StoreShelves(whatsNew = newest, deals = onSale, free = (freeNow + freeToPlay).distinctBy { it.id }, trending = searchStore(24, sortBy = "relevancy", sortDir = "DESC", releasedOnly = true))
if (!result.isEmpty) {
featuredCache = Cached(result, now)
persist(context, result)
Log.i(TAG, "shelves: freeNow=${freeNow.size} sale=${onSale.size} new=${newest.size} f2p=${freeToPlay.size}")
return result
}
Log.w(TAG, "every store feed came back empty; using the on-disk mirror")
return loadPersisted(context)
}
fun cachedFeatured(context: Context): StoreShelves? = featuredCache?.value ?: loadPersisted(context)
fun search(query: String): List<CatalogItem> {
val term = query.trim()
if (term.length < 2) return emptyList()
val key = term.lowercase(Locale.ROOT)
val now = System.currentTimeMillis()
searchCache[key]?.let { if (now - it.at < SEARCH_TTL_MS) return it.value }
val results = searchStore(30, keywords = term)
searchCache[key] = Cached(results, now)
return results
}
/** This week's giveaway titles (100% off right now). */
private fun fetchPromos(): List<CatalogItem> {
val body = StoreNet.get("$PROMOS?locale=en-US&country=${country()}&allowCountries=${country()}") ?: return emptyList()
return runCatching {
val elements = JSONObject(body).optJSONObject("data")?.optJSONObject("Catalog")?.optJSONObject("searchStore")?.optJSONArray("elements") ?: JSONArray()
val out = ArrayList<CatalogItem>()
for (i in 0 until elements.length()) {
val el = elements.optJSONObject(i) ?: continue
val current = el.optJSONObject("promotions")?.optJSONArray("promotionalOffers")
if (current == null || current.length() == 0) continue
val inner = current.optJSONObject(0)?.optJSONArray("promotionalOffers") ?: continue
if (inner.length() == 0) continue
val discount = inner.optJSONObject(0)?.optJSONObject("discountSetting")
if (discount == null || discount.optInt("discountPercentage", -1) != 0) continue
val end = inner.optJSONObject(0)?.optString("endDate", "").orEmpty()
val base = parseElement(el) ?: continue
out.add(base.copy(isFree = true, hasPrice = true, finalPrice = "Free", originalPrice = base.originalPrice.ifBlank {
el.optJSONObject("price")?.optJSONObject("totalPrice")?.optJSONObject("fmtPrice")?.optString("originalPrice", "").orEmpty()
}, discountPercent = 100, tags = if (end.length >= 10) "Free until ${end.take(10)}" else base.tags))
}
out.distinctBy { it.id }
}.onFailure { Log.w(TAG, "promos parse failed: ${it.message}") }.getOrDefault(emptyList())
}
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: ""); put("tags", i.tags)
put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice); put("disc", i.discountPercent)
put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
if (i.mature) put("mature", true)
}
private fun itemFromJson(o: JSONObject): CatalogItem {
val extra = HashMap<String, String>()
o.optJSONObject("extra")?.let { e -> e.keys().forEach { k -> extra[k] = e.optString(k) } }
return CatalogItem(
store = Store.EPIC, id = o.optString("id"), title = o.optString("title"), imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"), finalPrice = o.optString("final"), originalPrice = o.optString("orig"),
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
mature = o.optBoolean("mature"),
)
}
private fun listToJson(l: List<CatalogItem>) = JSONArray().apply { l.forEach { put(itemToJson(it)) } }
private fun listFromJson(a: JSONArray?): List<CatalogItem> = if (a == null) emptyList() else List(a.length()) { a.optJSONObject(it) }.filterNotNull().map(::itemFromJson)
private fun persist(context: Context, f: StoreShelves) {
runCatching {
EpicPrefs.get(context).edit().putString(KEY_FEATURED, JSONObject().put("new", listToJson(f.whatsNew)).put("deals", listToJson(f.deals)).put("free", listToJson(f.free)).put("trending", listToJson(f.trending)).toString()).putLong("store_featured_at", System.currentTimeMillis()).apply()
}
}
private fun loadPersisted(context: Context): StoreShelves? = runCatching {
val s = EpicPrefs.get(context).getString(KEY_FEATURED, null) ?: return null
val o = JSONObject(s)
StoreShelves(listFromJson(o.optJSONArray("new")), listFromJson(o.optJSONArray("deals")), listFromJson(o.optJSONArray("free")), listFromJson(o.optJSONArray("trending"))).takeIf { !it.isEmpty }
}.getOrNull()
}
@@ -0,0 +1,101 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
/**
* The GOG sign-in: the token the OAuth page hands back, kept in StoreAccounts' private file,
* refreshed through auth.gog.com when it has expired (the client id and secret are GOG Galaxy's
* own, public ones, as every third-party GOG client uses). Nothing here is ever logged: the token
* endpoint's URL itself carries the secret and the refresh token.
*/
object GogAuth {
private const val TAG = "GogAuth"
private const val CLIENT_ID = "46899977096215655"
private const val CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
private const val TOKEN_URL = "https://auth.gog.com/token?client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&grant_type=refresh_token&refresh_token="
/** The login page, as GOG Galaxy opens it: an implicit grant that returns the token in the redirect's fragment. */
const val AUTH_URL = "https://auth.gog.com/auth?client_id=$CLIENT_ID" +
"&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=token&layout=client2"
const val REDIRECT_PREFIX = "https://embed.gog.com/on_login_success"
const val GALAXY_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) GOG Galaxy/2.0"
fun authUrl(state: String): String = "$AUTH_URL&state=${Uri.encode(state)}"
fun isSignedIn(context: Context): Boolean = StoreAccounts.read(context, Store.GOG)?.optString("access_token", "")?.isNotEmpty() == true
/** Saves a fresh sign-in and looks the account's name up for the chip. Blocking. */
fun saveLogin(context: Context, accessToken: String, refreshToken: String?, userId: String?) {
var username = ""
runCatching {
val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = accessToken, userAgent = GALAXY_UA)
if (body != null) username = JSONObject(body).optString("username", "")
}
val json = JSONObject().put("access_token", accessToken).put("username", username)
.put("login_time", System.currentTimeMillis() / 1000L).put("expires_in", 3600)
if (!refreshToken.isNullOrEmpty()) json.put("refresh_token", refreshToken)
if (!userId.isNullOrEmpty()) json.put("user_id", userId)
StoreAccounts.write(context, Store.GOG, json)
Log.i(TAG, "signed in")
}
/** The raw stored token, expired or not; null when signed out. */
fun storedToken(context: Context): String? = StoreAccounts.read(context, Store.GOG)?.optString("access_token", "")?.ifEmpty { null }
/** A usable access token, refreshed when the recorded login time says it has expired; null when signed out or the refresh failed. */
@JvmStatic
fun validToken(context: Context): String? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val token = json.optString("access_token", "").ifEmpty { return null }
val loginTime = json.optLong("login_time", 0L)
val expiresIn = json.optLong("expires_in", 3600L)
val now = System.currentTimeMillis() / 1000L
return if (loginTime == 0L || now >= loginTime + expiresIn - 60) refresh(context) else token
}
/** Refreshes the access token; the new one, or null on any failure. Blocking. */
@JvmStatic
fun refresh(context: Context): String? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val refreshToken = json.optString("refresh_token", "").ifEmpty { return null }
var conn: HttpURLConnection? = null
return try {
conn = (URL(TOKEN_URL + refreshToken).openConnection() as HttpURLConnection).apply { connectTimeout = 15_000; readTimeout = 15_000 }
if (conn.responseCode != 200) { Log.w(TAG, "refresh: HTTP ${conn.responseCode}"); return null }
val body = conn.inputStream.bufferedReader().readText()
val o = JSONObject(body)
val access = o.optString("access_token", "").ifEmpty { return null }
json.put("access_token", access)
o.optString("refresh_token", "").takeIf { it.isNotEmpty() }?.let { json.put("refresh_token", it) }
json.put("login_time", System.currentTimeMillis() / 1000L).put("expires_in", o.optLong("expires_in", 3600L))
StoreAccounts.write(context, Store.GOG, json)
access
} catch (e: Exception) {
// Not the throwable: HttpURLConnection's messages echo the URL, which embeds the secrets.
Log.w(TAG, "refresh failed: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/** Hosts the GOG login page hands off to for a social sign-in; they refuse an embedded browser's identity. */
fun isIdentityProviderHost(host: String?): Boolean {
val h = host?.lowercase() ?: return false
return h == "google.com" || h.endsWith(".google.com") || h == "youtube.com" || h.endsWith(".youtube.com") ||
h == "facebook.com" || h.endsWith(".facebook.com") || h == "apple.com" || h.endsWith(".apple.com")
}
fun isGogHost(host: String?): Boolean {
val h = host?.lowercase() ?: return false
return h == "gog.com" || h.endsWith(".gog.com")
}
}
@@ -0,0 +1,121 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.content.Intent
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/** GOG in the Stores section: sign-in, the library, the public shelves, installs. */
object GogBackend : StoreBackend {
override val store: Store = Store.GOG
override fun signIn(context: Context) {
context.startActivity(Intent(context, StoreLoginActivity::class.java).putExtra(StoreLoginActivity.EXTRA_STORE, store.id).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun signOut(context: Context) {
GogPrefs.get(context).edit().remove("library_cache").remove("library_cache_v2").remove("library_synced_at").apply()
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
// The cache first, so the tab has something while the sync runs.
val cached = GogLibrary.cached(app)
if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { GogLibrary.toCatalogItem(app, it) }) }
StoresState.post { StoresState.status[store] = "Fetching your library…" }
Thread({
val result = GogLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
is GogLibrary.SyncResult.Ok -> { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { GogLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store) }
is GogLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
GogLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "GOG session expired: sign in again."
GogLibrary.SyncResult.Busy -> {}
}
}
}, "gog-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
Thread({
val shelves = GogStoreCatalog.featured(app, force)
StoresState.post { if (shelves != null) StoresState.shelves[store] = markOwned(shelves) }
}, "gog-shelves").start()
}
/** The shelves with the library's titles marked owned, so their cards offer Install. */
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
}
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl)
val folder = StoreInstallRoot.folderFor(app, Store.GOG, game.gameId, game.title, root)
val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
DownloadQueue.enqueue(app, entry) { InstallJob(app, game, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
/** One GOG install, as the queue runs it. */
private class InstallJob(val app: Context, val game: GogGame, val item: CatalogItem, val folder: File) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST)
StoreInstalls.begin(folder, Store.GOG, game.gameId, game.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl)
var downloading = false
val result = GogDownloadManager.install(app, game, folder, object : GogDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {
when {
message.startsWith("Finishing") -> handle.stage(DownloadStage.INSTALL, message)
message.startsWith("Downloading") || message.startsWith("Verified") || message.startsWith("Resuming") -> {
if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message)
}
else -> handle.stage(DownloadStage.MANIFEST, message)
}
}
override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) }
override fun onLog(line: String) { handle.log(line) }
override fun onSizes(downloadBytes: Long, diskBytes: Long) { handle.diskSize(diskBytes) }
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL })
handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install")
}
}, cancelled) ?: return null
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL, "Registering with Steam…")
val sidecar = StoreGameSidecar(
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
)
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, listOf(File(folder, ".gog_chunks")))
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.stores.gog;
/** One GOG game as the library cache holds it. */
public class GogGame {
public final String gameId;
public final String title;
public final String imageUrl;
public final String description;
public final String developer;
public final String category;
/** Content-system generation of the Windows build: 1 or 2 (0 = unknown). */
public final int generation;
/** gamesdb.gog.com 2:3 box art, or null when the lookup found none. */
public final String verticalCover;
public GogGame(String gameId, String title, String imageUrl, String description, String developer,
String category, int generation, String verticalCover) {
this.gameId = gameId;
this.title = title;
this.imageUrl = imageUrl;
this.description = description;
this.developer = developer;
this.category = category;
this.generation = generation;
this.verticalCover = verticalCover;
}
}
@@ -0,0 +1,211 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.cleanStoreText
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* The signed-in account's GOG library: the owned ids from embed.gog.com, each product's details
* from api.gog.com, and a cache so the tab paints at once. A sync fetches only what the cache
* lacks unless forced or 15 minutes stale. Ported from Bannerlator's GogLibraryRepo.
*/
object GogLibrary {
private const val TAG = "GogLibrary"
// v2: the card art moved from the store page's backdrop to the Galaxy library art; an older cache is re-fetched.
private const val CACHE_KEY = "library_cache_v2"
private const val LAST_SYNC_KEY = "library_synced_at"
private const val THROTTLE_MS = 6L * 60L * 60L * 1000L
private val syncing = AtomicBoolean(false)
sealed interface SyncResult {
class Ok(val games: List<GogGame>, val fetched: Int) : SyncResult
class Failed(val message: String) : SyncResult
object NotLoggedIn : SyncResult
object Busy : SyncResult
}
/** The cached library, alphabetical; empty until the first sync. */
fun cached(context: Context): List<GogGame> {
val json = GogPrefs.get(context).getString(CACHE_KEY, null) ?: return emptyList()
return runCatching {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
GogGame(o.optString("gameId", ""), o.optString("title", ""), o.optString("imageUrl", ""), cleanStoreText(o.optString("description", "")),
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null })
}.filter { it.gameId.isNotEmpty() }.sortedBy { it.title.lowercase() }
}.getOrDefault(emptyList())
}
fun sizeOf(context: Context, gameId: String): Long = GogPrefs.get(context).getLong("size_$gameId", 0L)
/** Owned ids diffed against the cache; fetches what is new, or everything when forced / empty / stale. Blocking. */
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult {
if (!syncing.compareAndSet(false, true)) return SyncResult.Busy
try {
val app = context.applicationContext
val p = GogPrefs.get(app)
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
onStatus("Fetching game list…")
val gamesJson = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token, userAgent = GogAuth.GALAXY_UA)
?: return SyncResult.Failed("Couldn't reach GOG")
val ids = ArrayList<String>()
val parsed = runCatching {
val owned = JSONObject(gamesJson).optJSONArray("owned")
if (owned != null) for (i in 0 until owned.length()) {
val id = owned.optLong(i).toString()
// 1801418160 is GOG's own "Galaxy" entry, in every library.
if (id != "1801418160" && id != "0") ids.add(id)
}
}.isSuccess
if (!parsed) return SyncResult.Failed("Error parsing library")
val ownedSet = ids.toHashSet()
val cachedList = cached(app)
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
val heavy = force || cachedList.isEmpty() || stale
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val idsToFetch = if (heavy) ids else ids.filter { it !in cachedIds }
val fetchSet = idsToFetch.toHashSet()
val merged = LinkedHashMap<String, GogGame>()
for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in fetchSet) merged[g.gameId] = g
if (idsToFetch.isEmpty()) {
val list = merged.values.toList()
saveCache(p, list)
return SyncResult.Ok(list.sortedBy { it.title.lowercase() }, 0)
}
onStatus("Syncing ${idsToFetch.size} game${if (idsToFetch.size == 1) "" else "s"}…")
val pool = Executors.newFixedThreadPool(5)
val futures = idsToFetch.map { id -> pool.submit(Callable<GogGame?> { fetchGame(p, id, token) }) }
pool.shutdown()
var fetched = 0
for ((idx, f) in futures.withIndex()) {
val g = runCatching { f.get() }.getOrNull()
if (g != null) { merged[g.gameId] = g; fetched++ }
if ((idx + 1) % 5 == 0) onStatus("Syncing… ${idx + 1}/${futures.size}")
}
val finalList = merged.values.toList()
saveCache(p, finalList)
if (heavy) p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
Log.i(TAG, "sync: owned=${ids.size} fetched=$fetched cached=${finalList.size} heavy=$heavy")
return SyncResult.Ok(finalList.sortedBy { it.title.lowercase() }, fetched)
} catch (e: Exception) {
Log.w(TAG, "sync failed: ${e.message}")
return SyncResult.Failed(e.message ?: "Sync failed")
} finally {
syncing.set(false)
}
}
/** One product's details, or null for anything that is not a Windows game (DLC, movies, other platforms). */
private fun fetchGame(prefs: SharedPreferences, id: String, token: String): GogGame? {
try {
val productJson = StoreNet.get("https://api.gog.com/products/$id?expand=downloads,description", bearer = token) ?: return null
val prod = JSONObject(productJson)
if (prod.optBoolean("is_secret", false)) return null
val gameType = prod.optString("game_type", "")
if (gameType.isNotEmpty() && gameType != "game" && gameType != "pack") return null
var title = prod.optJSONObject("title")?.optString("*")
if (title.isNullOrEmpty()) title = prod.optString("title")
if (title.isNullOrEmpty()) return null
// Card art from the v2 game record, the way the Galaxy client draws its library: the dark
// Galaxy background for the wide card and the box art for the tall one. The product's
// `images.background` is the store page's fade-out backdrop (it ends in white on a
// card) and is never used; the logo or icon is the last resort.
val images = prod.optJSONObject("images")
var imageUrl = ""
var boxArt = ""
val v2 = runCatching { StoreNet.get("https://api.gog.com/v2/games/$id?locale=en-US")?.let { JSONObject(it) } }.getOrNull()
v2?.optJSONObject("_links")?.let { links ->
imageUrl = links.optJSONObject("galaxyBackgroundImage")?.optString("href", "").orEmpty()
boxArt = links.optJSONObject("boxArtImage")?.optString("href", "").orEmpty()
}
if (imageUrl.isEmpty()) imageUrl = images?.optString("logo2x", "") ?: ""
if (imageUrl.isEmpty()) imageUrl = images?.optString("icon", "") ?: ""
// The account endpoint hands back template keys for some products; the v2 record's text
// is tried next, and when that is a template too the game simply has no description.
val desc = cleanStoreText(prod.optJSONObject("description")?.optString("lead", ""))
.ifEmpty { cleanStoreText(v2?.optString("overview", "")) }
.ifEmpty { cleanStoreText(v2?.optString("description", "")) }
val developer = prod.optJSONObject("developers")?.optString("name", "") ?: prod.optString("developer", "")
val genres = prod.optJSONArray("genres")
val category = if (genres != null && genres.length() > 0) genres.optJSONObject(0)?.optString("name", "") ?: "" else ""
var generation = 1
var hasWindowsBuild = false
runCatching {
val buildsJson = StoreNet.get("https://content-system.gog.com/products/$id/os/windows/builds?generation=2", bearer = token, userAgent = GogAuth.GALAXY_UA)
val items = buildsJson?.let { JSONObject(it).optJSONArray("items") }
if (items != null && items.length() > 0) {
hasWindowsBuild = true
var maxGen = 0
for (bi in 0 until items.length()) maxGen = maxOf(maxGen, items.optJSONObject(bi)?.optInt("generation", 0) ?: 0)
if (maxGen > 0) generation = maxGen
}
}
val ed = prefs.edit().putInt("gen_$id", generation)
prod.optString("release_date", "").takeIf { it.isNotEmpty() }?.let { ed.putString("release_$id", it) }
ed.apply()
if (prefs.getLong("size_$id", -1L) <= 0) {
val size = GogDownloadManager.fetchInstallSizeBytes(id, token)
if (size > 0) prefs.edit().putLong("size_$id", size).apply()
}
var hasWindowsInstaller = false
prod.optJSONObject("downloads")?.optJSONArray("installers")?.let { arr ->
for (di in 0 until arr.length()) if (arr.optJSONObject(di)?.optString("os", "") == "windows") { hasWindowsInstaller = true; break }
}
if (!hasWindowsBuild && !hasWindowsInstaller) return null
var verticalCover: String? = boxArt.ifEmpty { prefs.getString("vcover_$id", null) }
if (verticalCover.isNullOrEmpty()) {
verticalCover = fetchVerticalCover(id).ifEmpty { StoreNet.sgdbPoster(title) }
}
if (!verticalCover.isNullOrEmpty()) prefs.edit().putString("vcover_$id", verticalCover).apply()
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null })
} catch (_: Exception) {
return null
}
}
private fun fetchVerticalCover(productId: String): String = try {
val extJson = StoreNet.get("https://gamesdb.gog.com/platforms/gog/external_releases/$productId")
val gameId = extJson?.let { JSONObject(it).optString("game_id", "") }.orEmpty()
if (gameId.isEmpty()) "" else {
val gameJson = StoreNet.get("https://gamesdb.gog.com/games/$gameId")
val fmt = gameJson?.let { JSONObject(it).optJSONObject("vertical_cover") }?.optString("url_format", "").orEmpty()
if (fmt.isEmpty()) "" else fmt.replace("{formatter}", "").replace("{ext}", "webp")
}
} catch (_: Exception) { "" }
private fun saveCache(prefs: SharedPreferences, games: List<GogGame>) {
runCatching {
val arr = JSONArray()
for (g in games) {
arr.put(JSONObject().put("gameId", g.gameId).put("title", g.title).put("imageUrl", g.imageUrl).put("description", g.description)
.put("developer", g.developer).put("category", g.category).put("generation", g.generation)
.apply { if (!g.verticalCover.isNullOrEmpty()) put("verticalCover", g.verticalCover) })
}
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
}
}
/** A cached game as the page's card. */
fun toCatalogItem(context: Context, g: GogGame): CatalogItem = CatalogItem(
store = Store.GOG, id = g.gameId, title = g.title,
imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null },
tallImageUrl = GogStoreCatalog.absolutize(g.verticalCover ?: g.imageUrl).ifBlank { null },
tags = g.category, developer = g.developer, description = g.description, owned = true,
sizeBytes = sizeOf(context, g.gameId),
storeUrl = "https://www.gog.com/en/game/${g.gameId}",
)
}
@@ -0,0 +1,109 @@
// JNI symbols depend on this package path and class name (app/src/main/rust/stores/JNI.md).
package com.droiddeck.launcher.stores.gog
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
/**
* The native GOG download engine in `libdroiddeckstores.so`: the gen2 chunk loop (depot manifests
* → chunk fetch + inflate + MD5 → assembled files) and the gen1 range loop. One [start] is one
* download loop; the manager owns everything before and after it. Method names and signatures
* are Bannerlator's `BlGogDownload`, only the package and class changed.
*/
object GogNative {
private const val TAG = "GogNative"
/** gen2: depot manifests → chunk fetch + inflate + MD5 (base install, DLC, dependencies). */
const val KIND_GEN2_CHUNKS = 0
/** gen1: build manifest → per-file HTTP Range GET streamed to disk. */
const val KIND_GEN1_RANGES = 1
/** Callbacks from the engine; every method runs on a native thread. */
interface Listener {
/**
* One file reached its final state. [verified] = resume-skip (existing file passed size+MD5;
* no bytes credited); otherwise a freshly assembled, verified and renamed file ([fileBytes]
* = its decompressed size). [filesDone] counts both; [bytesDone] counts assembled bytes only.
*/
fun onProgress(bytesDone: Long, bytesTotal: Long, filesDone: Int, filesTotal: Int, file: String, fileBytes: Long, verified: Boolean)
/**
* Byte progress between file completions, every 250 ms while fetching (and once at the end):
* assembled bytes plus what the files in flight have so far. Drives the bar and the speed.
*/
fun onBytes(bytesDone: Long, bytesTotal: Long)
/** Engine diagnostics (already in logcat under `GogNative`). */
fun onLog(line: String)
/**
* Exactly once per [start] that returned a non-zero handle. [linkExpiry] = the run died on an
* HTTP 401/403/404/500, the codes an expired secure link returns: refresh it and run again.
*/
fun onComplete(success: Boolean, cancelled: Boolean, linkExpiry: Boolean, error: String, bytesWritten: Long, filesDone: Int)
}
@Volatile private var available: Boolean? = null
/** True when the library loads and the GOG exports bind. Cached after the first call. */
@JvmStatic
fun isAvailable(): Boolean {
available?.let { return it }
val ok = try {
StoresNative.ensureLoaded() && nativeProbe() == 1
} catch (t: Throwable) {
Log.w(TAG, "native GOG engine unavailable: ${t.javaClass.simpleName}: ${t.message}")
false
}
available = ok
return ok
}
/**
* Starts one download loop on a native thread; returns its handle (0 = not started, no
* callbacks). See JNI.md for the parameters; they are what the manager already holds.
*/
@JvmStatic
fun start(
kind: Int, depotManifests: Array<String>, cdnBases: Array<String>, installDir: String, skipPaths: Array<String>,
caBundlePath: String, maxWorkers: Int, processWorkers: Int, sortLargestFirst: Boolean, label: String, listener: Listener,
): Long {
if (!isAvailable()) return 0L
return try {
nativeStart(kind, depotManifests, cdnBases, installDir, skipPaths, caBundlePath, maxWorkers, processWorkers, sortLargestFirst, label, listener)
} catch (t: Throwable) {
Log.e(TAG, "nativeStart threw: ${t.javaClass.simpleName}: ${t.message}")
0L
}
}
/** Requests cancellation; `onComplete(cancelled = true)` follows. Idempotent; 0 is a no-op. */
@JvmStatic
fun cancel(handle: Long) {
if (handle == 0L) return
runCatching { nativeCancel(handle) }
}
/** Releases the handle, once, after `onComplete`. */
@JvmStatic
fun release(handle: Long) {
if (handle == 0L) return
runCatching { nativeRelease(handle) }
}
@JvmStatic
private external fun nativeProbe(): Int
@JvmStatic
private external fun nativeStart(
kind: Int, depotManifests: Array<String>, cdnBases: Array<String>, installDir: String, skipPaths: Array<String>,
caBundlePath: String, maxWorkers: Int, processWorkers: Int, sortLargestFirst: Boolean, label: String, listener: Listener,
): Long
@JvmStatic
private external fun nativeCancel(handle: Long)
@JvmStatic
private external fun nativeRelease(handle: Long)
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.content.SharedPreferences
/**
* GOG's own records that are not credentials: the library cache, per-game facts the sync learnt
* (generation, release date, size, cover), a build's client id. App-private preferences; the
* sign-in itself lives in StoreAccounts. Public, not internal: the Java download manager reads it
* and Kotlin mangles internal members' names for Java.
*/
object GogPrefs {
@JvmStatic
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences("stores.gog", Context.MODE_PRIVATE)
}
@@ -0,0 +1,209 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.Calendar
import java.util.Currency
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
/**
* GOG's public catalog - the same unauthenticated service the gog.com store page uses:
* `catalog.gog.com/v1/catalog?order=desc:trending&productType=in:game,pack&...` with `discounted`,
* `price=between:0,0` and `query=like:` filters. Products carry pre-formatted prices and a
* discount string; Windows-only titles are kept. Cached in-process (shelves 30 min, searches
* 5 min) and the last good shelves mirrored to prefs so the tab paints offline too.
*/
object GogStoreCatalog {
private const val TAG = "GogStore"
private const val CATALOG = "https://catalog.gog.com/v1/catalog"
private const val FEATURED_TTL_MS = 3 * 60 * 60 * 1000L
private const val SEARCH_TTL_MS = 5 * 60 * 1000L
// v2: entries carry the mature mark; an older cache would show everything until it aged out.
private const val KEY_FEATURED = "store_featured_v2"
private val supportedCurrencies = setOf(
"USD", "EUR", "GBP", "AUD", "CAD", "CHF", "DKK", "NOK", "PLN", "SEK", "BRL", "CNY",
"HKD", "ILS", "JPY", "KRW", "MXN", "NZD", "SGD", "TRY", "UAH", "ZAR", "INR", "RUB",
)
private class Cached<T>(val value: T, val at: Long)
private var featuredCache: Cached<StoreShelves>? = null
private val searchCache = ConcurrentHashMap<String, Cached<List<CatalogItem>>>()
private fun country(): String = Locale.getDefault().country.takeIf { it.length == 2 }?.uppercase(Locale.ROOT) ?: "US"
private fun currency(): String {
val cc = runCatching { Currency.getInstance(Locale("", country())).currencyCode }.getOrNull()
return if (cc != null && cc in supportedCurrencies) cc else "USD"
}
private fun baseQuery(limit: Int, order: String, extra: String = ""): String =
"$CATALOG?limit=$limit&order=$order&productType=in:game,pack&page=1&countryCode=${country()}&locale=en-US&currencyCode=${currency()}$extra"
/** GET the catalog; on a refusal of the locale's currency, retry as US / USD. */
private fun fetchProducts(url: String): List<CatalogItem> {
var body = StoreNet.get(url)
if (body == null && !url.contains("countryCode=US&locale=en-US&currencyCode=USD")) {
body = StoreNet.get(url.replace(Regex("countryCode=[A-Z]{2}"), "countryCode=US").replace(Regex("currencyCode=[A-Z]{3}"), "currencyCode=USD"))
}
return if (body == null) emptyList() else parseProducts(body)
}
/** The Store tab's shelves. Null only when every feed failed and nothing is cached. Blocking. */
fun featured(context: Context, force: Boolean = false): StoreShelves? {
val now = System.currentTimeMillis()
featuredCache?.let { if (!force && now - it.at < FEATURED_TTL_MS) return it.value }
// Shelves saved on an earlier run are as good while they are young: shown, not fetched again.
if (!force && now - GogPrefs.get(context).getLong("store_featured_at", 0L) < FEATURED_TTL_MS) cachedFeatured(context)?.let { return it }
val trending = fetchProducts(baseQuery(24, "desc:trending"))
val newest = fetchProducts(baseQuery(40, "desc:releaseDate")).filter { !isFuture(it.releaseDate) }.take(24)
val deals = fetchProducts(baseQuery(24, "desc:discount", "&discounted=eq:true"))
val free = fetchProducts(baseQuery(24, "desc:trending", "&price=between:0,0"))
val result = StoreShelves(whatsNew = newest, deals = deals, free = free, trending = trending)
if (!result.isEmpty) {
featuredCache = Cached(result, now)
persist(context, result)
Log.i(TAG, "shelves: trending=${trending.size} new=${newest.size} deals=${deals.size} free=${free.size}")
return result
}
Log.w(TAG, "every catalog feed came back empty; using the on-disk mirror")
return loadPersisted(context)
}
fun cachedFeatured(context: Context): StoreShelves? = featuredCache?.value ?: loadPersisted(context)
fun search(query: String): List<CatalogItem> {
val term = query.trim()
if (term.length < 2) return emptyList()
val key = term.lowercase(Locale.ROOT)
val now = System.currentTimeMillis()
searchCache[key]?.let { if (now - it.at < SEARCH_TTL_MS) return it.value }
val results = fetchProducts(baseQuery(40, "desc:score", "&query=like:" + URLEncoder.encode(term, "UTF-8")))
searchCache[key] = Cached(results, now)
return results
}
private fun parseProducts(body: String): List<CatalogItem> = try {
val arr = JSONObject(body).optJSONArray("products") ?: JSONArray()
val out = ArrayList<CatalogItem>(arr.length())
for (i in 0 until arr.length()) arr.optJSONObject(i)?.let { p -> parseProduct(p)?.let { out.add(it) } }
out.distinctBy { it.id }
} catch (e: Exception) {
Log.w(TAG, "catalog parse failed: ${e.message}")
emptyList()
}
fun parseProduct(p: JSONObject): CatalogItem? {
val id = p.optString("id", "")
val title = p.optString("title", "")
if (id.isEmpty() || title.isEmpty()) return null
// Windows only: the app cannot run anything else.
val os = p.optJSONArray("operatingSystems")
if (os != null && os.length() > 0) {
var windows = false
for (i in 0 until os.length()) if (os.optString(i).equals("windows", true)) windows = true
if (!windows) return null
}
val price = p.optJSONObject("price")
val finalAmount = price?.optJSONObject("finalMoney")?.optString("amount")?.toDoubleOrNull()
val finalStr = price?.optString("final", "").orEmpty()
val baseStr = price?.optString("base", "").orEmpty()
val discount = price?.optString("discount", "").orEmpty().filter { it.isDigit() }.toIntOrNull() ?: 0
val isFree = price != null && (finalAmount == 0.0 || finalStr == "$0.00" || finalStr == "0")
val genres = p.optJSONArray("genres")
val tags = buildList {
if (genres != null) for (i in 0 until minOf(3, genres.length())) genres.optJSONObject(i)?.optString("name")?.takeIf { it.isNotBlank() }?.let { add(it) }
}.joinToString(", ")
val slug = p.optString("slug", "")
val storeLink = p.optString("storeLink", "").ifBlank { if (slug.isNotBlank()) "https://www.gog.com/en/game/$slug" else "" }
val mature = isMature(p)
return CatalogItem(
store = Store.GOG, id = id, title = title,
imageUrl = p.optString("coverHorizontal", "").ifBlank { null },
tallImageUrl = p.optString("coverVertical", "").ifBlank { null },
tags = tags, isFree = isFree, hasPrice = price != null && finalStr.isNotBlank(),
finalPrice = finalStr, originalPrice = if (discount > 0) baseStr else "",
discountPercent = if (discount > 0 && baseStr.isNotBlank()) discount else 0,
storeUrl = storeLink, developer = p.optJSONArray("developers")?.optString(0, "").orEmpty(),
releaseDate = p.optString("releaseDate", ""),
extra = buildMap { if (slug.isNotBlank()) put("slug", slug) },
mature = mature,
)
}
/**
* GOG's own data: `ratings[]` carries each system's age (`esrbRating` 17, `pegiRating` 18,
* `uskRating` 18, `gogRating` 18, ...) and `tags[]` its content tags (`mature`, `nsfw`,
* `sexual-content`, `nudity`). The catalog has no filter for either, so this is client-side.
*/
fun isMature(p: JSONObject): Boolean {
val ages = ArrayList<Int>()
p.optJSONArray("ratings")?.let { r -> for (i in 0 until r.length()) r.optJSONObject(i)?.optString("ageRating")?.toIntOrNull()?.let { ages.add(it) } }
val tags = ArrayList<String>()
p.optJSONArray("tags")?.let { t -> for (i in 0 until t.length()) t.optJSONObject(i)?.let { o -> tags.add(o.optString("slug")); tags.add(o.optString("name")) } }
return com.droiddeck.launcher.stores.matureByAge(ages) || com.droiddeck.launcher.stores.matureByTags(tags)
}
/** GOG returns protocol-relative `//images…` URLs in a few places. */
@JvmStatic
fun absolutize(url: String?): String {
if (url.isNullOrBlank()) return ""
return if (url.startsWith("//")) "https:$url" else url
}
/** "YYYY.MM.DD" later than today: a pre-order, kept out of What's new. */
private fun isFuture(date: String): Boolean {
if (date.length < 10) return false
val parts = date.substring(0, 10).split('.', '-')
if (parts.size != 3) return false
val y = parts[0].toIntOrNull() ?: return false
val m = parts[1].toIntOrNull() ?: return false
val d = parts[2].toIntOrNull() ?: return false
val cal = Calendar.getInstance()
val today = cal.get(Calendar.YEAR) * 10000 + (cal.get(Calendar.MONTH) + 1) * 100 + cal.get(Calendar.DAY_OF_MONTH)
return y * 10000 + m * 100 + d > today
}
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
put("tags", i.tags); put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice)
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate)
if (i.mature) put("mature", true)
}
private fun itemFromJson(o: JSONObject): CatalogItem = CatalogItem(
store = Store.GOG, id = o.optString("id"), title = o.optString("title"),
imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"),
finalPrice = o.optString("final"), originalPrice = o.optString("orig"), discountPercent = o.optInt("disc"),
storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"),
mature = o.optBoolean("mature"),
)
private fun listToJson(l: List<CatalogItem>) = JSONArray().apply { l.forEach { put(itemToJson(it)) } }
private fun listFromJson(a: JSONArray?): List<CatalogItem> {
if (a == null) return emptyList()
return List(a.length()) { i -> a.optJSONObject(i) }.filterNotNull().map(::itemFromJson)
}
private fun persist(context: Context, f: StoreShelves) {
runCatching {
val o = JSONObject().put("trending", listToJson(f.trending)).put("new", listToJson(f.whatsNew)).put("deals", listToJson(f.deals)).put("free", listToJson(f.free))
GogPrefs.get(context).edit().putString(KEY_FEATURED, o.toString()).putLong("store_featured_at", System.currentTimeMillis()).apply()
}
}
private fun loadPersisted(context: Context): StoreShelves? = runCatching {
val s = GogPrefs.get(context).getString(KEY_FEATURED, null) ?: return null
val o = JSONObject(s)
StoreShelves(listFromJson(o.optJSONArray("new")), listFromJson(o.optJSONArray("deals")), listFromJson(o.optJSONArray("free")), listFromJson(o.optJSONArray("trending"))).takeIf { !it.isEmpty }
}.getOrNull()
}
@@ -83,6 +83,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -312,7 +313,7 @@ internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolea
@Composable
internal fun AppDialog(
shown: MutableTransitionState<Boolean>, close: () -> Unit, label: String, wide: Boolean,
modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier, maxWidth: Dp? = null, content: @Composable ColumnScope.() -> Unit,
) {
val pal = LocalPalette.current
// The page's ring stays out of sight behind it.
@@ -335,7 +336,7 @@ internal fun AppDialog(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.padding(12.dp)
.widthIn(max = if (wide) 860.dp else 560.dp)
.widthIn(max = maxWidth ?: if (wide) 860.dp else 560.dp)
.fillMaxWidth()
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 24).dp)
.shadow(24.dp, Shape16, ambientColor = Color.Black, spotColor = Color.Black)
@@ -230,6 +230,11 @@ private fun Content(
StorePage(s, a, modifier)
return
}
// Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it.
if (selected == "stores" && s.gameStoresEnabled) {
StoresPage(s, a, modifier)
return
}
// The Games tab lays out its own list and detail.
if (selected == "games" || selected.startsWith("app:")) {
GamesPage(s, a, selected, onSelect, modifier)
@@ -197,15 +197,19 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA
}
}
Box {
SecondaryButton(stringResource(R.string.games_manage_saves), compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.games_saves), note = summary) { first ->
MenuItem(stringResource(R.string.games_import_saves), checked = false, detail = stringResource(R.string.games_import_saves_hint), focusRequester = first) {
// A GOG or Epic game's saves are its cloud saves: the one place for them, named so.
val cloudGame = com.droiddeck.launcher.stores.Store.byId(g.source).let { it == com.droiddeck.launcher.stores.Store.GOG || it == com.droiddeck.launcher.stores.Store.EPIC }
val label = stringResource(if (cloudGame) R.string.store_cloud_saves else R.string.games_manage_saves)
SecondaryButton(label, compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = if (cloudGame) label else stringResource(R.string.games_saves), note = summary) { first ->
if (cloudGame) CloudRows(g, open, first)
MenuItem(stringResource(R.string.games_import_saves), checked = false, focusRequester = if (cloudGame) null else first) {
open = false; a.onSaveImport(g)
}
MenuItem(stringResource(R.string.games_export_gamehub), checked = false, detail = stringResource(R.string.games_export_gamehub_hint)) {
MenuItem(stringResource(R.string.games_export_gamehub), checked = false) {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB)
}
MenuItem(stringResource(R.string.games_export_winlator), checked = false, detail = stringResource(R.string.games_export_winlator_hint)) {
MenuItem(stringResource(R.string.games_export_winlator), checked = false) {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR)
}
found?.firstOrNull()?.let { d ->
@@ -217,14 +221,74 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA
}
}
/**
* A GOG or Epic game's cloud saves: the Cloud saves switch, Upload and Download - three rows from
* the first frame, so nothing moves under the pad while the cloud is checked. Until the check is
* back the two actions are disabled and read "…"; then only their subtitles change: the last sync,
* "No cloud saves", or a conflict, when Upload and Download become Keep local and Keep cloud.
*/
@Composable
private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compose.ui.focus.FocusRequester) {
val store = com.droiddeck.launcher.stores.Store.byId(g.source)?.takeIf { it != com.droiddeck.launcher.stores.Store.AMAZON } ?: return
val id = g.storeId ?: return
val context = androidx.compose.ui.platform.LocalContext.current.applicationContext
var busy by remember(g.gameId) { androidx.compose.runtime.mutableStateOf<String?>(null) }
var tick by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(0) }
val status by androidx.compose.runtime.produceState<com.droiddeck.launcher.stores.CloudSaves.Status?>(null, g.gameId, open, tick) {
if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
runCatching { com.droiddeck.launcher.stores.CloudSaves.status(context, store, id) }.getOrNull()
}
}
// The game's own switch (sidecar "cloud"), known locally: right from the first frame.
val folder = g.gameFiles
var enabled by remember(g.gameId, open) { androidx.compose.runtime.mutableStateOf(folder?.let { com.droiddeck.launcher.stores.StoreGameSidecar.read(it)?.cloud } ?: true) }
val st = status
val supported = st?.supported ?: true
val conflict = st != null && st.conflicts.isNotEmpty()
fun sync(up: Boolean, force: Boolean = false) {
if (busy != null) return
busy = if (up) "up" else "down"
Thread({
val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id, force) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id, force)
com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, if (r.ok) context.getString(R.string.cloud_done, r.files) else r.reason, android.widget.Toast.LENGTH_SHORT).show() }
}, "cloud-manual").start()
}
MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, focusRequester = first,
detail = if (!supported) stringResource(R.string.cloud_none) else null) {
val f = folder ?: return@MenuItem
val next = !enabled
Thread({
val written = runCatching { com.droiddeck.launcher.stores.StoreGameSidecar.updateCloud(f, next) }.getOrNull()
com.droiddeck.launcher.stores.StoresState.post { enabled = written?.cloud ?: enabled }
}, "cloud-switch").start()
}
val ready = st != null && supported && busy == null
val pending = stringResource(R.string.cloud_pending)
val summary = when {
st == null || busy != null -> pending
!supported -> stringResource(R.string.cloud_none)
conflict -> stringResource(R.string.cloud_conflict_files, st.conflicts.size)
st.lastSync > 0 -> stringResource(R.string.cloud_last_sync, android.text.format.DateUtils.getRelativeTimeSpanString(st.lastSync).toString())
else -> stringResource(R.string.cloud_never)
}
// A conflict turns the two actions into its two answers; the rows stay where they are.
MenuItem(stringResource(if (conflict) R.string.cloud_keep_local else R.string.cloud_upload), checked = false, enabled = ready, detail = summary) { sync(up = true, force = conflict) }
MenuItem(stringResource(if (conflict) R.string.cloud_keep_cloud else R.string.cloud_download), checked = false, enabled = ready, detail = if (st == null || busy != null) pending else null) { sync(up = false, force = conflict) }
}
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
@Composable
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
Text(
(lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(),
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SourceChip(g.source)
// A Custom game's chip already says what its eyebrow would; the line then stays empty.
val eyebrow = lastPlayedText(g.lastPlayed) ?: libraryLabel(g)
if (eyebrow.isNotEmpty()) Text(
eyebrow.uppercase(),
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Text(
g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis,
@@ -277,10 +341,14 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
}
Column(modifier = Modifier.weight(1f)) {
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
// Where it came from beside when it was played: a store's games sit among Steam's own.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SourceChip(g.source, small = true)
Text(
playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -329,11 +397,15 @@ private fun playedSpan(lastPlayed: Long): String? {
).toString()
}
/**
* Where a never-played game lives, beside its source chip: Steam's library, the Games storage for
* a store install (the chip names the store), "Custom game" for a folder the user added.
*/
@Composable
private fun libraryLabel(library: String): String = when (library) {
"internal" -> stringResource(R.string.games_internal)
Library.ADDED -> stringResource(R.string.games_added)
else -> library
private fun libraryLabel(g: Library.SteamGame): String = when {
g.library != Library.ADDED -> if (g.library == "internal") stringResource(R.string.games_internal) else g.library
g.source != Library.ADDED -> stringResource(R.string.games_store_storage)
else -> ""
}
/**
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LocalMall
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.SystemUpdate
@@ -57,6 +58,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -101,7 +103,7 @@ internal fun SideRail(
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
Column(
@@ -112,6 +114,9 @@ internal fun SideRail(
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
// The game stores, right under Games: what they install lands there. Its badge counts
// the downloads in flight, so a download started here is never out of sight.
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
@@ -134,7 +139,10 @@ internal fun SideRail(
@Composable
private fun RailItem(
label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean, height: Dp,
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
badge: Boolean = false,
/** A number on the item (downloads in flight); 0 shows nothing. */
count: Int = 0,
onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -182,6 +190,12 @@ private fun RailItem(
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
.size(8.dp).clip(CircleShape).background(AttentionAmber),
)
if (count > 0) Text(
if (count > 9) "9+" else count.toString(),
fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 4.dp else 6.dp, end = if (iconOnly) 5.dp else 12.dp)
.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 5.dp, vertical = 1.dp),
)
}
}
@@ -129,6 +129,17 @@ class FrontEndState(
val animationsEnabled: Boolean = true,
/** The Flathub Store, a beta the user turns on in Setup. */
val storeEnabled: Boolean = false,
/** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */
val gameStoresEnabled: Boolean = false,
/** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */
val gameStoresSpeedTier: String = "fast",
/** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */
val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY,
/** A page to open once: "stores:downloads", or a selection such as "app:<id>" (a notification's tap). */
val navigate: String? = null,
val storesShowMature: Boolean = false,
/** Downloads queued, running or paused across the three stores; the rail item's badge. */
val storeDownloadsActive: Int = 0,
/** The Updates page: DroidDeck's own builds and the channel followed. */
val updates: UpdatesState = UpdatesState(),
)
@@ -179,6 +190,13 @@ class FrontEndActions(
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
val onGameStoresEnabled: (Boolean) -> Unit = {},
val onGameStoresSpeedTier: (String) -> Unit = {},
val onStoresOpenTab: (String) -> Unit = {},
val onNavigated: () -> Unit = {},
val onStoresShowMature: (Boolean) -> Unit = {},
/** The Games list rebuilt now: a store install or removal changed what is on disk. */
val onLibraryChanged: () -> Unit = {},
val onHomeApp: () -> Unit = {},
val onHomeScreen: (Boolean) -> Unit = {},
val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> },
@@ -356,6 +374,11 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) {
var selected by rememberSaveable { mutableStateOf(if (s.shortcutPicker) "games" else "steam") }
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
LaunchedEffect(s.navigate) {
val target = s.navigate ?: return@LaunchedEffect
if (target == "stores:downloads") { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" } else selected = target
a.onNavigated()
}
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) }
var wirelessAdbDesiredEnabled by rememberSaveable { mutableStateOf(false) }
@@ -391,8 +414,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The Store turned off in Setup takes its page with it.
// The Store turned off in Setup takes its page with it; so does Stores.
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" }
// The last game uninstalled leaves the Games tab on its empty state.
LaunchedEffect(s.steamGames.isEmpty()) {
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
@@ -76,6 +76,7 @@ import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.R
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
@@ -367,6 +368,24 @@ internal fun SetupPanel(
s.storeEnabled,
) { a.onStoreEnabled(it) }
}
SettingsGroup(stringResource(R.string.setup_stores)) {
ToggleRow(
host, "stores-enabled", stringResource(R.string.setup_stores_show),
null, s.gameStoresEnabled,
) { a.onGameStoresEnabled(it) }
// The same rows as the Stores page's own cog, for whoever looks here first.
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
s.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) }
SettingsRow(stringResource(R.string.setup_stores_speed), null) {
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
}
StoresEngineRow()
}
}
}
}
@@ -456,6 +475,15 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
}
}
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
// An Epic game's own launch choices, kept in its sidecar so a launch from the Steam client
// honours them too (droiddeck-store-launch reads the same file).
// Epic's launch choices; cloud saves live in the game's Cloud saves view, so a GOG game has no card.
val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC }
if (cardStore != null && game?.gameFiles != null) add { m ->
Box(m) {
StoreLaunchCard(host, game.gameFiles!!, cardStore)
}
}
if (wincompKey != null) add { m ->
SettingCard(
stringResource(R.string.wincomp_title),
@@ -479,6 +507,53 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
}
}
/** Epic's account page behind its sign-in: signing in there presents any step the account still owes. */
private const val EPIC_RESOLVE_URL = "https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Faccount%2Fpersonal"
/** The Epic card: the sign-in and offline switches, read from and written to the game's sidecar, and Resolve Epic sign-in. */
@Composable
private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.droiddeck.launcher.stores.Store) {
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
var sidecar by remember(folder) { mutableStateOf(com.droiddeck.launcher.stores.StoreGameSidecar.read(folder)) }
val options = sidecar?.epic ?: com.droiddeck.launcher.stores.EpicOptions()
val epic = store == com.droiddeck.launcher.stores.Store.EPIC
val key = "store-" + store.id
val value = buildList {
if (epic && options.offline) add(stringResource(R.string.epic_card_offline))
else if (epic && options.eos) add(stringResource(R.string.epic_card_eos))
}.ifEmpty { listOf(stringResource(R.string.epic_card_none)) }.joinToString(" · ")
// Each switch goes straight to the sidecar on disk - both launch paths read it there - and the
// card shows what was read back, so a write that did not take is never shown as done.
fun set(change: (com.droiddeck.launcher.stores.EpicOptions) -> com.droiddeck.launcher.stores.EpicOptions) {
Thread({
val written = runCatching { com.droiddeck.launcher.stores.StoreGameSidecar.updateEpic(folder, change) }
.onFailure { android.util.Log.w("EpicLaunchCard", "could not write ${folder.name}: ${it.message}") }.getOrNull()
if (written != null) android.util.Log.i("EpicLaunchCard", "epic options ${written.id} eos=${written.epic.eos} offline=${written.epic.offline}")
else android.util.Log.w("EpicLaunchCard", "epic options not saved for ${folder.name}")
com.droiddeck.launcher.stores.StoresState.post { sidecar = written ?: com.droiddeck.launcher.stores.StoreGameSidecar.read(folder) }
}, "epic-options").start()
}
SettingCard(store.shortLabel, value, "card:$key", Modifier.fillMaxSize()) {
host.open = if (host.open == key) null else key
}
AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = store.shortLabel) { first ->
if (epic) {
MenuItem(stringResource(R.string.epic_eos), checked = options.eos, focusRequester = first) { set { it.copy(eos = !it.eos) } }
MenuItem(stringResource(R.string.epic_offline), checked = options.offline) { set { it.copy(offline = !it.offline) } }
}
if (epic) {
// Epic asks some accounts to accept something once (privacy policy, EULA) before a game may
// sign in - EOS's "corrective action". Signing in on Epic's site shows it.
MenuItem(stringResource(R.string.epic_resolve), checked = false) {
host.open = null
runCatching {
appContext.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(EPIC_RESOLVE_URL)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
}
}
@Composable
private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -233,6 +233,19 @@ class MenuHost {
@Composable
fun rememberMenuHost(): MenuHost = remember { MenuHost() }
/** Under the anchor with left edges aligned, kept on screen: a dropdown that belongs to a tab. */
private class BelowStartProvider(private val gap: Int) : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
var x = anchorBounds.left
if (x + popupContentSize.width > windowSize.width - 8) x = windowSize.width - 8 - popupContentSize.width
if (x < 8) x = 8
var y = anchorBounds.bottom + gap
if (y + popupContentSize.height > windowSize.height - 8) y = anchorBounds.top - gap - popupContentSize.height
if (y < 8) y = 8
return IntOffset(x, y)
}
}
private class BelowEndProvider(private val gap: Int) : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
var x = anchorBounds.right - popupContentSize.width
@@ -245,7 +258,12 @@ private class BelowEndProvider(private val gap: Int) : PopupPositionProvider {
}
@Composable
fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, note: String? = null, content: @Composable ColumnScope.(FocusRequester) -> Unit) {
fun AnchoredMenu(
open: Boolean, onDismiss: () -> Unit, title: String? = null, note: String? = null,
/** A tab's dropdown: under its anchor, left edges aligned, as wide as its entries (within [minWidth]..260 dp). */
compact: Boolean = false, minWidth: androidx.compose.ui.unit.Dp = 120.dp,
content: @Composable ColumnScope.(FocusRequester) -> Unit,
) {
val state = remember { MutableTransitionState(false) }
state.targetState = open
if (!state.currentState && !state.targetState && state.isIdle) return
@@ -259,14 +277,14 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
}
}
val gap = with(LocalDensity.current) { 6.dp.roundToPx() }
val provider = remember(gap) { BelowEndProvider(gap) }
val provider = remember(gap, compact) { if (compact) BelowStartProvider(gap) else BelowEndProvider(gap) }
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Popup(popupPositionProvider = provider, onDismissRequest = onDismiss, properties = PopupProperties(focusable = true)) {
AnimatedVisibility(
visibleState = state,
enter = fadeIn(Motion.tw(180)) + scaleIn(Motion.sp(0.7f), initialScale = 0.94f, transformOrigin = TransformOrigin(1f, 0f)),
exit = fadeOut(Motion.tw(140)) + scaleOut(Motion.tw(140), targetScale = 0.96f, transformOrigin = TransformOrigin(1f, 0f)),
enter = fadeIn(Motion.tw(180)) + scaleIn(Motion.sp(0.7f), initialScale = 0.94f, transformOrigin = TransformOrigin(if (compact) 0f else 1f, 0f)),
exit = fadeOut(Motion.tw(140)) + scaleOut(Motion.tw(140), targetScale = 0.96f, transformOrigin = TransformOrigin(if (compact) 0f else 1f, 0f)),
label = "menu",
) {
// A long list (a driver menu with its downloads) must not run off the screen: the menu is
@@ -274,7 +292,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
val maxHeight = (androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp * 0.86f).dp
Column(
modifier = Modifier
.widthIn(min = 220.dp, max = 340.dp)
.then(if (compact) Modifier.widthIn(min = minWidth, max = 260.dp).width(androidx.compose.foundation.layout.IntrinsicSize.Max) else Modifier.widthIn(min = 220.dp, max = 340.dp))
.heightIn(max = maxHeight)
.shadow(24.dp, RowShape, ambientColor = Color.Black, spotColor = Color.Black)
.clip(RowShape)
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.stores.Store
// Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder.
/** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */
internal class SourceColours(val fill: Color, val ink: Color, val dot: Color)
internal fun sourceColours(source: String): SourceColours = when (source) {
Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8))
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
}
@Composable
internal fun sourceLabel(source: String): String = when (source) {
Library.SOURCE_STEAM -> stringResource(R.string.source_steam)
Store.GOG.id -> Store.GOG.label
Store.EPIC.id -> Store.EPIC.label
Store.AMAZON.id -> Store.AMAZON.label
else -> stringResource(R.string.source_added)
}
/** The source chip itself; [small] for a list row, the regular size for a hero or a card. */
@Composable
internal fun SourceChip(source: String, small: Boolean = false, modifier: Modifier = Modifier) {
val c = sourceColours(source)
Text(
sourceLabel(source), fontSize = if (small) 10.sp else 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.4.sp,
color = c.ink, maxLines = 1,
modifier = modifier.clip(RoundedCornerShape(6.dp)).background(c.fill)
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 1.dp else 2.dp),
)
}
/** A status chip in the stores' colours: green for done ("Installed"), blue for busy, amber for a warning. */
@Composable
internal fun StateChip(text: String, tone: ChipTone, small: Boolean = false) {
val pal = LocalPalette.current
val (fill, ink) = when (tone) {
ChipTone.OK -> Color(0xFF16321F) to pal.good
ChipTone.BUSY -> pal.signal.copy(alpha = 0.18f) to pal.signal
ChipTone.WARN -> Color(0xFF3A2E10) to Color(0xFFFFC24D)
ChipTone.DEAL -> Color(0xFF1E3A1A) to Color(0xFF9BE27A)
ChipTone.PLAIN -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text, fontSize = if (small) 9.sp else 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.3.sp, color = ink, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(fill).padding(horizontal = if (small) 5.dp else 8.dp, vertical = if (small) 1.dp else 2.dp),
)
}
internal enum class ChipTone { OK, BUSY, WARN, DEAL, PLAIN }
@@ -0,0 +1,118 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.formatBytes
// A store game's own page: hero, the one main action, how it is launched and where it lives.
@Composable
internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: FrontEndActions, onBack: () -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val id = key.substringAfter(':')
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
val installed = StoresState.installedGame(store, id)
val download = StoresState.download("${store.id}:$id")?.takeIf { it.isActive }
// Removal asks twice: one stray press of A should not cost a download.
var confirmRemove by remember(key) { mutableStateOf(false) }
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
val title = item?.title ?: installed?.sidecar?.title ?: id
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(bottom = 12.dp)) {
BackLink(store.label, compact = true, onClick = onBack)
SourceChip(store.id)
}
}
Rise(1) {
Box(Modifier.fillMaxWidth().heightIn(min = if (narrow) 150.dp else 180.dp).clip(Shape16).background(artBrush(hueOf(title)))) {
val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent)))
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
// An install names where it went (internal storage, the card), a download where it is going.
val state = when {
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location
item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
else -> stringResource(R.string.stores_eyebrow_store)
}
val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: ""
Text((state + size).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal)
Text(title, fontSize = if (narrow) 24.sp else 30.sp, lineHeight = if (narrow) 27.sp else 33.sp, fontWeight = FontWeight.Black, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
Actions {
when {
installed != null -> {
// Launching, the prefix and the files belong to the Games tab, where every
// installed game has them; here an install only says it is in Steam and
// offers the one thing Games does not: removing it.
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
SecondaryButton(if (confirmRemove) stringResource(R.string.stores_uninstall_confirm) else stringResource(R.string.stores_uninstall), enabled = download == null) {
if (!confirmRemove) confirmRemove = true
else { confirmRemove = false; StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() }; onBack() }
}
}
download != null -> {
ActionChip(downloadLabel(download), ok = false)
if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause)) { com.droiddeck.launcher.stores.download.DownloadQueue.pause(download.key) }
else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true) { com.droiddeck.launcher.stores.download.DownloadQueue.resume(ctx, download.key) }
ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) }
}
item?.owned == true && StoresState.isUnfinished(item) -> {
PrimaryButton(stringResource(R.string.stores_resume_install), main = true) { StoresState.requestInstall(ctx, item) }
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
}
item?.owned == true -> PrimaryButton(
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), main = true,
) { StoresState.requestInstall(ctx, item) }
item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true) { openStoreUrl(ctx, item) }
item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true) { openStoreUrl(ctx, item) }
}
BusyChip(s)
}
}
}
}
// Plain text only, and nothing at all when the store sent a template key instead of words.
// A description that is only the title again (some stores fill it so) is no description.
val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty()
.takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty()
if (description.isNotBlank()) Rise(2) {
Text(description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp))
}
}
@@ -0,0 +1,328 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.draw.alpha
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.automirrored.outlined.Undo
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Pause
import coil.compose.AsyncImage
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.stores.formatBytes
import com.droiddeck.launcher.stores.formatSpeed
// The Downloads chip: one queue for the three stores, its settings and the engine log.
@Composable
internal fun StoresDownloadsPane(s: FrontEndState, a: FrontEndActions) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val wide = maxWidth >= 700.dp
val list: @Composable () -> Unit = { DownloadList(s, a) }
val side: @Composable () -> Unit = { DownloadSettings(s, a) }
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Box(Modifier.weight(3f)) { list() }
Box(Modifier.weight(2f)) { side() }
} else Column { list(); side() }
}
}
@Composable
private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val entries = StoresState.downloads
if (entries.isEmpty()) {
Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.stores_downloads_empty), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
return
}
// Under way on top; finished below a line whose X clears them all (rows only - a failed
// download's kept files stay until its game page's Clear).
val (active, finished) = entries.partition { it.isActive }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
// The list changes only when rows come, go or change state; each row reads its own progress.
active.forEachIndexed { i, row -> key(row.key) { Rise(1 + i.coerceAtMost(5)) { DownloadCard(StoresState.download(row.key) ?: row, s, a) } } }
if (finished.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = if (active.isEmpty()) 0.dp else 4.dp)) {
Box(Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line))
RoundAction(Icons.Outlined.Close, stringResource(R.string.stores_dl_clear_all), size = 30.dp) { DownloadQueue.dismissFinished() }
}
finished.forEach { row ->
key(row.key) {
val d = StoresState.download(row.key) ?: row
if (d.state == DownloadState.FAILED) DownloadCard(d, s, a) else FinishedRow(d, s, a)
}
}
}
}
}
/** A finished download: its name and store, and Play when it installed. */
@Composable
private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surface).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
SourceChip(d.store.id, small = true)
Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, modifier = Modifier.weight(1f))
if (d.state == DownloadState.INSTALLED) RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) }
}
}
/**
* A row's action as a round icon button - the label is its description - sized like the old
* compact buttons and focusable by pad. [primary] fills it with the accent, [danger] in red.
*/
@Composable
internal fun RoundAction(
icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, primary: Boolean = false, danger: Boolean = false,
enabled: Boolean = true, size: androidx.compose.ui.unit.Dp = 36.dp, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
val hot = rememberHot(src)
val fill = when { danger -> Color(0xFFD9443B); primary -> pal.signal; else -> colors.surface.copy(alpha = 0.85f) }
val tint = if (primary || danger) Color.White else if (hot) pal.signal else colors.onBackground
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(size).clip(androidx.compose.foundation.shape.CircleShape).background(fill)
.glideBorder(hot, androidx.compose.foundation.shape.CircleShape, if (primary || danger) colors.onBackground else pal.signal, pal.line)
.alpha(if (enabled) 1f else 0.4f)
.hoverable(src).clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, enabled = enabled, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) { androidx.compose.material3.Icon(icon, description, tint = tint, modifier = Modifier.size(size * 0.5f)) }
}
/** Cancel in two taps: the stop icon, then a red delete and a keep that undoes it (back by itself after a few seconds). */
@Composable
private fun CancelAction(onDelete: () -> Unit) {
var armed by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(armed) { if (armed) { kotlinx.coroutines.delay(4000); armed = false } }
if (!armed) RoundAction(Icons.Outlined.Close, stringResource(R.string.stores_dl_cancel)) { armed = true }
else {
RoundAction(Icons.Outlined.Delete, stringResource(R.string.stores_dl_cancel_confirm), danger = true) { armed = false; onDelete() }
RoundAction(Icons.AutoMirrored.Outlined.Undo, stringResource(R.string.stores_notification_keep)) { armed = false }
}
}
@Composable
private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val ink = Color.White
val dim = Color.White.copy(alpha = 0.72f)
// The game's wide art is the row: cropped to it, a dark wash from the left so the copy reads
// in white there, the art clear on the right.
Box(Modifier.fillMaxWidth().heightIn(min = 88.dp).clip(Shape12).background(Color(0xFF101318)).border(1.dp, pal.line, Shape12)) {
if (!d.cover.isNullOrEmpty()) AsyncImage(model = d.cover, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
else Spacer(Modifier.matchParentSize().background(artBrush(hueOf(d.name))))
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to Color.Black.copy(alpha = 0.92f), 0.5f to Color.Black.copy(alpha = 0.7f), 1f to Color.Black.copy(alpha = 0.15f))))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(d.name, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
SourceChip(d.store.id, small = true)
val where = listOfNotNull(d.location.takeIf { it.isNotBlank() }, d.diskBytes.takeIf { it > 0 }?.let { formatBytes(it) }).joinToString(" · ")
if (where.isNotEmpty()) Text(where, fontSize = 11.sp, color = dim, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
StageBar(d)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
val meta = when (d.state) {
DownloadState.INSTALLED -> stringResource(R.string.stores_installed_chip)
DownloadState.CANCELLED -> stringResource(R.string.stores_dl_cancelled)
// The reason is in the engine log; the row only says what is kept for Resume.
DownloadState.FAILED -> if (d.bytesDone > 0) stringResource(R.string.stores_dl_failed_kept, formatBytes(d.bytesDone)) else stringResource(R.string.stores_dl_failed)
DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused)
DownloadState.QUEUED -> if (d.queuePosition > 0) stringResource(R.string.stores_dl_queued_at, d.queuePosition) else stringResource(R.string.stores_dl_queued)
DownloadState.RUNNING -> {
val head = downloadLabel(d)
when {
d.stage == DownloadStage.DOWNLOAD && d.bytesTotal > 0 -> stringResource(R.string.stores_dl_with, head, stringResource(R.string.stores_dl_amount, formatBytes(d.bytesDone), formatBytes(d.bytesTotal)))
d.stageItemsTotal > 0 -> stringResource(R.string.stores_dl_with, head, stringResource(R.string.stores_dl_items, d.stageItems, d.stageItemsTotal))
else -> head
}
}
}
Text(meta, fontSize = 12.sp, color = dim, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (d.state == DownloadState.RUNNING && (d.stage == DownloadStage.DOWNLOAD || d.stage == DownloadStage.INSTALL) && d.speedBps > 0) {
Text(formatSpeed(d.speedBps), fontSize = 12.sp, color = dim, maxLines = 1)
if (d.etaSeconds >= 0) Text(eta(d.etaSeconds), fontSize = 12.sp, color = dim, maxLines = 1)
}
}
}
// The row's actions on the right, over the art: icons, their labels as descriptions.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
when (d.state) {
DownloadState.RUNNING, DownloadState.QUEUED -> {
RoundAction(Icons.Filled.Pause, stringResource(R.string.stores_dl_pause)) { DownloadQueue.pause(d.key) }
CancelAction { DownloadQueue.cancel(ctx, d.key) }
}
DownloadState.PAUSED -> {
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { DownloadQueue.resume(ctx, d.key) }
CancelAction { DownloadQueue.cancel(ctx, d.key) }
}
DownloadState.FAILED -> RoundAction(Icons.Filled.Refresh, stringResource(R.string.stores_dl_resume), primary = true) { DownloadQueue.retry(ctx, d.key) }
DownloadState.INSTALLED -> RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) }
DownloadState.CANCELLED -> {}
}
}
}
}
}
/**
* One segment per stage - Manifest, Download, Verify, Install - equal widths: a passed or skipped
* stage full, the active one filling with its own progress (a sliver while it has nothing to count),
* the rest empty.
*/
@Composable
private fun StageBar(d: DownloadEntry) {
val stages = listOf(DownloadStage.MANIFEST, DownloadStage.DOWNLOAD, DownloadStage.VERIFY, DownloadStage.INSTALL)
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth()) {
for (st in stages) {
val active = d.state != DownloadState.INSTALLED && d.stage == st
val fill = when {
active -> d.stageFraction.let { if (it < 0f) 0.04f else it }
// A stage a store skips or folds into another (Epic checks chunks while it fetches,
// Amazon writes while it downloads) reads complete once a later one has started.
d.passed(st) || st.ordinal < d.stage.ordinal || d.stage == DownloadStage.DONE -> 1f
else -> 0f
}
Box(Modifier.weight(1f)) {
ProgressBarThin(
fill, paused = active && d.state == DownloadState.PAUSED, verify = active && st == DownloadStage.VERIFY,
height = 6.dp, done = !active && fill >= 1f,
)
}
}
}
}
private fun eta(seconds: Long): String = when {
seconds < 60 -> "<1 min"
seconds < 3600 -> "~${seconds / 60} min"
else -> "~${seconds / 3600} h ${(seconds % 3600) / 60} m"
}
/** The queue's settings beside it: speed tier, downloads at a time, and the engine log. */
@Composable
private fun DownloadSettings(s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
SectionTitle(stringResource(R.string.stores_download_manager), null)
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surface).border(1.dp, pal.line, Shape12).padding(horizontal = 14.dp, vertical = 10.dp)) {
DownloadControls(s, a)
}
}
/**
* The queue's two knobs on one line - the speed tier on the left, downloads at a time on the
* right; the same control sits in the cog's popup. A narrow page puts them on two tight lines.
*/
@Composable
internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
var parallel by remember { mutableStateOf(DownloadQueue.parallel(ctx)) }
val tier: @Composable () -> Unit = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(R.string.setup_stores_speed), fontSize = 12.sp, color = colors.onSurfaceVariant)
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
}
}
val count: @Composable () -> Unit = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(R.string.stores_parallel), fontSize = 12.sp, color = colors.onSurfaceVariant)
SegmentedTabs(listOf(1 to "1", 2 to "2", 3 to "3"), parallel) { parallel = it; DownloadQueue.setParallel(ctx, it) }
}
}
BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 460.dp) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Bottom) { tier(); count() }
else Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { tier(); count() }
}
}
/**
* The chip row's cog: the section's own settings in a small card - which tab a store opens on
* and the speed tier. Setup › Stores repeats them under its gate. Every install is added to Steam;
* that is not a choice.
*/
@Composable
internal fun StoresSettingsDialog(s: FrontEndState, a: FrontEndActions, onDismiss: () -> Unit) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
AppDialog(shown, close, "storesSettings", wide = false, maxWidth = 440.dp) {
DialogHeader(stringResource(R.string.stores_settings_eyebrow), stringResource(R.string.stores_title))
Rise(1) {
Column {
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
s.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
SettingsRow(stringResource(R.string.stores_show_mature), null) {
ToggleSwitch(s.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
}
// The queue's two knobs as one compact row, the same control the Downloads page has.
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(s, a) }
}
}
Rise(2) { Actions { SecondaryButton(stringResource(R.string.common_ok), onClick = close) } }
}
}
@@ -0,0 +1,109 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SdCard
import androidx.compose.material.icons.outlined.Smartphone
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.formatBytes
/**
* "Install <game>:" over one row per place - internal storage, the card - each the action
* itself: icon, name, free space. Asked only when a card is there. The row picked last time
* starts focused, so a pad confirms the usual place with one press; nothing is chosen for the user.
*/
@Composable
internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
val targets = remember { StoreInstallRoot.targets(ctx) }
val remembered = remember { SessionPrefs.storesInstallTarget(ctx) }
val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0)
val focus = remember { List(targets.size) { FocusRequester() } }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus[defaultIndex] } }
AppDialog(shown, close, "installWhere", wide = false, maxWidth = 440.dp) {
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(R.string.stores_install_title, item.title), fontSize = 15.sp, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
)
Text(
stringResource(R.string.common_cancel), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant,
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
Rise(1) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
targets.forEachIndexed { i, t ->
TargetRow(
label = if (t.removable) t.label else stringResource(R.string.stores_target_internal),
free = stringResource(R.string.stores_target_free, formatBytes(t.freeBytes)),
removable = t.removable, focus = focus[i],
) {
SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath)
StoresState.install(ctx, item, t.root)
close()
}
}
}
}
}
}
@Composable
private fun TargetRow(label: String, free: String, removable: Boolean, focus: FocusRequester, onPick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().focusRequester(focus).clip(Shape12)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f))
.glideBorder(hot, Shape12, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onPick)
.controllerConfirm(onClick = onPick)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Icon(if (removable) Icons.Outlined.SdCard else Icons.Outlined.Smartphone, null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(22.dp))
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text(free, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
}
}
@@ -0,0 +1,686 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.blur
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.formatBytes
// The Stores section: GOG, Epic Games and Amazon Games libraries and storefronts, and one
// download queue for the three, in a single full-width pane.
/** The chip row's fourth entry, beside the three stores. */
private const val DOWNLOADS = "downloads"
private val TABS = listOf("store", "library")
/**
* The tabs a store has: Store and Library (whose dropdown filters All / Installed). Amazon has no
* public catalog, so Library alone.
*/
internal fun tabsFor(store: Store?): List<String> = if (store == Store.AMAZON) listOf("library") else TABS
/** [tab] if [store] has it, else Library (the open-on Store choice, or an old Installed / All, falls back there). */
internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library"
@Composable
internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
val padH = if (narrow) 14.dp else 22.dp
var chip by rememberSaveable { mutableStateOf(Store.GOG.id) }
var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") }
var query by rememberSaveable { mutableStateOf("") }
var openGame by rememberSaveable { mutableStateOf<String?>(null) }
var settings by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } }
val store = Store.byId(chip)
// A tab this store does not have (Store or All on Amazon) reads as Library.
val shownTab = tabFor(store, tab)
LaunchedEffect(Unit) { StoresState.refresh(ctx) }
// A store the account is signed into fills itself when its chip is on screen.
LaunchedEffect(chip, StoresState.accounts[store]) { if (store != null && StoresState.isSignedIn(store)) StoresState.open(ctx, store) }
// A pad's focus sits on something the page is about to replace - the card that opens a game,
// a tab's contents - and would be lost with it, leaving the next press to land on the rail.
// Each move says where focus goes next: into the game page's main action, back to the card
// it was opened from, or onto the first card of a tab or store just picked.
val ff = LocalFrontFocus.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
var focusMove by remember { mutableStateOf<Pair<String, Int>?>(null) }
var lastOpened by remember { mutableStateOf<String?>(null) }
fun move(kind: String) { focusMove = kind to (focusMove?.second ?: 0) + 1 }
fun open(key: String) { lastOpened = key; openGame = key; move("detail") }
fun closeGame() { openGame = null; move("card") }
fun switchTab(to: String) { tab = to; move("first") }
// Picking a chip opens the tab Setup says (Library or Store); a tab the user switches to
// afterwards stays until another chip is picked.
val pickChip: (String) -> Unit = { id ->
chip = id; openGame = null; query = ""
if (id != DOWNLOADS) tab = tabFor(Store.byId(id), if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library")
move("first")
}
BackHandler(enabled = openGame != null) { closeGame() }
val scroll = rememberScrollState()
LaunchedEffect(chip, tab, openGame) { scroll.scrollTo(0) }
LaunchedEffect(focusMove, openGame) {
if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard || ff == null) return@LaunchedEffect
val target = when {
openGame != null -> if (ff.primaryAttached > 0) ff.primary else ff.items["back:${store?.label}"]
focusMove?.first == "card" -> lastOpened?.let { ff.items["card:$it"] } ?: ff.firstTile.takeIf { ff.firstTileAttached > 0 }
focusMove?.first == "first" -> ff.firstTile.takeIf { ff.firstTileAttached > 0 }
else -> null
} ?: return@LaunchedEffect
var landed = false
focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } }
}
Column(
modifier = modifier.padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
.bumpers(
onPrevious = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + t.size - 1) % t.size]) } },
onNext = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + 1) % t.size]) } },
),
) {
// The chip row stays put and paints nothing - the page's background shows through it. What
// scrolls is clipped at the row's lower edge and, once scrolled, faded out there: the
// content's own alpha, not a colour laid over it.
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Rise(0) { StoreChips(chip, s.storeDownloadsActive, onPick = pickChip, onSettings = { settings = true }) }
}
val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() }
Box(
Modifier.fillMaxWidth().weight(1f, fill = false).clipToBounds()
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
if (scroll.value > 0 && size.height > fade) drawRect(
Brush.verticalGradient(0f to Color.Transparent, fade / size.height to Color.Black),
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
},
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) {
when {
chip == DOWNLOADS -> StoresDownloadsPane(s, a)
store == null -> {}
// Signed out, or a sign-in that ran out (a launch could not get its code): sign in again.
!StoresState.isSignedIn(store) || StoresState.expired[store] == true -> Rise(1) { SignInCard(store) }
// One focus group, so the pad walks the page's own controls - back, the hero's
// actions, the cards - and reaches the rail only with Left from them.
openGame != null -> Column(Modifier.fillMaxWidth().focusGroup()) { StoreGameDetail(store, openGame!!, s, a, onBack = { closeGame() }) }
else -> Storefront(store, shownTab, query, s, a, onTab = { switchTab(it) }, onQuery = { query = it }, onOpen = { open(it) })
}
}
}
}
if (settings) StoresSettingsDialog(s, a) { settings = false }
StoresState.pendingInstall?.let { item -> InstallWhereDialog(item) { StoresState.pendingInstall = null } }
}
// ---- the chip row -------------------------------------------------------------------------------
/** Four equal chips - the stores with a signed-in dot, Downloads with its count - and the cog. */
@Composable
private fun StoreChips(selected: String, active: Int, onPick: (String) -> Unit, onSettings: () -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
for (store in Store.entries) {
StoreChip(
label = store.shortLabel, on = selected == store.id, key = store.id, modifier = Modifier.weight(1f),
lead = {
val c = sourceColours(store.id)
Box(Modifier.size(12.dp).clip(CircleShape).background(c.dot).alpha(if (StoresState.isSignedIn(store) && StoresState.expired[store] != true) 1f else 0.35f))
},
description = store.label + if (StoresState.isSignedIn(store) && StoresState.expired[store] != true) "" else " " + stringResource(R.string.stores_chip_signed_out),
) { onPick(store.id) }
}
StoreChip(
label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, key = DOWNLOADS, modifier = Modifier.weight(1f),
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
trail = if (active > 0) { { CountPill(active) } } else null,
) { onPick(DOWNLOADS) }
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings), onSettings)
}
}
@Composable
private fun StoreChip(
label: String, on: Boolean, key: String, modifier: Modifier, lead: @Composable () -> Unit,
trail: (@Composable () -> Unit)? = null, description: String? = null, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
val ink = if (on) colors.onBackground else if (hot) colors.onBackground else colors.onSurfaceVariant
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
modifier = modifier.paneItem("storechip:$key").heightIn(min = 44.dp).clip(shape)
.background(if (on) pal.signal.copy(alpha = 0.16f) else colors.surface)
.glideBorder(hot, shape, pal.signal, if (on) pal.signal.copy(alpha = 0.7f) else pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) { lead() }
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
trail?.invoke()
}
}
/** The chip row's cog: icon only, the chips' height; opens the section's settings. */
@Composable
private fun IconChip(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("storechip:settings").size(44.dp).clip(CircleShape).background(colors.surface)
.glideBorder(hot, CircleShape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick),
) { Icon(icon, description, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(20.dp)) }
}
@Composable
internal fun CountPill(count: Int) {
val pal = LocalPalette.current
Text(
count.toString(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 6.dp, vertical = 1.dp),
)
}
// ---- sign-in -----------------------------------------------------------------------------------
/** No account for this store yet: who it signs in as, and the button that opens its login page. */
@Composable
private fun SignInCard(store: Store) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(18.dp),
) {
StoreLogo(store, 56.dp)
Column(modifier = Modifier.weight(1f)) {
Text(store.label, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
if (!narrow) PrimaryButton(stringResource(R.string.stores_signin), main = true) { StoresState.signIn(ctx, store) }
}
if (narrow) Actions { PrimaryButton(stringResource(R.string.stores_signin), main = true) { StoresState.signIn(ctx, store) } }
}
}
/** A square in the store's colour with its name on it: the stores ship no logo this app may bundle. */
@Composable
internal fun StoreLogo(store: Store, size: Dp) {
val c = sourceColours(store.id)
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(Shape14).background(c.fill).border(1.dp, c.dot.copy(alpha = 0.5f), Shape14)) {
Text(store.shortLabel, fontSize = (size.value / 4).sp, fontWeight = FontWeight.Black, color = c.ink, maxLines = 1)
}
}
// ---- storefront --------------------------------------------------------------------------------
@Composable
private fun Storefront(
store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions,
onTab: (String) -> Unit, onQuery: (String) -> Unit, onOpen: (String) -> Unit,
) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val library = StoresState.library[store] ?: emptyList()
val shelves = StoresState.shelves[store]
// The Installed tab, its count and every card's installed mark come from the installs on disk.
val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) }
val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } }
val q = query.trim().lowercase()
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q)
// The storefront leaves out what the store rates or tags as adult unless the user shows it; owned games always show.
fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned
fun shelf(l: List<CatalogItem>?) = l?.filter(::shown)
// The Store tab's search: the catalog the shelves brought and the library, each title once.
val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
// Library's dropdown: All (every owned game, the default) or Installed.
var installedOnly by rememberSaveable(store) { mutableStateOf(false) }
var filterOpen by remember { mutableStateOf(false) }
val libraryLabel = if (installedOnly) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedItems.size)
else stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
Rise(1) {
SubTabs(
listOf("store" to stringResource(R.string.stores_tab_store), "library" to libraryLabel).filter { it.first in tabsFor(store) },
tab,
onPick = { key -> if (key == "library") { if (tab == "library") filterOpen = !filterOpen else onTab(key) } else onTab(key) },
menuFor = { key, tabWidth ->
if (key == "library") AnchoredMenu(filterOpen, onDismiss = { filterOpen = false }, compact = true, minWidth = tabWidth) { first ->
MenuItem(stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_all), library.size), checked = !installedOnly, focusRequester = first) { installedOnly = false; filterOpen = false; onTab("library") }
MenuItem(stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedItems.size), checked = installedOnly) { installedOnly = true; filterOpen = false; onTab("library") }
}
},
trailing = {
// A purchase made elsewhere shows on a refresh; otherwise the library refreshes itself every six hours.
SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) }
val name = StoresState.accounts[store] ?: ""
SmallTextButton(stringResource(R.string.stores_signout_named, name)) { StoresState.signOut(ctx, store) }
},
)
}
Rise(2) {
SearchField(
query, onQuery,
placeholder = when {
tab == "store" -> stringResource(R.string.stores_search_catalog, store.label)
installedOnly -> stringResource(R.string.stores_search_installed)
else -> stringResource(R.string.stores_search_library)
},
)
}
StoresState.status[store]?.let { line -> Rise(3) { SyncBar(line) } }
StoresState.problems[store]?.let { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(it) } } }
// The first card drawn on the tab is where a pad lands after a tab or chip change.
var firstPlaced = false
val card: @Composable (CatalogItem) -> Unit = { item ->
val first = !firstPlaced
firstPlaced = true
GameCard(item, store, s, a, installedKeys, onOpen, first)
}
when (tab) {
"library" -> if (installedOnly) Grid(installedItems.filter(::matches), stringResource(R.string.stores_installed_empty), card)
else Grid(library.filter(::matches), if (library.isEmpty() && StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_nothing_matches), card)
else -> {
if (q.isNotEmpty()) Grid(everything.filter { matches(it) && shown(it) }, stringResource(R.string.stores_nothing_matches), card)
else {
Shelf(stringResource(R.string.stores_shelf_new), shelf(shelves?.whatsNew), card)
Shelf(stringResource(R.string.stores_shelf_deals), shelf(shelves?.deals), card)
Shelf(stringResource(R.string.stores_shelf_free), shelf(shelves?.free), card)
Shelf(stringResource(R.string.stores_shelf_trending), shelf(shelves?.trending), card)
Shelf(stringResource(R.string.stores_shelf_library), library, card)
if (shelves == null && library.isEmpty()) Rise(4) { Note(if (StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_shelves_loading)) }
else if (shelves != null && shelves.isEmpty && store == Store.AMAZON) Rise(4) { Note(stringResource(R.string.stores_amazon_no_catalog)) }
}
}
}
}
/** Underlined tabs as the preview draws them, with [trailing] (the account) at the right. */
@Composable
private fun SubTabs(
tabs: List<Pair<String, String>>, selected: String, onPick: (String) -> Unit,
menuFor: @Composable (String, androidx.compose.ui.unit.Dp) -> Unit = { _, _ -> }, trailing: @Composable () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState())) {
for ((key, label) in tabs) {
val on = key == selected
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pick = { onPick(key) }
val density = androidx.compose.ui.platform.LocalDensity.current
var width by remember { mutableStateOf(0.dp) }
Box(Modifier.onSizeChanged { width = with(density) { it.width.toDp() } }) {
Column(
modifier = Modifier.paneItem("storetab:$key").clip(RoundedCornerShape(8.dp))
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = if (on) colors.onBackground else colors.onSurfaceVariant, maxLines = 1)
Box(Modifier.padding(top = 4.dp).fillMaxWidth().height(2.dp).background(if (on) pal.signal else Color.Transparent))
}
// A tab's own dropdown (Library's filter), anchored under it.
menuFor(key, width)
}
}
}
trailing()
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
@Composable
private fun SmallTextButton(text: String, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Text(
text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = if (hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.paneItem("btn:$text").clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick).padding(horizontal = 8.dp, vertical = 6.dp),
)
}
@Composable
private fun SearchField(query: String, onQuery: (String) -> Unit, placeholder: String) {
AdbTextField(
value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search,
placeholder = placeholder, compact = true, modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
/** A horizontal row of cards under a title; nothing at all when the list is empty or not here yet. */
@Composable
private fun Shelf(title: String, items: List<CatalogItem>?, card: @Composable (CatalogItem) -> Unit) {
if (items.isNullOrEmpty()) return
val colors = MaterialTheme.colorScheme
Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 2.dp)) {
for (item in items.take(24)) key(item.key) { Box(Modifier.width(CardWidth)) { card(item) } }
}
}
/** Cards in rows that fill the width; [empty] when there are none. */
@Composable
private fun Grid(items: List<CatalogItem>, empty: String, card: @Composable (CatalogItem) -> Unit) {
if (items.isEmpty()) { Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { Text(empty, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) }; return }
BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 10.dp)) {
val gap = 10.dp
val cols = ((maxWidth + gap) / (CardWidth + gap)).toInt().coerceAtLeast(1)
val w = (maxWidth - gap * (cols - 1)) / cols
Column(verticalArrangement = Arrangement.spacedBy(gap)) {
for (row in items.chunked(cols)) Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth()) {
for (item in row) key(item.key) { Box(Modifier.width(w)) { card(item) } }
}
}
}
}
/**
* A card's least width: shelf cards are this wide and a grid fits as many columns as the width
* allows at it (the columns then share the leftover), so a display-density setting changes the
* column count rather than the cards' size. 25% under the first 150 dp: five columns where there
* were four on the Pocket FIT.
*/
private val CardWidth = 112.dp
/**
* One title: its wide art, the title, a price or status line and one button that says the one
* thing to do next - Install with its size, Play, the download's progress, Get for free, or View
* on the store. Tapping the card itself opens the game's page.
*/
@Composable
private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set<String>, onOpen: (String) -> Unit, first: Boolean = false) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.985f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val installed = item.id in installedKeys
val download = StoresState.download("${store.id}:${item.id}")?.takeIf { it.isActive }
val open = { onOpen(item.key) }
// A size the library did not give is looked up once the card is on screen or close to it.
val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() }
val ahead = screen / 2
Column(
modifier = Modifier.fillMaxWidth()
.onGloballyPositioned { c ->
if (item.owned && item.sizeBytes <= 0) {
val b = c.boundsInWindow()
if (b.bottom > -ahead && b.top < screen + ahead && b.width > 0f) com.droiddeck.launcher.stores.StoreSizes.request(ctx, item)
}
}
.paneItem("card:${item.key}").then(if (first) Modifier.firstTile() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open),
) {
CardFace(item, download) { CardMarker(item, installed, download) }
}
}
/** A store's sync as a thin bar and its count ("25/55"); a moving bar when it has no count yet. */
@Composable
private fun SyncBar(line: String) {
val count = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line)
val done = count?.groupValues?.get(1)?.toIntOrNull()
val total = count?.groupValues?.get(2)?.toIntOrNull()?.takeIf { it > 0 }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Box(Modifier.weight(1f)) {
if (done != null && total != null) androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp))
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(3.dp))
}
if (done != null && total != null) Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
/** How tall the card's one line is, over the blurred foot of its art. */
private val CardStrip = 26.dp
/**
* The card is its art: sharp above, and under the one line a blurred, darkened copy of the same
* picture, placed so the two meet without a seam. Blur needs API 31; below it the copy is only
* darkened, more strongly.
*/
@Composable
private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, marker: @Composable () -> Unit) {
val url = item.imageUrl ?: item.tallImageUrl
val blurs = android.os.Build.VERSION.SDK_INT >= 31
BoxWithConstraints(Modifier.fillMaxWidth()) {
val total = maxWidth * 9f / 16f + CardStrip
Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) {
if (url != null) AsyncImage(
model = url, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().wrapContentHeight(Alignment.Bottom, unbounded = true).height(total).blur(24.dp),
)
Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.45f else 0.7f)))
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.matchParentSize().padding(horizontal = 8.dp),
) {
Text(item.title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
marker()
}
}
// A soft edge where the sharp art meets the strip.
Spacer(Modifier.align(Alignment.BottomCenter).padding(bottom = CardStrip).fillMaxWidth().height(14.dp).background(Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.35f))))
if (download != null) Box(Modifier.align(Alignment.BottomCenter).padding(start = 6.dp, end = 6.dp, bottom = CardStrip + 5.dp)) {
ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY)
}
}
}
}
/** The card's right-hand value: done, resume, progress, size, or the price. */
@Composable
private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val small = 10.sp
when {
// Installed: its size on disk and a small check after it.
installed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
val ctx = LocalContext.current
val game = StoresState.installedGame(item.store, item.id)
if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) }
com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.dp))
}
download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
// The size in the title's own type; nothing until it is known.
item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1)
item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
StateChip("-${item.discountPercent}%", ChipTone.DEAL, small = true)
Text(item.finalPrice, fontSize = small, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1)
}
item.hasPrice -> Text(item.finalPrice, fontSize = small, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1)
}
}
@Composable
internal fun CardArt(item: CatalogItem, modifier: Modifier) {
val url = item.imageUrl ?: item.tallImageUrl
val colors = MaterialTheme.colorScheme
// A dark ground under the image, so art with transparency never ends in white, and a quiet
// fade into the card's surface at the foot.
Box(modifier.background(colors.surfaceVariant).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
else Text(item.title, fontSize = 10.sp, fontWeight = FontWeight.Black, color = Color.White, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(6.dp))
Spacer(Modifier.matchParentSize().background(androidx.compose.ui.graphics.Brush.verticalGradient(0.6f to Color.Transparent, 1f to colors.surface.copy(alpha = 0.85f))))
}
}
@Composable
internal fun ProgressBarThin(fraction: Float, paused: Boolean = false, verify: Boolean = false, height: Dp = 4.dp, done: Boolean = false) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val fill = when { done -> pal.good; paused -> Color(0xFFFFC24D); verify -> Color(0xFF9B6DFF); else -> pal.signal }
// The fill glides to each new value over a quarter second - progress arrives a few times a second
// and in steps - and is read only while drawing, so the bar animates without relayout.
val shown = androidx.compose.animation.core.animateFloatAsState(
fraction.coerceIn(0f, 1f), androidx.compose.animation.core.tween(250, easing = androidx.compose.animation.core.LinearEasing), label = "bar",
)
Box(
Modifier.fillMaxWidth().height(height).clip(RoundedCornerShape(height / 2)).background(colors.surfaceVariant)
.drawBehind { drawRect(fill, size = androidx.compose.ui.geometry.Size(size.width * shown.value, size.height)) },
)
}
@Composable
internal fun downloadLabel(d: com.droiddeck.launcher.stores.download.DownloadEntry): String = when (d.state) {
DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused)
DownloadState.QUEUED -> stringResource(R.string.stores_dl_queued)
else -> if (d.stageFraction >= 0f) stringResource(R.string.stores_dl_active_percent, activeStageLabel(d), d.percent) else activeStageLabel(d)
}
/** The active stage as the row and the page name it: Checking (files already there), Downloading, Verifying, Installing. */
@Composable
internal fun activeStageLabel(d: com.droiddeck.launcher.stores.download.DownloadEntry): String = when (d.stage) {
DownloadStage.MANIFEST -> if (d.stageItemsTotal > 0) stringResource(R.string.stores_stage_checking) else stringResource(R.string.stores_stage_manifest)
DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_downloading)
DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verifying)
DownloadStage.INSTALL -> stringResource(R.string.stores_stage_installing)
DownloadStage.DONE -> stringResource(R.string.stores_stage_done)
}
/** Two taps: the first arms it ([confirm] shows), the second acts; it disarms itself after a few seconds. */
@Composable
internal fun ConfirmButton(label: String, confirm: String, compact: Boolean = false, onConfirm: () -> Unit) {
var armed by remember { mutableStateOf(false) }
LaunchedEffect(armed) { if (armed) { kotlinx.coroutines.delay(4000); armed = false } }
SecondaryButton(if (armed) confirm else label, compact = compact) { if (!armed) armed = true else { armed = false; onConfirm() } }
}
@Composable
internal fun stageLabel(stage: DownloadStage): String = when (stage) {
DownloadStage.MANIFEST -> stringResource(R.string.stores_stage_manifest)
DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_download)
DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verify)
DownloadStage.INSTALL -> stringResource(R.string.stores_stage_install)
DownloadStage.DONE -> stringResource(R.string.stores_stage_done)
}
/** The game's web page, in the device's browser: this app buys nothing and claims nothing itself. */
internal fun openStoreUrl(ctx: android.content.Context, item: CatalogItem) {
val url = item.storeUrl.ifBlank { return }
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
/**
* Launches an installed store game the way the Games tab does: through its Steam shortcut. The
* shortcut exists once the client has (re)started since the install; until then the user is told.
*/
internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }
if (game == null) {
android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
return
}
com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) }
}
@@ -0,0 +1,20 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.StoresNative
// Controls the Stores section and its Setup group share.
/** Whether the native download engine is in this build, said where the stores are turned on. */
@Composable
internal fun StoresEngineRow() {
val version = StoresNative.version
SettingsRow(
stringResource(R.string.setup_stores_engine),
version,
) {
Chip(if (version != null) stringResource(R.string.setup_stores_engine_chip_ready) else stringResource(R.string.setup_stores_engine_chip_missing), ok = version != null)
}
}
-3
View File
@@ -909,11 +909,8 @@
<string name="games_manage_saves">Gestionar partidas guardadas</string>
<string name="games_saves">Partidas guardadas</string>
<string name="games_import_saves">Importar partidas guardadas…</string>
<string name="games_import_saves_hint">Zip de GameHub o Winlator. Antes se hace una copia de las partidas actuales.</string>
<string name="games_export_gamehub">Exportar como zip de GameHub…</string>
<string name="games_export_gamehub_hint">Para GameHub y BannerHub</string>
<string name="games_export_winlator">Exportar como zip de Winlator…</string>
<string name="games_export_winlator_hint">Para Winlator, WinNative y Bannerlator</string>
<string name="games_open_save_folder">Abrir carpeta de guardado</string>
<string name="games_never_played">Nunca jugado</string>
<string name="games_runtime_busy_percent">Runtime ocupado · %1$d%%</string>
-3
View File
@@ -800,11 +800,8 @@
<string name="games_manage_saves">Gérer les sauvegardes</string>
<string name="games_saves">Sauvegardes de jeu</string>
<string name="games_import_saves">Importer des sauvegardes…</string>
<string name="games_import_saves_hint">Archive GameHub ou Winlator. Les sauvegardes actuelles seront sauvegardées avant l\'importation.</string>
<string name="games_export_gamehub">Exporter en tant qu\'archive GameHub…</string>
<string name="games_export_gamehub_hint">Compatible avec GameHub et BannerHub</string>
<string name="games_export_winlator">Exporter en tant qu\'archive Winlator…</string>
<string name="games_export_winlator_hint">Compatible avec Winlator, WinNative et Bannerlator</string>
<string name="games_open_save_folder">Ouvrir le dossier de sauvegarde</string>
<string name="games_never_played">Jamais lancé</string>
<string name="games_runtime_busy_percent">Environnement d\'exécution occupé · %1$d%%</string>
-3
View File
@@ -901,11 +901,8 @@
<string name="games_manage_saves">セーブデータの管理</string>
<string name="games_saves">ゲームセーブデータ</string>
<string name="games_import_saves">セーブデータをインポート…</string>
<string name="games_import_saves_hint">GameHubまたはWinlatorのアーカイブ。インポート前に現在のセーブデータをバックアップします。</string>
<string name="games_export_gamehub">GameHubアーカイブとしてエクスポート…</string>
<string name="games_export_gamehub_hint">GameHubおよびBannerHubに適しています</string>
<string name="games_export_winlator">Winlatorアーカイブとしてエクスポート…</string>
<string name="games_export_winlator_hint">Winlator、WinNative、Bannerlatorに適しています</string>
<string name="games_open_save_folder">セーブフォルダを開く</string>
<string name="games_never_played">未プレイ</string>
<string name="games_runtime_busy_percent">実行環境がビジー · %1$d%%</string>
-3
View File
@@ -901,11 +901,8 @@
<string name="games_manage_saves">저장 데이터 관리</string>
<string name="games_saves">게임 저장 데이터</string>
<string name="games_import_saves">저장 데이터 가져오기…</string>
<string name="games_import_saves_hint">GameHub 또는 Winlator 아카이브. 가져오기 전에 현재 저장 데이터를 백업합니다.</string>
<string name="games_export_gamehub">GameHub 아카이브로 내보내기…</string>
<string name="games_export_gamehub_hint">GameHub 및 BannerHub에 적합</string>
<string name="games_export_winlator">Winlator 아카이브로 내보내기…</string>
<string name="games_export_winlator_hint">Winlator, WinNative 및 Bannerlator에 적합</string>
<string name="games_open_save_folder">저장 폴더 열기</string>
<string name="games_never_played">플레이한 적 없음</string>
<string name="games_runtime_busy_percent">런타임 사용 중 · %1$d%%</string>
-3
View File
@@ -884,11 +884,8 @@
<string name="games_manage_saves">Управление сохранениями</string>
<string name="games_saves">Сохранения игры</string>
<string name="games_import_saves">Импорт сохранений…</string>
<string name="games_import_saves_hint">Архив GameHub или Winlator. Перед импортом текущие сохранения будут сохранены в резервной копии.</string>
<string name="games_export_gamehub">Экспорт как архив GameHub…</string>
<string name="games_export_gamehub_hint">Подходит для GameHub и BannerHub</string>
<string name="games_export_winlator">Экспорт как архив Winlator…</string>
<string name="games_export_winlator_hint">Подходит для Winlator, WinNative и Bannerlator</string>
<string name="games_open_save_folder">Открыть папку сохранений</string>
<string name="games_never_played">Никогда не запускалась</string>
<string name="games_runtime_busy_percent">Среда выполнения занята · %1$d%%</string>
@@ -901,11 +901,8 @@
<string name="games_manage_saves">管理存档</string>
<string name="games_saves">游戏存档</string>
<string name="games_import_saves">导入存档…</string>
<string name="games_import_saves_hint">GameHub 或 Winlator 的 zip 包。会先备份当前存档。</string>
<string name="games_export_gamehub">导出为 GameHub zip 包…</string>
<string name="games_export_gamehub_hint">用于 GameHub 和 BannerHub</string>
<string name="games_export_winlator">导出为 Winlator zip 包…</string>
<string name="games_export_winlator_hint">用于 Winlator、WinNative 和 Bannerlator</string>
<string name="games_open_save_folder">打开存档文件夹</string>
<string name="games_never_played">从未游玩</string>
<string name="games_runtime_busy_percent">运行时繁忙 · %1$d%%</string>
@@ -901,11 +901,8 @@
<string name="games_manage_saves">管理存檔</string>
<string name="games_saves">遊戲存檔</string>
<string name="games_import_saves">匯入存檔…</string>
<string name="games_import_saves_hint">GameHub 或 Winlator zip。會先備份目前的存檔。</string>
<string name="games_export_gamehub">匯出為 GameHub zip…</string>
<string name="games_export_gamehub_hint">適用於 GameHub 和 BannerHub</string>
<string name="games_export_winlator">匯出為 Winlator zip…</string>
<string name="games_export_winlator_hint">適用於 Winlator、WinNative 和 Bannerlator</string>
<string name="games_open_save_folder">開啟存檔資料夾</string>
<string name="games_never_played">從未玩過</string>
<string name="games_runtime_busy_percent">運行環境繁忙 · %1$d%%</string>
@@ -901,11 +901,8 @@
<string name="games_manage_saves">管理存檔</string>
<string name="games_saves">遊戲存檔</string>
<string name="games_import_saves">匯入存檔…</string>
<string name="games_import_saves_hint">GameHub 或 Winlator 壓縮檔。匯入前會先備份目前存檔。</string>
<string name="games_export_gamehub">匯出為 GameHub 壓縮檔…</string>
<string name="games_export_gamehub_hint">適用於 GameHub 和 BannerHub</string>
<string name="games_export_winlator">匯出為 Winlator 壓縮檔…</string>
<string name="games_export_winlator_hint">適用於 Winlator、WinNative 和 Bannerlator</string>
<string name="games_open_save_folder">開啟存檔資料夾</string>
<string name="games_never_played">從未執行</string>
<string name="games_runtime_busy_percent">執行環境繁忙 · %1$d%%</string>
+160 -3
View File
@@ -911,11 +911,8 @@
<string name="games_manage_saves">Manage saves</string>
<string name="games_saves">Game saves</string>
<string name="games_import_saves">Import saves…</string>
<string name="games_import_saves_hint">GameHub or Winlator zip. Current saves are backed up first.</string>
<string name="games_export_gamehub">Export as GameHub zip…</string>
<string name="games_export_gamehub_hint">For GameHub and BannerHub</string>
<string name="games_export_winlator">Export as Winlator zip…</string>
<string name="games_export_winlator_hint">For Winlator, WinNative and Bannerlator</string>
<string name="games_open_save_folder">Open save folder</string>
<string name="games_never_played">Never played</string>
<string name="games_runtime_busy_percent">Runtime busy · %1$d%%</string>
@@ -926,6 +923,8 @@
<string name="games_last_played">Last played %1$s</string>
<string name="games_internal">Internal storage</string>
<string name="games_added">Added game</string>
<string name="games_custom">Custom game</string>
<string name="games_store_storage">Games storage</string>
<string name="gpu_auto_hint">The best pair for this GPU, kept up to date</string>
<string name="gpu_manual_hint">Pick a pair yourself</string>
<string name="gpu_checking">Checking for drivers…</string>
@@ -1957,4 +1956,162 @@
<string name="device_info_copied">Copied.</string>
<string name="force_ssbs_title">Force SSBS</string>
<string name="force_ssbs_hint">Keeps a CPU speed setting on that Wine keeps turning off. Can make Windows games faster on Snapdragon 8 Elite; little change seen on older chips. Applies on the next game launch.</string>
<!-- Stores: GOG, Epic Games and Amazon Games -->
<string name="rail_stores">Stores</string>
<string name="source_steam">Steam</string>
<string name="source_added">Custom</string>
<string name="setup_stores">Stores</string>
<string name="setup_stores_show">Show Stores in the rail</string>
<string name="setup_stores_open_on">Open a store on</string>
<string name="setup_stores_speed">Download speed tier</string>
<string name="setup_stores_engine">Stores engine</string>
<string name="setup_stores_engine_chip_ready">● Engine ready</string>
<string name="setup_stores_engine_chip_missing">Not built</string>
<string name="stores_tier_balanced">Balanced</string>
<string name="stores_tier_fast">Fast</string>
<string name="stores_tier_max">Max</string>
<string name="stores_title">Stores</string>
<string name="stores_settings">Stores settings</string>
<string name="stores_settings_eyebrow">Settings</string>
<string name="stores_chip_signed_out">(not signed in)</string>
<string name="stores_tab_store">Store</string>
<string name="stores_tab_installed">Installed</string>
<string name="stores_tab_library">Library</string>
<string name="stores_tab_all">All</string>
<string name="stores_tab_downloads">Downloads</string>
<string name="stores_tab_count">%1$s (%2$d)</string>
<string name="stores_signin">Sign in</string>
<string name="stores_signout_named">%1$s · sign out</string>
<string name="stores_search_catalog">Search the %1$s catalog…</string>
<string name="stores_search_installed">Search installed games…</string>
<string name="stores_search_library">Search your library…</string>
<string name="stores_installed_empty">Nothing installed</string>
<string name="stores_library_loading">Loading</string>
<string name="stores_nothing_matches">No games</string>
<string name="stores_shelves_loading">Loading the store…</string>
<string name="stores_amazon_no_catalog">No catalog</string>
<string name="stores_shelf_new">What\'s new</string>
<string name="stores_shelf_deals">Deals</string>
<string name="stores_shelf_free">Free games</string>
<string name="stores_shelf_trending">Trending</string>
<string name="stores_shelf_library">Your library</string>
<string name="stores_play">Play</string>
<string name="stores_install">Install</string>
<string name="stores_resume_install">Resume install</string>
<string name="stores_install_size">Install · %1$s</string>
<string name="stores_get_free">Get for free</string>
<string name="stores_view_on">View on %1$s</string>
<string name="stores_buy_price">Buy · %1$s</string>
<string name="stores_installed_chip">Installed</string>
<string name="stores_in_library">In library</string>
<string name="stores_free">Free</string>
<string name="stores_resume">Resume</string>
<string name="stores_refresh">Refresh</string>
<string name="stores_show_mature">Show mature content</string>
<string name="epic_card_title">Epic</string>
<string name="epic_card_eos">EOS</string>
<string name="epic_card_offline">Offline</string>
<string name="epic_card_none">Off</string>
<string name="epic_eos">Epic sign-in (EOS)</string>
<string name="epic_offline">Launch offline</string>
<string name="epic_resolve">Resolve Epic sign-in</string>
<string name="store_cloud_saves">Cloud saves</string>
<string name="cloud_upload">Upload to cloud</string>
<string name="cloud_download">Download from cloud</string>
<string name="cloud_none">No cloud saves</string>
<string name="cloud_conflict">Cloud conflict</string>
<string name="cloud_conflict_files">Cloud conflict · %1$d files</string>
<string name="cloud_pending">…</string>
<string name="cloud_keep_cloud">Keep cloud</string>
<string name="cloud_keep_local">Keep local</string>
<string name="cloud_checking">Checking cloud…</string>
<string name="cloud_last_sync">Last sync %1$s</string>
<string name="cloud_never">Not synced yet</string>
<string name="cloud_done">%1$d files</string>
<string name="stores_eyebrow_installed">installed</string>
<string name="stores_eyebrow_library">in your library</string>
<string name="stores_eyebrow_store">store</string>
<string name="stores_in_steam">✓ In Steam</string>
<string name="stores_uninstall">Uninstall</string>
<string name="stores_uninstall_confirm">Press again to uninstall</string>
<string name="stores_launch_not_registered">Steam picks the game up when it next starts; launch it from there, or start Steam now.</string>
<string name="stores_card_proton">Proton</string>
<string name="stores_card_proton_value">Steam\'s choice per game</string>
<string name="stores_card_audio">Audio</string>
<string name="stores_card_audio_value">DirectAudio</string>
<string name="stores_card_components">Components</string>
<string name="stores_card_components_value">FEX, DXVK, VKD3D</string>
<string name="stores_card_engine">Engine</string>
<string name="stores_card_engine_value">%1$s · native</string>
<string name="stores_card_engine_java">%1$s · built-in</string>
<string name="stores_how_it_runs">How it runs</string>
<string name="stores_kv_fetched">Fetched from</string>
<string name="stores_kv_fetched_value">%1$s, with your account</string>
<string name="stores_kv_installed_to">Installed to</string>
<string name="stores_kv_installed_to_value">Games storage (Games/%1$s/%2$s)</string>
<string name="stores_kv_launched_by">Launched by</string>
<string name="stores_kv_launched_by_value">Steam and Proton, the same path as Steam games</string>
<string name="stores_kv_launched_by_epic">Steam and Proton · Epic sign-in per launch, EOS overlay off</string>
<string name="stores_kv_launched_by_amazon">Steam and Proton · Amazon Games Services variables set</string>
<string name="stores_kv_steam">Steam</string>
<string name="stores_kv_steam_on">added to Steam on install (Deck UI + Steam Input)</string>
<string name="stores_downloads_empty">No downloads</string>
<string name="stores_dl_paused">Paused</string>
<string name="stores_dl_queued">Queued</string>
<string name="stores_dl_queued_at">Queued · #%1$d</string>
<string name="stores_dl_cancelled">Cancelled</string>
<string name="stores_dl_failed">Failed</string>
<string name="stores_dl_failed_reason">Failed · %1$s</string>
<string name="stores_dl_stage_percent">%1$s · %2$d%%</string>
<string name="stores_dl_bytes">%1$s / %2$s · %3$d%%</string>
<string name="stores_dl_pause">Pause</string>
<string name="stores_dl_resume">Resume</string>
<string name="stores_dl_cancel">Cancel</string>
<string name="stores_dl_retry">Retry</string>
<string name="stores_dl_clear">Clear</string>
<string name="stores_dl_clear_all">Clear finished</string>
<string name="stores_stage_manifest">Manifest</string>
<string name="stores_stage_download">Download</string>
<string name="stores_stage_verify">Verify</string>
<string name="stores_stage_install">Install</string>
<string name="stores_stage_done">Done</string>
<string name="stores_stage_checking">Checking</string>
<string name="stores_stage_downloading">Downloading</string>
<string name="stores_dl_cancel_confirm">Delete download?</string>
<string name="stores_dl_failed_kept">Failed · %1$s kept</string>
<string name="stores_stage_verifying">Verifying</string>
<string name="stores_stage_installing">Installing</string>
<string name="stores_dl_active_percent">%1$s %2$d%%</string>
<string name="stores_dl_with">%1$s · %2$s</string>
<string name="stores_dl_amount">%1$s / %2$s</string>
<string name="stores_dl_items">%1$d/%2$d files</string>
<string name="stores_download_manager">Download manager</string>
<string name="stores_parallel">Downloads at a time</string>
<string name="stores_install_where">Install to</string>
<string name="stores_install_title">Install %1$s:</string>
<string name="stores_target_internal">Internal</string>
<string name="stores_target_free">%1$s free</string>
<string name="stores_notification_channel">Store downloads</string>
<string name="stores_notification_channel_hint">Shows game download progress and keeps downloads running in the background</string>
<string name="stores_notification_one">Downloading</string>
<string name="stores_notification_many">%1$d downloads</string>
<string name="stores_notification_waiting">Preparing…</string>
<string name="stores_notification_done_channel">Finished store downloads</string>
<string name="stores_notification_installed">%1$s installed</string>
<string name="stores_notification_failed">%1$s download failed</string>
<string name="stores_notification_delete">Delete</string>
<string name="stores_notification_keep">Keep</string>
<string name="stores_login_title">Sign in to %1$s</string>
<string name="stores_login_provider_title">Sign in with your provider</string>
<string name="stores_login_reload">Reload</string>
<string name="stores_login_close">Close</string>
<string name="stores_login_retry">Try again</string>
<string name="stores_login_finishing">Finishing sign-in…</string>
<string name="stores_login_error_network">The sign-in page could not be loaded: %1$s</string>
<string name="stores_login_error_idp_blocked">This provider refuses to sign in from an embedded page. Sign in with your store password instead.</string>
<string name="stores_login_error_http">The sign-in page answered with an error (HTTP %1$d).</string>
<string name="stores_login_error_ssl">The connection to the sign-in page is not secure.</string>
<string name="stores_login_error_verification">The sign-in could not be verified. Try again.</string>
<string name="stores_login_error_generic">Sign-in failed. Try again.</string>
</resources>
+1729
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "droiddeckstores"
version = "0.1.0"
edition = "2021"
# The engines are a port of Bannerlator's bl-steam-client store modules (GPL-3.0-or-later, derived
# from WinNative's wn-steam-client; the Epic selection/verify rules trace back to GameNative and
# Legendary, both GPL-3.0). DroidDeck is GPL-3.0, so the crate keeps the licence it came with.
license = "GPL-3.0-or-later"
publish = false
[lib]
name = "droiddeckstores"
crate-type = ["cdylib"]
[dependencies]
jni = "0.21"
# setpriority/gettid: every engine thread runs at nice +10 (see src/priority.rs). Already in the
# dependency tree through tokio, at the version Cargo.lock pins.
libc = "0.2"
# zlib inflate for GOG gen2 chunks and Epic chunk bodies. The pure-Rust backend keeps the .so free
# of a C zlib and of the NDK sysroot's libz, which the apk does not ship.
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# The fetch core drives every request through ONE tokio current-thread runtime and feeds a sync
# process pool; rustls (via ring) is the TLS stack so there is no OpenSSL to cross-compile. `gzip`
# transparently inflates a CDN answer that comes back content-encoded. No `blocking` client: the
# three engines are async-fetch only.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
tokio = { version = "1", default-features = false, features = ["rt", "net", "time", "sync"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
# preserve_order keeps JSON object keys in source order, as in Bannerlator, so the GOG depot plan
# walks a manifest's files in the order the manifest lists them (largest-first sorting happens
# after, explicitly).
serde_json = { version = "1", features = ["preserve_order"] }
# Epic chunks are SHA-1 verified, Amazon files SHA-256; GOG's MD5 is the in-tree md5_small.
sha1 = "0.10"
sha2 = "0.10"
[profile.release]
codegen-units = 1
lto = "thin"
strip = "symbols"
+272
View File
@@ -0,0 +1,272 @@
# `libdroiddeckstores.so` — the JNI contract
The Kotlin side of the store download engines mirrors this file exactly. Every symbol the library
exports is listed here with the class it binds to, the JNI signature, when it is called and on
which thread. The engines came from Bannerlator's `bl-steam-client` crate; the method names and
signatures are identical to Bannerlator's `BlGogDownload` / `BlEpicDownload` / `BlAmazonDownload`,
only the package and class names changed.
Load it once with `System.loadLibrary("droiddeckstores")`. The library is built by Gradle
(`buildRustStores`, see `app/build.gradle`) and is absent from a `-PskipRust=true` build, so every
call site must be ready for `UnsatisfiedLinkError` on `loadLibrary` or on the first `external`
call and degrade to "store unavailable".
## Conventions that hold for all three engines
- The `external` functions are declared `@JvmStatic` in a Kotlin `object` (a `JClass` arrives as
the second JNI argument). Declaring them in a plain `object` without `@JvmStatic` also binds —
the symbol name is the same — but keep `@JvmStatic` so the contract matches Bannerlator's.
- No overloads: the exported names are the short JNI form (`Java_<package>_<Class>_<method>`), so
adding a second `nativeStart` with a different parameter list would NOT bind.
- Listener objects are only ever reached through `CallVoidMethod` by name + signature on the
object that was passed to `nativeStart`. The library never does `FindClass`, so the listener's
class may be any class (interface, nested interface, anonymous object) that declares the listed
methods with exactly these signatures. The names suggested below are what the Rust doc comments
use.
- Every listener method runs on a native thread (the run's worker thread, a process-pool thread
or the throughput reporter) that is attached to the JVM as a daemon. Never touch Views directly;
post to the main thread. A Java exception thrown by a listener method is cleared and ignored by
the native side — it does not stop the run.
- `nativeStart` returns an opaque handle (`long`). `0` means the run did not start. The handle is
a heap pointer: call `nativeRelease(handle)` exactly once per non-zero handle (after
`onComplete`), never twice, never with a made-up value.
- `nativeCancel(handle)` is idempotent and safe at any time between `nativeStart` and
`nativeRelease`. The run stops at its next scheduling point; `onComplete` still fires.
- `nativeRelease(handle)` is safe even while a cancelled run is still winding down: the worker
thread owns its own references (cancel flag, listener `GlobalRef`).
- `caBundlePath`: a PEM bundle the HTTPS client adds as extra root certificates; `""` = the roots
rustls ships with (webpki). Bannerlator extracts the system CA store to a file for this; on
DroidDeck `""` is the sensible default unless a CDN fails TLS verification.
- `maxWorkers` is the ceiling of the adaptive in-flight request window; `processWorkers` is the
number of sync inflate+hash+write threads. Bannerlator passes its "download threads" preference
for both (8 by default).
## `com.droiddeck.launcher.stores.StoresNative`
| Method | JNI signature | Symbol |
|---|---|---|
| `nativeVersion(): String` | `()Ljava/lang/String;` | `Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion` |
Returns the crate version from `Cargo.toml` (`"0.1.0"`). Call it from Setup, after
`System.loadLibrary("droiddeckstores")`, to prove the library loads and binds; show the string
next to the other component versions. May return `null` only if the JVM cannot allocate a string.
```kotlin
package com.droiddeck.launcher.stores
object StoresNative {
@JvmStatic external fun nativeVersion(): String
}
```
## `com.droiddeck.launcher.stores.gog.GogNative`
GOG gen2 chunk engine (depot manifests → chunk fetch + zlib inflate + MD5) and gen1 (build manifest
→ per-file HTTP Range GET streamed to disk). One `nativeStart` = one download loop (base install,
DLC install, or a dependency-redist assembly). The Kotlin manager owns everything before (token,
builds, manifest head, product + language depot selection, secure-link resolution, disk guard) and
after it (marker files, exe resolution, DLC markers, redist orchestration).
| Method | JNI signature | Symbol |
|---|---|---|
| `nativeProbe(): Int` | `()I` | `Java_com_droiddeck_launcher_stores_gog_GogNative_nativeProbe` |
| `nativeStart(...)`: Long | `(I[Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;IIZLjava/lang/String;Ljava/lang/Object;)J` (last parameter is the listener; its static type is the app's choice) | `Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart` |
| `nativeCancel(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_gog_GogNative_nativeCancel` |
| `nativeRelease(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_gog_GogNative_nativeRelease` |
`nativeProbe()` always returns `1`; it exists so `isAvailable()` can catch `UnsatisfiedLinkError`
once at startup instead of mid-download.
`nativeStart` parameters, in order:
| # | Parameter | Meaning |
|---|---|---|
| 1 | `kind: Int` | `0` = `KIND_GEN2_CHUNKS`, `1` = `KIND_GEN1_RANGES` (anything else → gen2) |
| 2 | `depotManifests: Array<String>` | gen2: the inflated depot-manifest JSON strings, in fetch order, already filtered by product + language; gen1: the inflated build manifest (one element) |
| 3 | `cdnBases: Array<String>` | gen2: **every** CDN base of the secure-link answer — one per `urls[]` entry, each resolved the way `parseCdnUrl` resolves `urls[0]` today (query string intact), in GOG's order — or the one unauthenticated dependency store base (an array of one); gen1: `emptyArray()` (file URLs are in the manifest). Blank entries are ignored and a base whose host already appeared is dropped (the first wins). The fetch core spreads the window across the distinct hosts with a per-host cap of `max(6, ceil(maxWorkers / hosts))`, rotating a failed chunk to another CDN; one base behaves exactly like the old single `cdnBase`. The start log line lists them as `hosts=a,b,c`. |
| 4 | `installDir: String` | absolute install directory |
| 5 | `skipPaths: Array<String>` | relative paths already completed by an earlier run of this same download (secure-link refresh re-run): counted done without re-hashing, no progress event |
| 6 | `caBundlePath: String` | see conventions |
| 7 | `maxWorkers: Int` | window ceiling for this loop (Bannerlator: its download-threads preference, 8, or 1 for redists) |
| 8 | `processWorkers: Int` | inflate/hash/write threads |
| 9 | `sortLargestFirst: Boolean` | base install = `true` (largest files first); DLC / dependency = `false` |
| 10 | `label: String` | log prefix, e.g. `"gog base"`; `""` → `"gog"` |
| 11 | `listener` | see below |
Returns `0` without any callback when: the listener is null, the JavaVM cannot be obtained, the
install dir or manifest list is empty, `cdnBases` holds no usable base for gen2, or the worker
thread cannot be spawned. Otherwise the run is on a thread named `gog-dl` and `onComplete` fires exactly once.
Suggested listener: `com.droiddeck.launcher.stores.gog.GogNative.Listener` (an interface).
| Listener method | JNI signature | When |
|---|---|---|
| `onProgress(bytesDone: Long, bytesTotal: Long, filesDone: Int, filesTotal: Int, file: String, fileBytes: Long, verified: Boolean)` | `(JJIILjava/lang/String;JZ)V` | one file reached its final state. `verified = true` = resume-skip (existing file passed size+MD5; no bytes credited, `fileBytes` = its size); `false` = freshly assembled, size+MD5-verified and renamed (`fileBytes` = its decompressed size). `filesDone` counts both, `bytesDone` counts assembled bytes only. Process-pool / worker thread. |
| `onBytes(bytesDone: Long, bytesTotal: Long)` | `(JJ)V` | byte progress between file completions: every 250 ms while the run is fetching, only when the value moved, plus once when the fetch ends. `bytesDone` = bytes of assembled files + bytes of files still in flight (gen2: their finished chunks, decompressed; gen1: what the current attempt has written; a retried attempt starts over, so nothing is counted twice); `bytesTotal` is the same total `onProgress` reports. It never goes past the next `onProgress.bytesDone` by more than the in-flight files and is equal to it once they finish. Drive the progress bar and speed from this; keep `onProgress` for file counts and per-file events. Called from the engine's ticker thread. Optional: a listener without it still works (the call fails quietly). |
| `onLog(line: String)` | `(Ljava/lang/String;)V` | engine diagnostics; the same line already went to logcat under the tag `GogNative`. Any native thread. |
| `onComplete(success: Boolean, cancelled: Boolean, linkExpiry: Boolean, error: String, bytesWritten: Long, filesDone: Int)` | `(ZZZLjava/lang/String;JI)V` | exactly once, from the worker thread, last. `linkExpiry = true` = the run died on an HTTP 401/403/404/500, the codes the GOG secure link returns once it expired: refresh the link and call `nativeStart` again with the finished files in `skipPaths`. `error` is `""` on success. |
On-disk protocol (what a resumed run, by either engine, relies on): a file is assembled into
`<file>.bhtmp`, then renamed over `<file>` once its size and MD5 match the manifest; an existing
`<file>` whose size+MD5 match is skipped. The engine writes nothing else.
## `com.droiddeck.launcher.stores.epic.EpicNative`
Two runs make up the native half of an Epic install:
1. **Fetch** (`nativeStart`): fills the chunk cache with verified, decompressed chunks for the
files the manager says are pending. The Kotlin manager still fetches and parses the manifest
API JSON, downloads the manifest, selects files (install tags), runs the delta/verify pass and
does every post-install step. The engine re-parses the same manifest bytes (ChunksV4 binary
or legacy JSON), rebuilds the chunk plan for the pending files, skips chunks already in the
cache, and writes verified chunks with a `.part` + rename protocol, so the on-disk state is the
same whichever side fetched.
2. **Assemble** (`nativeAssemble`): writes the pending files from the cache, one sequential write
per file, SHA-1 checked as the bytes go out when the manifest hashes the file. This is the
same loop the Kotlin manager runs today (`for part in file.parts: write(chunk[offset..+size])`,
same "Missing chunk X for Y" wording); the Kotlin loop remains a valid fallback.
**The chunk cache directory** is the `chunkCacheDir` parameter of both calls (always pass the
same value to both for one install):
- `""` — today's behaviour: the cache is `<installDir>/.chunks`. The fetch leaves it in place;
`nativeAssemble` removes it whole at the end of a successful run (as the Kotlin loop's
`deleteDir(.chunks)` does), and never deletes a chunk early.
- any other absolute path — a **scratch cache**, meant for internal storage when the game
installs to an SD card (each byte then hits the card once instead of twice; the card's write
speed stops starving the fetchers). The fetch fills it exactly like `.chunks`. The assembler
reference-counts chunk GUIDs across the files it was given and **deletes each cached chunk as
soon as the last file that uses it has been written and verified**, then removes the directory
at the end of a successful run. A cancelled or failed run (fetch or assembly) leaves the cache
exactly as it stands, for resume: the next run's delta pass skips finished files and the next
fetch skips chunks still cached. The directory is created by the fetch if it does not exist;
give each install its own (e.g. `<cacheRoot>/epic/<appName>`), since a successful assembly
removes it.
| Method | JNI signature | Symbol |
|---|---|---|
| `nativeStart(...)`: Long | `([BLjava/lang/String;Ljava/lang/String;[Ljava/lang/String;[IIJLjava/lang/String;IILjava/lang/Object;)J` (last parameter is the listener) | `Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeStart` |
| `nativeAssemble(...)`: Long | `([BLjava/lang/String;Ljava/lang/String;[ILjava/lang/Object;)J` (last parameter is the listener) | `Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeAssemble` |
| `nativeCancel(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeCancel` |
| `nativeRelease(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeRelease` |
`nativeCancel` / `nativeRelease` take the handle of either run. There is no `nativeProbe`;
Bannerlator probes by catching the `Throwable` from `loadLibrary` / the first `nativeStart`.
```kotlin
@JvmStatic private external fun nativeStart(
manifest: ByteArray, installDir: String, chunkCacheDir: String, cdnPrefixes: Array<String>,
pendingFileIdx: IntArray, expectedChunks: Int, expectedBytes: Long, caBundlePath: String,
maxWorkers: Int, processWorkers: Int, listener: Listener,
): Long
@JvmStatic private external fun nativeAssemble(
manifest: ByteArray, installDir: String, chunkCacheDir: String, fileIdx: IntArray, listener: Listener,
): Long
```
### `nativeStart` parameters, in order
| # | Parameter | Meaning |
|---|---|---|
| 1 | `manifest: ByteArray` | the raw manifest bytes the manager downloaded (binary ChunksV4 or JSON) |
| 2 | `installDir: String` | absolute install directory |
| 3 | `chunkCacheDir: String` | `""` = `<installDir>/.chunks`; else the scratch cache directory (see above) |
| 4 | `cdnPrefixes: Array<String>` | the CDN base URLs from the manifest API answer (`.../ChunksV4/<n>/` style); one or many, distinct hosts widen the window |
| 5 | `pendingFileIdx: IntArray` | indices into the manifest's file list of the files still to fetch (the manager's delta/verify result) |
| 6 | `expectedChunks: Int` | the manager's own needed-chunk count for a cross-check; `-1` = no cross-check |
| 7 | `expectedBytes: Long` | the manager's `Σ max(fileSize, 1)` for a cross-check; `-1` = no cross-check |
| 8 | `caBundlePath: String` | see conventions |
| 9 | `maxWorkers: Int` | window ceiling (Bannerlator: 8) |
| 10 | `processWorkers: Int` | inflate/verify/write threads |
| 11 | `listener` | see below |
Planning (manifest parse + plan + cross-check) runs synchronously on the calling thread, so a
plan failure returns `0` BEFORE any fetch; the manager then runs its own fallback loop. On `0`,
`onComplete(false, reason, 0)` has already been called synchronously (except for a null listener).
On success, `onPlan` has been called synchronously before `nativeStart` returns, then the fetch
runs on a new thread. The engine's `plan` log line names the cache:
`plan chunk_dir=ChunksV4 cache_dir=<dir> scratch=<bool> files_pending=… chunks=… bytes=… …`.
### `nativeAssemble` parameters, in order
| # | Parameter | Meaning |
|---|---|---|
| 1 | `manifest: ByteArray` | the same manifest bytes as the fetch |
| 2 | `installDir: String` | absolute install directory (files are written at `installDir/<filename with \\ → />`) |
| 3 | `chunkCacheDir: String` | the same value the fetch was given (`""` or the scratch directory) |
| 4 | `fileIdx: IntArray` | indices into the manifest's file list of the files to write — the fetch's `pendingFileIdx`, in one call |
| 5 | `listener` | see below |
Parse + plan run on the calling thread: an unparsable manifest, an index out of range or an
empty `installDir` returns `0` after a synchronous `onComplete(false, reason, 0)` and nothing is
written. Otherwise the assembly runs on a new thread. Each file is created fresh and written in
part order; on any error (missing chunk, short chunk, I/O error, SHA-1 mismatch) the file being
written is deleted, the run fails with that error, no further files are touched and the cache is
left as it is. A manifest SHA-1 of all zeros (and a JSON manifest, which has none) means no hash
check — the Kotlin delta pass treats those the same way. The run's log lines:
`assemble cache_dir=… scratch=… files=… bytes=… chunks_referenced=…`, then `cache removed …` /
`cache not removed …: <why>` (not fatal) and `assembled files=… bytes=… chunks_removed_early=…`.
### Listener
Suggested: `com.droiddeck.launcher.stores.epic.EpicNative.Listener` (nested interface, as in
Bannerlator). One interface serves both runs.
| Listener method | JNI signature | When |
|---|---|---|
| `onPlan(chunksTotal: Int, bytesTotal: Long, chunkDir: String)` | `(IJLjava/lang/String;)V` | fetch only: once, synchronously inside `nativeStart`, before any fetch: the plan the engine derived (`chunkDir` is the manifest's, e.g. `ChunksV4`, not the cache directory) |
| `onProgress(bytesDone: Long, bytesTotal: Long, done: Int, total: Int)` | `(JJII)V` | fetch: per accounted chunk (cached-skip or fetched), `done`/`total` = chunks; assembly: after each file written, `bytesDone` = bytes written so far, `done`/`total` = files. Native threads. |
| `onLog(line: String)` | `(Ljava/lang/String;)V` | engine log line, already in logcat under `EpicNative`; any native thread (also synchronously from `nativeStart`/`nativeAssemble` for the start line) |
| `onComplete(success: Boolean, error: String, bytes: Long)` | `(ZLjava/lang/String;J)V` | terminal, exactly once (worker thread; or synchronously when the call returns `0`). Fetch: `bytes` = credited bytes (cached-skip + fetched). Assembly: `bytes` = bytes written. `error` is `"cancelled"` on cancel. |
Bannerlator's `BlEpicDownload.run(...)` wraps `nativeStart` in a blocking call that polls an
`AtomicBoolean` cancel flag every 250 ms, calls `nativeCancel` on cancel, waits up to 5 s for
`onComplete`, then `nativeRelease` in `finally`. The same wrapper shape fits `nativeAssemble`
(cancel is honoured between files; a multi-GB file finishes before the run stops).
## `com.droiddeck.launcher.stores.amazon.AmazonNative`
The file-fetch pool of an Amazon Games install: one streamed HTTP GET per manifest file, SHA-256
checked, written as `<dest>.tmp` and renamed. The manager keeps manifest/auth/markers/post-install
and hands the engine a JSON plan.
| Method | JNI signature | Symbol |
|---|---|---|
| `nativeStart(...)`: Long | `(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/Object;)J` (last parameter is the listener) | `Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart` |
| `nativeCancel(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeCancel` |
| `nativeRelease(handle: Long)` | `(J)V` | `Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeRelease` |
`nativeStart` parameters, in order:
| # | Parameter | Meaning |
|---|---|---|
| 1 | `planJson: String` | a JSON array, one object per manifest file: `{"relPath": "...", "url": "https://...", "size": <long>, "sha256hex": "..."}` (`sha256hex` may be `""` = no hash check). Every manifest file goes in: the engine applies the size-based resume-skip itself. |
| 2 | `installDir: String` | absolute install directory |
| 3 | `caBundlePath: String` | see conventions |
| 4 | `maxWorkers: Int` | window size; `<= 0` = the engine default (8, Amazon's own `MAX_PARALLEL`) |
| 5 | `processWorkers: Int` | sync verify+write threads (`<= 0` → 1) |
| 6 | `listener` | see below |
Returns `0` when the listener is null (no callback), or when `planJson` / `installDir` is empty
(`onComplete(false, "empty plan or install dir", 0)` fires synchronously first), or when the
listener `GlobalRef` cannot be taken (no callback). The engine keeps the `JavaVM` it takes from
the first `nativeStart`.
Suggested listener: `com.droiddeck.launcher.stores.amazon.AmazonNative.Listener` (an interface).
| Listener method | JNI signature | When |
|---|---|---|
| `onProgress(bytesDone: Long, bytesTotal: Long, filesDone: Long, filesTotal: Long)` | `(JJJJ)V` | once up front with the resume-skipped credit, then after each committed file (and as pieces stream in); `bytesDone` includes skipped files. Native threads. |
| `onLog(line: String)` | `(Ljava/lang/String;)V` | one diagnostic line (`engine=rust …`, `fetch-window …`, `summary …`). The Amazon engine does NOT write to logcat itself: the Kotlin side is the one place that logs these, so each line appears once. |
| `onComplete(success: Boolean, error: String, bytesWritten: Long)` | `(ZLjava/lang/String;J)V` | exactly once, when the run succeeds, fails or is cancelled (`error = "cancelled"`). |
Bannerlator's `BlAmazonDownload.runBlocking(...)` polls a cancel probe every 100 ms, calls
`nativeCancel` once, and `nativeRelease` in `finally` after `onComplete`; on thread interrupt it
cancels and waits up to 30 s for the run to wind down before releasing.
## Log tags
Lines go to logcat under `GogNative` and `EpicNative` (the Amazon engine logs only through
`onLog`). Bannerlator used `BL_GOG_DL` / `BL_EPIC_DL` / `BL_AMAZON_DL`; the line grammar
(`fetch-start`, `fetch-window`, `throughput`, `fetch-end`, `summary`) is unchanged, so its tuning
notes still read against a DroidDeck log.
+990
View File
@@ -0,0 +1,990 @@
//! Amazon Games download adapter over the shared fetch core.
//!
//! Replaces ONLY the byte-fetching pool of `AmazonDownloadManager.install()` (Step 4).
//! Java still resolves the download spec, fetches and parses `manifest.proto`, and hands
//! this module the resolved file list (one JSON entry per manifest file with the exact
//! URL the Java loop would have opened). The adapter mirrors the Java rules 1:1 where the
//! core allows it — see Bannerlator's `docs/RUST_AMAZON_PARITY.md` §4 / §9 for the table and the
//! documented deviations:
//!
//! * one `FetchItem` per WHOLE file, streamed (`FetchOptions.stream = true`): pieces are
//! appended to `<dest>.tmp` as they arrive, 8 files wide like Java's pool, memory bounded
//! to the pieces not yet written;
//! * resume-skip = `st_size == manifest size`, no hash check on skip (Java `:250`);
//! * SHA-256 is accumulated while streaming and checked in `on_finish` when the manifest
//! carries a hash; a mismatch deletes the tmp and is a retryable failure (Java `:269`);
//! * `<dest>.tmp` → delete existing dest → rename (Java `:280`); rename failure is fatal
//! without retry (Java `:281`);
//! * a piece credits its wire length to progress as it is written (Java `:347`), and pieces
//! of an attempt that later fails stay counted (Java never rolls `totalDownloaded` back).
pub mod jni;
use crate::fetch_core::{run_fetch, FetchItem, FetchOptions, FetchSink, SinkError};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Java `AmazonDownloadManager.MAX_PARALLEL`.
pub const MAX_PARALLEL: usize = 8;
/// Java `AmazonDownloadManager.DOWNLOAD_USER_AGENT`.
pub const DOWNLOAD_USER_AGENT: &str = "nile/0.1 Amazon";
/// Java `conn.setReadTimeout(120000)`; in stream mode the core applies this to the response
/// headers and then as an idle deadline per received piece — the same shape as a read timeout.
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
/// Java `new File(installDir, file.unixPath() + ".tmp")`.
pub const TMP_SUFFIX: &str = ".tmp";
/// Log label used in the core's `fetch-window` lines.
pub const LABEL: &str = "amazon";
/// One resolved manifest file as serialised by Java (`AmazonDownloadManager.buildRustPlan`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanEntry {
/// `ManifestFile.unixPath()` — relative to the install dir, forward slashes.
pub rel_path: String,
/// `AmazonApiClient.appendPath(downloadUrl, "files/" + hashHex)` — signed, whole file.
pub url: String,
/// `ManifestFile.size`.
pub size: u64,
/// SHA-256 to verify against; EMPTY = Java would not verify (`hashAlgorithm != 0` or
/// no hash bytes).
pub sha256: Vec<u8>,
}
/// Parse the JSON array `[{relPath, url, size, sha256hex}]`. `relPath` is normalised the same
/// way as `ManifestFile.unixPath()` so a caller that forgot is still byte-identical.
pub fn parse_plan(json: &str) -> Result<Vec<PlanEntry>, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|err| format!("plan json: {err}"))?;
let Some(array) = value.as_array() else {
return Err("plan json: expected an array".to_string());
};
let mut entries = Vec::with_capacity(array.len());
for (index, item) in array.iter().enumerate() {
let rel_path = item
.get("relPath")
.and_then(|v| v.as_str())
.map(unix_path)
.unwrap_or_default();
if rel_path.is_empty() {
return Err(format!("plan json: entry {index} has no relPath"));
}
let url = item
.get("url")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if url.is_empty() {
return Err(format!("plan json: entry {index} ({rel_path}) has no url"));
}
let size = item
.get("size")
.and_then(|v| v.as_u64().or_else(|| v.as_i64().map(|s| s.max(0) as u64)))
.unwrap_or(0);
let sha_hex = item
.get("sha256hex")
.and_then(|v| v.as_str())
.unwrap_or_default();
let sha256 = if sha_hex.is_empty() {
Vec::new()
} else {
hex_decode(sha_hex)
.ok_or_else(|| format!("plan json: entry {index} ({rel_path}) bad sha256hex"))?
};
entries.push(PlanEntry {
rel_path,
url,
size,
sha256,
});
}
Ok(entries)
}
/// `ManifestFile.unixPath()`.
pub fn unix_path(path: &str) -> String {
path.replace('\\', "/")
}
/// Lowercase/uppercase hex → bytes; `None` on odd length or a non-hex digit.
pub fn hex_decode(hex: &str) -> Option<Vec<u8>> {
let bytes = hex.as_bytes();
if bytes.len() % 2 != 0 {
return None;
}
let nibble = |b: u8| -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
};
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks(2) {
out.push((nibble(pair[0])? << 4) | nibble(pair[1])?);
}
Some(out)
}
/// `new File(installDir, rel)` — Java resolves the child textually under the parent, so
/// build the path by concatenation (never `Path::join`, which would let a leading `/` escape).
pub fn dest_path(install_dir: &str, rel_path: &str) -> PathBuf {
let base = install_dir.trim_end_matches('/');
PathBuf::from(format!("{base}/{rel_path}"))
}
/// `new File(installDir, rel + ".tmp")`.
pub fn tmp_path(install_dir: &str, rel_path: &str) -> PathBuf {
dest_path(install_dir, &format!("{rel_path}{TMP_SUFFIX}"))
}
/// Java `:250`: `destFile.exists() && destFile.length() == file.size`. `File.length()` is
/// `st_size`, which is what `Metadata::len()` returns — including for a directory, so the
/// (degenerate) behaviours match too. No hash is checked on skip, on purpose.
pub fn is_present_and_complete(install_dir: &str, entry: &PlanEntry) -> bool {
fs::metadata(dest_path(install_dir, &entry.rel_path))
.map(|meta| meta.len() == entry.size)
.unwrap_or(false)
}
/// `scheme://host[:port]` of a URL — the per-host cap key. Amazon serves every file from the
/// one signed CDN base, so this yields a single host.
pub fn host_key(url: &str) -> String {
let (scheme, rest) = match url.find("://") {
Some(idx) => (&url[..idx], &url[idx + 3..]),
None => ("https", url),
};
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
format!("{scheme}://{}", &rest[..end])
}
/// Improvements round 1: `max(6, ceil(max_workers / distinct_hosts))` — a single-host store
/// (Amazon's one signed a2z base) gets the whole ceiling on its one host; the core clamps the
/// window to `hosts × per_host_cap`, so anything smaller would silently cut the ceiling.
pub fn per_host_cap_for(max_workers: usize, distinct_hosts: usize) -> usize {
let hosts = distinct_hosts.max(1);
let per_host = max_workers.max(1).div_ceil(hosts);
per_host.max(crate::fetch_tuning::PER_HOST_CAP)
}
/// The resolved work for one run.
#[derive(Debug, Default)]
pub struct Plan {
/// Items to fetch (index into `entries` via `FetchItem.id`).
pub items: Vec<FetchItem>,
/// Every manifest file, in manifest order (fetched and skipped alike).
pub entries: Vec<PlanEntry>,
/// Σ size of all entries = `ParsedManifest.totalInstallSize`.
pub total_bytes: u64,
/// Bytes credited up front for files that passed the resume check.
pub skipped_bytes: u64,
/// Number of files that passed the resume check.
pub skipped_files: u64,
/// Distinct host keys in fetch order.
pub hosts: Vec<String>,
}
/// Apply the Java resume-skip rule to every entry and turn the rest into whole-file items.
pub fn build_plan(entries: Vec<PlanEntry>, install_dir: &str) -> Plan {
let mut plan = Plan {
total_bytes: entries.iter().map(|e| e.size).sum(),
..Plan::default()
};
for (index, entry) in entries.iter().enumerate() {
if is_present_and_complete(install_dir, entry) {
plan.skipped_bytes = plan.skipped_bytes.saturating_add(entry.size);
plan.skipped_files += 1;
continue;
}
let host = host_key(&entry.url);
if !plan.hosts.contains(&host) {
plan.hosts.push(host);
}
plan.items.push(FetchItem {
id: index as u64,
urls: vec![entry.url.clone()],
reserve: entry.size,
range: None,
});
}
if plan.hosts.is_empty() {
plan.hosts.push("https://amazon".to_string());
}
plan.entries = entries;
plan
}
/// One `<dest>.tmp` being streamed: the open handle, the running hash and the bytes written
/// by the current attempt.
struct OpenTmp {
file: File,
hasher: Sha256,
written: u64,
}
/// Sink: stream pieces into `<dest>.tmp` (SHA-256 accumulated on the fly), verify and rename
/// in `on_finish`. `process` (whole-body mode) does the same in one step.
pub struct AmazonSink {
install_dir: String,
entries: Vec<PlanEntry>,
cancel: Arc<AtomicBool>,
files_done: AtomicU64,
/// Per-item open tmp files. The map lock is held only to look the entry up; the write
/// itself happens under the per-item lock so 8 streams never serialise on one mutex.
open: Mutex<HashMap<u64, Arc<Mutex<OpenTmp>>>>,
}
impl AmazonSink {
pub fn new(install_dir: &str, entries: Vec<PlanEntry>, cancel: Arc<AtomicBool>) -> Self {
Self {
install_dir: install_dir.to_string(),
entries,
cancel,
files_done: AtomicU64::new(0),
open: Mutex::new(HashMap::new()),
}
}
pub fn files_done(&self) -> u64 {
self.files_done.load(Ordering::Relaxed)
}
fn entry(&self, item: &FetchItem) -> Result<&PlanEntry, SinkError> {
self.entries
.get(item.id as usize)
.ok_or_else(|| SinkError::Fatal(format!("plan index {} out of range", item.id)))
}
/// Java `:260` — `destFile.getParentFile().mkdirs()` before each attempt; a failure
/// surfaces on the create/write below, like Java's.
fn ensure_parent(&self, entry: &PlanEntry) {
if let Some(parent) = dest_path(&self.install_dir, &entry.rel_path).parent() {
let _ = fs::create_dir_all(parent);
}
}
/// Java `:280-284`: delete an existing dest, rename tmp → dest; rename failure fails the
/// file with NO retry (→ whole install fails).
fn rename_into_place(&self, entry: &PlanEntry) -> Result<(), SinkError> {
let dest = dest_path(&self.install_dir, &entry.rel_path);
let tmp = tmp_path(&self.install_dir, &entry.rel_path);
if dest.exists() {
let _ = fs::remove_file(&dest);
}
if let Err(err) = fs::rename(&tmp, &dest) {
let _ = fs::remove_file(&tmp);
return Err(SinkError::Fatal(format!(
"Failed to rename tmp → {} ({err})",
dest.display()
)));
}
self.files_done.fetch_add(1, Ordering::Relaxed);
Ok(())
}
/// Java `:267-277`: SHA-256 check when the manifest carries a hash; a mismatch deletes the
/// tmp and is retried.
fn verify(&self, entry: &PlanEntry, digest: &[u8]) -> Result<(), SinkError> {
if !entry.sha256.is_empty() && digest != entry.sha256.as_slice() {
let _ = fs::remove_file(tmp_path(&self.install_dir, &entry.rel_path));
return Err(SinkError::Retry(format!(
"SHA-256 mismatch for: {}",
entry.rel_path
)));
}
Ok(())
}
/// Whole-body path (`FetchOptions.stream = false`): verify → write tmp → rename.
pub fn commit(&self, entry: &PlanEntry, body: &[u8]) -> Result<u64, SinkError> {
if !entry.sha256.is_empty() {
let digest = Sha256::digest(body);
if digest.as_slice() != entry.sha256.as_slice() {
return Err(SinkError::Retry(format!(
"SHA-256 mismatch for: {}",
entry.rel_path
)));
}
}
self.ensure_parent(entry);
let tmp = tmp_path(&self.install_dir, &entry.rel_path);
if let Err(err) = fs::write(&tmp, body) {
let _ = fs::remove_file(&tmp);
return Err(SinkError::Retry(format!("write {}: {err}", tmp.display())));
}
self.rename_into_place(entry)?;
Ok(body.len() as u64)
}
/// After the run: close and delete every tmp that never reached `on_finish` (a stream that
/// failed after max attempts, or was cut by cancel / a fatal error). Java deletes the tmp
/// of every failed or cancelled attempt (`:271`, `:288`, `:293`, `:297`), so no `.tmp`
/// may survive a run on either engine. Returns how many were removed.
pub fn cleanup_partials(&self) -> usize {
let leftovers: Vec<(u64, Arc<Mutex<OpenTmp>>)> = match self.open.lock() {
Ok(mut open) => open.drain().collect(),
Err(_) => return 0,
};
let mut removed = 0;
for (id, open) in leftovers {
drop(open);
if let Some(entry) = self.entries.get(id as usize) {
if fs::remove_file(tmp_path(&self.install_dir, &entry.rel_path)).is_ok() {
removed += 1;
}
}
}
removed
}
}
impl FetchSink for AmazonSink {
fn process(&self, item: &FetchItem, body: Vec<u8>) -> Result<u64, SinkError> {
if self.cancel.load(Ordering::Relaxed) {
return Err(SinkError::Fatal("cancelled".to_string()));
}
let entry = self.entry(item)?;
self.commit(entry, &body)
}
fn on_chunk(&self, item: &FetchItem, offset: u64, data: &[u8]) -> Result<(), SinkError> {
if self.cancel.load(Ordering::Relaxed) {
// Java `:342-344`: cancel inside the read loop → tmp deleted (cleanup_partials).
return Err(SinkError::Fatal("cancelled".to_string()));
}
let entry = self.entry(item)?;
let tmp = tmp_path(&self.install_dir, &entry.rel_path);
let open = if offset == 0 {
// (Re)start of an attempt: Java opens a fresh FileOutputStream (truncating).
self.ensure_parent(entry);
let file = File::create(&tmp).map_err(|err| {
let _ = fs::remove_file(&tmp);
SinkError::Retry(format!("create {}: {err}", tmp.display()))
})?;
let open = Arc::new(Mutex::new(OpenTmp {
file,
hasher: Sha256::new(),
written: 0,
}));
match self.open.lock() {
Ok(mut map) => {
map.insert(item.id, Arc::clone(&open));
}
Err(_) => return Err(SinkError::Fatal("sink map poisoned".to_string())),
}
open
} else {
let found = match self.open.lock() {
Ok(map) => map.get(&item.id).cloned(),
Err(_) => return Err(SinkError::Fatal("sink map poisoned".to_string())),
};
match found {
Some(open) => open,
None => {
return Err(SinkError::Retry(format!(
"piece at offset {offset} without a stream start for: {}",
entry.rel_path
)))
}
}
};
let Ok(mut state) = open.lock() else {
return Err(SinkError::Fatal("sink item poisoned".to_string()));
};
if state.written != offset {
let _ = fs::remove_file(&tmp);
return Err(SinkError::Retry(format!(
"piece out of order ({} != {offset}) for: {}",
state.written, entry.rel_path
)));
}
if let Err(err) = state.file.write_all(data) {
// Java: IOException inside downloadFile → tmp deleted → next attempt.
let _ = fs::remove_file(&tmp);
return Err(SinkError::Retry(format!("write {}: {err}", tmp.display())));
}
state.hasher.update(data);
state.written = state.written.saturating_add(data.len() as u64);
Ok(())
}
fn on_finish(&self, item: &FetchItem, total_len: u64) -> Result<u64, SinkError> {
let entry = self.entry(item)?;
let tmp = tmp_path(&self.install_dir, &entry.rel_path);
let taken = match self.open.lock() {
Ok(mut map) => map.remove(&item.id),
Err(_) => return Err(SinkError::Fatal("sink map poisoned".to_string())),
};
let (written, digest) = match taken {
Some(open) => {
let Ok(mut state) = open.lock() else {
return Err(SinkError::Fatal("sink item poisoned".to_string()));
};
if let Err(err) = state.file.flush() {
let _ = fs::remove_file(&tmp);
return Err(SinkError::Retry(format!("flush {}: {err}", tmp.display())));
}
let written = state.written;
let digest = std::mem::replace(&mut state.hasher, Sha256::new()).finalize();
(written, digest)
}
None => {
// Zero-byte body: no piece ever arrived. Java would have created an empty tmp.
if total_len != 0 {
return Err(SinkError::Retry(format!(
"finish without a stream for: {}",
entry.rel_path
)));
}
self.ensure_parent(entry);
if let Err(err) = File::create(&tmp) {
return Err(SinkError::Retry(format!("create {}: {err}", tmp.display())));
}
(0, Sha256::new().finalize())
}
};
if written != total_len {
let _ = fs::remove_file(&tmp);
return Err(SinkError::Retry(format!(
"stream length {written} != {total_len} for: {}",
entry.rel_path
)));
}
self.verify(entry, digest.as_slice())?;
self.rename_into_place(entry)?;
Ok(0)
}
}
/// Outcome of one `run_download`.
#[derive(Clone, Debug, Default)]
pub struct RunResult {
pub success: bool,
pub cancelled: bool,
pub error: String,
/// Bytes fetched by this run (skipped files excluded; failed attempts' pieces included,
/// like Java's `totalDownloaded`).
pub bytes_written: u64,
/// Files committed by this run plus files skipped by the resume check.
pub files_done: u64,
pub files_total: u64,
}
/// Throughput bookkeeping for the final summary line (avg over the run, peak over ≥500 ms
/// progress intervals — the same sampling window the Java speed label uses).
struct SpeedMeter {
started: Instant,
last_at: Instant,
last_bytes: u64,
peak_bps: f64,
}
impl SpeedMeter {
fn new() -> Self {
let now = Instant::now();
Self {
started: now,
last_at: now,
last_bytes: 0,
peak_bps: 0.0,
}
}
fn observe(&mut self, bytes: u64) {
let now = Instant::now();
let dt = now.duration_since(self.last_at).as_secs_f64();
if dt >= 0.5 {
let bps = bytes.saturating_sub(self.last_bytes) as f64 / dt;
if bps > self.peak_bps {
self.peak_bps = bps;
}
self.last_at = now;
self.last_bytes = bytes;
}
}
fn summary(&self, bytes: u64) -> (f64, f64, f64) {
let elapsed = self.started.elapsed().as_secs_f64().max(0.001);
let avg = bytes as f64 / elapsed;
(elapsed, avg * 8.0 / 1_000_000.0, self.peak_bps * 8.0 / 1_000_000.0)
}
}
/// Blocking: plan → stream-fetch → verify/rename, calling `progress(bytes_done, bytes_total,
/// files_done, files_total)` after each written piece and each finished file (skipped files
/// are pre-credited) and `log` for every diagnostic line. `bytes_done` includes skipped bytes,
/// matching Java's `totalDownloaded` (which credits skipped files at `:251`).
pub fn run_download(
plan_json: &str,
install_dir: &str,
ca_bundle_path: &str,
max_workers: usize,
process_workers: usize,
cancel: Arc<AtomicBool>,
progress: &(dyn Fn(u64, u64, u64, u64) + Sync),
log: &(dyn Fn(&str) + Sync),
) -> RunResult {
let max_workers = if max_workers == 0 { MAX_PARALLEL } else { max_workers };
let process_workers = process_workers.max(1);
let entries = match parse_plan(plan_json) {
Ok(entries) => entries,
Err(err) => {
log(&format!("engine=rust plan error: {err}"));
return RunResult {
error: err,
..RunResult::default()
};
}
};
let plan = build_plan(entries, install_dir);
let files_total = plan.entries.len() as u64;
let fetch_bytes: u64 = plan.items.iter().map(|item| item.reserve).sum();
let per_host_cap = per_host_cap_for(max_workers, plan.hosts.len());
log(&format!(
"engine=rust mode=stream plan={} files skip={} ({} bytes) fetch={} ({} bytes) hosts={} workers={} per_host_cap={} process={} dir={} {}",
files_total,
plan.skipped_files,
plan.skipped_bytes,
plan.items.len(),
fetch_bytes,
plan.hosts.len(),
max_workers,
per_host_cap,
process_workers,
install_dir,
crate::priority::log_field()
));
progress(plan.skipped_bytes, plan.total_bytes, plan.skipped_files, files_total);
if plan.items.is_empty() {
log("summary bytes=0 elapsed=0.000 avg_mbps=0.0 peak_mbps=0.0 files=all-present");
return RunResult {
success: true,
files_done: plan.skipped_files,
files_total,
..RunResult::default()
};
}
if cancel.load(Ordering::Relaxed) {
return RunResult {
cancelled: true,
error: "cancelled".to_string(),
files_done: plan.skipped_files,
files_total,
..RunResult::default()
};
}
let opts = FetchOptions {
max_workers,
// Every Amazon file comes from the one signed CDN base: give that host the whole
// ceiling (`per_host_cap_for`), or the core would clamp the window to hosts × cap.
per_host_cap,
timeout: REQUEST_TIMEOUT,
headers: vec![("User-Agent".to_string(), DOWNLOAD_USER_AGENT.to_string())],
ca_bundle_path: ca_bundle_path.to_string(),
process_workers,
label: LABEL.to_string(),
// Whole files, streamed into the tmp as they arrive: 8 wide like Java's pool, memory
// bounded to the pieces not yet written.
stream: true,
..FetchOptions::default()
};
let sink = AmazonSink::new(install_dir, plan.entries.clone(), Arc::clone(&cancel));
let meter = Mutex::new(SpeedMeter::new());
let skipped_bytes = plan.skipped_bytes;
let skipped_files = plan.skipped_files;
let total_bytes = plan.total_bytes;
let progress_cb = |credited: u64, items_ok: u64| {
if let Ok(mut meter) = meter.lock() {
meter.observe(credited);
}
progress(
skipped_bytes.saturating_add(credited),
total_bytes,
skipped_files.saturating_add(items_ok),
files_total,
);
};
let outcome = run_fetch(
plan.items,
&plan.hosts,
&opts,
&sink,
cancel.as_ref(),
&progress_cb,
log,
);
let leftovers = sink.cleanup_partials();
let (elapsed, avg_mbps, peak_mbps) = meter
.lock()
.map(|meter| meter.summary(outcome.bytes_credited))
.unwrap_or((0.0, 0.0, 0.0));
let files_done = skipped_files.saturating_add(sink.files_done());
log(&format!(
"summary bytes={} elapsed={:.3} avg_mbps={:.1} peak_mbps={:.1} files={}/{} items_ok={} tmp_removed={} cancelled={} error={}",
outcome.bytes_credited,
elapsed,
avg_mbps,
peak_mbps,
files_done,
files_total,
outcome.items_ok,
leftovers,
outcome.cancelled,
outcome.error.as_deref().unwrap_or("")
));
let cancelled = outcome.cancelled || cancel.load(Ordering::Relaxed);
let error = if cancelled {
outcome.error.unwrap_or_else(|| "cancelled".to_string())
} else {
outcome.error.unwrap_or_default()
};
RunResult {
success: !cancelled && error.is_empty(),
cancelled,
error,
bytes_written: outcome.bytes_credited,
files_done,
files_total,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn scratch_dir() -> String {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"amazon-test-{}-{}",
std::process::id(),
n
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir.to_string_lossy().into_owned()
}
fn sha_hex(data: &[u8]) -> String {
crate::gog::to_hex(Sha256::digest(data).as_slice())
}
fn item_for(entry: &PlanEntry, id: u64) -> FetchItem {
FetchItem {
id,
urls: vec![entry.url.clone()],
reserve: entry.size,
range: None,
}
}
const PLAN: &str = r#"[
{"relPath":"Binaries\\Win64\\Game.exe","url":"https://d1.cdn.example/base/files/aa?Sig=1","size":4,"sha256hex":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},
{"relPath":"data/level1.pak","url":"https://d1.cdn.example/base/files/bb?Sig=1","size":2,"sha256hex":""},
{"relPath":"readme.txt","url":"https://d2.cdn.example/base/files/cc?Sig=1","size":0,"sha256hex":""}
]"#;
#[test]
fn parses_plan_and_normalises_paths() {
let entries = parse_plan(PLAN).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].rel_path, "Binaries/Win64/Game.exe");
assert_eq!(entries[0].size, 4);
assert_eq!(entries[0].sha256.len(), 32);
assert!(entries[1].sha256.is_empty());
assert_eq!(entries[2].size, 0);
}
#[test]
fn rejects_bad_plan() {
assert!(parse_plan("{}").is_err());
assert!(parse_plan(r#"[{"url":"x","size":1}]"#).is_err());
assert!(parse_plan(r#"[{"relPath":"a","size":1}]"#).is_err());
assert!(parse_plan(r#"[{"relPath":"a","url":"u","size":1,"sha256hex":"zz"}]"#).is_err());
}
#[test]
fn hex_roundtrip() {
assert_eq!(hex_decode("00ff10"), Some(vec![0x00, 0xff, 0x10]));
assert_eq!(hex_decode("00FF"), Some(vec![0x00, 0xff]));
assert_eq!(hex_decode("0"), None);
assert_eq!(hex_decode("0g"), None);
}
#[test]
fn per_host_cap_gives_single_host_the_whole_ceiling() {
assert_eq!(per_host_cap_for(32, 1), 32);
assert_eq!(per_host_cap_for(32, 3), 11);
assert_eq!(per_host_cap_for(8, 1), 8);
assert_eq!(per_host_cap_for(4, 1), 6);
assert_eq!(per_host_cap_for(0, 0), 6);
}
#[test]
fn host_key_strips_path_and_query() {
assert_eq!(
host_key("https://d1.cdn.example/base/files/aa?Sig=1"),
"https://d1.cdn.example"
);
assert_eq!(host_key("http://h:8080/x"), "http://h:8080");
assert_eq!(host_key("nohost"), "https://nohost");
}
#[test]
fn dest_path_never_escapes_install_dir() {
assert_eq!(
dest_path("/inst/", "a/b.txt"),
PathBuf::from("/inst/a/b.txt")
);
assert_eq!(dest_path("/inst", "/abs.txt"), PathBuf::from("/inst//abs.txt"));
assert_eq!(tmp_path("/inst", "a.bin"), PathBuf::from("/inst/a.bin.tmp"));
}
#[test]
fn build_plan_skips_only_size_matched_files() {
let dir = scratch_dir();
let entries = parse_plan(PLAN).unwrap();
// Entry 1 present with the right size → skipped (no hash check even if wrong bytes).
fs::create_dir_all(format!("{dir}/data")).unwrap();
fs::write(format!("{dir}/data/level1.pak"), b"zz").unwrap();
// Entry 0 present with the wrong size → fetched.
fs::create_dir_all(format!("{dir}/Binaries/Win64")).unwrap();
fs::write(format!("{dir}/Binaries/Win64/Game.exe"), b"tes").unwrap();
let plan = build_plan(entries, &dir);
assert_eq!(plan.total_bytes, 6);
assert_eq!(plan.skipped_files, 1);
assert_eq!(plan.skipped_bytes, 2);
assert_eq!(plan.items.len(), 2);
assert_eq!(plan.items[0].id, 0);
assert_eq!(plan.items[0].reserve, 4);
assert!(plan.items[0].range.is_none());
assert_eq!(plan.items[1].id, 2);
assert_eq!(
plan.hosts,
vec!["https://d1.cdn.example".to_string(), "https://d2.cdn.example".to_string()]
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn zero_size_missing_file_is_fetched_and_present_zero_is_skipped() {
let dir = scratch_dir();
let entry = PlanEntry {
rel_path: "empty.txt".into(),
url: "https://h/x".into(),
size: 0,
sha256: Vec::new(),
};
assert!(!is_present_and_complete(&dir, &entry));
fs::write(format!("{dir}/empty.txt"), b"").unwrap();
assert!(is_present_and_complete(&dir, &entry));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn whole_body_sink_verifies_writes_and_renames() {
let dir = scratch_dir();
let body = b"test".to_vec();
let entry = PlanEntry {
rel_path: "sub/Game.exe".into(),
url: "https://h/x".into(),
size: 4,
sha256: hex_decode(&sha_hex(&body)).unwrap(),
};
let cancel = Arc::new(AtomicBool::new(false));
let sink = AmazonSink::new(&dir, vec![entry.clone()], Arc::clone(&cancel));
let item = item_for(&entry, 0);
// Mismatch → Retry, nothing on disk.
match sink.process(&item, b"nope".to_vec()) {
Err(SinkError::Retry(msg)) => assert!(msg.contains("SHA-256 mismatch")),
Err(SinkError::Fatal(msg)) => panic!("expected Retry, got Fatal({msg})"),
Ok(_) => panic!("expected Retry, got Ok"),
}
assert!(!dest_path(&dir, "sub/Game.exe").exists());
assert!(!tmp_path(&dir, "sub/Game.exe").exists());
// Match → written, renamed, tmp gone, credited body length.
assert_eq!(sink.process(&item, body.clone()).unwrap(), 4);
assert_eq!(fs::read(dest_path(&dir, "sub/Game.exe")).unwrap(), body);
assert!(!tmp_path(&dir, "sub/Game.exe").exists());
assert_eq!(sink.files_done(), 1);
// Existing dest is replaced (Java `:280`).
assert_eq!(sink.process(&item, body.clone()).unwrap(), 4);
// Cancel → Fatal, no write.
cancel.store(true, Ordering::Relaxed);
assert!(matches!(
sink.process(&item, body),
Err(SinkError::Fatal(_))
));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn stream_sink_appends_verifies_and_renames() {
let dir = scratch_dir();
let body = b"hello world".to_vec();
let entry = PlanEntry {
rel_path: "sub/big.bin".into(),
url: "https://h/x".into(),
size: body.len() as u64,
sha256: hex_decode(&sha_hex(&body)).unwrap(),
};
let sink = AmazonSink::new(&dir, vec![entry.clone()], Arc::new(AtomicBool::new(false)));
let item = item_for(&entry, 0);
sink.on_chunk(&item, 0, b"hello").unwrap();
assert!(tmp_path(&dir, "sub/big.bin").exists());
sink.on_chunk(&item, 5, b" wor").unwrap();
sink.on_chunk(&item, 9, b"ld").unwrap();
assert_eq!(sink.on_finish(&item, 11).unwrap(), 0);
assert_eq!(fs::read(dest_path(&dir, "sub/big.bin")).unwrap(), body);
assert!(!tmp_path(&dir, "sub/big.bin").exists());
assert_eq!(sink.files_done(), 1);
assert_eq!(sink.cleanup_partials(), 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn stream_sink_restart_truncates_and_mismatch_retries() {
let dir = scratch_dir();
let body = b"abcdef".to_vec();
let entry = PlanEntry {
rel_path: "f.bin".into(),
url: "https://h/x".into(),
size: 6,
sha256: hex_decode(&sha_hex(&body)).unwrap(),
};
let sink = AmazonSink::new(&dir, vec![entry.clone()], Arc::new(AtomicBool::new(false)));
let item = item_for(&entry, 0);
// Attempt 1 delivers garbage, then the core restarts at offset 0.
sink.on_chunk(&item, 0, b"zzzz").unwrap();
sink.on_chunk(&item, 0, b"abc").unwrap();
sink.on_chunk(&item, 3, b"xyz").unwrap();
match sink.on_finish(&item, 6) {
Err(SinkError::Retry(msg)) => assert!(msg.contains("SHA-256 mismatch")),
Err(SinkError::Fatal(msg)) => panic!("expected Retry, got Fatal({msg})"),
Ok(_) => panic!("expected Retry, got Ok"),
}
assert!(!tmp_path(&dir, "f.bin").exists());
assert!(!dest_path(&dir, "f.bin").exists());
// Out-of-order piece → Retry.
sink.on_chunk(&item, 0, b"abc").unwrap();
assert!(matches!(
sink.on_chunk(&item, 5, b"f"),
Err(SinkError::Retry(_))
));
// Length mismatch at finish → Retry.
sink.on_chunk(&item, 0, b"abc").unwrap();
assert!(matches!(sink.on_finish(&item, 6), Err(SinkError::Retry(_))));
// Piece without a start → Retry.
assert!(matches!(
sink.on_chunk(&item, 3, b"def"),
Err(SinkError::Retry(_))
));
// Good attempt.
sink.on_chunk(&item, 0, b"abc").unwrap();
sink.on_chunk(&item, 3, b"def").unwrap();
assert_eq!(sink.on_finish(&item, 6).unwrap(), 0);
assert_eq!(fs::read(dest_path(&dir, "f.bin")).unwrap(), body);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn stream_sink_cleanup_removes_unfinished_tmp_and_zero_len_finishes() {
let dir = scratch_dir();
let big = PlanEntry {
rel_path: "cut.bin".into(),
url: "https://h/x".into(),
size: 10,
sha256: Vec::new(),
};
let empty = PlanEntry {
rel_path: "empty.bin".into(),
url: "https://h/y".into(),
size: 0,
sha256: Vec::new(),
};
let cancel = Arc::new(AtomicBool::new(false));
let sink = AmazonSink::new(&dir, vec![big.clone(), empty.clone()], Arc::clone(&cancel));
sink.on_chunk(&item_for(&big, 0), 0, b"12345").unwrap();
assert!(tmp_path(&dir, "cut.bin").exists());
// Zero-byte body: on_finish with no pieces creates and commits an empty file.
assert_eq!(sink.on_finish(&item_for(&empty, 1), 0).unwrap(), 0);
assert_eq!(fs::read(dest_path(&dir, "empty.bin")).unwrap(), b"");
// Cancel mid-stream → Fatal; cleanup deletes the partial tmp like Java does.
cancel.store(true, Ordering::Relaxed);
assert!(matches!(
sink.on_chunk(&item_for(&big, 0), 5, b"6"),
Err(SinkError::Fatal(_))
));
assert_eq!(sink.cleanup_partials(), 1);
assert!(!tmp_path(&dir, "cut.bin").exists());
assert!(!dest_path(&dir, "cut.bin").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn stream_sink_skips_hash_when_manifest_has_none() {
let dir = scratch_dir();
let entry = PlanEntry {
rel_path: "a.bin".into(),
url: "https://h/x".into(),
size: 3,
sha256: Vec::new(),
};
let sink = AmazonSink::new(&dir, vec![entry.clone()], Arc::new(AtomicBool::new(false)));
let item = item_for(&entry, 0);
sink.on_chunk(&item, 0, b"abc").unwrap();
assert_eq!(sink.on_finish(&item, 3).unwrap(), 0);
assert_eq!(fs::read(dest_path(&dir, "a.bin")).unwrap(), b"abc");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn run_download_all_present_short_circuits() {
let dir = scratch_dir();
fs::write(format!("{dir}/a.bin"), b"abc").unwrap();
let plan = r#"[{"relPath":"a.bin","url":"https://h/x","size":3,"sha256hex":""}]"#;
let seen = Mutex::new(Vec::new());
let logs = Mutex::new(Vec::new());
let result = run_download(
plan,
&dir,
"",
0,
1,
Arc::new(AtomicBool::new(false)),
&|done, total, fd, ft| seen.lock().unwrap().push((done, total, fd, ft)),
&|line| logs.lock().unwrap().push(line.to_string()),
);
assert!(result.success);
assert_eq!(result.files_done, 1);
assert_eq!(result.files_total, 1);
assert_eq!(seen.lock().unwrap().as_slice(), &[(3, 3, 1, 1)]);
assert!(logs.lock().unwrap()[0].starts_with("engine=rust mode=stream"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn run_download_reports_plan_error() {
let result = run_download(
"not json",
"/nonexistent",
"",
8,
2,
Arc::new(AtomicBool::new(false)),
&|_, _, _, _| {},
&|_| {},
);
assert!(!result.success);
assert!(result.error.contains("plan json"));
}
}
+229
View File
@@ -0,0 +1,229 @@
//! JNI facade for the Amazon download adapter.
//!
//! Kotlin side: `com.droiddeck.launcher.stores.amazon.AmazonNative` (`nativeStart` /
//! `nativeCancel` / `nativeRelease`) with an `AmazonNative.Listener`
//! (`onProgress(JJJJ)V`, `onLog(String)V`, `onComplete(ZString;J)V`). Mirrors the shape of
//! the GOG and Epic facades: the run happens on a spawned native
//! thread, callbacks attach the JavaVM as daemon threads and hold a `GlobalRef` to the
//! listener; cancel flips an `AtomicBool`. This module keeps its own `JavaVM` handle (taken
//! from the calling `JNIEnv` on first use) so it depends on nothing outside this module.
use crate::amazon::{run_download, MAX_PARALLEL};
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::sys::{jint, jlong, JNI_FALSE, JNI_TRUE};
use jni::{JNIEnv, JavaVM};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread;
/// Logcat tag (`adb logcat -s AmazonNative`). Every engine line reaches logcat exactly ONCE:
/// this module only forwards lines to the listener (`onLog`), and the Java manager is the one
/// place that writes them to `android.util.Log` + its debug file. (Round 1 fix: the native side
/// used to also call `__android_log_write`, so each line appeared twice.)
pub const LOG_TAG: &str = "AmazonNative";
static JVM: OnceLock<JavaVM> = OnceLock::new();
fn clear_pending_exception(env: &mut JNIEnv) {
if env.exception_check().unwrap_or(false) {
let _ = env.exception_clear();
}
}
fn jstring_to_string(env: &mut JNIEnv, value: &JString) -> Option<String> {
env.get_string(value)
.ok()
.map(|s| s.to_string_lossy().into_owned())
}
/// One live run; boxed and handed to Kotlin as an opaque `jlong`.
struct AmazonRun {
cancel: Arc<AtomicBool>,
}
fn to_handle(run: Box<AmazonRun>) -> jlong {
Box::into_raw(run) as jlong
}
unsafe fn from_handle<'a>(handle: jlong) -> Option<&'a AmazonRun> {
if handle == 0 {
None
} else {
Some(&*(handle as *const AmazonRun))
}
}
fn call_log(env: &mut JNIEnv, listener: &JObject, line: &str) {
let Ok(text) = env.new_string(line) else {
clear_pending_exception(env);
return;
};
let text_obj = JObject::from(text);
let _ = env.call_method(
listener,
"onLog",
"(Ljava/lang/String;)V",
&[JValue::Object(&text_obj)],
);
clear_pending_exception(env);
}
fn call_progress(
env: &mut JNIEnv,
listener: &JObject,
bytes_done: u64,
bytes_total: u64,
files_done: u64,
files_total: u64,
) {
let _ = env.call_method(
listener,
"onProgress",
"(JJJJ)V",
&[
JValue::Long(bytes_done as jlong),
JValue::Long(bytes_total as jlong),
JValue::Long(files_done as jlong),
JValue::Long(files_total as jlong),
],
);
clear_pending_exception(env);
}
fn call_complete(
env: &mut JNIEnv,
listener: &JObject,
success: bool,
error: &str,
bytes_written: u64,
) {
let Ok(error) = env.new_string(error) else {
clear_pending_exception(env);
return;
};
let error_obj = JObject::from(error);
let _ = env.call_method(
listener,
"onComplete",
"(ZLjava/lang/String;J)V",
&[
JValue::Bool(if success { JNI_TRUE } else { JNI_FALSE }),
JValue::Object(&error_obj),
JValue::Long(bytes_written as jlong),
],
);
clear_pending_exception(env);
}
/// Attach the current (native) thread and run `f` with the listener object.
fn with_attached_env(listener: &GlobalRef, f: impl FnOnce(&mut JNIEnv, &JObject)) {
let Some(vm) = JVM.get() else {
return;
};
let Ok(mut env) = vm.attach_current_thread_as_daemon() else {
return;
};
f(&mut env, listener.as_obj());
}
/// `AmazonNative.nativeStart(planJson, installDir, caBundlePath, maxWorkers,
/// processWorkers, listener) -> handle`. Returns 0 (and fires `onComplete(false, …)`) when the
/// inputs are unusable. The run itself happens on a spawned thread.
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart(
mut env: JNIEnv,
_class: JClass,
plan_json: JString,
install_dir: JString,
ca_bundle_path: JString,
max_workers: jint,
process_workers: jint,
listener: JObject,
) -> jlong {
if listener.is_null() {
return 0;
}
if JVM.get().is_none() {
if let Ok(vm) = env.get_java_vm() {
let _ = JVM.set(vm);
}
}
let plan_json = jstring_to_string(&mut env, &plan_json).unwrap_or_default();
let install_dir = jstring_to_string(&mut env, &install_dir).unwrap_or_default();
let ca_bundle_path = jstring_to_string(&mut env, &ca_bundle_path).unwrap_or_default();
if plan_json.is_empty() || install_dir.is_empty() {
call_complete(&mut env, &listener, false, "empty plan or install dir", 0);
return 0;
}
let Ok(listener) = env.new_global_ref(&listener) else {
clear_pending_exception(&mut env);
return 0;
};
let max_workers = if max_workers <= 0 {
MAX_PARALLEL
} else {
max_workers as usize
};
let process_workers = process_workers.max(1) as usize;
let cancel = Arc::new(AtomicBool::new(false));
let run = Box::new(AmazonRun {
cancel: Arc::clone(&cancel),
});
thread::spawn(move || {
crate::priority::background();
let log_listener = listener.clone();
let log = move |line: &str| {
with_attached_env(&log_listener, |env, obj| call_log(env, obj, line));
};
let progress_listener = listener.clone();
let progress = move |bytes_done: u64, bytes_total: u64, files_done: u64, files_total: u64| {
with_attached_env(&progress_listener, |env, obj| {
call_progress(env, obj, bytes_done, bytes_total, files_done, files_total)
});
};
let result = run_download(
&plan_json,
&install_dir,
&ca_bundle_path,
max_workers,
process_workers,
cancel,
&progress,
&log,
);
with_attached_env(&listener, |env, obj| {
call_complete(env, obj, result.success, &result.error, result.bytes_written)
});
});
to_handle(run)
}
/// `AmazonNative.nativeCancel(handle)` — flips the run's cancel flag; the core stops
/// issuing requests, in-flight bodies are dropped, `onComplete(false, "cancelled", …)` follows.
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeCancel(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if let Some(run) = unsafe { from_handle(handle) } {
run.cancel.store(true, Ordering::Relaxed);
}
}
/// `AmazonNative.nativeRelease(handle)` — frees the handle. Safe at any time: the worker
/// thread owns its own `Arc` of the cancel flag and the listener `GlobalRef`.
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeRelease(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle != 0 {
unsafe {
drop(Box::from_raw(handle as *mut AmazonRun));
}
}
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more