From 42fa39672b1874d6e93cbe70ede557754476bc37 Mon Sep 17 00:00:00 2001 From: Jochem Kuipers Date: Mon, 27 Oct 2025 11:56:17 +0100 Subject: [PATCH 1/2] feat: Add functionality to auto-load DLCs and Title Updates from configured directories --- .../android/viewmodels/DlcViewModel.kt | 2 +- .../android/viewmodels/HomeViewModel.kt | 131 +++++++++++++++++- .../android/viewmodels/SettingsViewModel.kt | 24 ++++ .../viewmodels/TitleUpdateViewModel.kt | 2 +- .../org/kenjinx/android/views/SettingViews.kt | 18 +++ 5 files changed, 173 insertions(+), 4 deletions(-) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt index 58862a981..92fedaddd 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt @@ -188,7 +188,7 @@ class DlcViewModel(val titleId: String) { } } - private fun saveChanges() { + fun saveChanges() { data?.apply { dlcItemsState?.forEach { item -> for (container in this) { diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt index 92e9073a7..41ce25003 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.kenjinx.android.KenjinxNative +import org.kenjinx.android.KeyboardMode import org.kenjinx.android.MainActivity import java.util.Locale import kotlin.concurrent.thread @@ -104,10 +106,14 @@ class HomeViewModel( return@sortWith strA.length - strB.length } - for(game in loadedCache) - { + for (game in loadedCache) { game.getGameInfo() } + + // Auto-load DLCs and Title Updates from configured directories (if any) + try { + autoloadContent() + } catch (_: Throwable) { } } finally { isLoading.value = false GlobalScope.launch(Dispatchers.Main){ @@ -116,4 +122,125 @@ class HomeViewModel( } } } + + // Helper function to compare update versions from filenames + // Returns true if newPath represents a newer version than currentPath + private fun shouldSelectNewerUpdate(currentPath: String, newPath: String, allPaths: List): Boolean { + // Extract version numbers from filenames using regex pattern [vXXXXXX] + val versionPattern = Regex("\\[v(\\d+)]") + + val currentVersion = versionPattern.find(currentPath.lowercase(Locale.getDefault()))?.groupValues?.get(1)?.toIntOrNull() ?: 0 + val newVersion = versionPattern.find(newPath.lowercase(Locale.getDefault()))?.groupValues?.get(1)?.toIntOrNull() ?: 0 + + return newVersion > currentVersion + } + + // Scans configured directories for NSPs containing DLCs/Updates and associates them to known titles. + private fun autoloadContent() { + val prefs = sharedPref ?: return + + // Prefer a single, explicitly selected updates/DLC folder (updatesFolder). Fallback to legacy autoloadDirs. + val updatesFolder = prefs.getString("updatesFolder", "") ?: "" + val dirs: List = if (updatesFolder.isNotBlank()) { + listOf(updatesFolder.trim()) + } else { + // Legacy: semicolon-separated list under key autoloadDirs + val raw = prefs.getString("autoloadDirs", "") ?: "" + if (raw.isBlank()) emptyList() else raw.split(';').map { it.trim() }.filter { it.isNotEmpty() } + } + + if (dirs.isEmpty()) return + + // Build a map of titleId -> helpers + val gamesByTitle = loadedCache.mapNotNull { g -> + val tid = g.titleId + if (!tid.isNullOrBlank()) tid.lowercase(Locale.getDefault()) to tid else null + }.toMap() + + var updatesAdded = 0 + var dlcAdded = 0 + + for (dir in dirs) { + val base = java.io.File(dir) + if (!base.exists() || !base.isDirectory) continue + + base.walkTopDown().forEach fileLoop@{ f -> + if (!f.isFile) return@fileLoop + val name = f.name.lowercase(Locale.getDefault()) + if (!name.endsWith(".nsp")) return@fileLoop + + // Extract title ID from filename + val tidPattern = Regex("\\[([0-9a-fA-F]{16})]") + val tidMatch = tidPattern.find(name) ?: return@fileLoop + val fileTid = tidMatch.groupValues[1].lowercase(Locale.getDefault()) + + // Try to find DLC content for all games + var isDlc = false + try { + for ((_, tidOrig) in gamesByTitle) { + val contents = KenjinxNative.deviceGetDlcContentList(f.absolutePath, tidOrig.toLong(16)) + + if (contents.isNotEmpty()) { + isDlc = true + val containerPath = f.absolutePath + val vm = DlcViewModel(tidOrig) + val already = vm.data?.any { it.path == containerPath } == true + + if (!already) { + val container = DlcContainerList(containerPath) + for (content in contents) { + container.dlc_nca_list.add( + DlcContainer( + true, + KenjinxNative.deviceGetDlcTitleId(containerPath, content).toLong(16), + content + ) + ) + } + vm.data?.add(container) + vm.saveChanges() + dlcAdded++ + } + break + } + } + } catch (_: Throwable) { } + + if (isDlc) return@fileLoop + + // Treat as Title Update - convert update ID to base ID + // Update title IDs end in 800, base game IDs end in 000 + val baseTid = if (fileTid.endsWith("800")) { + fileTid.substring(0, fileTid.length - 3) + "000" + } else { + fileTid + } + + val originalTid = gamesByTitle[baseTid] + if (originalTid != null) { + val vm = TitleUpdateViewModel(originalTid) + val path = f.absolutePath + val exists = (vm.data?.paths?.contains(path) == true) + + if (!exists) { + // Add the new update path + vm.data?.paths?.add(path) + + // Auto-select this update if it's newer than the currently selected one + // or if no update is currently selected + val currentSelected = vm.data?.selected ?: "" + val shouldSelect = currentSelected.isEmpty() || + shouldSelectNewerUpdate(currentSelected, path, vm.data?.paths ?: mutableListOf()) + + if (shouldSelect) { + vm.data?.selected = path + } + + vm.saveChanges() + updatesAdded++ + } + } + } + } + } } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt index 7f522425f..6b796c57c 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt @@ -226,6 +226,30 @@ class SettingsViewModel(val activity: MainActivity) { ) } + fun openUpdatesFolder() { + val path = sharedPref.getString("updatesFolder", "") ?: "" + + activity.storageHelper!!.onFolderSelected = { _, folder -> + val p = folder.getAbsolutePath(activity) + sharedPref.edit { + putString("updatesFolder", p) + } + activity.storageHelper!!.onFolderSelected = previousFolderCallback + + // Trigger a reload of the game list to apply the new updates/DLC folder + MainActivity.mainViewModel?.homeViewModel?.requestReload() + MainActivity.mainViewModel?.homeViewModel?.ensureReloadIfNecessary() + } + + if (path.isEmpty()) + activity.storageHelper?.storage?.openFolderPicker() + else + activity.storageHelper?.storage?.openFolderPicker( + activity.storageHelper!!.storage.requestCodeFolderPicker, + FileFullPath(activity, path) + ) + } + fun selectKey(installState: MutableState) { if (installState.value != KeyInstallState.File) return diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt index a2bf41488..67972b1b9 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt @@ -176,7 +176,7 @@ class TitleUpdateViewModel(val titleId: String) { } } - private fun saveChanges() { + fun saveChanges() { val metadata = data ?: TitleUpdateMetadata() val gson = Gson() diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt index 2d0f4f565..3ba7d148d 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.MailOutline import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Memory import androidx.compose.material.icons.outlined.Panorama import androidx.compose.material.icons.outlined.Settings @@ -421,6 +422,23 @@ class SettingViews { isFullWidth = false, ) } + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ){ + ActionButton( + onClick = { + settingsViewModel.openUpdatesFolder() + }, + text = "Select Updates/DLC Folder", + icon = Icons.Outlined.Folder, + modifier = Modifier.weight(1f), + isFullWidth = false, + ) + } Row( modifier = Modifier .fillMaxWidth() From 4e94f4731b878383d192bfbf759d3db37dc52d6a Mon Sep 17 00:00:00 2001 From: Jochem Kuipers Date: Mon, 27 Oct 2025 12:37:16 +0100 Subject: [PATCH 2/2] feat: Refactor autoloadContent to streamline directory scanning for NSPs --- .../android/viewmodels/HomeViewModel.kt | 140 ++++++++---------- 1 file changed, 65 insertions(+), 75 deletions(-) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt index 41ce25003..ce4232ec5 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt @@ -15,8 +15,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.kenjinx.android.KenjinxNative -import org.kenjinx.android.KeyboardMode import org.kenjinx.android.MainActivity +import java.io.File import java.util.Locale import kotlin.concurrent.thread @@ -125,7 +125,7 @@ class HomeViewModel( // Helper function to compare update versions from filenames // Returns true if newPath represents a newer version than currentPath - private fun shouldSelectNewerUpdate(currentPath: String, newPath: String, allPaths: List): Boolean { + private fun shouldSelectNewerUpdate(currentPath: String, newPath: String): Boolean { // Extract version numbers from filenames using regex pattern [vXXXXXX] val versionPattern = Regex("\\[v(\\d+)]") @@ -135,21 +135,13 @@ class HomeViewModel( return newVersion > currentVersion } - // Scans configured directories for NSPs containing DLCs/Updates and associates them to known titles. + // Scans configured directory for NSPs containing DLCs/Updates and associates them to known titles. private fun autoloadContent() { val prefs = sharedPref ?: return - // Prefer a single, explicitly selected updates/DLC folder (updatesFolder). Fallback to legacy autoloadDirs. val updatesFolder = prefs.getString("updatesFolder", "") ?: "" - val dirs: List = if (updatesFolder.isNotBlank()) { - listOf(updatesFolder.trim()) - } else { - // Legacy: semicolon-separated list under key autoloadDirs - val raw = prefs.getString("autoloadDirs", "") ?: "" - if (raw.isBlank()) emptyList() else raw.split(';').map { it.trim() }.filter { it.isNotEmpty() } - } - if (dirs.isEmpty()) return + if (updatesFolder.isEmpty()) return // Build a map of titleId -> helpers val gamesByTitle = loadedCache.mapNotNull { g -> @@ -160,85 +152,83 @@ class HomeViewModel( var updatesAdded = 0 var dlcAdded = 0 - for (dir in dirs) { - val base = java.io.File(dir) - if (!base.exists() || !base.isDirectory) continue + val base = File(updatesFolder) + if (!base.exists() || !base.isDirectory) return - base.walkTopDown().forEach fileLoop@{ f -> - if (!f.isFile) return@fileLoop - val name = f.name.lowercase(Locale.getDefault()) - if (!name.endsWith(".nsp")) return@fileLoop + base.walkTopDown().forEach fileLoop@{ f -> + if (!f.isFile) return@fileLoop + val name = f.name.lowercase(Locale.getDefault()) + if (!name.endsWith(".nsp")) return@fileLoop - // Extract title ID from filename - val tidPattern = Regex("\\[([0-9a-fA-F]{16})]") - val tidMatch = tidPattern.find(name) ?: return@fileLoop - val fileTid = tidMatch.groupValues[1].lowercase(Locale.getDefault()) + // Extract title ID from filename + val tidPattern = Regex("\\[([0-9a-fA-F]{16})]") + val tidMatch = tidPattern.find(name) ?: return@fileLoop + val fileTid = tidMatch.groupValues[1].lowercase(Locale.getDefault()) - // Try to find DLC content for all games - var isDlc = false - try { - for ((_, tidOrig) in gamesByTitle) { - val contents = KenjinxNative.deviceGetDlcContentList(f.absolutePath, tidOrig.toLong(16)) + // Try to find DLC content for all games + var isDlc = false + try { + for ((_, tidOrig) in gamesByTitle) { + val contents = KenjinxNative.deviceGetDlcContentList(f.absolutePath, tidOrig.toLong(16)) - if (contents.isNotEmpty()) { - isDlc = true - val containerPath = f.absolutePath - val vm = DlcViewModel(tidOrig) - val already = vm.data?.any { it.path == containerPath } == true + if (contents.isNotEmpty()) { + isDlc = true + val containerPath = f.absolutePath + val vm = DlcViewModel(tidOrig) + val already = vm.data?.any { it.path == containerPath } == true - if (!already) { - val container = DlcContainerList(containerPath) - for (content in contents) { - container.dlc_nca_list.add( - DlcContainer( - true, - KenjinxNative.deviceGetDlcTitleId(containerPath, content).toLong(16), - content - ) + if (!already) { + val container = DlcContainerList(containerPath) + for (content in contents) { + container.dlc_nca_list.add( + DlcContainer( + true, + KenjinxNative.deviceGetDlcTitleId(containerPath, content).toLong(16), + content ) - } - vm.data?.add(container) - vm.saveChanges() - dlcAdded++ + ) } - break + vm.data?.add(container) + vm.saveChanges() + dlcAdded++ } + break } - } catch (_: Throwable) { } - - if (isDlc) return@fileLoop - - // Treat as Title Update - convert update ID to base ID - // Update title IDs end in 800, base game IDs end in 000 - val baseTid = if (fileTid.endsWith("800")) { - fileTid.substring(0, fileTid.length - 3) + "000" - } else { - fileTid } + } catch (_: Throwable) { } - val originalTid = gamesByTitle[baseTid] - if (originalTid != null) { - val vm = TitleUpdateViewModel(originalTid) - val path = f.absolutePath - val exists = (vm.data?.paths?.contains(path) == true) + if (isDlc) return@fileLoop - if (!exists) { - // Add the new update path - vm.data?.paths?.add(path) + // Treat as Title Update - convert update ID to base ID + // Update title IDs end in 800, base game IDs end in 000 + val baseTid = if (fileTid.endsWith("800")) { + fileTid.substring(0, fileTid.length - 3) + "000" + } else { + fileTid + } - // Auto-select this update if it's newer than the currently selected one - // or if no update is currently selected - val currentSelected = vm.data?.selected ?: "" - val shouldSelect = currentSelected.isEmpty() || - shouldSelectNewerUpdate(currentSelected, path, vm.data?.paths ?: mutableListOf()) + val originalTid = gamesByTitle[baseTid] + if (originalTid != null) { + val vm = TitleUpdateViewModel(originalTid) + val path = f.absolutePath + val exists = (vm.data?.paths?.contains(path) == true) - if (shouldSelect) { - vm.data?.selected = path - } + if (!exists) { + // Add the new update path + vm.data?.paths?.add(path) - vm.saveChanges() - updatesAdded++ + // Auto-select this update if it's newer than the currently selected one + // or if no update is currently selected + val currentSelected = vm.data?.selected ?: "" + val shouldSelect = currentSelected.isEmpty() || + shouldSelectNewerUpdate(currentSelected, path) + + if (shouldSelect) { + vm.data?.selected = path } + + vm.saveChanges() + updatesAdded++ } } }