Stores: the section's skeleton, store sidecar on added games, source badges, Setup gate

A new Stores rail item (GOG, Epic Games, Amazon Games), off by default behind
Setup > Stores. The page is full width: four chips and a cog whose popup
holds the section's settings (which tab a store opens on, add installs to
Steam, speed tier); a signed-out store shows its sign-in card.

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

The download queue, its foreground service and the Downloads page are in
place for the store clients that follow.
This commit is contained in:
The412Banner committed 2026-10-08 16:40:30 -04:00
1 parent 0091b751f4
commit 9f71b427bf
35 files changed
+2911 -22

No files matched your search

+5
View File
@@ -163,6 +163,11 @@
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
<service
android:name=".stores.download.StoreDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -164,6 +164,10 @@ class MainActivity : ComponentActivity() {
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var gameStoresEnabled by mutableStateOf(false)
private var gameStoresAddToSteam by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
@@ -523,6 +527,10 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresAddToSteam = SessionPrefs.gameStoresAddToSteam(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
applyLauncherFullscreen()
updates.start()
setContent {
@@ -576,6 +584,11 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
gameStoresEnabled = gameStoresEnabled,
gameStoresAddToSteam = gameStoresAddToSteam,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
@@ -586,7 +599,8 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
// A store game may need something done first (an Epic game its sign-in code).
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
@@ -702,6 +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 ->
@@ -6,6 +6,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import java.io.File
import java.util.zip.CRC32
@@ -16,13 +18,21 @@ object AddedGames {
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
class Game(
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
val folder: File, val name: String,
/** The game's own .exe: what the icon is read from and what the settings page lists. */
val exe: File,
/** The guest path the shortcut runs: [exe], or a store install's launcher .bat in front of it. */
val guestExe: String, val guestDir: String,
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
val appId: Long,
/** What steam://rungameid/ takes for a shortcut. */
val gameId: Long,
val candidates: List<File>,
val steamAppId: Int? = null,
/** Where the game came from: a store's id, or [Library.ADDED] for a folder the user added. */
val source: String = Library.ADDED,
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
val storeId: String? = null,
) {
fun folderName(): String = folder.name
}
@@ -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 <root>/<Store>/, 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<Game>) {
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))
}
}
@@ -24,12 +24,18 @@ object Library {
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
val icon: File? = null,
/** Where the game came from: [SOURCE_STEAM], a store's id (gog, epic, amazon) or [ADDED] for a folder the user added. */
val source: String = SOURCE_STEAM,
/** The store's own id for a store install; null otherwise. */
val storeId: String? = null,
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
const val ADDED = "added"
/** The [SteamGame.source] of a title Steam itself installed. */
const val SOURCE_STEAM = "steam"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
@@ -155,6 +161,7 @@ object Library {
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
)
}
}).distinctBy { it.gameId }
@@ -26,6 +26,8 @@ object LibraryCache {
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
icon = o.file("icon"),
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
)
}
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
@@ -36,7 +38,8 @@ object LibraryCache {
for (g in games) array.put(
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -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
@@ -0,0 +1,187 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.security.SecureRandom
/**
* Adds and removes a store game's shortcut in a Steam client that is running right now, so it
* shows without waiting for the next client start. The runtime starts the client with its
* DevTools port open (droiddeck-session writes it to session/agent/cdp-port) and the client's own
* JavaScript API has `SteamClient.Apps.AddShortcut`; this speaks the small part of the DevTools
* protocol that one `Runtime.evaluate` needs, over a plain socket, nothing else.
*
* Best-effort throughout: no session, no port, no SharedJSContext, or an API that moved and the
* shortcut simply arrives with the shortcuts writer at the next start. The appid Steam derives
* (CRC of the quoted exe plus the name) is the one the writer computes, so the two never disagree.
*/
object SteamLiveShortcuts {
private const val TAG = "SteamLive"
private const val TAG_NAME = "droiddeck-app"
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
/** Adds every store game in [games] the client does not list yet. Blocking; worker thread. */
fun sync(context: Context, games: List<AddedGames.Game>) {
val wanted = games.filter { it.source != Library.ADDED }
if (wanted.isEmpty() || !clientRunning()) return
val list = JSONArray()
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir))
val js = """
(async () => {
const want = ${list};
const have = await SteamClient.Apps.GetAllShortcuts();
const strip = s => (s || "").replace(/^"|"$/g, "");
let added = 0;
for (const w of want) {
if (have.some(a => a.data && strip(a.data.strExePath) === w.exe && a.data.strAppName === w.name)) continue;
const id = await SteamClient.Apps.AddShortcut(w.name, w.exe, w.dir, "");
if (!id) continue;
try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {}
try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {}
added++;
}
return added;
})()
""".trimIndent()
val result = evaluate(context, js) ?: return
Log.i(TAG, "live shortcuts: $result added")
if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client")
}
/** Removes the shortcut with [appId] from the running client. Blocking; worker thread. */
fun remove(context: Context, appId: Long) {
if (!clientRunning()) return
val result = evaluate(context, "(async () => { await SteamClient.Apps.RemoveShortcut(${appId and 0xFFFFFFFFL}); return 1; })()")
if (result != null) Log.i(TAG, "live shortcuts: removed $appId")
}
private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested
/** `Runtime.evaluate` of [expression] in the client's SharedJSContext; the value as text, or null. */
private fun evaluate(context: Context, expression: String): String? {
val portFile = File(LinuxRuntime.sessionRoot(context), "agent/cdp-port")
val port = runCatching { portFile.readText().trim().toInt() }.getOrNull() ?: return null
val targets = runCatching { JSONArray(get("http://127.0.0.1:$port/json")) }.getOrNull() ?: return null
var wsUrl: String? = null
for (i in 0 until targets.length()) {
val t = targets.optJSONObject(i) ?: continue
if (t.optString("title").contains("SharedJSContext") || t.optString("url").contains("SharedJSContext")) { wsUrl = t.optString("webSocketDebuggerUrl").ifEmpty { null }; break }
}
if (wsUrl == null) { Log.w(TAG, "no SharedJSContext target"); return null }
return try {
WebSocket(wsUrl, 8000).use { ws ->
ws.sendText(JSONObject().put("id", 1).put("method", "Runtime.evaluate").put("params", JSONObject()
.put("expression", expression).put("returnByValue", true).put("awaitPromise", true).put("userGesture", true)).toString())
val deadline = System.currentTimeMillis() + 15_000
while (System.currentTimeMillis() < deadline) {
val message = JSONObject(ws.receiveText() ?: return null)
if (message.optInt("id") != 1) continue
if (message.has("error")) { Log.w(TAG, "cdp: ${message.optJSONObject("error")}"); return null }
val result = message.optJSONObject("result") ?: return null
result.optJSONObject("exceptionDetails")?.let { Log.w(TAG, "js: ${it.optJSONObject("exception")?.optString("description") ?: it.optString("text")}"); return null }
return result.optJSONObject("result")?.opt("value")?.toString()
}
null
}
} catch (e: Exception) {
Log.w(TAG, "cdp: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
private fun get(url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 3000; c.readTimeout = 3000
try { return c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
}
/** The least of RFC 6455 a localhost DevTools talk needs: upgrade, masked text out, text in. */
private class WebSocket(url: String, timeoutMs: Int) : AutoCloseable {
private val socket: Socket
private val out: OutputStream
private val input: DataInputStream
init {
val u = URL(url.replaceFirst("ws://", "http://"))
socket = Socket(u.host, if (u.port > 0) u.port else 80).apply { soTimeout = timeoutMs }
out = socket.getOutputStream()
input = DataInputStream(socket.getInputStream())
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
val path = u.path.ifEmpty { "/" } + (u.query?.let { "?$it" } ?: "")
out.write(("GET $path HTTP/1.1\r\nHost: ${u.host}:${u.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Key: ${Base64.encodeToString(key, Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
out.flush()
val status = readLine(input) ?: throw java.io.IOException("no upgrade response")
if (!status.contains(" 101 ")) throw java.io.IOException("upgrade refused: $status")
while (true) { val line = readLine(input) ?: break; if (line.isEmpty()) break }
}
private fun readLine(s: InputStream): String? {
val buf = ByteArrayOutputStream()
while (true) {
val b = s.read()
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
buf.write(b)
}
}
fun sendText(text: String) {
val payload = text.toByteArray()
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
val frame = ByteArrayOutputStream()
frame.write(0x81)
when {
payload.size < 126 -> frame.write(0x80 or payload.size)
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
else -> { frame.write(0x80 or 127); for (i in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * i)) and 0xFF).toInt()) }
}
frame.write(mask)
for (i in payload.indices) frame.write((payload[i].toInt() xor mask[i % 4].toInt()) and 0xFF)
out.write(frame.toByteArray()); out.flush()
}
/** The next text message (fragments joined); null on close. */
fun receiveText(): String? {
val message = ByteArrayOutputStream()
while (true) {
val b0 = input.read(); if (b0 < 0) return null
val b1 = input.read(); if (b1 < 0) return null
val fin = b0 and 0x80 != 0
val opcode = b0 and 0x0F
var len = (b1 and 0x7F).toLong()
if (len == 126L) len = input.readUnsignedShort().toLong()
else if (len == 127L) len = input.readLong()
val masked = b1 and 0x80 != 0
val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null
if (len > 16L * 1024 * 1024) throw java.io.IOException("frame too large")
val data = ByteArray(len.toInt()).also { input.readFully(it) }
if (mask != null) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i % 4].toInt()).toByte()
when (opcode) {
0x8 -> return null
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
0x1, 0x0 -> { message.write(data); if (fin) return message.toString("UTF-8") }
else -> {}
}
}
}
override fun close() { runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }; runCatching { socket.close() } }
}
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.stores
/**
* The three storefronts the Stores section talks to. [id] is what the sidecar, the prefs file
* names and the Games badge carry; it never changes once a game is installed under it.
*/
enum class Store(val id: String, val label: String, val shortLabel: String) {
GOG("gog", "GOG", "GOG"),
EPIC("epic", "Epic Games", "Epic"),
AMAZON("amazon", "Amazon Games", "Amazon");
companion object {
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
}
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.File
/**
* Each store's sign-in, kept in the app's private files: `filesDir/stores/<store>/credentials.json`.
* Tokens never go to shared storage and never into a log; the file holds whatever the store's auth
* flow handed back (access and refresh tokens, expiry, the account's display name, and for Amazon
* the device registration), as one JSON object the store's own client reads and writes.
*/
object StoreAccounts {
private const val TAG = "StoreAccounts"
private const val FILE = "credentials.json"
/** The store's private folder: credentials, caches, the engine's scratch. */
fun dir(context: Context, store: Store): File = File(File(context.filesDir, "stores"), store.id).apply { mkdirs() }
fun credentialsFile(context: Context, store: Store): File = File(dir(context, store), FILE)
/** The stored credentials, or null when signed out or unreadable (never logged: the text is the secret). */
fun read(context: Context, store: Store): JSONObject? {
val f = credentialsFile(context, store)
if (!f.isFile) return null
return try { JSONObject(f.readText()) } catch (e: Exception) { Log.w(TAG, "${store.id}: credentials unreadable (${e.javaClass.simpleName})"); null }
}
fun write(context: Context, store: Store, json: JSONObject) {
val f = credentialsFile(context, store)
val tmp = File(f.path + ".tmp")
tmp.writeText(json.toString())
tmp.setReadable(false, false); tmp.setReadable(true, true)
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
fun clear(context: Context, store: Store) {
credentialsFile(context, store).delete()
}
/** The account's display name when signed in (the store's own name for it, or a stand-in), else null. */
fun signedInAs(context: Context, store: Store): String? {
val json = read(context, store) ?: return null
if (json.optString("access_token", "").isEmpty()) return null
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
}
}
@@ -0,0 +1,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 <store>" land. */
val storeUrl: String = "",
val developer: String = "",
val releaseDate: String = "",
val description: String = "",
/** True for a title the signed-in account owns (a library entry). */
val owned: Boolean = false,
/** The install size in bytes when known, else 0. */
val sizeBytes: Long = 0L,
/** Per-store identifiers the install needs (Epic namespace / catalog id / app name, Amazon entitlement / sku). */
val extra: Map<String, String> = emptyMap(),
) {
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
/** Stable key across stores ("gog:1207658924"). */
val key: String get() = "${store.id}:$id"
}
/** A store's storefront shelves: what the Store tab shows above the account's own library. */
class StoreShelves(
val whatsNew: List<CatalogItem> = emptyList(),
val deals: List<CatalogItem> = emptyList(),
val free: List<CatalogItem> = emptyList(),
val trending: List<CatalogItem> = emptyList(),
) {
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
}
/** A game a store installed, as found on disk through its sidecar. */
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"
@@ -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<String> = emptyList(),
val env: Map<String, String> = 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<String, String> = 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<String> = this.args,
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
installedAt: Long = this.installedAt, addToSteam: Boolean = this.addToSteam,
cover: String? = this.cover, hero: String? = this.hero, extra: Map<String, String> = 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<String, String>()
o.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.optString(k) } }
val extra = LinkedHashMap<String, String>()
o.optJSONObject("extra")?.let { e -> e.keys().forEach { k -> extra[k] = e.optString(k) } }
return StoreGameSidecar(
store, id, title, exe, launcher, args, env,
installVersion = o.optString("installVersion", ""),
installedAt = o.optLong("installedAt", 0L),
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 == ".." }
}
}
@@ -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 storage>/Games/<Store>/<title>`.
*
* 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" } }
}
}
@@ -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()
}
}
@@ -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?
}
@@ -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
}
@@ -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)
}
@@ -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()
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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))
}
}
@@ -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)
@@ -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,
)
}
}
}
}
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LocalMall
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.SystemUpdate
@@ -57,6 +58,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -101,7 +103,7 @@ internal fun SideRail(
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
Column(
@@ -112,6 +114,9 @@ internal fun SideRail(
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
// The game stores, right under Games: what they install lands there. Its badge counts
// the downloads in flight, so a download started here is never out of sight.
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
@@ -134,7 +139,10 @@ internal fun SideRail(
@Composable
private fun RailItem(
label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean, height: Dp,
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
badge: Boolean = false,
/** A number on the item (downloads in flight); 0 shows nothing. */
count: Int = 0,
onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -182,6 +190,12 @@ private fun RailItem(
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
.size(8.dp).clip(CircleShape).background(AttentionAmber),
)
if (count > 0) Text(
if (count > 9) "9+" else count.toString(),
fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 4.dp else 6.dp, end = if (iconOnly) 5.dp else 12.dp)
.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 5.dp, vertical = 1.dp),
)
}
}
@@ -129,6 +129,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"
@@ -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()
}
}
}
}
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.stores.Store
// Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder.
/** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */
internal class SourceColours(val fill: Color, val ink: Color, val dot: Color)
internal fun sourceColours(source: String): SourceColours = when (source) {
Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8))
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
}
@Composable
internal fun sourceLabel(source: String): String = when (source) {
Library.SOURCE_STEAM -> stringResource(R.string.source_steam)
Store.GOG.id -> Store.GOG.label
Store.EPIC.id -> Store.EPIC.label
Store.AMAZON.id -> Store.AMAZON.label
else -> stringResource(R.string.source_added)
}
/** The source chip itself; [small] for a list row, the regular size for a hero or a card. */
@Composable
internal fun SourceChip(source: String, small: Boolean = false, modifier: Modifier = Modifier) {
val c = sourceColours(source)
Text(
sourceLabel(source), fontSize = if (small) 10.sp else 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.4.sp,
color = c.ink, maxLines = 1,
modifier = modifier.clip(RoundedCornerShape(6.dp)).background(c.fill)
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 1.dp else 2.dp),
)
}
/** A status chip in the stores' colours: green for done ("Installed"), blue for busy, amber for a warning. */
@Composable
internal fun StateChip(text: String, tone: ChipTone, small: Boolean = false) {
val pal = LocalPalette.current
val (fill, ink) = when (tone) {
ChipTone.OK -> Color(0xFF16321F) to pal.good
ChipTone.BUSY -> pal.signal.copy(alpha = 0.18f) to pal.signal
ChipTone.WARN -> Color(0xFF3A2E10) to Color(0xFFFFC24D)
ChipTone.DEAL -> Color(0xFF1E3A1A) to Color(0xFF9BE27A)
ChipTone.PLAIN -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text, fontSize = if (small) 9.sp else 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.3.sp, color = ink, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(fill).padding(horizontal = if (small) 5.dp else 8.dp, vertical = if (small) 1.dp else 2.dp),
)
}
internal enum class ChipTone { OK, BUSY, WARN, DEAL, PLAIN }
@@ -0,0 +1,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))
}
}
@@ -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) } }
}
}
@@ -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) }
}
@@ -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)
}
}
+128
View File
@@ -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>
@@ -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))
}
}
@@ -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\""))
}
}
+50
View File
@@ -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.
+74
View File
@@ -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()