Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2a57f43
Merge pull request #44 from Ark-Repoleved/dev
kevin930321 Apr 12, 2026
0d03174
feat: replace catalog index with local bundle scanner
kevin930321 Apr 13, 2026
3451503
fix(proguard): keep Gson TypeToken generic signatures for ModCacheInfo
kevin930321 Apr 13, 2026
c9676c9
feat: integrate local bundle scanning into Kotlin layer with Shizuku …
kevin930321 Apr 13, 2026
7c571be
fix: use externalCacheDir for Shizuku temp files (shell UID cannot wr…
kevin930321 Apr 13, 2026
3ce883d
perf: fast name extraction - read m_Name from raw bytes instead of fu…
kevin930321 Apr 13, 2026
81e9fcc
feat: Optimize local bundle scan UX with smart trigger and progress d…
kevin930321 Apr 13, 2026
1e504a3
fix: Keep module list loading during bundle scan and center dialog bu…
kevin930321 Apr 13, 2026
ae78574
Revert "fix: Keep module list loading during bundle scan and center d…
kevin930321 Apr 13, 2026
344f71a
Revert "feat: Optimize local bundle scan UX with smart trigger and pr…
kevin930321 Apr 13, 2026
1be33d2
feat: add bundle scan confirmation and version mismatch dialogs
kevin930321 Apr 13, 2026
2a23f46
fix(scan): wait for user confirmation before ending initialization state
kevin930321 Apr 13, 2026
e378b9f
ui(scan): move Skip button to left, simplify scan log text
kevin930321 Apr 13, 2026
753e1d0
feat(resolver): prioritize common-ui-prefabs-group bundles when resol…
kevin930321 Apr 13, 2026
962939d
Merge pull request #47 from Ark-Repoleved/feature/scan-confirm-dialogs
kevin930321 Apr 13, 2026
ae80664
fix(indexer): correct entry_data byte offsets in catalog parsing for …
kevin930321 Apr 13, 2026
230faf8
fix(scan): correct catalog parsing and improve resolver priority logic
kevin930321 Apr 13, 2026
5ad48d9
feat: use catalog dependency chain for authoritative asset-to-bundle …
kevin930321 Apr 13, 2026
d34374e
refactor(resolver): use catalog to filter scan results instead of byp…
kevin930321 Apr 13, 2026
b790aa9
fix: refine catalog-based bundle resolution with multi-variant keys a…
kevin930321 Apr 13, 2026
86e4033
feat: use bundle-level reverse-lookup with similarity scoring for cat…
kevin930321 Apr 13, 2026
66df964
fix(ModRepository): invalidate mod cache if local_bundle_index.json i…
kevin930321 Apr 14, 2026
b975137
perf(ModRepository): replace DocumentFile with batch ContentResolver …
kevin930321 Apr 14, 2026
31fefb1
perf: parallelize init, skip redundant finalizeScan, cache loaded index
kevin930321 Apr 14, 2026
8d6577f
fix: revert to sequential init to avoid Python startup race condition
kevin930321 Apr 14, 2026
9009322
feat: add global quality selector (SD/HD/FHD) and persist settings
kevin930321 Apr 14, 2026
a7578f7
ui: remove Quality: prefix from quality selector button
kevin930321 Apr 14, 2026
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
10 changes: 9 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,12 @@
-keep class com.chaquo.python.runtime.** { *; }

# Shizuku
-keep class com.example.bd2modmanager.service.ShizukuFileService { *; }
-keep class com.example.bd2modmanager.service.ShizukuFileService { *; }

# Gson: preserve generic signatures for TypeToken
-keepattributes Signature
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken

# Keep data model classes used with Gson serialization
-keep class com.example.bd2modmanager.data.model.** { *; }
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ package com.example.bd2modmanager;
interface IFileService {
boolean copyFile(String sourcePath, String destPath);
boolean copyDirectory(String sourceDirPath, String destDirPath);
String listBundleDirectory(String sharedDirPath);
void destroy();
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/example/bd2modmanager/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ class MainActivity : ComponentActivity() {
onDismiss = { viewModel.resetMergeState() }
)
}

val showVersionMismatch by viewModel.showVersionMismatchWarning.collectAsState()
if (showVersionMismatch) {
VersionMismatchWarningDialog(
onDismiss = { viewModel.dismissVersionMismatchWarning() }
)
}

val bundleScanState by viewModel.bundleScanState.collectAsState()
BundleScanDialog(
state = bundleScanState,
onConfirm = { viewModel.confirmBundleScan() },
onDismiss = { viewModel.dismissBundleScan() }
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum class MatchStrategy {
EXACT,
NORMALIZED,
EXTENSION_MAPPING,
LOCAL_SCAN,
FALLBACK,
NONE
}
Expand Down Expand Up @@ -123,3 +124,27 @@ sealed class MergeState {
data class Finished(val message: String) : MergeState()
data class Failed(val error: String) : MergeState()
}

sealed class BundleScanState {
object Idle : BundleScanState()
data class Confirmation(val bundleCount: Int) : BundleScanState()
data class Scanning(
val currentIndex: Int,
val totalCount: Int,
val currentBundle: String,
val progressMessage: String = ""
) : BundleScanState()
data class Finished(
val scannedCount: Int,
val failedCount: Int,
val message: String
) : BundleScanState()
data class Failed(val error: String) : BundleScanState()
}

data class BundleCheckResult(
val bundleListJson: String,
val needsScanJson: String,
val hashMap: Map<String, String>,
val needsScanCount: Int
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,26 @@ class CharacterRepository(private val context: Context) {

private var characterLut: Map<String, List<CharacterInfo>> = emptyMap()

suspend fun updateCharacterData(): Boolean {
/**
* Returns the update status: "SUCCESS", "SKIPPED", or "FAILED".
* - "SUCCESS" means characters.json was regenerated with a new version.
* - "SKIPPED" means the local version was already up to date.
* - "FAILED" means the update attempt failed.
*/
suspend fun updateCharacterData(quality: String): String {
return withContext(Dispatchers.IO) {
var success = false
var status = "FAILED"
try {
if (!Python.isStarted()) {
Python.start(com.chaquo.python.android.AndroidPlatform(context))
}
val py = Python.getInstance()
val mainScript = py.getModule("main_script")
val result = mainScript.callAttr("update_character_data", context.filesDir.absolutePath).asList()
val status = result[0].toString() // SUCCESS, SKIPPED, FAILED
val result = mainScript.callAttr("update_character_data", context.filesDir.absolutePath, quality).asList()
val resultStatus = result[0].toString() // SUCCESS, SKIPPED, FAILED
val message = result[1].toString()

when (status) {
when (resultStatus) {
"SUCCESS" -> {
println("Successfully ran scraper and saved characters.json: $message")
// When a new characters.json is generated, the mod cache becomes invalid.
Expand All @@ -39,27 +45,31 @@ class CharacterRepository(private val context: Context) {
cacheFile.delete()
println("Deleted mod cache to force re-scan.")
}
success = true
status = "SUCCESS"
}
"SKIPPED" -> {
println("Scraper skipped: $message")
success = true
status = "SKIPPED"
}
"FAILED" -> {
println("Scraper script failed: $message. Will use local version if available.")
success = false
status = "FAILED"
}
}
} catch (e: Exception) {
e.printStackTrace()
println("Failed to execute scraper python script, will use local version. Error: ${e.message}")
success = false
status = "FAILED"
}
characterLut = parseCharacterJson()
success
status
}
}

fun hasLocalCharactersJson(): Boolean {
return File(context.filesDir, CHARACTERS_JSON_FILENAME).exists()
}

private suspend fun parseCharacterJson(): Map<String, List<CharacterInfo>> {
return withContext(Dispatchers.IO) {
val lut = mutableMapOf<String, MutableList<CharacterInfo>>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.example.bd2modmanager.data.repository

import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import android.provider.DocumentsContract
import com.chaquo.python.Python
import com.example.bd2modmanager.data.model.MatchStrategy
import com.example.bd2modmanager.data.model.ModCacheInfo
Expand Down Expand Up @@ -51,12 +51,36 @@ class ModRepository(
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 }
.forEach { file ->
val uriString = file.uri.toString()
val lastModified = file.lastModified()
// Single ContentResolver query replaces DocumentFile.listFiles() + per-file queries.
// DocumentFile does O(n*k) queries; this does O(1) regardless of file count.
val treeDocId = DocumentsContract.getTreeDocumentId(dirUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(dirUri, treeDocId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
)

context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
val modifiedCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)

while (cursor.moveToNext()) {
val documentId = cursor.getString(idCol) ?: continue
val displayName = cursor.getString(nameCol) ?: continue
val mimeType = cursor.getString(mimeCol) ?: ""
val lastModified = cursor.getLong(modifiedCol)

val isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
val isZip = displayName.endsWith(".zip", ignoreCase = true)
if (!isDirectory && !isZip) continue

val fileUri = DocumentsContract.buildDocumentUriUsingTree(dirUri, documentId)
val uriString = fileUri.toString()
val cachedInfo = existingCache[uriString]

if (cachedInfo != null && cachedInfo.lastModified == lastModified) {
Expand All @@ -68,7 +92,7 @@ class ModRepository(
costume = cachedInfo.costume,
type = cachedInfo.type,
isEnabled = false,
uri = file.uri,
uri = fileUri,
targetHashedName = cachedInfo.targetHashedName,
isDirectory = cachedInfo.isDirectory,
resolutionState = cachedInfo.resolutionState,
Expand All @@ -79,25 +103,25 @@ class ModRepository(
)
)
} else {
val modName = file.name?.removeSuffix(".zip") ?: ""
val isDirectory = file.isDirectory
val modName = displayName.removeSuffix(".zip")
val modDetails = if (isDirectory) {
extractModDetailsFromDirectory(file.uri)
extractModDetailsFromDirectory(fileUri)
} else {
extractModDetailsFromUri(file.uri)
extractModDetailsFromUri(fileUri)
}
candidates.add(
ScannedModCandidate(
uriString = uriString,
lastModified = lastModified,
name = modName,
uri = file.uri,
uri = fileUri,
isDirectory = isDirectory,
modDetails = modDetails
)
)
}
}
}

if (candidates.isNotEmpty()) {
if (!Python.isStarted()) {
Expand All @@ -113,10 +137,13 @@ class ModRepository(
}
}

val prefs = context.getSharedPreferences("app_settings", android.content.Context.MODE_PRIVATE)
val selectedQuality = prefs.getString("selected_quality", "HD") ?: "HD"

val (batchSuccess, batchResults) = ModdingService.resolveModBatch(
batchPayload.toString(),
context.filesDir.absolutePath,
"HD"
selectedQuality
) { }

val resultsById = mutableMapOf<Int, JSONObject>()
Expand Down Expand Up @@ -217,6 +244,12 @@ class ModRepository(
val cacheFile = getModCacheFile()
if (!cacheFile.exists()) return emptyMap()

val indexFile = File(context.filesDir, "local_bundle_index.json")
if (indexFile.exists() && indexFile.lastModified() > cacheFile.lastModified()) {
// Unify: If the bundle index updated, all mod cache entries are potentially stale
return emptyMap()
}

return try {
val json = cacheFile.readText()
val type = object : TypeToken<Map<String, ModCacheInfo>>() {}.type
Expand Down Expand Up @@ -263,12 +296,24 @@ class ModRepository(
val fileNames = mutableListOf<String>()
var fileId: String? = null
try {
DocumentFile.fromTreeUri(context, dirUri)?.listFiles()?.forEach { file ->
val entryName = file.name ?: ""
if (shouldIgnoreModEntry(entryName)) return@forEach
fileNames.add(entryName)
if (file.isFile) {
if (fileId == null) fileId = characterRepository.extractFileId(entryName)
val docId = DocumentsContract.getDocumentId(dirUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(dirUri, docId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val entryName = cursor.getString(nameCol) ?: continue
val mimeType = cursor.getString(mimeCol) ?: ""
if (shouldIgnoreModEntry(entryName)) continue
fileNames.add(entryName)
val isFile = mimeType != DocumentsContract.Document.MIME_TYPE_DIR
if (isFile && fileId == null) {
fileId = characterRepository.extractFileId(entryName)
}
}
}
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,68 @@ object ModdingService {
Pair(false, e.message ?: "An unknown error occurred in Kotlin during spine merge.")
}
}

// --- Local bundle scanning (three-step API) ---

fun checkScanNeeded(outputDir: String, bundleListJson: String, onProgress: (String) -> Unit): String {
return try {
val py = Python.getInstance()
val mainScript = py.getModule("main_script")

val result = mainScript.callAttr(
"check_scan_needed",
outputDir,
bundleListJson,
PyObject.fromJava(onProgress)
).toString()

result
} catch (e: Exception) {
e.printStackTrace()
"[]"
}
}

fun scanSingleBundle(bundleName: String, bundleHash: String, tempDataPath: String, onProgress: (String) -> Unit): Triple<Boolean, Int, String> {
return try {
val py = Python.getInstance()
val mainScript = py.getModule("main_script")

val result = mainScript.callAttr(
"scan_single_bundle",
bundleName,
bundleHash,
tempDataPath,
PyObject.fromJava(onProgress)
).asList()

val success = result[0].toBoolean()
val assetCount = result[1].toInt()
val message = result[2].toString()
Triple(success, assetCount, message)
} catch (e: Exception) {
e.printStackTrace()
Triple(false, 0, e.message ?: "Unknown error during bundle scan.")
}
}

fun finalizeScan(outputDir: String, onProgress: (String) -> Unit): Pair<Boolean, String> {
return try {
val py = Python.getInstance()
val mainScript = py.getModule("main_script")

val result = mainScript.callAttr(
"finalize_scan",
outputDir,
PyObject.fromJava(onProgress)
).asList()

val success = result[0].toBoolean()
val message = result[1].toString()
Pair(success, message)
} catch (e: Exception) {
e.printStackTrace()
Pair(false, e.message ?: "Unknown error during scan finalization.")
}
}
}
Loading
Loading