mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-17 04:37:03 -04:00
Compare commits
74
Commits
main
...
feat/stores
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df88872c98 | ||
|
|
5109fa4839 | ||
|
|
3c64c6b3b8 | ||
|
|
f5c343bde0 | ||
|
|
6626cf99b7 | ||
|
|
1d18b43bc2 | ||
|
|
62502877fc | ||
|
|
d62b96ac15 | ||
|
|
fd11ed82c4 | ||
|
|
39ff0a653d | ||
|
|
6a175f1097 | ||
|
|
cc981aa69f | ||
|
|
41422407c4 | ||
|
|
b323a0b33e | ||
|
|
0849a7c18e | ||
|
|
889e783520 | ||
|
|
90a8a526d5 | ||
|
|
1580e3ee64 | ||
|
|
935b70c585 | ||
|
|
bf811040ae | ||
|
|
272cd60b19 | ||
|
|
a844483080 | ||
|
|
1d1de10b2d | ||
|
|
7ab0c12c97 | ||
|
|
cd85eb1624 | ||
|
|
dd2b2b08c3 | ||
|
|
97a0f1553f | ||
|
|
78e4cb8369 | ||
|
|
64d5f92391 | ||
|
|
3dbc4ecc6d | ||
|
|
2f1e0a1348 | ||
|
|
ff9807ad29 | ||
|
|
37b6f336a7 | ||
|
|
1a527e8ed1 | ||
|
|
1379396b99 | ||
|
|
e835f5c523 | ||
|
|
4748a217b3 | ||
|
|
96d8678dd3 | ||
|
|
e0b0af4290 | ||
|
|
8940b0ca32 | ||
|
|
cb2f7c0109 | ||
|
|
a5f608b9d7 | ||
|
|
236eeddeec | ||
|
|
3a90ac0665 | ||
|
|
dd4c4a294c | ||
|
|
a0bd480576 | ||
|
|
01c7248965 | ||
|
|
b6903db58c | ||
|
|
33caa8a57c | ||
|
|
fbd068c6b8 | ||
|
|
35dfd99013 | ||
|
|
6ca3052eb7 | ||
|
|
2d70341214 | ||
|
|
5a1e5deba9 | ||
|
|
a2433f1965 | ||
|
|
f9db495e3f | ||
|
|
fdb9cbb7a5 | ||
|
|
79e1d30d6c | ||
|
|
a4841dff9d | ||
|
|
21aeab0126 | ||
|
|
fd0c6f124f | ||
|
|
9f5a2f39cd | ||
|
|
61a9a48e70 | ||
|
|
9c0f87a4c8 | ||
|
|
b5ae6cc63f | ||
|
|
4bcfca58e2 | ||
|
|
0c59f551af | ||
|
|
0687e3477e | ||
|
|
f467bb22e8 | ||
|
|
dad1aa2dc7 | ||
|
|
9f71b427bf | ||
|
|
0091b751f4 | ||
|
|
05097b0eea | ||
|
|
401a650b5b |
No files matched your search
@@ -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'
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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¤cyCode=${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¤cyCode=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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Generated
+1729
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user