Merge branch 'libryujinx_bionic' into 'libryujinx_bionic'

Feature: Autoload title updates and dlc from selected folder

See merge request kenji-nx/ryujinx!6
This commit is contained in:
Jochem Kuipers 2025-12-09 12:19:22 +01:00
commit 26769ca770
5 changed files with 163 additions and 4 deletions

View file

@ -188,7 +188,7 @@ class DlcViewModel(val titleId: String) {
}
}
private fun saveChanges() {
fun saveChanges() {
data?.apply {
dlcItemsState?.forEach { item ->
for (container in this) {

View file

@ -14,7 +14,9 @@ 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.MainActivity
import java.io.File
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,115 @@ 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): 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 directory for NSPs containing DLCs/Updates and associates them to known titles.
private fun autoloadContent() {
val prefs = sharedPref ?: return
val updatesFolder = prefs.getString("updatesFolder", "") ?: ""
if (updatesFolder.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
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
// 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)
if (shouldSelect) {
vm.data?.selected = path
}
vm.saveChanges()
updatesAdded++
}
}
}
}
}

View file

@ -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<KeyInstallState>) {
if (installState.value != KeyInstallState.File)
return

View file

@ -176,7 +176,7 @@ class TitleUpdateViewModel(val titleId: String) {
}
}
private fun saveChanges() {
fun saveChanges() {
val metadata = data ?: TitleUpdateMetadata()
val gson = Gson()

View file

@ -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()