Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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,
Expand All @@ -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<ResolvedTarget> = emptyList(),
val unresolvedFiles: List<String> = emptyList(),
val errorReason: String? = null
)

data class ModCacheInfo(
Expand All @@ -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<String> = emptyList(),
val errorReason: String? = null
)

data class CharacterInfo(val character: String, val costume: String, val type: String, val hashedName: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<ModInfo> {
return withContext(Dispatchers.IO) {
val existingCache = loadModCache()
val newCache = mutableMapOf<String, ModCacheInfo>()
val tempModsList = mutableListOf<ModInfo>()
val candidates = mutableListOf<ScannedModCandidate>()
val files = DocumentFile.fromTreeUri(context, dirUri)?.listFiles() ?: emptyArray()

files.filter { it.isDirectory || it.name?.endsWith(".zip", ignoreCase = true) == true }
Expand All @@ -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") ?: ""
Expand All @@ -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<Int, JSONObject>()
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 }
Expand Down Expand Up @@ -150,4 +264,59 @@ class ModRepository(
}
return ModDetails(fileId, fileNames)
}

private fun buildResolverFallback(fileNames: List<String>): 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<String> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
add(array.optString(i))
}
}
}

private fun parseResolvedTargets(array: JSONArray?): List<ResolvedTarget> {
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()
)
)
}
}
}
}
Loading
Loading