diff --git a/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt b/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt index 61ad721de..820ce6d44 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt @@ -2,6 +2,34 @@ package com.example.bd2modmanager.data.model import android.net.Uri +enum class ResolutionState { + KNOWN, + MISC, + UNKNOWN, + INVALID +} + +enum class MatchStrategy { + EXACT, + NORMALIZED, + EXTENSION_MAPPING, + FALLBACK, + NONE +} + +data class ResolvedTarget( + val originalFileName: String, + val normalizedCandidates: List = emptyList(), + val resolvedAssetKey: String? = null, + val resolvedBundleName: String? = null, + val resolvedBundlePath: String? = null, + val assetType: String? = null, + val targetHash: String? = null, + val familyKey: String? = null, + val matchStrategy: MatchStrategy = MatchStrategy.NONE, + val confidence: Float = 0f +) + data class ModInfo( val name: String, val character: String, @@ -10,7 +38,13 @@ data class ModInfo( val isEnabled: Boolean, val uri: Uri, val targetHashedName: String?, - val isDirectory: Boolean + val isDirectory: Boolean, + val resolutionState: ResolutionState = ResolutionState.UNKNOWN, + val targetHash: String? = targetHashedName, + val resolvedFamilyKey: String? = null, + val resolvedTargets: List = emptyList(), + val unresolvedFiles: List = emptyList(), + val errorReason: String? = null ) data class ModCacheInfo( @@ -21,7 +55,12 @@ data class ModCacheInfo( val costume: String, val type: String, val targetHashedName: String?, - val isDirectory: Boolean + val isDirectory: Boolean, + val resolutionState: ResolutionState = ResolutionState.UNKNOWN, + val targetHash: String? = targetHashedName, + val resolvedFamilyKey: String? = null, + val unresolvedFiles: List = emptyList(), + val errorReason: String? = null ) data class CharacterInfo(val character: String, val costume: String, val type: String, val hashedName: String) diff --git a/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt index a6961081d..9d6b54d75 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt @@ -3,13 +3,20 @@ package com.example.bd2modmanager.data.repository import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile +import com.chaquo.python.Python +import com.example.bd2modmanager.data.model.MatchStrategy import com.example.bd2modmanager.data.model.ModCacheInfo import com.example.bd2modmanager.data.model.ModDetails import com.example.bd2modmanager.data.model.ModInfo +import com.example.bd2modmanager.data.model.ResolutionState +import com.example.bd2modmanager.data.model.ResolvedTarget +import com.example.bd2modmanager.service.ModdingService import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject import java.io.File import java.util.zip.ZipInputStream @@ -24,11 +31,21 @@ class ModRepository( private val gson = Gson() + private data class ScannedModCandidate( + val uriString: String, + val lastModified: Long, + val name: String, + val uri: Uri, + val isDirectory: Boolean, + val modDetails: ModDetails + ) + suspend fun scanMods(dirUri: Uri): List { return withContext(Dispatchers.IO) { val existingCache = loadModCache() val newCache = mutableMapOf() val tempModsList = mutableListOf() + val candidates = mutableListOf() val files = DocumentFile.fromTreeUri(context, dirUri)?.listFiles() ?: emptyArray() files.filter { it.isDirectory || it.name?.endsWith(".zip", ignoreCase = true) == true } @@ -37,17 +54,24 @@ class ModRepository( val lastModified = file.lastModified() val cachedInfo = existingCache[uriString] - val modInfo = if (cachedInfo != null && cachedInfo.lastModified == lastModified) { + if (cachedInfo != null && cachedInfo.lastModified == lastModified) { newCache[uriString] = cachedInfo - ModInfo( - name = cachedInfo.name, - character = cachedInfo.character, - costume = cachedInfo.costume, - type = cachedInfo.type, - isEnabled = false, - uri = file.uri, - targetHashedName = cachedInfo.targetHashedName, - isDirectory = cachedInfo.isDirectory + tempModsList.add( + ModInfo( + name = cachedInfo.name, + character = cachedInfo.character, + costume = cachedInfo.costume, + type = cachedInfo.type, + isEnabled = false, + uri = file.uri, + targetHashedName = cachedInfo.targetHashedName, + isDirectory = cachedInfo.isDirectory, + resolutionState = cachedInfo.resolutionState, + targetHash = cachedInfo.targetHash, + resolvedFamilyKey = cachedInfo.resolvedFamilyKey, + unresolvedFiles = cachedInfo.unresolvedFiles, + errorReason = cachedInfo.errorReason + ) ) } else { val modName = file.name?.removeSuffix(".zip") ?: "" @@ -57,33 +81,123 @@ class ModRepository( } else { extractModDetailsFromUri(file.uri) } - val bestMatch = characterRepository.findBestMatch(modDetails.fileId, modDetails.fileNames) - - val newCacheInfo = ModCacheInfo( - uriString = uriString, - lastModified = lastModified, - name = modName, - character = bestMatch?.character ?: "Unknown", - costume = bestMatch?.costume ?: "Unknown", - type = bestMatch?.type ?: "idle", - targetHashedName = bestMatch?.hashedName, - isDirectory = isDirectory + candidates.add( + ScannedModCandidate( + uriString = uriString, + lastModified = lastModified, + name = modName, + uri = file.uri, + isDirectory = isDirectory, + modDetails = modDetails + ) ) - newCache[uriString] = newCacheInfo + } + } + + if (candidates.isNotEmpty()) { + if (!Python.isStarted()) { + Python.start(com.chaquo.python.android.AndroidPlatform(context)) + } + + val batchPayload = JSONArray().apply { + candidates.forEachIndexed { index, candidate -> + put(JSONObject().apply { + put("id", index) + put("fileNames", JSONArray(candidate.modDetails.fileNames)) + }) + } + } + + val (batchSuccess, batchResults) = ModdingService.resolveModBatch( + batchPayload.toString(), + context.cacheDir.absolutePath, + "HD" + ) { } + + val resultsById = mutableMapOf() + if (batchSuccess && batchResults != null) { + for (i in 0 until batchResults.length()) { + val item = batchResults.optJSONObject(i) ?: continue + val id = item.optInt("id", -1) + val result = item.optJSONObject("result") ?: continue + if (id >= 0) resultsById[id] = result + } + } + + candidates.forEachIndexed { index, candidate -> + val resolvePayload = resultsById[index] ?: buildResolverFallback(candidate.modDetails.fileNames) + val resolutionState = parseResolutionState(resolvePayload) + val targetHash = resolvePayload.optString("targetHash").ifBlank { null } + val resolvedFamilyKey = resolvePayload.optString("resolvedFamilyKey").ifBlank { null } + val unresolvedFiles = jsonArrayToStringList(resolvePayload.optJSONArray("unresolvedFiles")) + val errorReason = resolvePayload.optString("errorReason").ifBlank { null } + val resolvedTargets = parseResolvedTargets(resolvePayload.optJSONArray("resolvedTargets")) + val bestMatch = characterRepository.findBestMatch(candidate.modDetails.fileId, candidate.modDetails.fileNames) + + val displayCharacter: String + val displayCostume: String + val displayType: String + when { + resolutionState == ResolutionState.INVALID -> { + displayCharacter = "Invalid Mod" + displayCostume = "Split Required" + displayType = "invalid" + } + resolutionState == ResolutionState.UNKNOWN -> { + displayCharacter = "Unknown" + displayCostume = "Unknown" + displayType = "unknown" + } + bestMatch != null -> { + displayCharacter = bestMatch.character + displayCostume = bestMatch.costume + displayType = bestMatch.type + } + else -> { + displayCharacter = "Other" + displayCostume = "Other" + displayType = "misc" + } + } + + val resolvedHash = targetHash + val newCacheInfo = ModCacheInfo( + uriString = candidate.uriString, + lastModified = candidate.lastModified, + name = candidate.name, + character = displayCharacter, + costume = displayCostume, + type = displayType, + targetHashedName = resolvedHash, + isDirectory = candidate.isDirectory, + resolutionState = resolutionState, + targetHash = resolvedHash, + resolvedFamilyKey = resolvedFamilyKey, + unresolvedFiles = unresolvedFiles, + errorReason = errorReason + ) + newCache[candidate.uriString] = newCacheInfo + tempModsList.add( ModInfo( - name = modName, - character = bestMatch?.character ?: "Unknown", - costume = bestMatch?.costume ?: "Unknown", - type = bestMatch?.type ?: "idle", + name = candidate.name, + character = displayCharacter, + costume = displayCostume, + type = displayType, isEnabled = false, - uri = file.uri, - targetHashedName = bestMatch?.hashedName, - isDirectory = isDirectory + uri = candidate.uri, + targetHashedName = resolvedHash, + isDirectory = candidate.isDirectory, + resolutionState = resolutionState, + targetHash = resolvedHash, + resolvedFamilyKey = resolvedFamilyKey, + resolvedTargets = resolvedTargets, + unresolvedFiles = unresolvedFiles, + errorReason = errorReason ) - } - tempModsList.add(modInfo) + ) } + } saveModCache(newCache) tempModsList.sortedBy { it.name } @@ -150,4 +264,59 @@ class ModRepository( } return ModDetails(fileId, fileNames) } + + private fun buildResolverFallback(fileNames: List): JSONObject { + return JSONObject().apply { + put("resolutionState", ResolutionState.UNKNOWN.name) + put("errorReason", "Resolver failed") + put("unresolvedFiles", JSONArray(fileNames)) + put("resolvedTargets", JSONArray()) + } + } + + private fun parseResolutionState(payload: JSONObject): ResolutionState { + return try { + ResolutionState.valueOf(payload.optString("resolutionState", ResolutionState.UNKNOWN.name)) + } catch (_: Exception) { + ResolutionState.UNKNOWN + } + } + + private fun jsonArrayToStringList(array: JSONArray?): List { + if (array == null) return emptyList() + return buildList { + for (i in 0 until array.length()) { + add(array.optString(i)) + } + } + } + + private fun parseResolvedTargets(array: JSONArray?): List { + if (array == null) return emptyList() + return buildList { + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val candidates = jsonArrayToStringList(obj.optJSONArray("normalizedCandidates")) + val strategy = try { + MatchStrategy.valueOf(obj.optString("matchStrategy", MatchStrategy.NONE.name)) + } catch (_: Exception) { + MatchStrategy.NONE + } + add( + ResolvedTarget( + originalFileName = obj.optString("originalFileName"), + normalizedCandidates = candidates, + resolvedAssetKey = obj.optString("resolvedAssetKey").ifBlank { null }, + resolvedBundleName = obj.optString("resolvedBundleName").ifBlank { null }, + resolvedBundlePath = obj.optString("resolvedBundlePath").ifBlank { null }, + assetType = obj.optString("assetType").ifBlank { null }, + targetHash = obj.optString("targetHash").ifBlank { null }, + familyKey = obj.optString("familyKey").ifBlank { null }, + matchStrategy = strategy, + confidence = obj.optDouble("confidence", 0.0).toFloat() + ) + ) + } + } + } } diff --git a/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt b/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt index 4d66c9be7..387e1e294 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt @@ -3,6 +3,8 @@ package com.example.bd2modmanager.service import com.chaquo.python.PyObject import com.chaquo.python.Python +import org.json.JSONArray +import org.json.JSONObject object ModdingService { @@ -74,6 +76,50 @@ object ModdingService { } } + fun resolveModFiles(fileNamesJson: String, outputDir: String, quality: String, onProgress: (String) -> Unit): Pair { + return try { + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + + val result = mainScript.callAttr( + "resolve_mod_files", + fileNamesJson, + outputDir, + quality, + PyObject.fromJava(onProgress) + ).asList() + + val success = result[0].toBoolean() + val payload = result[1].toString() + Pair(success, if (success) JSONObject(payload) else null) + } catch (e: Exception) { + e.printStackTrace() + Pair(false, null) + } + } + + fun resolveModBatch(modsJson: String, outputDir: String, quality: String, onProgress: (String) -> Unit): Pair { + return try { + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + + val result = mainScript.callAttr( + "resolve_mod_batch", + modsJson, + outputDir, + quality, + PyObject.fromJava(onProgress) + ).asList() + + val success = result[0].toBoolean() + val payload = result[1].toString() + Pair(success, if (success) JSONArray(payload) else null) + } catch (e: Exception) { + e.printStackTrace() + Pair(false, null) + } + } + fun mergeSpineAssets(modPath: String, onProgress: (String) -> Unit): Pair { return try { val py = Python.getInstance() diff --git a/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt b/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt index 9b91f06d5..e393b9c83 100644 --- a/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt +++ b/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt @@ -307,7 +307,7 @@ fun UninstallConfirmationDialog( title = { Text("Confirm Restore") }, text = { Text( - "Are you sure you want to restore the original file for this group?\n\nTarget: ${targetHash.take(12)}...", + "Are you sure you want to restore the original file for this group?\n\nTarget: ${targetHash}", textAlign = TextAlign.Center ) }, diff --git a/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt b/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt index 99ee90ff4..f4af6ff0b 100644 --- a/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt +++ b/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt @@ -29,8 +29,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.example.bd2modmanager.data.model.ModInfo +import com.example.bd2modmanager.data.model.ResolutionState import com.example.bd2modmanager.ui.viewmodel.MainViewModel import com.valentinilk.shimmer.shimmer @@ -46,7 +50,14 @@ fun ModScreen( val modSourceDirectoryUri by viewModel.modSourceDirectoryUri.collectAsState() val modsList by viewModel.filteredModsList.collectAsState() val allModsList by viewModel.modsList.collectAsState() - val groupedMods = modsList.groupBy { it.targetHashedName ?: "Unknown" } + val groupedMods = modsList.groupBy { + when (it.resolutionState) { + ResolutionState.KNOWN -> it.targetHash ?: "Unknown" + ResolutionState.MISC -> it.targetHash ?: "Unknown" + ResolutionState.UNKNOWN -> "Unknown" + ResolutionState.INVALID -> "Invalid" + } + } val selectedMods by viewModel.selectedMods.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val showShimmer by viewModel.showShimmer.collectAsState() @@ -120,7 +131,9 @@ fun ModScreen( modifier = Modifier.size(width = 48.dp, height = 40.dp), contentAlignment = Alignment.Center ) { - val allModsCount = allModsList.size + val allModsCount = allModsList.count { + it.resolutionState == ResolutionState.KNOWN + } val selectedModsCount = selectedMods.size val checkboxState = when { selectedModsCount == 0 -> ToggleableState.Off @@ -272,20 +285,56 @@ fun ModScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - Text( - text = "Target: ${hash.take(12)}...", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f) - ) + when (hash) { + "Unknown" -> { + Text( + text = "Unknown", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } + "Invalid" -> { + Text( + text = "Invalid", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } + else -> { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Target: ", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + AutoShrinkText( + text = hash, + maxFontSize = MaterialTheme.typography.titleMedium.fontSize, + minFontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } + } + } - IconButton(onClick = { onUninstallRequest(hash) }) { - Icon( - Icons.Default.Delete, - contentDescription = "Uninstall", - tint = MaterialTheme.colorScheme.primary - ) + if (hash != "Unknown" && hash != "Invalid") { + IconButton(onClick = { onUninstallRequest(hash) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Uninstall", + tint = MaterialTheme.colorScheme.primary + ) + } } val modsInGroupUris = modsInGroup.map { it.uri }.toSet() @@ -330,6 +379,35 @@ fun ModScreen( } } +@Composable +private fun AutoShrinkText( + text: String, + modifier: Modifier = Modifier, + maxFontSize: TextUnit = 20.sp, + minFontSize: TextUnit = 12.sp, + fontWeight: FontWeight? = null, + color: androidx.compose.ui.graphics.Color = LocalContentColor.current +) { + var currentFontSize by remember(text) { mutableStateOf(maxFontSize) } + + Text( + text = text, + modifier = modifier, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + fontSize = currentFontSize, + fontWeight = fontWeight, + color = color, + style = LocalTextStyle.current.copy(color = color), + onTextLayout = { result -> + if (result.hasVisualOverflow && currentFontSize > minFontSize) { + currentFontSize = (currentFontSize.value - 1f).coerceAtLeast(minFontSize.value).sp + } + } + ) +} + @Composable fun NoSearchResultsScreen(query: String) { Column( @@ -405,6 +483,7 @@ fun EmptyModsScreen() { @OptIn(ExperimentalFoundationApi::class) @Composable fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit, onLongPress: () -> Unit) { + val isSelectable = modInfo.resolutionState == ResolutionState.KNOWN val elevation by animateDpAsState(if (isSelected) 4.dp else 1.dp, label = "elevation") ElevatedCard( elevation = CardDefaults.cardElevation(defaultElevation = elevation), @@ -414,7 +493,7 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit .clip(CardDefaults.shape) .pointerInput(Unit) { detectTapGestures( - onTap = { onToggleSelection() }, + onTap = { if (isSelectable) onToggleSelection() }, onLongPress = { onLongPress() } ) } @@ -423,7 +502,7 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - Checkbox(checked = isSelected, onCheckedChange = { onToggleSelection() }) + Checkbox(checked = isSelected, onCheckedChange = { if (isSelectable) onToggleSelection() }, enabled = isSelectable) Spacer(Modifier.width(4.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -440,16 +519,25 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit ) } Spacer(Modifier.width(8.dp)) + val typeIcon = when (modInfo.type.lowercase()) { + "idle" -> Icons.Default.Person + "cutscene" -> Icons.Default.Movie + else -> Icons.Default.Category + } AssistChip( - onClick = { /* No action */ }, - label = { Text(modInfo.type.uppercase(), style = MaterialTheme.typography.labelSmall) }, + onClick = {}, + label = { + Text( + text = modInfo.type.uppercase(), + style = MaterialTheme.typography.labelSmall + ) + }, leadingIcon = { - val icon = when(modInfo.type.lowercase()) { - "idle" -> Icons.Default.Person - "cutscene" -> Icons.Default.Movie - else -> Icons.Default.Category - } - Icon(icon, contentDescription = modInfo.type, Modifier.size(14.dp)) + Icon( + imageVector = typeIcon, + contentDescription = modInfo.type, + modifier = Modifier.size(14.dp) + ) }, modifier = Modifier.heightIn(max = 24.dp) ) diff --git a/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt index fa2832e4f..b0f2d8d06 100644 --- a/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.withContext @@ -111,8 +112,14 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() + private var initialized = false + private var scanJob: Job? = null + private var pendingScanUri: Uri? = null fun initialize(context: Context) { + if (initialized) return + initialized = true + characterRepository = CharacterRepository(context) modRepository = ModRepository(context, characterRepository) @@ -164,7 +171,10 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } fun toggleSelectAll() { - val filteredModUris = filteredModsList.value.map { it.uri }.toSet() + val filteredModUris = filteredModsList.value + .filter { it.resolutionState == ResolutionState.KNOWN } + .map { it.uri } + .toSet() val currentSelections = _selectedMods.value val selectedFilteredUris = currentSelections.intersect(filteredModUris) @@ -176,7 +186,13 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } fun toggleSelectAllForGroup(groupHash: String) { - val modsInGroup = filteredModsList.value.filter { it.targetHashedName == groupHash }.map { it.uri }.toSet() + val modsInGroup = filteredModsList.value + .filter { + it.targetHash == groupHash && + it.resolutionState == ResolutionState.KNOWN + } + .map { it.uri } + .toSet() val currentSelections = _selectedMods.value val groupSelections = currentSelections.intersect(modsInGroup) @@ -191,8 +207,11 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( val allMods = _modsList.value val jobs = _selectedMods.value .mapNotNull { uri -> allMods.find { it.uri == uri } } - .filter { !it.targetHashedName.isNullOrBlank() } - .groupBy { it.targetHashedName!! } + .filter { + !it.targetHash.isNullOrBlank() && + it.resolutionState == ResolutionState.KNOWN + } + .groupBy { it.targetHash!! } .map { (hash, mods) -> RepackJob(hash, mods) } if (jobs.isNotEmpty()) { @@ -232,6 +251,9 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private suspend fun processSingleJob(context: Context, installJob: InstallJob, cacheKey: String) { val job = installJob.job val hashedName = job.hashedName + var originalDataCache: File? = null + var repackedDataCache: File? = null + val modAssetsDir = File(context.cacheDir, "temp_mod_assets_${hashedName}") try { updateJobStatus(hashedName, JobStatus.Downloading("Starting download...")) @@ -242,12 +264,10 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( if (!downloadSuccess) { throw Exception("Download failed: $messageOrPath") } - val originalDataCache = File(messageOrPath) + originalDataCache = File(messageOrPath) val relativePath = originalDataCache.relativeTo(context.cacheDir) - updateJobStatus(hashedName, JobStatus.Installing("Extracting mod files...")) - val modAssetsDir = File(context.cacheDir, "temp_mod_assets_${hashedName}") if (modAssetsDir.exists()) modAssetsDir.deleteRecursively() modAssetsDir.mkdirs() @@ -270,22 +290,18 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } updateJobStatus(hashedName, JobStatus.Installing("Repacking bundle...")) - val repackedDataCache = File(context.cacheDir, "repacked/${relativePath.path}") + repackedDataCache = File(context.cacheDir, "repacked/${relativePath.path}") repackedDataCache.parentFile?.mkdirs() val (repackSuccess, repackMessage) = ModdingService.repackBundle(originalDataCache.absolutePath, modAssetsDir.absolutePath, repackedDataCache.absolutePath, useAstc.value) { progress -> updateJobStatus(hashedName, JobStatus.Installing(progress)) } - originalDataCache.delete() - modAssetsDir.deleteRecursively() - if (!repackSuccess) { throw Exception("Repack failed: $repackMessage") } val publicUri = saveFileToDownloads(context, repackedDataCache, relativePath.path, "Shared") - repackedDataCache.delete() if (publicUri != null) { updateJobStatus(hashedName, JobStatus.Finished(relativePath.path)) @@ -302,6 +318,16 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( fullError } updateJobStatus(hashedName, JobStatus.Failed(displayMessage = displayError, detailedLog = fullError)) + } finally { + try { + originalDataCache?.takeIf { it.exists() }?.delete() + } catch (_: Exception) {} + try { + repackedDataCache?.takeIf { it.exists() }?.delete() + } catch (_: Exception) {} + try { + if (modAssetsDir.exists()) modAssetsDir.deleteRecursively() + } catch (_: Exception) {} } } @@ -593,19 +619,32 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( fun scanModSourceDirectory(dirUri: Uri) { - _isLoading.value = true - viewModelScope.launch(Dispatchers.IO) { - isUpdatingCharacters.first { !it } // Wait for character data to be ready - try { - val mods = modRepository.scanMods(dirUri) + pendingScanUri = dirUri + if (scanJob?.isActive == true) return + + scanJob = viewModelScope.launch(Dispatchers.IO) { + while (true) { + val currentUri = pendingScanUri ?: break + pendingScanUri = null + withContext(Dispatchers.Main) { - _modsList.value = mods - _selectedMods.value = emptySet() + _isLoading.value = true } - } finally { - withContext(Dispatchers.Main) { - _isLoading.value = false + + isUpdatingCharacters.first { !it } // Wait for character data to be ready + try { + val mods = modRepository.scanMods(currentUri) + withContext(Dispatchers.Main) { + _modsList.value = mods + _selectedMods.value = emptySet() + } + } finally { + withContext(Dispatchers.Main) { + _isLoading.value = false + } } + + if (pendingScanUri == null) break } } } diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py new file mode 100644 index 000000000..02ef054b2 --- /dev/null +++ b/app/src/main/python/catalog_indexer.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +import base64 +import json +import re +import struct +from pathlib import Path + +from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array + + +INDEX_SCHEMA_VERSION = 5 + + +def _normalize_filename(filename: str): + filename = (filename or "").strip().lower() + if not filename: + return [] + + candidates = [filename] + if filename.endswith('.atlas'): + candidates.append(filename[:-6] + '.atlas.txt') + if filename.endswith('.skel'): + candidates.append(filename[:-5] + '.skel.bytes') + if filename.endswith('.skel.txt'): + candidates.append(filename[:-9] + '.skel.bytes') + return list(dict.fromkeys(candidates)) + + +def _extract_family_key(name: str): + if not name: + return None + lowered = name.lower() + stem = Path(lowered).stem + + if stem.endswith('.atlas'): + stem = stem[:-6] + if stem.endswith('.skel'): + stem = stem[:-5] + + stem = re.sub(r'_(\d+)$', '', stem) + return stem + + +def _score_asset_key_for_base(asset_key: str, asset_base: str): + score = 0 + if asset_key == asset_base: + score += 1000 + if '/censorship/' not in asset_key: + score += 100 + if asset_key.endswith('/' + asset_base): + score += 20 + score -= len(asset_key) + return score + + +def _cleanup_stale_indexes(output_dir, quality, keep_version): + pattern = f"asset_index_{quality.lower()}_*.json" + for path in Path(output_dir).glob(pattern): + if path.name != f"asset_index_{quality.lower()}_{keep_version}.json": + try: + path.unlink() + except Exception: + pass + + +def build_asset_index(catalog_content): + if not catalog_content: + return { + 'schemaVersion': INDEX_SCHEMA_VERSION, + 'version': None, + 'strings': [], + 'records': [], + 'assetsByExactKey': {}, + 'assetsByBaseName': {} + } + + bucket_array = base64.b64decode(catalog_content['m_BucketDataString']) + key_array = base64.b64decode(catalog_content['m_KeyDataString']) + extra_data = base64.b64decode(catalog_content['m_ExtraDataString']) + entry_data = base64.b64decode(catalog_content['m_EntryDataString']) + internal_ids = catalog_content.get('m_InternalIds') or [] + + num_buckets = struct.unpack_from('= 0: + bundle_info = read_object_from_byte_array(extra_data, data_index) + if bundle_info: + bundles[m] = { + 'bundle_name': bundle_info.get('m_BundleName'), + 'bundle_hash': bundle_info.get('m_Hash'), + 'bundle_size': bundle_info.get('m_BundleSize'), + 'download_name': str(keys[primary_key_index]) if primary_key_index < len(keys) else '' + } + + def resolve_bundle_info(entry_index): + if entry_index in bundles: + return bundles[entry_index] + if entry_index < 0 or entry_index >= len(entries): + return None + dep_idx = entries[entry_index]['dependency_index'] + if dep_idx < 0 or dep_idx >= len(dependency_map): + return None + deps = dependency_map[dep_idx] or [] + for dep_entry in deps: + info = bundles.get(dep_entry) + if info: + return info + return None + + strings = [] + string_ids = {} + records = [] + record_ids = {} + assets_by_exact = {} + base_candidates = {} + + def intern_string(value): + if value is None: + return -1 + value = str(value) + existing = string_ids.get(value) + if existing is not None: + return existing + idx = len(strings) + strings.append(value) + string_ids[value] = idx + return idx + + def intern_record(asset_key, target_hash, family_key): + asset_key_id = intern_string(asset_key) + target_hash_id = intern_string(target_hash) + family_key_id = intern_string(family_key) if family_key else -1 + record_key = (asset_key_id, target_hash_id, family_key_id) + existing = record_ids.get(record_key) + if existing is not None: + return existing + idx = len(records) + records.append([asset_key_id, target_hash_id, family_key_id]) + record_ids[record_key] = idx + return idx + + def append_unique_ref(mapping, key, record_id): + existing = mapping.get(key) + if existing is None: + mapping[key] = record_id + return + if isinstance(existing, list): + if record_id not in existing: + existing.append(record_id) + return + if existing != record_id: + mapping[key] = [existing, record_id] + + for i in range(len(entries)): + primary_key_index = entries[i]['primary_key_index'] + internal_id_index = entries[i]['internal_id_index'] + raw_key = keys[primary_key_index] if primary_key_index < len(keys) else None + internal_id = internal_ids[internal_id_index] if 0 <= internal_id_index < len(internal_ids) else None + bundle_info = resolve_bundle_info(i) + if not bundle_info: + continue + + target_hash = bundle_info.get('bundle_name') + if not target_hash: + continue + + candidate_keys = [] + if isinstance(raw_key, str) and raw_key: + candidate_keys.append(raw_key.lower()) + if isinstance(internal_id, str) and internal_id: + lowered_internal = internal_id.lower() + if lowered_internal not in candidate_keys: + candidate_keys.append(lowered_internal) + + if not candidate_keys: + continue + + for asset_key in candidate_keys: + asset_base = Path(asset_key).name.lower() + family_key = _extract_family_key(asset_base) + record_id = intern_record(asset_key, target_hash, family_key) + append_unique_ref(assets_by_exact, asset_key, record_id) + + group_key = (asset_base, target_hash, family_key) + candidate = base_candidates.get(group_key) + score = _score_asset_key_for_base(asset_key, asset_base) + if candidate is None or score > candidate[0]: + base_candidates[group_key] = (score, record_id) + + assets_by_base = {} + for (asset_base, _target_hash, _family_key), (_score, record_id) in base_candidates.items(): + append_unique_ref(assets_by_base, asset_base, record_id) + + return { + 'schemaVersion': INDEX_SCHEMA_VERSION, + 'version': None, + 'strings': strings, + 'records': records, + 'assetsByExactKey': assets_by_exact, + 'assetsByBaseName': assets_by_base + } + + +def load_or_build_asset_index(output_dir, quality, version, catalog_content): + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + _cleanup_stale_indexes(output_path, quality, version) + + index_path = output_path.joinpath(f"asset_index_{quality.lower()}_{version}.json") + if index_path.exists(): + try: + with open(index_path, 'r', encoding='utf-8') as f: + index = json.load(f) + if index.get('schemaVersion') == INDEX_SCHEMA_VERSION: + return index + except Exception: + pass + try: + index_path.unlink() + except Exception: + pass + + index = build_asset_index(catalog_content) + index['version'] = version + with open(index_path, 'w', encoding='utf-8') as f: + json.dump(index, f, ensure_ascii=False, separators=(',', ':')) + return index + + +def normalize_filename(filename: str): + return _normalize_filename(filename) diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index 49abfedcf..2037c6d76 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -10,6 +10,9 @@ import cdn_downloader from unpacker import unpack_bundle as unpacker_main import spine_merger +import resolver +import catalog_indexer +import json # --- Global Cache for CDN Catalog --- # In-memory cache for the catalog JSON content. @@ -21,6 +24,12 @@ # to ensure that different installation processes use different caches. current_cache_key = None + +def _prune_catalog_cache(keep_version=None): + stale_versions = [version for version in catalog_cache.keys() if version != keep_version] + for version in stale_versions: + catalog_cache.pop(version, None) + def unpack_bundle(bundle_path: str, output_dir: str, progress_callback=None): """ Entry point for Kotlin to unpack a bundle. @@ -73,6 +82,8 @@ def report_progress(message): return False, "Failed to get CDN version." report_progress(f"Latest version is {version}. Checking catalog...") + with catalog_cache_lock: + _prune_catalog_cache(version) # The download_catalog function will now use the shared cache catalog_content, error = cdn_downloader.download_catalog( output_dir, quality, version, catalog_cache, catalog_cache_lock, progress_callback @@ -102,7 +113,72 @@ def report_progress(message): report_progress(f"A critical error occurred: {error_message}") return False, error_message -import json +def ensure_asset_index(output_dir: str, quality: str = "HD", progress_callback=None): + def report_progress(message): + if progress_callback: + progress_callback(message) + print(message) + + try: + report_progress(f"Fetching CDN version for {quality} quality...") + version = cdn_downloader.get_cdn_version(quality) + if not version: + return False, "Failed to get CDN version.", None + + report_progress(f"Latest version is {version}. Checking catalog...") + with catalog_cache_lock: + _prune_catalog_cache(version) + catalog_content, error = cdn_downloader.download_catalog( + output_dir, quality, version, catalog_cache, catalog_cache_lock, progress_callback + ) + if error: + return False, error, None + + report_progress("Building/loading asset index...") + index = catalog_indexer.load_or_build_asset_index(output_dir, quality, version, catalog_content) + return True, version, index + except Exception as e: + import traceback + error_message = traceback.format_exc() + report_progress(f"A critical error occurred while building asset index: {error_message}") + return False, error_message, None + + +def resolve_mod_files(file_names_json: str, output_dir: str, quality: str = "HD", progress_callback=None): + try: + file_names = json.loads(file_names_json) if isinstance(file_names_json, str) else file_names_json + success, version_or_error, index = ensure_asset_index(output_dir, quality, progress_callback) + if not success: + return False, version_or_error + + result = resolver.resolve_mod_folder(file_names, index) + return True, json.dumps(result) + except Exception as e: + import traceback + return False, traceback.format_exc() + + +def resolve_mod_batch(mods_json: str, output_dir: str, quality: str = "HD", progress_callback=None): + try: + mods = json.loads(mods_json) if isinstance(mods_json, str) else mods_json + success, version_or_error, index = ensure_asset_index(output_dir, quality, progress_callback) + if not success: + return False, version_or_error + + results = [] + for mod in mods or []: + mod_id = mod.get("id") + file_names = mod.get("fileNames") or [] + resolved = resolver.resolve_mod_folder(file_names, index) + results.append({ + "id": mod_id, + "result": resolved + }) + return True, json.dumps(results) + except Exception: + import traceback + return False, traceback.format_exc() + def update_character_data(output_dir: str): """ diff --git a/app/src/main/python/repacker/repacker.py b/app/src/main/python/repacker/repacker.py index bf6dd745e..45320b128 100644 --- a/app/src/main/python/repacker/repacker.py +++ b/app/src/main/python/repacker/repacker.py @@ -256,6 +256,7 @@ def sort_key(filename): from utils.file_operations import find_file_case_insensitive + def compress_image_astc(image_bytes, width, height, block_x, block_y): astcenc = _load_astcenc_library() if not astcenc: diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py new file mode 100644 index 000000000..9e7dcf148 --- /dev/null +++ b/app/src/main/python/resolver.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +import re +from pathlib import Path + +from catalog_indexer import normalize_filename + + +def resolve_mod_folder(mod_file_names, asset_index): + unresolved_files = [] + file_matches = [] + + assets_by_base = (asset_index or {}).get('assetsByBaseName', {}) + assets_by_exact = (asset_index or {}).get('assetsByExactKey', {}) + + for file_name in mod_file_names or []: + base_name = Path(file_name).name + lowered_full = (file_name or '').replace('\\', '/').lower() + candidates = _expand_candidates(base_name) + matches = [] + + exact_match = assets_by_exact.get(lowered_full) + if exact_match: + exact_hits = _decode_hits(asset_index, exact_match) + exact_hits = _prefer_primary_hits(exact_hits) + for hit in exact_hits: + matches.append(_build_match(base_name, candidates, hit, 'EXACT', 1.0)) + + for candidate in candidates: + hits = assets_by_base.get(candidate.lower()) or [] + hits = _decode_hits(asset_index, hits) + hits = _prefer_primary_hits(hits) + strategy = 'EXACT' if candidate.lower() == base_name.lower() else 'EXTENSION_MAPPING' + confidence = 1.0 if strategy == 'EXACT' else 0.9 + for hit in hits: + matches.append(_build_match(base_name, candidates, hit, strategy, confidence)) + + matches = _dedupe_matches(matches) + + if matches: + file_matches.append({ + 'fileName': base_name, + 'candidates': candidates, + 'matches': matches, + 'targetHashes': {m.get('targetHash') for m in matches if m.get('targetHash')} + }) + else: + unresolved_files.append(base_name) + + candidate_sets = [entry['targetHashes'] for entry in file_matches if entry['targetHashes']] + + if not candidate_sets: + return { + 'targetHash': None, + 'resolvedFamilyKey': None, + 'resolvedTargets': [], + 'unresolvedFiles': unresolved_files, + 'resolutionState': 'UNKNOWN', + 'errorReason': 'No matching target could be resolved' + } + + intersection = set(candidate_sets[0]) + for target_set in candidate_sets[1:]: + intersection &= target_set + + union = set() + for target_set in candidate_sets: + union |= target_set + + if len(intersection) == 1: + target_hash = next(iter(intersection)) + elif len(union) == 1: + target_hash = next(iter(union)) + else: + return { + 'targetHash': None, + 'resolvedFamilyKey': None, + 'resolvedTargets': _select_representative_matches(file_matches, None), + 'unresolvedFiles': unresolved_files, + 'resolutionState': 'INVALID', + 'errorReason': 'Multiple targets detected in one mod folder' + } + + resolved_targets = _select_representative_matches(file_matches, target_hash) + family_keys = { + _normalize_family_key(match.get('familyKey')) + for match in resolved_targets + if _normalize_family_key(match.get('familyKey')) + } + + return { + 'targetHash': target_hash, + 'resolvedFamilyKey': next(iter(family_keys)) if len(family_keys) == 1 else None, + 'resolvedTargets': resolved_targets, + 'unresolvedFiles': unresolved_files, + 'resolutionState': 'KNOWN', + 'errorReason': None + } + + +def _expand_candidates(base_name: str): + candidates = list(dict.fromkeys(normalize_filename(base_name))) + + bridge_key = _extract_sactx_bridge_key(base_name) + if bridge_key: + lowered = bridge_key.lower() + candidates.extend([ + lowered, + f"{lowered}.spriteatlasv2" + ]) + + return list(dict.fromkeys(candidates)) + + +def _extract_sactx_bridge_key(file_name: str): + lowered = (file_name or '').strip().lower() + if not lowered.startswith('sactx-'): + return None + + stem = Path(lowered).stem + match = re.search(r'-(localpacktitle\d+_[a-z0-9]+)-[0-9a-f]{6,}$', stem, re.IGNORECASE) + if not match: + return None + + return match.group(1) + + +def _decode_hits(asset_index, raw_hits): + if raw_hits is None: + return [] + if not isinstance(raw_hits, list): + raw_hits = [raw_hits] + + decoded = [] + for item in raw_hits: + hit = _decode_hit(asset_index, item) + if hit: + decoded.append(hit) + return decoded + + +def _decode_hit(asset_index, raw_hit): + if isinstance(raw_hit, dict): + return raw_hit + if not isinstance(raw_hit, int): + return None + + records = (asset_index or {}).get('records') or [] + strings = (asset_index or {}).get('strings') or [] + if raw_hit < 0 or raw_hit >= len(records): + return None + + record = records[raw_hit] + if not isinstance(record, list) or len(record) < 3: + return None + + asset_key = _string_at(strings, record[0]) + target_hash = _string_at(strings, record[1]) + family_key = _string_at(strings, record[2]) + + return { + 'assetKey': asset_key, + 'bundleName': None, + 'targetHash': target_hash, + 'familyKey': family_key + } + + +def _string_at(strings, index): + if index is None or index < 0 or index >= len(strings): + return None + return strings[index] + + +def _prefer_primary_hits(hits): + if not hits: + return [] + + primary_hits = [hit for hit in hits if '/censorship/' not in (hit.get('assetKey') or '')] + return primary_hits if primary_hits else hits + + +def _build_match(base_name: str, candidates, matched, strategy: str, confidence: float): + target_hash = matched.get('targetHash') + family_key = _normalize_family_key(matched.get('familyKey')) + return { + 'originalFileName': base_name, + 'normalizedCandidates': candidates, + 'resolvedAssetKey': matched.get('assetKey'), + 'resolvedBundleName': matched.get('bundleName'), + 'resolvedBundlePath': None, + 'assetType': _infer_asset_type(base_name), + 'targetHash': target_hash, + 'familyKey': family_key, + 'matchStrategy': strategy, + 'confidence': confidence + } + + +def _dedupe_matches(matches): + deduped = [] + seen = set() + for match in matches: + key = ( + match.get('resolvedAssetKey'), + match.get('targetHash'), + match.get('familyKey'), + match.get('matchStrategy') + ) + if key in seen: + continue + seen.add(key) + deduped.append(match) + return deduped + + +def _select_representative_matches(file_matches, target_hash): + resolved_targets = [] + for entry in file_matches: + matches = entry['matches'] + chosen = None + + if target_hash: + matching_target = [m for m in matches if m.get('targetHash') == target_hash] + chosen = _prefer_best_match(matching_target) + else: + chosen = _prefer_best_match(matches) + + if chosen: + resolved_targets.append(chosen) + + return resolved_targets + + +def _prefer_best_match(matches): + if not matches: + return None + + strategy_rank = { + 'EXACT': 0, + 'EXTENSION_MAPPING': 1, + 'NONE': 2 + } + + return sorted( + matches, + key=lambda m: ( + strategy_rank.get(m.get('matchStrategy'), 99), + m.get('resolvedAssetKey') or '' + ) + )[0] + + +def _infer_asset_type(file_name: str): + lowered = (file_name or '').lower() + if lowered.endswith('.png'): + return 'Texture2D' + if lowered.endswith('.atlas') or lowered.endswith('.atlas.txt') or lowered.endswith('.skel') or lowered.endswith('.skel.txt') or lowered.endswith('.skel.bytes'): + return 'TextAsset' + if lowered.endswith('.json'): + return 'JsonSkeleton' + return 'Unknown' + + +def _normalize_family_key(value: str): + if not value: + return None + lowered = value.lower() + lowered = re.sub(r'_(\d+)$', '', lowered) + return lowered