From d250b43eb9bcfd0126c19571a72104464b426660 Mon Sep 17 00:00:00 2001 From: The412Banner <205237651+The412Banner@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:50:03 -0400 Subject: [PATCH] Games: revamped add games feature (#492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding and managing your own (non-Steam) games moves out of the Steam settings into the Games tab, and works like Steam's own non-Steam Properties. - A + beside "Games N" opens a compact in-app picker: the storage first, then that storage's root, every folder, .exe files only. Picking an exe adds the game with its name, art, Custom badge and Steam shortcut. - "Add all games in this folder" adds every game folder inside at once. A summary opens immediately and fills in as each game is found, with a "?" where the exe was a close call and a red X to remove a game. - An edit button on each Custom game opens its editor: Name, Target (ranked exes, Other exe…), Start in, Launch options, the four pieces of art with thumbnails, and Remove. Edits go through the same Steam shortcut, so the appid never changes and nothing duplicates. - Art chooser per piece: images in the game folder, Steam's official art, SteamGridDB alternatives, your own image, or back to automatic. - Finding the exe: up to five folders deep, every exe scored (name match, Unreal -Shipping builds, bin folders and size up; launchers, crash reporters, installers, redistributables and depth down). - Real titles from the game's files (Steam manifest, GOG info, the exe's version info), keeping the folder's spacing when both spell the same title, underscores read as spaces. - Art: the exact Steam appid when the game names one, strict name matches otherwise, Steam's hashed asset paths for newer games, SteamGridDB as a fallback (built-in key from the SGDB_API_KEY secret, optional own key in Setup, sealed with the Keystore), and a cover made from the exe's icon when nothing else has one. - Removing a game is instant and keeps its files; adding it again brings it back. Steam's shortcut list is rewritten once, in the background. - The Games section is gone from Steam settings; folders added before keep listing their games. Artwork switch and SteamGridDB key are in Setup. --- .github/workflows/build.yml | 1 + app/build.gradle | 1 + .../com/droiddeck/launcher/MainActivity.kt | 118 ++-- .../droiddeck/launcher/frontend/AddedExes.kt | 48 ++ .../launcher/frontend/AddedGameArt.kt | 305 ++++++++-- .../launcher/frontend/AddedGameEdits.kt | 77 +++ .../droiddeck/launcher/frontend/AddedGames.kt | 295 +++++++++- .../launcher/frontend/AddedGamesPublisher.kt | 40 ++ .../launcher/frontend/GameExePicker.kt | 130 ++++ .../launcher/frontend/GameIdentifier.kt | 328 +++++++++++ .../droiddeck/launcher/frontend/Library.kt | 27 +- .../launcher/frontend/PeVersionInfo.kt | 250 ++++++++ .../launcher/frontend/SteamGridDb.kt | 160 +++++ .../launcher/session/SessionPrefs.kt | 58 ++ .../launcher/stores/SteamLiveShortcuts.kt | 47 +- .../launcher/stores/StoreInstalls.kt | 2 +- .../com/droiddeck/launcher/ui/AddAppDialog.kt | 20 +- .../droiddeck/launcher/ui/AddedGameEditor.kt | 555 ++++++++++++++++++ .../droiddeck/launcher/ui/ExePickerDialog.kt | 234 ++++++++ .../droiddeck/launcher/ui/FrontEndGames.kt | 145 ++++- .../droiddeck/launcher/ui/FrontEndScreen.kt | 12 + .../droiddeck/launcher/ui/FrontEndSetup.kt | 9 + .../launcher/ui/ModeSettingsDialog.kt | 64 +- .../droiddeck/launcher/ui/SettingsWidgets.kt | 13 + app/src/main/res/values-es/strings.xml | 59 +- app/src/main/res/values-fr/strings.xml | 55 +- app/src/main/res/values-ja/strings.xml | 55 +- app/src/main/res/values-ko/strings.xml | 55 +- app/src/main/res/values-ru/strings.xml | 59 +- app/src/main/res/values-zh-rCN/strings.xml | 55 +- app/src/main/res/values-zh-rHK/strings.xml | 55 +- app/src/main/res/values-zh-rTW/strings.xml | 55 +- app/src/main/res/values/strings.xml | 59 +- .../launcher/frontend/AddedExesTest.kt | 106 ++++ .../frontend/AddedGameArtLookupTest.kt | 102 ++++ .../launcher/frontend/AddedGameEditsTest.kt | 148 +++++ .../frontend/AddedGamesCandidatesTest.kt | 151 +++++ .../launcher/frontend/AddedGamesRemoveTest.kt | 74 +++ .../frontend/AddedGamesSpellingTest.kt | 14 + .../launcher/frontend/GameIdentifierTest.kt | 219 +++++++ .../droiddeck/launcher/ui/PickListingTest.kt | 52 ++ docs/development/added-games.md | 77 +++ docs/progress-log.md | 26 + .../usr/local/bin/droiddeck-steam-shortcuts | 19 +- tools/tests/test_steam_shortcuts.py | 36 ++ 45 files changed, 4175 insertions(+), 295 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/AddedExes.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/AddedGameEdits.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/GameExePicker.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/GameIdentifier.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/PeVersionInfo.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGameEditsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesCandidatesTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesRemoveTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesSpellingTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/GameIdentifierTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt create mode 100644 docs/development/added-games.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e8960396..389705314 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -411,6 +411,7 @@ jobs: id: build env: DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }} + SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }} run: | set -o pipefail python3 tools/release/runtime_inputs.py record diff --git a/app/build.gradle b/app/build.gradle index a69f39097..7734b1098 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -94,6 +94,7 @@ android { buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\"" buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L" buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\"" + buildConfigField "String", "SGDB_API_KEY", "\"${System.getenv("SGDB_API_KEY") ?: project.findProperty("sgdbApiKey") ?: ""}\"" buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\"" buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\"" buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\"" diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 8a4e27113..435f535a3 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -301,29 +301,34 @@ class MainActivity : ComponentActivity() { GameSaves.savesDir().mkdirs() pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path)) } - private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> - SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path) - addedGamesDirs = SessionPrefs.addedGamesDirs(this) - addedGamesArt = SessionPrefs.addedGamesArt(this) - refreshAddedGames() - refresh() - } + /** The Games tab's +: the picked .exe becomes a game (or selects / switches the one it belongs to). */ + private fun addGameExe(path: String) { + val exe = java.io.File(path) + Thread({ + val result = try { com.droiddeck.launcher.frontend.AddedGames.addExe(this, exe) } catch (e: Exception) { + Log.w(TAG, "add game: ${e.message}"); com.droiddeck.launcher.frontend.AddedGames.AddResult.NotFound + } + val game = when (result) { + is com.droiddeck.launcher.frontend.AddedGames.AddResult.Added -> result.game + is com.droiddeck.launcher.frontend.AddedGames.AddResult.Existing -> result.game + is com.droiddeck.launcher.frontend.AddedGames.AddResult.Switched -> result.game + else -> null + } + ui.post { + if (game == null) { + if (result is com.droiddeck.launcher.frontend.AddedGames.AddResult.Unreachable) + android.widget.Toast.makeText(this, R.string.games_add_unreachable, android.widget.Toast.LENGTH_LONG).show() + return@post + } + updateAddedGames(listOf(game.folder.path), then = { fetchAddedGameArt() }) + navRequest = "app:${game.steamAppId ?: game.appId.toInt()}" + } + }, "add-game-exe").start() } - private var pendingAddedGame: String? = null - private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> - val folder = pendingAddedGame ?: return@registerForActivityResult - pendingAddedGame = null - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> - SessionPrefs.setAddedGameExe(this, folder, path) - refreshAddedGames() - refresh() - } - } - private var addedGamesDirs by mutableStateOf>(emptyList()) private var addedGamesArt by mutableStateOf(true) @Volatile private var artFetchRunning = false - private var addedGames by mutableStateOf>(emptyList()) + /** The user entered a SteamGridDB API key of their own. */ + private var sgdbUserKey by mutableStateOf(false) private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> SessionPrefs.setRomsDir(this, path) @@ -590,6 +595,8 @@ class MainActivity : ComponentActivity() { gameStoresSpeedTier = gameStoresSpeedTier, storesOpenTab = storesOpenTab, navigate = navRequest, + addedGamesArt = addedGamesArt, + sgdbUserKey = sgdbUserKey, storesShowMature = storesShowMature, storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads, ), @@ -606,6 +613,20 @@ class MainActivity : ComponentActivity() { onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) }, onExportGameFile = { g -> pickGameExport(g) }, onSyncGameFiles = { pickGameExport(null) }, + onAddGameExe = { path -> addGameExe(path) }, + onAddedGamesChanged = { folders -> + // New games want art; an edit does not. + val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet() + updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() }) + }, + onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) }, + onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() }, + onSgdbKey = { key -> + Thread({ + com.droiddeck.launcher.frontend.SteamGridDb.setUserKey(this, key) + ui.post { sgdbUserKey = key.isNotBlank(); refreshAddedGames() } + }, "sgdb-key").start() + }, onStopGameFileSync = { Thread({ GameFileSync.disable(this) @@ -1252,9 +1273,6 @@ class MainActivity : ComponentActivity() { wifiDiscoveryLocation = wifiDiscoveryLocation, wifiDiscoveryAsked = wifiDiscoveryAsked, wifiDiscoveryBlocked = wifiDiscoveryBlocked, - addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, - addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), - addedGamesArt = addedGamesArt, deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null, deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null, deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent, @@ -1335,14 +1353,6 @@ class MainActivity : ComponentActivity() { SessionPrefs.setRunSteamAtStartup(this, on) runSteamAtStartup = on }, - onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) }, - onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() }, - onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() }, - onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() }, - onPickAddedGameExe = { folder -> - pendingAddedGame = folder - pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder)) - }, onDeckyInstall = { release -> decky.installDecky(release) }, onDeckyCheck = { decky.refreshDecky() }, onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled }, @@ -1397,18 +1407,42 @@ class MainActivity : ComponentActivity() { ) } - /** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */ - /** The added games for the session settings: a folder walk, so off the main thread. */ - private fun refreshAddedGames() { + /** + * The Games tab after these added games were added or edited: each read alone and put in + * place of its old entry (or added), no library-wide scan. [then] runs after, on the main thread. + */ + private fun updateAddedGames(folders: List, then: () -> Unit = {}) { + if (folders.isEmpty()) return Thread({ - val games = scanAddedGames() - ui.post { addedGames = games; fetchAddedGameArt() } - }, "added-games").start() + val fresh = folders.mapNotNull { f -> + com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) } + } + ui.post { + val paths = folders.toSet() + steamGames = (steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path in paths } + fresh).distinctBy { it.gameId } + then() + } + }, "added-games-update").start() } - /** Walks the added-games folders, which can sit on slow shared storage or an SD card. */ - private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g -> - com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first }) + /** An added game removed: off the Games tab now; the rest (cache, Steam, listing) off the main thread. */ + private fun removeAddedGame(folder: String, appId: Long?, name: String) { + steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder } + Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start() + } + + /** After the added games changed: art that is missing is fetched (off the main thread). */ + private fun refreshAddedGames() { + sgdbKeyKnown() + fetchAddedGameArt() + } + + /** Whether the user has a SteamGridDB key of their own, for Setup; read off the main thread. */ + private fun sgdbKeyKnown() { + Thread({ + val set = com.droiddeck.launcher.frontend.SteamGridDb.userKey(this).isNotEmpty() + ui.post { sgdbUserKey = set } + }, "sgdb-key-state").start() } private fun fetchAddedGameArt() { @@ -1447,7 +1481,6 @@ class MainActivity : ComponentActivity() { mangoapp = SessionPrefs.mangoapp(this) steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) - addedGamesDirs = SessionPrefs.addedGamesDirs(this) hdrOn = SessionPrefs.hdr(this, mode) fpsLimit = SessionPrefs.fpsLimit(this, mode) upscaler = SessionPrefs.upscaler(this) @@ -1469,12 +1502,10 @@ class MainActivity : ComponentActivity() { // added-games folders, the storage volumes) lands while it animates in. Thread({ drivers.refreshDrivers() - val games = scanAddedGames() val storage = GameStorage.options(this) val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this) ui.post { - addedGames = games storageOptions = storage if (mode == SessionService.MODE_STEAM) { decky.deckyInstalled = deckyInstalled @@ -1523,6 +1554,7 @@ class MainActivity : ComponentActivity() { private fun refresh() { if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) } + addedGamesArt = SessionPrefs.addedGamesArt(this) desktopInstalled = DesktopCatalog.desktopInstalled(this) offlineAccount = OfflineMode.account(this) offline = OfflineMode.enabled(this) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedExes.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedExes.kt new file mode 100644 index 000000000..62afb0b8a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedExes.kt @@ -0,0 +1,48 @@ +package com.droiddeck.launcher.frontend + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Games added from the Games tab's + rather than a Games folder: one picked .exe, or each game + * "Add all games in this folder" found. Kept by the game's folder in `files/added-exes.json`, + * which [AddedGames.scan] merges in. The exe chosen later, the shortcut appid, the edits and the + * removal live where every added game keeps them ([com.droiddeck.launcher.session.SessionPrefs]); + * this holds which folders are games and the exe they came with. + */ +object AddedExes { + /** [picked]: the user chose [exe]; false = [GameExePicker]'s best guess, which a scan ranks again. */ + class Entry(val folder: String, val exe: String, val picked: Boolean = true) + + private fun file(context: Context) = File(context.filesDir, "added-exes.json") + + @Synchronized + fun list(context: Context): List = try { + val array = JSONArray(file(context).takeIf { it.isFile }?.readText() ?: "[]") + (0 until array.length()).mapNotNull { i -> + val o = array.optJSONObject(i) ?: return@mapNotNull null + val folder = o.optString("folder"); val exe = o.optString("exe") + if (folder.isEmpty() || exe.isEmpty()) null else Entry(folder, exe, o.optBoolean("picked", true)) + } + } catch (e: Exception) { + emptyList() + } + + /** Adds [entry], replacing one for the same folder. */ + @Synchronized + fun put(context: Context, entry: Entry) = write(context, list(context).filter { it.folder != entry.folder } + entry) + + @Synchronized + fun drop(context: Context, folder: String) = write(context, list(context).filter { it.folder != folder }) + + private fun write(context: Context, entries: List) { + val array = JSONArray() + entries.forEach { array.put(JSONObject().put("folder", it.folder).put("exe", it.exe).put("picked", it.picked)) } + val f = file(context) + val tmp = File(f.path + ".tmp") + tmp.writeText(array.toString()) + if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt index ecc92a1db..a29e43679 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt @@ -12,8 +12,9 @@ import java.net.URL import java.net.URLEncoder /** - * Artwork for an added game: what the user put in the game's folder first, else what Steam's - * store has for the same title, fetched once into the app's cache. Four pieces, named the way + * Artwork for an added game: what the user put in the game's folder first, else what Steam has + * for it - by the Steam appid its files name, else by a store search on its title - else what + * SteamGridDB has ([SteamGridDb]), fetched once into the app's cache. Four pieces, named the way * the client names them in its grid folder: the portrait capsule, the wide header, the hero * background and the logo; plus an icon pulled out of the game's own .exe. The session hands * the paths to the runtime's shortcuts writer, which copies them into the client's grid. @@ -28,13 +29,40 @@ object AddedGameArt { val any get() = portrait != null || header != null || hero != null || logo != null } + /** + * The pieces a user can choose, as the client takes them: [assetType] is its custom-artwork + * number (null: the icon, set by path); [cacheName] the automatic file in the game's cache. + */ + enum class Slot(val key: String, val cacheName: String, val assetType: Int?) { + COVER("p", "p.jpg", 0), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null), + } + + /** Where a piece came from; [AUTO] = nothing found yet. */ + const val FOLDER = "folder" + const val STEAM = "steam" + const val SGDB = "sgdb" + const val FILE = "file" + const val EXE = "exe" + const val AUTO = "" + + /** A slot as it stands: the file shown and where it came from (one of [FOLDER].. [AUTO]); [chosen] = the user picked it. */ + class SlotState(val slot: Slot, val file: File?, val source: String, val chosen: Boolean) + + /** One image the chooser offers: its group ([FOLDER], [STEAM], [SGDB]), a thumbnail and the full image (URL or path). */ + class Option(val group: String, val thumb: String, val full: String) + + private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""") + + private fun chosen(cache: File, slot: Slot): File? = + cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull() + private val IMAGE = listOf("png", "jpg", "jpeg") private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait") private val HEADER = listOf("header", "banner", "library_header", "wide") private val HERO = listOf("hero", "background", "library_hero") private val LOGO = listOf("logo") - private fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}") + internal fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}") /** An image in the folder (or its `art` subfolder) under one of the given names, any image extension. */ private fun find(folder: File, names: List): File? { @@ -58,20 +86,112 @@ object AddedGameArt { ) } - /** The folder's art, else the cached Steam art, without touching the network. */ + /** The user's choice for each piece, else the folder's art, else the cached art; no network. */ fun resolve(context: Context, game: AddedGames.Game): Art { val own = local(game) val cache = cacheDir(context, game) fun cached(name: String) = File(cache, name).takeIf { it.isFile } return Art( - portrait = own.portrait ?: cached("p.jpg"), - header = own.header ?: cached("header.jpg"), - hero = own.hero ?: cached("hero.jpg"), - logo = own.logo ?: cached("logo.png"), - icon = own.icon ?: exeIcon(context, game), + portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true), + header = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false), + hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"), + logo = chosen(cache, Slot.LOGO) ?: own.logo ?: cached("logo.png"), + icon = chosen(cache, Slot.ICON) ?: own.icon ?: cached("icon-sgdb.png") ?: exeIcon(context, game), ) } + /** Each choosable piece with its file and where it came from, for the game's editor; no network. */ + fun describe(context: Context, game: AddedGames.Game): List { + val own = local(game) + val cache = cacheDir(context, game) + fun cached(name: String): Pair? = File(cache, name).takeIf { it.isFile }?.let { f -> + f to (File(cache, "$name.src").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: STEAM) + } + return Slot.values().map { slot -> + chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) } + val auto: Pair? = when (slot) { + Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE } + Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg") + Slot.LOGO -> own.logo?.let { it to FOLDER } ?: cached("logo.png") + Slot.ICON -> own.icon?.let { it to FOLDER } ?: File(cache, "icon-sgdb.png").takeIf { it.isFile }?.let { it to SGDB } + ?: exeIcon(context, game)?.let { it to EXE } + } + SlotState(slot, auto?.first, auto?.second ?: AUTO, false) + } + } + + private val CHOOSABLE = setOf("png", "jpg", "jpeg", "webp") + + /** Images in the game's folder (and its art folders) that could stand for a piece. */ + internal fun folderImages(folder: File): List = + listOf(folder, File(folder, "art"), File(folder, ".art")).flatMap { dir -> + dir.listFiles { f -> f.isFile && f.extension.lowercase() in CHOOSABLE }?.sortedBy { it.name.lowercase() }.orEmpty() + }.take(12) + + /** + * What the chooser offers for [slot]: the game folder's images, Steam's official piece (when the + * game has a Steam appid and Steam has the piece), and SteamGridDB's best few (with a key). + * Blocking: it asks the network. + */ + fun options(context: Context, game: AddedGames.Game, slot: Slot): List