Both store modules are rewritten from a protocol-level spec: new Rust engines behind a small JNI surface (nativeCall/nativeInstall) and thin Kotlin facades, with the launcher's own user agent. Epic: sign-in carries over, library, manifest download with byte-exact verification, assembly, uninstall, launch arguments, achievements. Amazon: sign-in carries over, library, manifest download with per-file hash checks, path checks, executable detection from fuel.json, uninstall. On-disk install layout is unchanged, so existing installs keep working. Store game pages now take controller focus on their lead hero action (Install, Uninstall, Pause, Resume, Get, Buy), fall back to Back, and keep D-pad focus inside the page while it covers the library.
12 KiB
Amazon Games module: clean-room rewrite
The Amazon Games integration (the Rust amazon module in libdroiddeckstores.so and the Kotlin
package com.droiddeck.launcher.stores.amazon) was rewritten so that it carries no code from the
earlier implementation. This page records how that was done and every judgement call the new code
makes.
Process
- Spec, written separately. A functional specification (six documents: summary and unknowns, service protocol, manifest format, install behaviour, on-disk compatibility, and the app-facing interface) was written from the old code by a separate pass. It describes behaviour and data formats only, not code structure.
- Implementation from the spec only. The implementer worked from those documents and from
code that is not Amazon-specific:
- read: the spec; the crate's
Cargo.toml,Cargo.lock,lib.rs,stores_jni.rs,priority.rs,fetch_tuning.rsand the sharedfetch_core.rs; the clean-room Epic module (Rust and Kotlin) as the structural template;JNI.md; the shared Kotlin store code (account store, install records, download queue, login activity, catalog and state); the signatures and call sites ofStoreSizes' Amazon branch (to keep them compiling); build files and CI; - not read: anything under
stores/amazon/(Kotlin, Java, tests) orsrc/amazon*(Rust) of the old tree, other branches or checkouts, or any Amazon client source. The old files were deleted withgit rmwithout being opened.
- read: the spec; the crate's
- Fresh code. Every file has its own structure, names and comments, with a plain description
and
SPDX-License-Identifier: GPL-3.0-or-later(the crate's licence). Where the spec left a fact open, the choice was made from general knowledge of Amazon's public services or from unauthenticated probes of the live endpoints (below), and is listed here.
Layout
Rust, app/src/main/rust/stores/src/amazon/:
| File | Role |
|---|---|
net.rs |
blocking HTTP (reqwest + rustls), the three User-Agent identities, hex / percent / base64url helpers, URL scrubbing for logs |
auth.rs |
per-attempt device serial, client id, PKCE verifier and challenge; sign-in URL; code from the return URL; registration, refresh, deregistration |
distribution.rs |
the distribution service call shape (X-Amz-Target, x-amzn-token, Content-Encoding: amz-1.0), refusal detection, the download lookup |
library.rs |
entitlement pages, field fallbacks, de-duplication, add-on filter, sort |
cdn.rs |
path insertion before the signed query, file URLs, the manifest GET |
codec.rs |
manifest framing (big-endian header length) and xz / LZMA-alone decompression (lzma-rs) |
proto.rs |
a strict protobuf wire reader (and test writers) |
manifest.rs |
packages → files, path normalisation and safety, duplicate handling |
install/ |
mod.rs (the run), check.rs (completeness, stray .tmp, free space), writer.rs (streamed download, SHA-256, atomic placement), tests.rs (loopback end-to-end) |
launch.rs |
fuel.json, exe scoring, the FuelPump environment |
service.rs, ffi.rs |
named operations and the JNI boundary |
Kotlin, app/src/main/java/com/droiddeck/launcher/stores/amazon/: AmazonBackend (the
StoreBackend, installs the login flow), AmazonLogin (the WebView flow), AmazonCredentialStore
(the account through StoreAccounts, single-flight refresh), AmazonPrefs, AmazonLibrary (sync,
cache, posters, publishing), AmazonInstaller (the queue job), AmazonNative (JNI), and two thin
wrappers kept for StoreSizes: AmazonApiClient (getGameDownload, getBytes, appendPath) and
AmazonManifest (parse(bytes).totalInstallSize).
The JNI surface (nativeConfigure, nativeCall, nativeFetch, nativeInspect, nativeInstall,
nativeCancel, nativeRelease) is documented in app/src/main/rust/stores/JNI.md. The old
nativeStart symbol is gone. The Java xz library stays in app/build.gradle: commons-compress
needs it for the .tar.xz Windows components, so only Amazon stopped using it.
Compatibility kept exactly
- Credentials (
stores/amazon/credentials.jsonthroughStoreAccounts): the same keys (access_token,refresh_token,device_serial,client_id,expires_at,display_name). A refresh mergesaccess_token,device_serial,client_id,expires_atinto the stored record and keeps every other key; a new sign-in writes the whole record. Registration and refresh keep the launcher's device constants, so existing refresh tokens keep working. - Preferences
stores.amazon:library_cache(same element keys),library_synced_at,vcover_<id>,size_<id>,version_<id>; sign-out removes only the first two. - Install folders, the sidecar keys (
extra.entitlementId,extra.sku, nocloudkey), the launcher name.droiddeck-launch.bat, the.tmpnaming and the two marker files. - The catalog item's
extra(entitlementId,sku,version="") so the shared size cache and the saved queue keep working.
User-Agents and what the probes showed
Unauthenticated probes of the live services (2026-10-14):
- Distribution service (
gaming.amazon.com):GetEntitlements,GetGameDownloadandGetLiveVersionIdsanswered identically forAGSLauncher/1.0.0,DroidDeck/…, a curl UA, with and without theUserAgentheader. A bad token gives HTTP 401<NotAuthorizedException/>; no token gives HTTP 400 withcom.amazon.coral.service#NotAuthorizedException. The probes cannot show whether the UA matters for an authorised call, so the launcher'sUser-AgentandUserAgentvalues are kept (Amazon's own client identifiers, one constant each innet.rs). - Identity API (
api.amazon.com): register, token and deregister gave the same validation errors whatever the UA (Firefox,DroidDeck/…, none). The desktop Firefox string is kept for these calls, the same as the WebView: it is a generic browser label, not a client name, and existing refresh tokens are known to work with it. - Sign-in page (
www.amazon.com/ap/signin): every UA got Amazon's bot check from the probe host, so nothing could be learned; the WebView keeps the desktop Firefox string (a sign-in page that misbehaves would lock every user out). - CDN: no URL is reachable without a token. Manifest and file requests now send
DroidDeck/<versionName>, the only third-party label the old integration sent; this needs the device test below.
Refusal detection follows the probes: 401, 403, and 400 whose body names NotAuthorizedException
mean "sign in again".
Judgement calls
Recommended fixes from the spec, adopted:
- Complete = size and SHA-256. One test for the space check and the download plan; a same-size corrupt file is repaired. Files without a SHA-256 (another algorithm) are judged by size. No cross-run "verified" cache: every present file is hashed on each run.
- A failed library page fails the sync and keeps the old cache; a refused token is reported with the shared "session expired: sign in again" line, also when a cache is shown.
- Single-flight refresh (
AmazonCredentialStore); a forced refresh after a failed library call skips the refresh when another caller already replaced the token. - Mark updating (
beforeReplace→StoreInstalls.markUpdating) once, before the first file is put in place (new or replacing; the shared step only acts on an installed record). - Manifest path safety: absolute paths, any
:(drive letters and stream names; Windows file names cannot contain it), NUL,./..and empty segments, and DroidDeck's own root files (.droiddeck-store.jsonand its.tmp,.droiddeck-launch.bat, the two markers, compared ignoring case) fail the install naming the path. A manifest that lists bothXandX.tmpis refused (the second would collide with the first's temporary file). The protobuf reader rejects truncation, over-long varints, groups and unknown wire types, field number 0, wrong wire types on the fields used, and a non-empty file without a hash or a SHA-256 that is not 32 bytes. - Exe scoring on relative paths. The Unreal-folder rule is tested against
"/" + relative pathso a top-levelBinaries/Win64/…still matches. The "first five letters" rule compares the title with the file name without.exe(the extension's letters would otherwise count). An empty title never matches the "path contains the title" rule. Ties: larger file, then the earlier path. Only regular files are candidates; symlinks are not followed; depth is capped at 16. - CDN User-Agent is
DroidDeck/<versionName>(see above). - Empty files whose hash is absent or the SHA-256 of empty input are created locally; stray
.tmpsiblings of manifest files are removed at run start (other.tmpfiles are untouched). - Markers
.amazon_download_in_progress/.amazon_download_completeare still written. - Duplicate paths: the later entry wins with a log warning; files sharing one hash are fetched once per path (no copy optimisation).
Other choices:
- Install size is the sum over distinct paths (equal to the spec's "duplicates included" sum
for every manifest without duplicate paths, which none have been seen to have); it is the
progress total, the disk size and
size_<id>. - Free space is measured in Rust (
statvfson the folder or its nearest existing parent), which is what the shared probe measures; unknown never blocks, a measured zero does. - Pre-download check is reported in the queue's Manifest stage ("Checking files…" with byte progress), like GOG and Epic: the Downloads UI orders stages Manifest → Download → Verify → Install, so a Verify stage before Download would read backwards.
- Concurrency: one streamed GET per file through the shared fetch core in stream mode (the
speed tier's window clamped to 1–128, per-host cap
max(6, ceil(window / hosts)), 120 s to the response headers, the core's 30 s idle cap on the body, its five attempts with back-off and its stall and 429 handling). A piece at offset 0 restarts the file (a retry); an out-of-order piece, a body longer than the manifest size, a short body or a hash mismatch deletes the.tmpand is retried; a write error, a refusedbeforeReplaceor a failed rename fails the run. - Decompression uses the
lzma-rscrate (pure Rust; xz with any check type and LZMA-alone), with a 256 MiB cap on the decompressed manifest. - Live version lookup (
GetLiveVersionIds) is not implemented; nothing used it. - DLC filter: add-ons are hidden only when they also name a parent product (as the spec's sync contract says), unless that would hide everything; the Rust call returns the filtered, sorted list plus the unfiltered count.
- Sign-in code: the authorization code is read from the return URL's query (
%XXdecoded,+kept literally; codes are URL-safe). The WebView flow passes the URL to Rust and never logs it. - Randomness for the serial and verifier comes from
/dev/urandom. fuel.jsonis found by exact name, else a case-insensitive match at the folder root; a UTF-8 BOM is accepted.Main.Commandis accepted after stripping leading/and./, and must be a safe relative path naming a regular file.- Logs: the Rust side strips the query string and fragment of every URL in a line before it
reaches
onLog; the shared redaction runs after that as before. - No engine, no store: without the native library, sign-in, sync and install report "Amazon Games: the store engine is not available"; there is no Java fallback.
What needs device testing
- An existing signed-in user stays signed in after the update: the library shows from cache at once, and a forced sync refreshes the token with the stored refresh token and lists the games.
- A fresh sign-in through the WebView (registration, display name), and sign-out (deregistration fired, library cache cleared).
- A library with many pages; add-ons hidden; posters.
- Install, pause, resume, cancel (folder removed); repair of an installed game (nothing downloaded when intact; a corrupted file fetched again; "Resume install" offered when a repair is interrupted); an SD-card install; out-of-space refusal.
- Every endpoint with the chosen User-Agents, the CDN with
DroidDeck/…in particular. - Launch of an existing install through its existing Steam shortcut (same shortcut, same prefix)
and of a fresh install (exe choice,
fuel.jsonarguments, the five FuelPump variables in.droiddeck-launch.bat); uninstall.