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")