From 2ff682a4f5e010c8ac2a77a5cbf5c4f1e97ae7a4 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Fri, 30 Oct 2026 16:58:23 -0400 Subject: [PATCH] fix: a folder of games with an extra non-game folder still lists each game (Rance 01) (#1822) * fix: a folder of games still splits into its games when it also holds a folder with no game, as with Rance 01 in a VNs folder A visual-novel folder on game storage held a steamapps folder with no exe in it. The group check asked every subfolder to hold a game, so the whole folder stayed one game and a pick in Rance 01 switched that game instead of adding its own. Folders without a game and Steam library folders no longer count, and old games folders being moved to entries split such a folder the same way. * Check a folder's own entry, removal and store ownership before splitting it, and keep folders with redist or extras as one game An old Games folder that was removed keeps its games removed after the split, a folder already added stays as it is, and a store game's folder is never split. * fix: a split games folder keeps the game it launched and that game's settings When an old games folder entry is split into one entry per game, the folder holding the selected exe is kept even if ranking left it out (or the parent stays whole when the exe sits directly in it), and the parent's name, app ID, launch options and other settings carry over to that game without overwriting settings it already has. * fix: a split games folder never migrates steamapps and keeps the launched exe A saved exe under a steamapps or hidden folder keeps the old entry whole instead of adding that folder as a game, and the game that inherits the old entry's settings keeps the exe the old entry launched when nothing was picked. --- .../droiddeck/launcher/frontend/AddedGames.kt | 6 +- .../launcher/frontend/GameExePicker.kt | 2 + .../launcher/frontend/GamesFolderMigration.kt | 36 +++++-- .../launcher/session/SessionPrefs.kt | 31 ++++-- .../launcher/frontend/AddedExesTest.kt | 13 +++ .../frontend/GamesFolderMigrationTest.kt | 99 +++++++++++++++++++ 6 files changed, 166 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 94b5bba6e..52806833a 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -170,9 +170,9 @@ object AddedGames { val files = folder.listFiles() ?: return null if (files.any { it.isFile && it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) }) return null if (declaredExes(folder).isNotEmpty()) return null - val dirs = files.filter { it.isDirectory && !it.name.startsWith(".") }.sortedBy { it.name.lowercase() } - if (dirs.size < 2 || dirs.any { it.name.lowercase() in GAME_PARTS }) return null - return dirs.takeIf { all -> all.all { rank(it).exes.isNotEmpty() } } + val dirs = files.filter { it.isDirectory && !it.name.startsWith(".") && !isSteamLibraryFolder(it.name) }.sortedBy { it.name.lowercase() } + if (dirs.any { it.name.lowercase() in GAME_PARTS || GameExePicker.auxiliaryDir(it) }) return null + return dirs.filter { rank(it).exes.isNotEmpty() }.takeIf { it.size >= 2 } } private fun expand(folder: File, entries: Set): List = diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameExePicker.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameExePicker.kt index f692c7523..2466c8df8 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameExePicker.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameExePicker.kt @@ -89,6 +89,8 @@ object GameExePicker { return score } + internal fun auxiliaryDir(dir: File): Boolean = skipDir(dir, dir.name) + private fun skipDir(dir: File, relative: String): Boolean { val name = dir.name.lowercase().trim() if (name in SKIP_DIRS || SKIP_DIR_PREFIX.any { name.startsWith(it) }) return true diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt index 2c544b010..91e546957 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt @@ -24,15 +24,33 @@ object GamesFolderMigration { for (root in roots) { var n = 0 val steamInstalls = AddedGames.steamInstallDirs(root.host) - for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) { - if (AddedGames.isSteamLibraryFolder(folder.name) || folder.name.lowercase() in steamInstalls) continue - if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue - if (folder.path in entries) continue - val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile } - val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue - AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null)) - entries.add(folder.path) - if (folder.path !in removed) n++ + for (top in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) { + if (AddedGames.isSteamLibraryFolder(top.name) || top.name.lowercase() in steamInstalls) continue + if (top.absolutePath in storeRoots || StoreGameSidecar.read(top) != null) continue + if (top.path in entries) continue + val topPick = SessionPrefs.addedGameExe(context, top.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile } + val groups = AddedGames.groupGames(top) + val played = topPick ?: groups?.let { AddedGames.rank(top).exes.firstOrNull() } + val owner = played?.let { exe -> top.listFiles { f -> f.isDirectory }?.firstOrNull { exe.path.startsWith(it.path + File.separator) } } + val folders = when { + groups == null -> listOf(top) + topPick == null || owner in groups -> groups + owner == null || owner.name.startsWith(".") || AddedGames.isSteamLibraryFolder(owner.name) -> listOf(top) + else -> (groups + owner).sortedBy { it.name.lowercase() } + } + for (folder in folders) { + if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue + if (folder.path in entries) continue + val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile } + ?: topPick?.takeIf { it.path.startsWith(folder.path + File.separator) } + val ranked = AddedGames.rank(folder).exes.firstOrNull() + val exe = picked ?: played?.takeIf { folder == owner } ?: ranked ?: continue + AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null || exe != ranked)) + entries.add(folder.path) + if (folder != top && folder == owner) SessionPrefs.inheritAddedGame(context, top.path, folder.path) + if (folder != top && top.path in removed) SessionPrefs.setAddedGameRemoved(context, folder.path, true) + else if (folder.path !in removed) n++ + } } Log.i(TAG, "games: migrated ${root.host} → $n games") } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 15f366a10..191cd4c0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -1023,21 +1023,34 @@ object SessionPrefs { if (previous != null && !p.contains("addedAppId:$folderPath")) { val moved = movedAddedGame(p.all, previous, folderPath) moved.keys.forEach { edit.remove(it) } - moved.values.forEach { (name, value) -> - when (value) { - is String -> edit.putString(name, value) - is Long -> edit.putLong(name, value) - is Int -> edit.putInt(name, value) - is Boolean -> edit.putBoolean(name, value) - is Float -> edit.putFloat(name, value) - } - } + moved.values.forEach { (name, value) -> edit.putValue(name, value) } val removed = removedAddedGames(context) if (previous in removed) edit.putString("removedAddedGames", removed.map { if (it == previous) folderPath else it }.joinToString("\n")) } edit.apply() } + fun inheritAddedGame(context: Context, from: String, to: String) = synchronized(addedGameLock) { + val p = prefs(context) + val all = p.all + val edit = p.edit() + all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.forEach { (name, value) -> + val key = name.removeSuffix(from) + to + if (key !in all) edit.putValue(key, value) + } + edit.apply() + } + + private fun android.content.SharedPreferences.Editor.putValue(name: String, value: Any?) { + when (value) { + is String -> putString(name, value) + is Long -> putLong(name, value) + is Int -> putInt(name, value) + is Boolean -> putBoolean(name, value) + is Float -> putFloat(name, value) + } + } + internal fun movedAddedGame(all: Map, from: String, to: String): Map> = all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.mapValues { (name, value) -> val moved = if (value is String && (value == from || value.startsWith("$from/"))) to + value.removePrefix(from) else value diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt index 39591f21a..5e8eed0c8 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -138,6 +138,19 @@ class AddedExesTest { SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") } + @Test fun aGroupFolderWithFoldersHoldingNoGameStillListsEachGame() { + val storage = visualNovels() + val vns = File(storage, "VNs") + File(vns, "steamapps/common").mkdirs() + File(vns, "Notes/readme.txt").apply { parentFile!!.mkdirs(); writeText("x") } + assertEquals(listOf("Albatross", "Angel Beats", "Rance 01", "Sayonara").map { File(vns, it).path }, + AddedGames.scan(app).map { it.folder.path }) + val pick = File(vns, "Rance 01/Rance01.exe") + assertEquals(pick.parentFile!!.path, result(AddedGames.addExe(app, pick)).folder.path) + assertEquals(4, AddedGames.scan(app).size) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + @Test fun anExePickedInAGroupFolderOnGameStorageIsItsOwnGame() { val storage = visualNovels() val vns = File(storage, "VNs") diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GamesFolderMigrationTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GamesFolderMigrationTest.kt index eb101d293..4994b3aeb 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/GamesFolderMigrationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GamesFolderMigrationTest.kt @@ -67,6 +67,105 @@ class GamesFolderMigrationTest { assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty()) } + @Test fun aFolderOfGamesInAnOldGamesFolderBecomesOneEntryPerGame() { + val pc = tmp.newFolder("PC") + val rance = File(pc, "VNs/Rance 01"); exe(pc, "VNs/Rance 01/Rance01.exe") + val albatross = File(pc, "VNs/Albatross"); exe(pc, "VNs/Albatross/Albatross Log.exe") + File(pc, "VNs/steamapps").mkdirs() + val solo = File(pc, "Solo"); exe(pc, "Solo/Solo.exe") + SessionPrefs.setAddedGamesDirs(app, listOf(pc.path)) + GamesFolderMigration.run(app) + assertEquals(setOf(albatross.path, rance.path, solo.path), AddedExes.list(app).map { it.folder }.toSet()) + assertEquals(setOf(albatross.path, rance.path, solo.path), AddedGames.scan(app).map { it.folder.path }.toSet()) + } + + @Test fun aRemovedOrAlreadyAddedFolderOfGamesKeepsItsState() { + val pc = tmp.newFolder("PC") + val gone = File(pc, "Gone"); exe(pc, "Gone/A/a.exe"); exe(pc, "Gone/B/b.exe") + val kept = File(pc, "Kept"); val keptExe = exe(pc, "Kept/A/a.exe"); exe(pc, "Kept/B/b.exe") + val store = File(pc, "Store"); exe(pc, "Store/Content/game.exe"); exe(pc, "Store/Tools/tool.exe") + StoreGameSidecar.file(store).writeText("""{"store":"gog","id":"2","title":"Store Game","exe":"Content/game.exe"}""") + val solo = File(pc, "Solo"); exe(pc, "Solo/Extras/viewer.exe"); exe(pc, "Solo/Content/solo.exe") + SessionPrefs.setAddedGamesDirs(app, listOf(pc.path)) + SessionPrefs.setAddedGameRemoved(app, gone.path, true) + AddedExes.put(app, AddedExes.Entry(kept.path, keptExe.path)) + GamesFolderMigration.run(app) + val entries = AddedExes.list(app).map { it.folder }.toSet() + assertEquals(setOf(File(gone, "A").path, File(gone, "B").path, kept.path, solo.path), entries) + assertTrue(SessionPrefs.removedAddedGames(app).containsAll(listOf(File(gone, "A").path, File(gone, "B").path))) + assertEquals(setOf(kept.path, solo.path), AddedGames.scan(app).map { it.folder.path }.toSet()) + } + + @Test fun aGameSelectedOutsideTheRankedFoldersStaysListed() { + val pc = tmp.newFolder("PC") + val vns = File(pc, "VNs") + exe(pc, "VNs/A/a.exe"); exe(pc, "VNs/B/b.exe") + val pick = exe(pc, "VNs/C/helper.exe") + val loose = File(pc, "Loose"); exe(pc, "Loose/A/a.exe"); exe(pc, "Loose/B/b.exe") + val loosePick = exe(pc, "Loose/setup.exe") + val lib = File(pc, "Lib"); exe(pc, "Lib/A/a.exe"); exe(pc, "Lib/B/b.exe") + val libPick = exe(pc, "Lib/steamapps/common/Tool/helper.exe") + SessionPrefs.setAddedGamesDirs(app, listOf(pc.path)) + SessionPrefs.setAddedGameExe(app, vns.path, pick.path) + SessionPrefs.setAddedGameExe(app, loose.path, loosePick.path) + SessionPrefs.setAddedGameExe(app, lib.path, libPick.path) + GamesFolderMigration.run(app) + val entries = AddedExes.list(app).associateBy { it.folder } + assertEquals(setOf(File(vns, "A").path, File(vns, "B").path, File(vns, "C").path, loose.path, lib.path), entries.keys) + assertEquals(libPick.path, entries.getValue(lib.path).exe) + assertEquals(pick.path, entries.getValue(File(vns, "C").path).exe) + assertTrue(entries.getValue(File(vns, "C").path).picked) + assertEquals(loosePick.path, entries.getValue(loose.path).exe) + val games = AddedGames.scan(app).associateBy { it.folder.path } + assertEquals(pick.path, games.getValue(File(vns, "C").path).exe.path) + assertEquals(loosePick.path, games.getValue(loose.path).exe.path) + } + + @Test fun aSplitFolderHandsItsSettingsToTheGameItLaunched() { + val pc = tmp.newFolder("PC") + val vns = File(pc, "VNs") + exe(pc, "VNs/Albatross/Albatross.exe") + val pick = exe(pc, "VNs/Rance 01/Rance01.exe") + File(pc, "VNs/Notes").mkdirs() + val plain = File(pc, "Plain"); exe(pc, "Plain/A/a.exe"); exe(pc, "Plain/B/b.exe") + SessionPrefs.setAddedGamesDirs(app, listOf(pc.path)) + SessionPrefs.setAddedGameExe(app, vns.path, pick.path) + SessionPrefs.addedGameAppId(app, vns.path, 0x8111_2222L) + SessionPrefs.setAddedGameName(app, vns.path, "Rance") + SessionPrefs.setAddedGameLaunch(app, vns.path, "-windowed") + SessionPrefs.setAddedGameName(app, File(vns, "Rance 01").path, "Rance 01 Remake") + SessionPrefs.addedGameAppId(app, plain.path, 0x8333_4444L) + GamesFolderMigration.run(app) + val rance = File(vns, "Rance 01").path + val games = AddedGames.scan(app).associateBy { it.folder.path } + assertEquals(0x8111_2222L, games.getValue(rance).appId) + assertEquals("Rance 01 Remake", games.getValue(rance).name) + assertEquals("-windowed", games.getValue(rance).launchOptions) + assertEquals(pick.path, games.getValue(rance).exe.path) + assertTrue(games.getValue(File(vns, "Albatross").path).appId != 0x8111_2222L) + assertEquals("", SessionPrefs.addedGameLaunch(app, File(vns, "Albatross").path)) + assertEquals(0x8333_4444L, games.getValue(File(plain, "A").path).appId) + assertTrue(games.getValue(File(plain, "B").path).appId != 0x8333_4444L) + } + + @Test fun aSplitFolderKeepsTheExeItLaunchedWithoutAPick() { + val pc = tmp.newFolder("PC") + val vns = File(pc, "VNs") + val launched = exe(pc, "VNs/Rance/VNsPlayer.exe"); exe(pc, "VNs/Rance/Rance.exe") + val other = exe(pc, "VNs/Other/Other.exe") + SessionPrefs.setAddedGamesDirs(app, listOf(pc.path)) + SessionPrefs.addedGameAppId(app, vns.path, 0x8555_6666L) + GamesFolderMigration.run(app) + val entries = AddedExes.list(app).associateBy { it.folder } + assertEquals(launched.path, entries.getValue(File(vns, "Rance").path).exe) + assertTrue(entries.getValue(File(vns, "Rance").path).picked) + assertFalse(entries.getValue(File(vns, "Other").path).picked) + val games = AddedGames.scan(app).associateBy { it.folder.path } + assertEquals(launched.path, games.getValue(File(vns, "Rance").path).exe.path) + assertEquals(0x8555_6666L, games.getValue(File(vns, "Rance").path).appId) + assertEquals(other.path, games.getValue(File(vns, "Other").path).exe.path) + } + @Test fun runningAgainChangesNothing() { val pc = tmp.newFolder("PC") exe(pc, "Alpha/Alpha.exe")