diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7fa9d195a..ec5cf1f7d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -163,6 +163,11 @@ + + 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 +716,11 @@ 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) }, + onGameStoresAddToSteam = { on -> SessionPrefs.setGameStoresAddToSteam(this, on); gameStoresAddToSteam = on }, + onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier }, + onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab }, + onLibraryChanged = { refreshAddedGames(); refresh() }, onAnimationsEnabled = { on -> SessionPrefs.setAnimationsEnabled(this, on) animationsEnabled = on @@ -903,6 +922,8 @@ 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. + if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this) 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 -> diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 4b9e5875e..446ef5724 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -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, 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 } @@ -69,6 +79,10 @@ object AddedGames { 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 +113,13 @@ 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 //, with a sidecar. + for (folder in StoreInstallRoot.gameFolders(context)) scanGame(context, folder, out) return out.distinctBy { it.folder.canonicalPath } } @@ -125,13 +143,20 @@ object AddedGames { private fun scanGame(context: Context, folder: File, out: MutableList) { run { + val sidecar = StoreGameSidecar.read(folder) + // A store install the user keeps out of Steam: not a shortcut, so not listed here. + if (sidecar != null && !sidecar.addToSteam) return 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 +168,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)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 82f7065c7..56e115914 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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) { @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt index 2add24685..cd946724a 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 87cd57b44..0d1c63992 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -53,6 +53,45 @@ 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() + } + + /** Whether a game a store installs is added to the Steam client's library as a shortcut. On by default. */ + fun gameStoresAddToSteam(context: Context): Boolean = prefs(context).getBoolean("gameStoresAddToSteam", true) + + fun setGameStoresAddToSteam(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("gameStoresAddToSteam", 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() + } + + const val STORES_OPEN_LIBRARY = "library" + const val STORES_OPEN_STORE = "store" + + /** 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 diff --git a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt new file mode 100644 index 000000000..475af804e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt @@ -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) { + 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() } } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt new file mode 100644 index 000000000..9ff97654b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt @@ -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 } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt new file mode 100644 index 000000000..1ea844095 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt @@ -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//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 } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt new file mode 100644 index 000000000..8df9c7117 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -0,0 +1,67 @@ +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 " 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 = emptyMap(), +) { + 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 = emptyList(), + val deals: List = emptyList(), + val free: List = emptyList(), + val trending: List = emptyList(), +) { + val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty() + val all: List get() = (whatsNew + deals + free + trending).distinctBy { it.id } +} + +/** A game a store installed, as found on disk through its sidecar. */ +class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) { + val key: String get() = "${sidecar.store.id}:${sidecar.id}" +} + +/** 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 > 0L -> "%.0f KB".format(bytes / 1024.0) + else -> "" +} + +fun formatSpeed(bytesPerSec: Long): String = if (bytesPerSec <= 0L) "" else "${formatBytes(bytesPerSec)}/s" diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt new file mode 100644 index 000000000..c6c7766f9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt @@ -0,0 +1,115 @@ +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. [addToSteam] off keeps + * the game out of the client's library (Setup › Stores, or the game page's "Add to Steam"). + * + * Paths are relative to the game folder with forward slashes, so the folder can move between + * storage volumes and stay valid. + */ +class StoreGameSidecar( + val store: Store, + val id: String, + val title: String, + val exe: String, + val launcher: String? = null, + val args: List = emptyList(), + val env: Map = emptyMap(), + val installVersion: String = "", + val installedAt: Long = 0L, + val addToSteam: Boolean = true, + val cover: String? = null, + val hero: String? = null, + /** Per-store identifiers the launcher needs again (Epic namespace / catalog id, Amazon entitlement). */ + val extra: Map = emptyMap(), +) { + 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 = this.args, + env: Map = this.env, installVersion: String = this.installVersion, + installedAt: Long = this.installedAt, addToSteam: Boolean = this.addToSteam, + cover: String? = this.cover, hero: String? = this.hero, extra: Map = this.extra, + ) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, addToSteam, cover, hero, extra) + + fun toJson(): JSONObject = JSONObject().apply { + put("version", VERSION) + put("store", store.id) + put("id", id) + put("title", title) + put("exe", exe) + 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) + put("addToSteam", addToSteam) + 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 + + fun file(folder: File): File = File(folder, FILE_NAME) + + /** 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('\\', '/').trimStart('/') + if (id.isEmpty() || title.isEmpty() || exe.isEmpty()) return null + // A relative path only: a sidecar that points outside its folder is not trusted. + if (!relativeInside(exe)) return null + val launcher = o.optString("launcher", "").replace('\\', '/').trimStart('/').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() + o.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.optString(k) } } + val extra = LinkedHashMap() + 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), + addToSteam = o.optBoolean("addToSteam", true), + cover = o.optString("cover", "").ifEmpty { null }, + hero = o.optString("hero", "").ifEmpty { null }, + extra = extra, + ) + } + + private fun relativeInside(path: String): Boolean = + path.isNotEmpty() && !path.startsWith("/") && !path.contains(":") && path.split('/').none { it == ".." } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt new file mode 100644 index 000000000..4c83872b8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.stores + +import android.content.Context +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.GameStorage +import java.io.File + +/** + * Where store games are installed: `/Games//`. + * + * The Games storage is the second Steam library when one is chosen (an SD card's app folder, + * bound into the session at /mnt/droiddeck-sd), else the runtime's own tree, where the folder + * sits at /root/Games/Stores - beside the user's added folders, which are bound under /root/Games + * one by one. Both roots are always scanned, so a game installed on the card before the setting + * changed stays in the library; new installs go to [installRoot]. + */ +object StoreInstallRoot { + private const val INTERNAL = "root/Games/Stores" + private const val ON_LIBRARY = "Games" + + /** The folder a new install goes under (before the store's own folder). */ + fun installRoot(context: Context): File = + GameStorage.effective(context)?.let { File(it.path, ON_LIBRARY) } ?: File(LinuxRuntime.rootDir(context), INTERNAL) + + /** Every root that may hold store games, the current install root first. */ + fun roots(context: Context): List<File> { + val internal = File(LinuxRuntime.rootDir(context), INTERNAL) + val library = GameStorage.effective(context)?.let { File(it.path, ON_LIBRARY) } + return listOfNotNull(library, internal).distinctBy { it.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; an existing folder with this game's sidecar wins over a fresh name. */ + fun folderFor(context: Context, store: Store, id: String, title: String): File { + for (root in roots(context)) { + val dir = storeDir(root, store) + dir.listFiles { f -> f.isDirectory }?.forEach { folder -> + val sidecar = StoreGameSidecar.read(folder) + if (sidecar != null && sidecar.store == store && sidecar.id == id) return folder + } + } + return File(storeDir(installRoot(context), store), folderName(title, id)) + } + + /** + * 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" } } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt new file mode 100644 index 000000000..ef405f57e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -0,0 +1,77 @@ +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 com.droiddeck.launcher.session.SessionPrefs +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" + + /** + * Finishes an install: writes the sidecar (and the launcher), then registers the game. Runs on + * the caller's worker thread. + */ + fun complete(context: Context, folder: File, sidecar: StoreGameSidecar) { + val app = context.applicationContext + val withLauncher = StoreLaunch.writeLauncher(folder, sidecar) + withLauncher.copy(addToSteam = sidecar.addToSteam && SessionPrefs.gameStoresAddToSteam(app)).write(folder) + register(app) + } + + /** 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}") + } + } + + /** Flips whether the game is a Steam shortcut; the listing follows. Off the main thread. */ + fun setAddToSteam(context: Context, game: InstalledStoreGame, on: Boolean, onDone: () -> Unit) { + val app = context.applicationContext + Thread({ + game.sidecar.copy(addToSteam = on).write(game.folder) + register(app) + StoresState.logLine("${if (on) "added" else "removed"} Steam shortcut for \"${game.sidecar.title}\"") + StoresState.post(onDone) + }, "stores-steam-toggle").start() + } + + /** + * 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) + register(app) + if (appId != null) SteamLiveShortcuts.remove(app, appId) + StoresState.logLine("uninstalled \"${game.sidecar.title}\"") + } + + fun deleteTree(dir: File) { + dir.listFiles()?.forEach { if (it.isDirectory) deleteTree(it) else it.delete() } + dir.delete() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt new file mode 100644 index 000000000..9167a91ba --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -0,0 +1,104 @@ +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("\"", "")}\"" + lines += "set \"DD_AUTH=\"" + if (sidecar.store == Store.EPIC) { + lines += "if not exist \"%~dp0$EPIC_CODE\" goto run" + lines += "set /p DD_CODE=<\"%~dp0$EPIC_CODE\"" + lines += "del \"%~dp0$EPIC_CODE\"" + lines += "set \"DD_AUTH=-AUTH_LOGIN=unused -AUTH_PASSWORD=%DD_CODE% -AUTH_TYPE=exchangecode\"" + lines += ":run" + } + val args = sidecar.args.joinToString(" ") { quoteArg(it) } + lines += "\"%~dp0$exe\"" + (if (args.isNotEmpty()) " $args" else "") + " %DD_AUTH%" + 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. + */ + fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) { + if (store != Store.EPIC) { onReady(); return } + val app = context.applicationContext + Thread({ + try { + val game = StoreInstallRoot.gameFolders(app).firstOrNull { folder -> StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id } == true } + if (game != null) { + val code = StoresState.backend(Store.EPIC)?.let { (it as? EpicLaunchSupport)?.exchangeCode(app) } + val file = File(game, EPIC_CODE) + if (code != null) { file.writeText(code); Log.i(TAG, "epic: exchange code ready for $id") } + else { file.delete(); Log.w(TAG, "epic: no exchange code; launching offline") } + } + } catch (e: Exception) { + Log.w(TAG, "epic prepare: ${e.message}") + } + StoresState.post(onReady) + }, "stores-launch-prep").start() + } + + /** The same, for a game the Games tab launches: a store's game by its [Library.SteamGame.source]. */ + 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? +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresNative.kt new file mode 100644 index 000000000..ffbf364f6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresNative.kt @@ -0,0 +1,53 @@ +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 } + + @JvmStatic + private external fun nativeVersion(): String +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt new file mode 100644 index 000000000..b8db7ea78 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -0,0 +1,157 @@ +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. */ + var downloads by mutableStateOf<List<DownloadEntry>>(emptyList()) + internal set + + /** The engine log the Downloads page shows: the last lines, newest last. */ + val log = mutableStateListOf<String>() + + /** The native engine's version once probed, "" when the probe said it is missing, null before. */ + var engine by mutableStateOf<String?>(null) + private set + + /** Downloads queued, running or paused across the stores (the rail badge). */ + val activeDownloads: Int get() = downloads.count { it.isActive } + + 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)?.let { InstalledStoreGame(it, folder) } + } + val version = if (StoresNative.available) StoresNative.version ?: "" else "" + main.post { + accounts.clear() + found.forEach { (store, name) -> accounts[store] = name } + installed = onDisk + engine = version + } + }, "stores-refresh").start() + } + + fun isSignedIn(store: Store): Boolean = accounts.containsKey(store) + + 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) { + 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() + } + + fun install(context: Context, item: CatalogItem) { + backends[item.store]?.install(context.applicationContext, item) ?: 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() + } + + /** Appends to the engine log with a clock, keeping the last 80 lines. Any thread. */ + fun logLine(text: String) { + val stamped = "${CLOCK.format(Date())} $text" + Log.i(TAG, text) + main.post { + log.add(stamped) + while (log.size > 80) log.removeAt(0) + } + } + + internal fun post(block: () -> Unit) = main.post(block) + + private val CLOCK = SimpleDateFormat("HH:mm:ss", 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) + fun install(context: Context, item: CatalogItem) + /** The store's own records of the install, before the folder goes (the queue row, cached ids). */ + fun uninstall(context: Context, game: InstalledStoreGame) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt new file mode 100644 index 000000000..a92ef0d5e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt @@ -0,0 +1,55 @@ +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, +) { + val key: String get() = "${store.id}:$id" + val isActive: Boolean get() = state == DownloadState.QUEUED || state == DownloadState.RUNNING || state == DownloadState.PAUSED + /** 0..1 for the bar: the byte fraction while downloading, full once past it. */ + val fraction: Float get() = when { + state == DownloadState.INSTALLED -> 1f + stage == DownloadStage.VERIFY || stage == DownloadStage.INSTALL || stage == DownloadStage.DONE -> 1f + bytesTotal > 0L -> (bytesDone.toDouble() / bytesTotal).toFloat().coerceIn(0f, 1f) + else -> 0f + } + val percent: Int get() = (fraction * 100).toInt() +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt new file mode 100644 index 000000000..33dcced0c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt @@ -0,0 +1,214 @@ +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() + fun stage(stage: DownloadStage, detail: String = "") = update(key) { it.copy(stage = stage, detail = detail.ifEmpty { it.detail }, state = DownloadState.RUNNING) } + fun progress(bytesDone: Long, bytesTotal: Long, detail: String? = null, speedBps: Long = -1L) = update(key) { + val speed = if (speedBps >= 0) speedBps else it.speedBps + val eta = if (speed > 0 && bytesTotal > bytesDone) (bytesTotal - bytesDone) / speed else -1L + it.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = bytesDone, bytesTotal = if (bytesTotal > 0) bytesTotal else it.bytesTotal, detail = detail ?: it.detail, speedBps = speed, etaSeconds = eta) + } + fun log(line: String) = StoresState.logLine(line) + } + + private class Item(var entry: DownloadEntry, val factory: () -> DownloadJob, var job: DownloadJob? = null, val cancelled: AtomicBoolean = AtomicBoolean(false), var pauseRequested: Boolean = false) + + private val lock = Any() + private val items = LinkedHashMap<String, Item>() + private var running = 0 + + fun parallel(context: Context): Int = SessionPrefs.gameStoresParallel(context) + + 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) + } + + /** 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) } + + /** Newest first: what was started last sits on top, as the preview lists them. Caller holds [lock]. */ + private fun publishLocked() { + val list = items.values.map { it.entry }.sortedByDescending { it.startedAt } + StoresState.post { StoresState.downloads = 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) { + 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}") + DownloadState.FAILED -> StoresState.logLine("${item.entry.store.label}: \"${item.entry.name}\" failed: $error") + else -> {} + } + StoreDownloadService.finish(context, item.entry.key) + advance(context) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt new file mode 100644 index 000000000..fd533ad81 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt @@ -0,0 +1,114 @@ +package com.droiddeck.launcher.stores.download + +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.os.Build +import android.os.IBinder +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 java.util.concurrent.ConcurrentHashMap + +/** + * Keeps the process alive while a store download runs, with one quiet notification for the queue. + * Without it Android demotes the app as soon as the launcher leaves the screen and a multi-gigabyte + * install dies half way; the session has its own service for the same reason. + * + * [start] is called when a download begins and [finish] when one ends; the notification is rebuilt + * from the queue's live rows, and the service stops itself when nothing is active. + */ +class StoreDownloadService : Service() { + override fun onCreate() { + super.onCreate() + instance = this + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + if (nm.getNotificationChannel(CHANNEL) == null) { + nm.createNotificationChannel(NotificationChannel(CHANNEL, getString(R.string.stores_notification_channel), NotificationManager.IMPORTANCE_LOW).apply { + description = getString(R.string.stores_notification_channel_hint) + setShowBadge(false) + }) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + instance = this + startForegroundCompat(build()) + if (active.isEmpty()) stopNow() + return START_NOT_STICKY + } + + override fun onDestroy() { + if (instance === this) instance = null + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun build(): Notification { + val entries = StoresState.downloads.filter { it.state == DownloadState.RUNNING } + val first = entries.firstOrNull() + val title = if (active.size > 1) getString(R.string.stores_notification_many, active.size) else getString(R.string.stores_notification_one) + val text = when { + first == null -> getString(R.string.stores_notification_waiting) + first.bytesTotal > 0 -> "${first.name} · ${first.percent}% (${formatBytes(first.bytesDone)} / ${formatBytes(first.bytesTotal)})" + else -> first.name + } + val tap = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE) + return Notification.Builder(this, CHANNEL) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(title).setContentText(text).setOngoing(true).setContentIntent(tap).build() + } + + private fun refresh() { + runCatching { (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(ID, build()) } + } + + 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() { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + companion object { + private const val TAG = "StoreDownloadService" + private const val CHANNEL = "store_downloads" + private const val ID = 9102 + private val active = ConcurrentHashMap.newKeySet<String>() + @Volatile private var instance: StoreDownloadService? = null + + /** A download started: the service is up (idempotent). */ + fun start(context: Context) { + StoresState.downloads.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}") + } + } + + /** The queue changed: redraw the notification line. */ + 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.downloads.none { it.state == DownloadState.RUNNING }) inst.stopNow() else inst.refresh() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadTier.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadTier.kt new file mode 100644 index 000000000..02c299eec --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadTier.kt @@ -0,0 +1,29 @@ +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. */ + val processWorkers: Int get() = (Runtime.getRuntime().availableProcessors() * decompressPerCore).toInt().coerceIn(2, 32) + + 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)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 26751674e..fcbefd1a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index c373f672b..4b10fff07 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -220,11 +220,14 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA /** 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) + 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, + ) + } Text( g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -277,10 +280,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, + ) + } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index efd4319ca..ac8ebc163 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -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), + ) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 8781f419b..deaafbb20 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -129,6 +129,16 @@ 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: a game a store installs is added to Steam's library as a shortcut. */ + val gameStoresAddToSteam: Boolean = true, + /** 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, + /** 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 +189,12 @@ class FrontEndActions( val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, val onStoreEnabled: (Boolean) -> Unit = {}, + val onGameStoresEnabled: (Boolean) -> Unit = {}, + val onGameStoresAddToSteam: (Boolean) -> Unit = {}, + val onGameStoresSpeedTier: (String) -> Unit = {}, + val onStoresOpenTab: (String) -> 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 = { _, _ -> }, @@ -391,8 +407,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" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 596575800..00c0b08d2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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,27 @@ internal fun SetupPanel( s.storeEnabled, ) { a.onStoreEnabled(it) } } + SettingsGroup(stringResource(R.string.setup_stores)) { + ToggleRow( + host, "stores-enabled", stringResource(R.string.setup_stores_show), + stringResource(R.string.setup_stores_show_hint), s.gameStoresEnabled, + ) { a.onGameStoresEnabled(it) } + ToggleRow( + host, "stores-add-to-steam", stringResource(R.string.setup_stores_add_to_steam), + stringResource(R.string.setup_stores_add_to_steam_hint), s.gameStoresAddToSteam, + ) { a.onGameStoresAddToSteam(it) } + // The same three rows as the Stores page's own cog, for whoever looks here first. + SettingsRow(stringResource(R.string.setup_stores_open_on), stringResource(R.string.setup_stores_open_on_hint)) { + 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.setup_stores_speed), stringResource(R.string.setup_stores_speed_hint)) { + SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) } + } + StoresEngineRow() + } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt new file mode 100644 index 000000000..c35a9beda --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt new file mode 100644 index 000000000..c72a3bdcd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -0,0 +1,179 @@ +package com.droiddeck.launcher.ui + +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.Spacer +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.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.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.StoreInstalls +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.downloads.firstOrNull { it.store == store && it.id == id && 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)) { + val state = when { + installed != null -> stringResource(R.string.stores_eyebrow_installed) + 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 -> { + PrimaryButton(stringResource(R.string.games_launch), main = true, icon = Icons.Filled.PlayArrow, enabled = !s.busy) { launchStoreGame(ctx, store, id, s, a) } + val inSteam = installed.sidecar.addToSteam + SecondaryButton(if (inSteam) stringResource(R.string.stores_in_steam) else stringResource(R.string.stores_add_to_steam)) { + StoreInstalls.setAddToSteam(ctx, installed, !inSteam) { StoresState.refresh(ctx); a.onLibraryChanged() } + } + s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }?.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> + SecondaryButton(stringResource(R.string.games_prefix)) { a.onBrowseFiles(dir) } + } + SecondaryButton(stringResource(R.string.games_files)) { a.onBrowseFiles(installed.folder) } + 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.state, download.stage, download.percent), 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) } + SecondaryButton(stringResource(R.string.stores_dl_cancel)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) } + } + 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.install(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) + } + } + } + } + if (item != null && item.description.isNotBlank()) Rise(2) { + Text(item.description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp)) + } + Rise(3) { + BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 16.dp)) { + val wide = maxWidth >= 640.dp + val launchSettings: @Composable () -> Unit = { + Column { + SectionTitle(stringResource(R.string.games_launch_settings), null) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + InfoCard(stringResource(R.string.stores_card_proton), stringResource(R.string.stores_card_proton_value), Modifier.weight(1f)) + InfoCard(stringResource(R.string.stores_card_audio), stringResource(R.string.stores_card_audio_value), Modifier.weight(1f)) + } + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + InfoCard(stringResource(R.string.stores_card_components), stringResource(R.string.stores_card_components_value), Modifier.weight(1f)) + InfoCard(stringResource(R.string.stores_card_engine), if (StoresState.engine.isNullOrEmpty()) stringResource(R.string.stores_card_engine_java, store.shortLabel) else stringResource(R.string.stores_card_engine_value, store.shortLabel), Modifier.weight(1f)) + } + } + } + val howItRuns: @Composable () -> Unit = { + Column { + SectionTitle(stringResource(R.string.stores_how_it_runs), null) + KeyValue(stringResource(R.string.stores_kv_fetched), stringResource(R.string.stores_kv_fetched_value, store.label)) + KeyValue(stringResource(R.string.stores_kv_installed_to), installed?.folder?.path ?: stringResource(R.string.stores_kv_installed_to_value, store.shortLabel, com.droiddeck.launcher.stores.StoreInstallRoot.folderName(title, id))) + KeyValue(stringResource(R.string.stores_kv_launched_by), when (store) { + Store.EPIC -> stringResource(R.string.stores_kv_launched_by_epic) + Store.AMAZON -> stringResource(R.string.stores_kv_launched_by_amazon) + Store.GOG -> stringResource(R.string.stores_kv_launched_by_value) + }) + KeyValue(stringResource(R.string.stores_kv_steam), when { + installed != null && installed.sidecar.addToSteam -> stringResource(R.string.stores_kv_steam_on) + installed != null -> stringResource(R.string.stores_kv_steam_off) + s.gameStoresAddToSteam -> stringResource(R.string.stores_kv_steam_on) + else -> stringResource(R.string.stores_kv_steam_off) + }) + } + } + if (wide) Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Box(Modifier.weight(2f)) { launchSettings() } + Box(Modifier.weight(1.2f)) { howItRuns() } + } else Column { launchSettings(); howItRuns() } + } + } +} + +@Composable +private fun InfoCard(label: String, value: String, modifier: Modifier) { + val colors = MaterialTheme.colorScheme + Column(modifier = modifier.clip(Shape12).background(colors.surface).border(1.dp, LocalPalette.current.line, Shape12).padding(horizontal = 14.dp, vertical = 10.dp)) { + Text(label, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1) + Text(value, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp)) + } +} + +@Composable +private fun KeyValue(k: String, v: String) { + val colors = MaterialTheme.colorScheme + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp)) { + Text(k, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(110.dp)) + Text(v, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.weight(1f)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt new file mode 100644 index 000000000..e0759ad74 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -0,0 +1,228 @@ +package com.droiddeck.launcher.ui + +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.heightIn +import androidx.compose.foundation.layout.padding +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.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 + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + entries.forEachIndexed { i, d -> key(d.key) { Rise(1 + i.coerceAtMost(5)) { DownloadCard(d, s, a) } } } + } +} + +@Composable +private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Column( + verticalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surface).border(1.dp, pal.line, Shape12).padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text(d.name, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + SourceChip(d.store.id, small = true, modifier = Modifier.padding(top = 2.dp)) + } + Stages(d) + } + ProgressBarThin( + if (d.state == DownloadState.INSTALLED) 1f else d.fraction, paused = d.state == DownloadState.PAUSED, + verify = d.stage == DownloadStage.VERIFY, height = 8.dp, done = d.state == DownloadState.INSTALLED, + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { + val meta = when (d.state) { + DownloadState.INSTALLED -> stringResource(R.string.stores_installed_chip) + DownloadState.CANCELLED -> stringResource(R.string.stores_dl_cancelled) + DownloadState.FAILED -> d.error?.let { stringResource(R.string.stores_dl_failed_reason, it) } ?: 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 -> when { + d.stage == DownloadStage.DOWNLOAD && d.bytesTotal > 0 -> stringResource(R.string.stores_dl_bytes, formatBytes(d.bytesDone), formatBytes(d.bytesTotal), d.percent) + else -> stageLabel(d.stage) + } + } + Text(meta, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (d.state == DownloadState.RUNNING && d.speedBps > 0) { + Text(formatSpeed(d.speedBps), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1) + if (d.etaSeconds >= 0) Text(eta(d.etaSeconds), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1) + } + } + if (d.detail.isNotBlank() && d.state == DownloadState.RUNNING) Text(d.detail, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Actions { + when (d.state) { + DownloadState.RUNNING, DownloadState.QUEUED -> { + SecondaryButton(stringResource(R.string.stores_dl_pause), compact = true) { DownloadQueue.pause(d.key) } + SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) } + } + DownloadState.PAUSED -> { + PrimaryButton(stringResource(R.string.stores_dl_resume), compact = true) { DownloadQueue.resume(ctx, d.key) } + SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) } + } + DownloadState.FAILED -> { + PrimaryButton(stringResource(R.string.stores_dl_retry), compact = true) { DownloadQueue.retry(ctx, d.key) } + SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) } + } + DownloadState.INSTALLED -> { + PrimaryButton(stringResource(R.string.stores_play), compact = true, icon = Icons.Filled.PlayArrow) { launchStoreGame(ctx, d.store, d.id, s, a) } + SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) } + } + DownloadState.CANCELLED -> SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) } + } + } + } +} + +/** Manifest → Download → Verify → Install → Done, the one under way lit, the ones behind it green. */ +@Composable +private fun Stages(d: DownloadEntry) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (stage in DownloadStage.entries) { + val passed = d.state == DownloadState.INSTALLED || stage.ordinal < d.stage.ordinal + val on = d.state == DownloadState.RUNNING && stage == d.stage + Text( + stageLabel(stage), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = when { on -> pal.signal; passed -> pal.good; else -> colors.onSurfaceVariant }, + modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(if (on) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } +} + +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(10.dp)) { + SettingCard(stringResource(R.string.setup_stores_speed), stringResource(R.string.stores_tier_note)) { + SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) } + } + SettingCard(stringResource(R.string.stores_parallel), stringResource(R.string.stores_parallel_note)) { + var parallel by remember { mutableStateOf(DownloadQueue.parallel(ctx)) } + SegmentedTabs(listOf(1 to "1", 2 to "2", 3 to "3"), parallel) { parallel = it; DownloadQueue.setParallel(ctx, it) } + } + } + SectionTitle(stringResource(R.string.stores_engine_log), null) + Box(Modifier.fillMaxWidth().heightIn(min = 60.dp, max = 220.dp).clip(Shape12).background(Color.Black).border(1.dp, pal.line, Shape12).padding(12.dp)) { + val lines = StoresState.log + Text( + if (lines.isEmpty()) stringResource(R.string.stores_log_idle) else lines.asReversed().joinToString("\n"), + fontSize = 11.sp, lineHeight = 16.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF9BE27A), + ) + } + Text(stringResource(R.string.stores_downloads_footnote), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp)) +} + +@Composable +private fun SettingCard(label: String, note: String, control: @Composable () -> Unit) { + val colors = MaterialTheme.colorScheme + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surface).border(1.dp, LocalPalette.current.line, Shape12).padding(horizontal = 14.dp, vertical = 10.dp)) { + Text(label, fontSize = 12.sp, color = colors.onSurfaceVariant) + control() + Text(note, fontSize = 12.sp, color = colors.onSurfaceVariant) + } +} + +/** + * The chip row's cog: the section's own settings in a small card - which tab a store opens on, + * whether installs go to Steam, the speed tier. Setup › Stores repeats them under its gate. + */ +@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) { + DialogHeader(stringResource(R.string.stores_settings_eyebrow), stringResource(R.string.stores_title)) + Rise(1) { + Column { + SettingsRow(stringResource(R.string.setup_stores_open_on), stringResource(R.string.setup_stores_open_on_hint)) { + 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.setup_stores_add_to_steam), stringResource(R.string.setup_stores_add_to_steam_hint)) { + ToggleSwitch(s.gameStoresAddToSteam, label = stringResource(R.string.setup_stores_add_to_steam)) { a.onGameStoresAddToSteam(it) } + } + SettingsRow(stringResource(R.string.setup_stores_speed), stringResource(R.string.setup_stores_speed_hint)) { + SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) } + } + } + } + Rise(2) { Actions { SecondaryButton(stringResource(R.string.common_ok), onClick = close) } } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt new file mode 100644 index 000000000..006a5e93c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -0,0 +1,532 @@ +package com.droiddeck.launcher.ui + +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.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.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", "installed", "library", "all") + +@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) } + val store = Store.byId(chip) + 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) } + // 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 = if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library" + } + BackHandler(enabled = openGame != null) { openGame = null } + val scroll = rememberScrollState() + LaunchedEffect(chip, tab, openGame) { scroll.scrollTo(0) } + Column( + modifier = modifier.padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) + .bumpers( + onPrevious = { if (openGame == null && chip != DOWNLOADS) tab = TABS[(TABS.indexOf(tab) + TABS.size - 1) % TABS.size] }, + onNext = { if (openGame == null && chip != DOWNLOADS) tab = TABS[(TABS.indexOf(tab) + 1) % TABS.size] }, + ), + ) { + Rise(0) { StoreChips(chip, s.storeDownloadsActive, onPick = pickChip, onSettings = { settings = true }) } + Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 10.dp, bottom = 16.dp)) { + when { + chip == DOWNLOADS -> StoresDownloadsPane(s, a) + store == null -> {} + !StoresState.isSignedIn(store) -> Rise(1) { SignInCard(store) } + openGame != null -> StoreGameDetail(store, openGame!!, s, a, onBack = { openGame = null }) + else -> Storefront(store, tab, query, s, a, onTab = { tab = it }, onQuery = { query = it }, onOpen = { openGame = it }) + } + } + } + if (settings) StoresSettingsDialog(s, a) { settings = false } +} + +// ---- 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)) 1f else 0.35f)) + }, + description = store.label + if (StoresState.isSignedIn(store)) "" 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 + val hint = when (store) { + Store.GOG -> stringResource(R.string.stores_signin_hint_gog) + Store.EPIC -> stringResource(R.string.stores_signin_hint_epic) + Store.AMAZON -> stringResource(R.string.stores_signin_hint_amazon) + } + 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(stringResource(R.string.stores_signin_title, store.label), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text(hint, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp)) + } + 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) } } + Text(stringResource(R.string.stores_signin_footnote), fontSize = 12.sp, color = colors.onSurfaceVariant) + if (StoresState.engine == "") Note(stringResource(R.string.stores_engine_missing_note)) + } +} + +/** 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] + val installedKeys = remember(StoresState.installed) { StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet() } + val q = query.trim().lowercase() + fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) + // Everything this store knows about: the library plus whatever the shelves brought, each title once. + val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } } + val installedItems = library.filter { it.id in installedKeys } + Rise(1) { + SubTabs( + listOf( + "store" to stringResource(R.string.stores_tab_store), + "installed" to stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedKeys.size), + "library" to stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size), + "all" to stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_all), everything.size), + ), + tab, onTab, + trailing = { + 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) + "installed" -> stringResource(R.string.stores_search_installed) + "library" -> stringResource(R.string.stores_search_library) + else -> stringResource(R.string.stores_search_all, store.label) + }, + ) + } + StoresState.status[store]?.let { Rise(3) { Text(it, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp)) } } + StoresState.problems[store]?.let { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(it) } } } + val card: @Composable (CatalogItem) -> Unit = { item -> GameCard(item, store, s, a, installedKeys, onOpen) } + when (tab) { + "installed" -> Grid(installedItems.filter(::matches), stringResource(R.string.stores_installed_empty, store.label), card) + "library" -> 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) + "all" -> Grid(everything.filter(::matches), stringResource(R.string.stores_nothing_matches), card) + else -> { + if (q.isNotEmpty()) Grid(everything.filter(::matches), stringResource(R.string.stores_nothing_matches), card) + else { + Shelf(stringResource(R.string.stores_shelf_new), stringResource(R.string.stores_shelf_new_hint), shelves?.whatsNew, card) + Shelf(stringResource(R.string.stores_shelf_deals), stringResource(R.string.stores_shelf_deals_hint), shelves?.deals, card) + Shelf(stringResource(R.string.stores_shelf_free), stringResource(R.string.stores_shelf_free_hint), shelves?.free, card) + Shelf(stringResource(R.string.stores_shelf_trending), stringResource(R.string.stores_shelf_trending_hint), shelves?.trending, card) + Shelf(stringResource(R.string.stores_shelf_library), stringResource(R.string.stores_shelf_library_hint, store.label), 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, 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) } + 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)) + } + } + } + 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, hint: String, items: List<CatalogItem>?, card: @Composable (CatalogItem) -> Unit) { + if (items.isNullOrEmpty()) return + val colors = MaterialTheme.colorScheme + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp, bottom = 6.dp)) { + Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(hint, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 1.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) } } + } + } + } +} + +private val CardWidth = 150.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) { + 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.downloads.firstOrNull { it.store == store && it.id == item.id && it.isActive } + val open = { onOpen(item.key) } + Column( + modifier = Modifier.fillMaxWidth().paneItem("card:${item.key}").graphicsLayer { scaleX = scale; scaleY = scale } + .clip(RoundedCornerShape(10.dp)).background(colors.surface).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), + ) { + CardArt(item, Modifier.fillMaxWidth().aspectRatio(16f / 9f)) + Column(verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 6.dp, bottom = 8.dp)) { + Text(item.title, fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis) + PriceLine(item, installed) + when { + installed -> CardButton(stringResource(R.string.stores_play), primary = true, icon = Icons.Filled.PlayArrow) { launchStoreGame(ctx, store, item.id, s, a) } + download != null -> { + ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY) + CardButton(downloadLabel(download.state, download.stage, download.percent), primary = false) { } + } + item.owned -> CardButton(if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), primary = true) { StoresState.install(ctx, item) } + item.isFree -> CardButton(stringResource(R.string.stores_get_free), primary = true) { openStoreUrl(ctx, item) } + else -> CardButton(stringResource(R.string.stores_view_on, store.shortLabel), primary = false) { openStoreUrl(ctx, item) } + } + } + } +} + +@Composable +internal fun CardArt(item: CatalogItem, modifier: Modifier) { + val url = item.imageUrl ?: item.tallImageUrl + Box(modifier.background(artBrush(hueOf(item.title)))) { + 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)) + } +} + +/** "In library" / "Installed", a free label, or the deal with its old price struck through. */ +@Composable +internal fun PriceLine(item: CatalogItem, installed: Boolean, small: Boolean = true) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + when { + installed -> StateChip(stringResource(R.string.stores_installed_chip), ChipTone.OK, small) + item.owned -> StateChip(stringResource(R.string.stores_in_library), ChipTone.OK, small) + item.isFree -> Text(stringResource(R.string.stores_free), fontSize = if (small) 11.sp else 13.sp, fontWeight = FontWeight.Bold, color = pal.good) + item.isDiscounted -> { + StateChip("-${item.discountPercent}%", ChipTone.DEAL, small) + Text(item.originalPrice, fontSize = if (small) 10.sp else 12.sp, color = colors.onSurfaceVariant, textDecoration = TextDecoration.LineThrough, maxLines = 1) + Text(item.finalPrice, fontSize = if (small) 11.sp else 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + } + item.hasPrice -> Text(item.finalPrice, fontSize = if (small) 11.sp else 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + else -> Spacer(Modifier.height(if (small) 14.dp else 18.dp)) + } + } +} + +/** The card's one button: full width, small type. A press does not also open the card. */ +@Composable +private fun CardButton(text: String, primary: Boolean, icon: androidx.compose.ui.graphics.vector.ImageVector? = null, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(6.dp) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth().heightIn(min = 30.dp).clip(shape) + .background(if (primary) pal.signal else colors.surfaceVariant) + .glideBorder(hot, shape, if (primary) colors.onBackground else pal.signal, if (primary) Color.Transparent else pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick).padding(horizontal = 6.dp, vertical = 5.dp), + ) { + if (icon != null) Icon(icon, null, tint = if (primary) pal.onSignal else colors.onBackground, modifier = Modifier.size(12.dp)) + Text(text, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = if (primary) pal.onSignal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@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 } + Box(Modifier.fillMaxWidth().height(height).clip(RoundedCornerShape(height / 2)).background(colors.surfaceVariant)) { + Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).height(height).background(fill)) + } +} + +@Composable +internal fun downloadLabel(state: DownloadState, stage: DownloadStage, percent: Int): String = when (state) { + DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused) + DownloadState.QUEUED -> stringResource(R.string.stores_dl_queued) + else -> stringResource(R.string.stores_dl_stage_percent, stageLabel(stage), percent) +} + +@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) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresWidgets.kt new file mode 100644 index 000000000..ad6267f5b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresWidgets.kt @@ -0,0 +1,21 @@ +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), + if (version != null) stringResource(R.string.setup_stores_engine_ready, version) + else stringResource(R.string.setup_stores_engine_missing), + ) { + Chip(if (version != null) stringResource(R.string.setup_stores_engine_chip_ready) else stringResource(R.string.setup_stores_engine_chip_missing), ok = version != null) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 50f9c637e..c8c82732b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1957,4 +1957,132 @@ <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">Added</string> + <string name="setup_stores">Stores</string> + <string name="setup_stores_show">Show Stores in the rail</string> + <string name="setup_stores_show_hint">GOG, Epic Games and Amazon Games libraries, with the download manager.</string> + <string name="setup_stores_add_to_steam">Add installed store games to Steam</string> + <string name="setup_stores_add_to_steam_hint">Creates a non-Steam shortcut so the game shows in Steam\'s Deck UI with Steam Input.</string> + <string name="setup_stores_open_on">Open a store on</string> + <string name="setup_stores_open_on_hint">Which tab a signed-in store opens when its chip is picked.</string> + <string name="setup_stores_speed">Download speed tier</string> + <string name="setup_stores_speed_hint">Also in Stores › Downloads.</string> + <string name="setup_stores_engine">Stores engine</string> + <string name="setup_stores_engine_ready">Native download engine %1$s.</string> + <string name="setup_stores_engine_missing">Stores engine not built: downloads run on the slower built-in fetcher.</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_title">Sign in to %1$s</string> + <string name="stores_signin_hint_gog">GOG account (same login as GOG Galaxy). Opens GOG\'s own sign-in page.</string> + <string name="stores_signin_hint_epic">Epic account (web sign-in). Opens Epic\'s own sign-in page.</string> + <string name="stores_signin_hint_amazon">Amazon account (device sign-in). Opens Amazon\'s own sign-in page.</string> + <string name="stores_signin">Sign in</string> + <string name="stores_signin_footnote">Tokens stay in DroidDeck\'s private storage and are refreshed by the engine; nothing goes through a third party.</string> + <string name="stores_engine_missing_note">This build has no native download engine: installs still work, on the slower built-in fetcher.</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_search_all">Search everything on %1$s…</string> + <string name="stores_installed_empty">Nothing installed from %1$s yet. Install from Library.</string> + <string name="stores_library_loading">Fetching your library…</string> + <string name="stores_nothing_matches">Nothing matches.</string> + <string name="stores_shelves_loading">Loading the store…</string> + <string name="stores_amazon_no_catalog">Amazon has no browsable catalog: claim games on Prime Gaming in a browser; they show up in Library after a sync.</string> + <string name="stores_shelf_new">What\'s new</string> + <string name="stores_shelf_new_hint">Just landed</string> + <string name="stores_shelf_deals">Deals</string> + <string name="stores_shelf_deals_hint">On sale this week</string> + <string name="stores_shelf_free">Free games</string> + <string name="stores_shelf_free_hint">Claim and keep</string> + <string name="stores_shelf_trending">Trending</string> + <string name="stores_shelf_trending_hint">Popular right now</string> + <string name="stores_shelf_library">Your library</string> + <string name="stores_shelf_library_hint">Owned on %1$s</string> + <string name="stores_play">Play</string> + <string name="stores_install">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_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_add_to_steam">Add to 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">shortcut added on install (Deck UI + Steam Input)</string> + <string name="stores_kv_steam_off">no shortcut (Stores settings)</string> + <string name="stores_downloads_empty">No downloads. Install something from a library.</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_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_download_manager">Download manager</string> + <string name="stores_tier_note">Caps how many chunks the engine fetches at once: Balanced 16 · Fast 32 · Max 96 in flight.</string> + <string name="stores_parallel">Downloads at a time</string> + <string name="stores_parallel_note">One manager for all three stores; Steam\'s own downloads are unaffected.</string> + <string name="stores_engine_log">Engine log</string> + <string name="stores_log_idle">idle</string> + <string name="stores_downloads_footnote">Stages mirror the engine: manifest → chunks → verify → install. A paused download keeps its files and carries on where it stopped.</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> </resources> diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesStoreScanTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesStoreScanTest.kt new file mode 100644 index 000000000..fde8bb6d3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesStoreScanTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLaunch +import java.io.File +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** A store install is one more folder the scan finds, named and aimed by its sidecar. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AddedGamesStoreScanTest { + private val app get() = RuntimeEnvironment.getApplication() + + private fun install(store: Store, id: String, title: String, exe: String, addToSteam: Boolean = true, launcher: Boolean = false): File { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), store), StoreInstallRoot.folderName(title, id)) + File(folder, exe).apply { parentFile!!.mkdirs(); writeText("game") } + File(folder, "unins000.exe").writeText("uninstaller") + var sidecar = StoreGameSidecar(store, id, title, exe, addToSteam = addToSteam, args = if (launcher) listOf("-EpicPortal") else emptyList()) + if (launcher) sidecar = StoreLaunch.writeLauncher(folder, sidecar) + sidecar.write(folder) + return folder + } + + @Test fun aStoreGameIsFoundUnderTheInternalRootWithItsSource() { + val folder = install(Store.GOG, "1207658924", "Heroes of Might and Magic 3: Complete", "HOMM3.exe") + val games = AddedGames.scan(app) + val game = games.single { it.folder.absolutePath == folder.absolutePath } + assertEquals("Heroes of Might and Magic 3: Complete", game.name) + assertEquals("HOMM3.exe", game.exe.name) + assertEquals(Store.GOG.id, game.source) + assertEquals("1207658924", game.storeId) + // Inside the runtime's tree the folder needs no bind: its guest path is its path from the tree's root. + assertEquals("/root/Games/Stores/GOG/Heroes of Might and Magic 3 Complete/HOMM3.exe", game.guestExe) + assertEquals("/root/Games/Stores/GOG/Heroes of Might and Magic 3 Complete", game.guestDir) + } + + @Test fun theLauncherIsWhatTheShortcutRunsAndTheExeIsWhatItShows() { + val folder = install(Store.EPIC, "abc", "Celeste", "Celeste.exe", launcher = true) + val game = AddedGames.scan(app).single { it.folder.absolutePath == folder.absolutePath } + assertEquals("Celeste.exe", game.exe.name) + assertTrue(game.guestExe.endsWith("/" + StoreLaunch.LAUNCHER)) + assertEquals(Store.EPIC.id, game.source) + } + + @Test fun aStoreGameKeptOutOfSteamIsNotListed() { + val folder = install(Store.AMAZON, "amzn1.adg.product.x", "Yakuza 0", "Yakuza0.exe", addToSteam = false) + assertTrue(AddedGames.scan(app).none { it.folder.absolutePath == folder.absolutePath }) + } + + @Test fun theListingCarriesTheLauncherAndTheTitle() { + val folder = install(Store.EPIC, "abc", "Celeste", "Celeste.exe", launcher = true) + val games = AddedGames.scan(app).filter { it.folder.absolutePath == folder.absolutePath } + val listing = AddedGames.writeListing(app, games).readText() + assertTrue(listing.contains("\"name\":\"Celeste\"")) + assertTrue(listing.contains(StoreLaunch.LAUNCHER)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt new file mode 100644 index 000000000..1802865b8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt @@ -0,0 +1,76 @@ +package com.droiddeck.launcher.stores + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StoreGameSidecarTest { + private val sample = StoreGameSidecar( + Store.EPIC, "abc123", "Celeste", "Celeste.exe", launcher = ".droiddeck-launch.bat", + args = listOf("-EpicPortal", "-epicusername=\"Some One\""), env = mapOf("FOO" to "bar"), + installVersion = "1.4.0", installedAt = 1700000000L, addToSteam = false, cover = "https://x/c.jpg", + extra = mapOf("namespace" to "ns"), + ) + + @Test fun roundTripsThroughJson() { + val back = StoreGameSidecar.parse(sample.toJson().toString())!! + assertEquals(Store.EPIC, back.store) + assertEquals("abc123", back.id) + assertEquals("Celeste", back.title) + assertEquals("Celeste.exe", back.exe) + assertEquals(".droiddeck-launch.bat", back.launcher) + assertEquals(sample.args, back.args) + assertEquals(sample.env, back.env) + assertEquals("1.4.0", back.installVersion) + assertEquals(1700000000L, back.installedAt) + assertFalse(back.addToSteam) + assertEquals("https://x/c.jpg", back.cover) + assertEquals("ns", back.extra["namespace"]) + } + + @Test fun defaultsWhenFieldsAreAbsent() { + val back = StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"bin\\game.exe"}""")!! + assertTrue(back.addToSteam) + assertEquals("bin/game.exe", back.exe) + assertNull(back.launcher) + assertTrue(back.args.isEmpty()) + } + + @Test fun refusesWhatIsNotASidecar() { + assertNull(StoreGameSidecar.parse("not json")) + assertNull(StoreGameSidecar.parse("""{"store":"steam","id":"1","title":"T","exe":"a.exe"}""")) + assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"","title":"T","exe":"a.exe"}""")) + // An exe outside the folder is not trusted: a sidecar must not point the shortcut anywhere. + assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"../other/a.exe"}""")) + assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"/abs/a.exe"}""")) + assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"C:\\a.exe"}""")!!.let { null }) + } + + @Test fun folderNamesAreSafeAndStable() { + assertEquals("Heroes of Might and Magic 3 Complete", StoreInstallRoot.folderName("Heroes of Might and Magic 3: Complete", "1")) + assertEquals("What Remains of Edith Finch", StoreInstallRoot.folderName("What Remains of Edith Finch...", "1")) + assertEquals("1207658924", StoreInstallRoot.folderName("日本語のみ", "1207658924")) + assertTrue(StoreInstallRoot.folderName("x".repeat(200), "1").length <= 60) + } + + @Test fun launcherStartsTheExeFromItsFolderWithArgsAndEnv() { + val text = StoreLaunch.launcherText(sample.copy(exe = "Binaries/Win64/Celeste.exe")) + assertTrue(text.contains("cd /d \"%~dp0Binaries\\Win64\"\r\n")) + assertTrue(text.contains("set \"FOO=bar\"\r\n")) + assertTrue(text.contains("\"%~dp0Binaries\\Win64\\Celeste.exe\" -EpicPortal -epicusername=\"Some One\" %DD_AUTH%\r\n")) + // Epic reads the one-shot exchange code the app leaves beside the launcher, then deletes it. + assertTrue(text.contains("set /p DD_CODE=<\"%~dp0.droiddeck-epic-code\"")) + assertTrue(text.contains("-AUTH_TYPE=exchangecode")) + } + + @Test fun gogLauncherHasNoEpicBlockAndIsOnlyWrittenWhenNeeded() { + val plain = StoreGameSidecar(Store.GOG, "1", "T", "game.exe") + val text = StoreLaunch.launcherText(plain.copy(args = listOf("-windowed"))) + assertFalse(text.contains("exchangecode")) + assertTrue(text.contains("\"%~dp0game.exe\" -windowed %DD_AUTH%")) + assertEquals("\"two words\"", StoreLaunch.quoteArg("two words")) + assertEquals("-x=\"a b\"", StoreLaunch.quoteArg("-x=\"a b\"")) + } +} diff --git a/docs/progress-log.md b/docs/progress-log.md index 6c2e6295f..6a72f28af 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -7,6 +7,56 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d --- +## 2026-10-08 - `feat/stores`: GOG, Epic Games and Amazon Games in the launcher (in progress) + +A new **Stores** section - the three storefronts' libraries and public catalogs, one download +queue for them, and every install registered in the Steam client exactly like an added folder. +Ported from Bannerlator's store clients (auth, API, library sync, download managers); its +Activities and XML are not ported, the UI is Compose, after the approved preview. The native +download engine is the Rust side's work (`libdroiddeckstores.so`, loaded lazily; the app runs +without it and Setup says "Stores engine not built"). + +- **Where it lives.** Rail item `stores` under Games, gated by Setup › Stores › "Show Stores in + the rail" (`SessionPrefs.gameStoresEnabled`, off by default); a count badge while anything is + downloading. The page is full width: four chips (GOG · Epic · Amazon · Downloads, a signed-in + dot per store) and a cog at the row's end whose popup holds the section's own settings - which + tab a store opens on (`storesOpenTab`, Library by default; applied on chip tap, never while the + user is switching tabs by hand), "Add installed store games to Steam" (`gameStoresAddToSteam`, + on), and the speed tier (`gameStoresSpeedTier`, Balanced / Fast / Max = 16 / 32 / 96 requests in + flight). Setup repeats the rows under the gate. A signed-out store shows its sign-in card first. +- **A store game is an added game.** The install lands at `<Games storage>/Games/<Store>/<title>` + (the SD library's folder when one is chosen, else the runtime's own `/root/Games/Stores`) with a + `.droiddeck-store.json` sidecar (store, id, title, exe, launcher, args, env, version, + addToSteam, art). `AddedGames.scan` now walks those folders too and `scanGame` reads the sidecar + for the exe and the name, so the game goes down the existing path: `session/added-games.json` + → `droiddeck-steam-shortcuts` at the client's next start. The listing is rewritten at install + time, and with a client running the shortcut is added live over the client's DevTools port + (`SteamClient.Apps.AddShortcut` + our tag + the compat tool; `SteamLiveShortcuts`), best-effort. + Uninstall removes the folder, the sidecar and the shortcut. +- **Launch.** A shortcut can only name an exe and the listing carries no launch options, so a game + that needs arguments or environment gets a `.droiddeck-launch.bat` beside it (Proton's steam.exe + shim hands it to Wine's cmd): Epic's identity arguments, Amazon's FuelPump variables. Epic's + per-launch exchange code is minted by the app right before a launch into `.droiddeck-epic-code`, + which the script reads once and deletes; with no code the game starts in its offline identity + mode. The EOS overlay is never provisioned. +- **Games tab badges.** `Library.SteamGame.source` (steam / gog / epic / amazon / added, from the + sidecar) with a coloured chip on every row and in the hero eyebrow; `LibraryCache` keeps it. +- **Downloads.** `DownloadQueue` runs 1-3 at a time, stages Manifest → Download → Verify → + Install, pause = stop and keep the files (every engine skips verified files on the rerun), + cancel deletes; `StoreDownloadService` keeps the process alive meanwhile; the page shows speed, + ETA, the tier, downloads-at-a-time and the engine log. +- Tests: `StoreGameSidecarTest`, `AddedGamesStoreScanTest` (JVM), `tools/tests/test_store_shortcuts.py` + (the writer with a launcher .bat as Exe, the appid the app computes, removal). +- Files: `stores/*` (Store, StoreGameSidecar, StoreInstallRoot, StoreInstalls, StoreLaunch, + StoreAccounts, StoresState, StoresNative, SteamLiveShortcuts, download/*), `ui/StoresPage.kt`, + `ui/StoresDetail.kt`, `ui/StoresDownloads.kt`, `ui/StoreChips.kt`, `ui/StoresWidgets.kt`; + `frontend/AddedGames.kt`, `frontend/Library.kt`, `frontend/LibraryCache.kt`, + `ui/FrontEndRail.kt`, `ui/FrontEndContent.kt`, `ui/FrontEndSetup.kt`, `ui/FrontEndGames.kt`, + `ui/FrontEndScreen.kt`, `MainActivity.kt`, `session/SessionPrefs.kt`, `AndroidManifest.xml` + (the download service), `res/values/strings.xml`. +- Status: milestone (a) - model, sidecar, badges, Setup, the page's skeleton - compiles on CI; + the store clients (b: GOG, c: Epic, d: Amazon) follow in this branch. + ## 2026-10-08 - `feat/directaudio-from-release`: DirectAudio from its own release, picked by interface, and the client gets the real engine Three things were wrong with audio at once, and one tidy-up @xXJSONDeruloXx asked for. diff --git a/tools/tests/test_store_shortcuts.py b/tools/tests/test_store_shortcuts.py new file mode 100644 index 000000000..082011e8f --- /dev/null +++ b/tools/tests/test_store_shortcuts.py @@ -0,0 +1,74 @@ +"""The shortcuts writer and a store install: the app registers a game GOG, Epic or Amazon +installed through the same listing as an added folder, pointing the shortcut at the game's +launcher .bat when it has one. These pin the parts of the writer that contract relies on.""" +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +import zlib + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' + + +def load(name): + loader = importlib.machinery.SourceFileLoader(name, str(BIN / name)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +shortcuts = load('droiddeck-steam-shortcuts') + +FOLDER = '/root/Games/Stores/Epic/Celeste' +LAUNCHER = FOLDER + '/.droiddeck-launch.bat' + + +def app_id(exe, name): + """The appid the app computes for a shortcut (AddedGames.scanGame): CRC32 of the quoted exe + and the name, high bit set - what Steam itself derives for a shortcut it is given.""" + return (zlib.crc32(('"%s"' % exe + name).encode()) | 0x80000000) & 0xFFFFFFFF + + +class StoreShortcutsTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.steam = self.root / 'Steam' + self.config = self.steam / 'userdata/123/config' + self.config.mkdir(parents=True) + self.listing = self.root / 'added-games.json' + + def write(self, *games): + self.listing.write_text(json.dumps(list(games))) + subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(self.listing)], + check=True, capture_output=True) + return shortcuts.parse((self.config / 'shortcuts.vdf').read_bytes())['shortcuts'] + + def test_a_launcher_bat_is_the_shortcuts_exe_and_the_folder_its_start_dir(self): + game = dict(name='Celeste', exe=LAUNCHER, folder=FOLDER, dir=FOLDER, appid=app_id(LAUNCHER, 'Celeste'), seen=0) + entries = self.write(game) + entry = next(iter(entries.values())) + self.assertEqual('"%s"' % LAUNCHER, entry['Exe']) + self.assertEqual('"%s"' % FOLDER, entry['StartDir']) + self.assertEqual('Celeste', entry['AppName']) + self.assertIn('droiddeck-app', entry['tags'].values()) + + def test_the_appid_the_app_computes_is_the_one_written(self): + appid = app_id(LAUNCHER, 'Celeste') + entries = self.write(dict(name='Celeste', exe=LAUNCHER, folder=FOLDER, dir=FOLDER, appid=appid, seen=0)) + entry = next(iter(entries.values())) + self.assertEqual(appid, entry['appid'] & 0xFFFFFFFF) + + def test_an_uninstalled_game_leaves_the_listing_and_the_shortcut_goes_with_it(self): + game = dict(name='Celeste', exe=LAUNCHER, folder=FOLDER, dir=FOLDER, appid=app_id(LAUNCHER, 'Celeste'), seen=0) + self.assertEqual(1, len(self.write(game))) + self.assertEqual(0, len(self.write())) + + +if __name__ == '__main__': + unittest.main()