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