From 0d0317417f0b6b5f239ddf795912c71e95b0de1e Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 15:28:59 +0800 Subject: [PATCH 01/25] feat: replace catalog index with local bundle scanner --- app/src/main/python/local_bundle_indexer.py | 298 +++++++++++ app/src/main/python/main_script.py | 364 ++++++++----- app/src/main/python/resolver.py | 537 ++++++-------------- 3 files changed, 693 insertions(+), 506 deletions(-) create mode 100644 app/src/main/python/local_bundle_indexer.py diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py new file mode 100644 index 00000000..c61fb4f6 --- /dev/null +++ b/app/src/main/python/local_bundle_indexer.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- +""" +Scans locally cached game bundles to build an asset-name-to-bundle mapping. + +Provides a three-step API designed for Kotlin/Shizuku integration: + + Step 1: check_scan_needed() — Compare bundle list with cache, return which need scanning + Step 2: scan_single_bundle() — Scan one bundle from a temp file (called per bundle) + Step 3: finalize_scan() — Merge cached + new results, save final index + +This architecture lets the Kotlin layer handle Shizuku-mediated file I/O +(copying __data files from the game's private directory to a temp path) +while Python handles only the UnityPy parsing. +""" +import json +import os +import time + +# Lazy-loaded UnityPy reference +_UnityPy = None + +INDEX_SCHEMA_VERSION = 1 + +# Only scan object types that carry meaningful m_Name for modding. +# Skipping Transform, GameObject, Material, Shader, Mesh, etc. avoids +# expensive obj.read() calls and dramatically speeds up scanning. +SCAN_TYPES = frozenset({"Texture2D", "TextAsset", "Sprite"}) + +# Extension mapping to match unpacker/repacker conventions. +# Texture2D m_Name has no extension in Unity — we add ".png" to match +# what the unpacker exports and what users expect. +_EXTENSION_MAP = {"Texture2D": ".png", "Sprite": ".png"} + +# Module-level state for an ongoing scan session. +# Populated by check_scan_needed(), updated by scan_single_bundle(), +# consumed and cleared by finalize_scan(). +_scan_state = None + + +def _ensure_unitypy(): + """Lazy-load and configure UnityPy.""" + global _UnityPy + if _UnityPy is not None: + return _UnityPy + + from UnityPy.helpers import TypeTreeHelper + TypeTreeHelper.read_typetree_boost = False + import UnityPy + UnityPy.config.FALLBACK_UNITY_VERSION = '2022.3.22f1' + _UnityPy = UnityPy + return _UnityPy + + +def _get_asset_extension(type_name): + """Return the file extension for an object type, or empty string.""" + return _EXTENSION_MAP.get(type_name, "") + + +def _scan_bundle_file(file_path): + """ + Scan a single bundle file and return a sorted list of unique asset names. + + Only reads objects of types in SCAN_TYPES to minimize parsing overhead. + Asset names are normalized to lowercase with appropriate file extensions. + """ + unitypy = _ensure_unitypy() + env = unitypy.load(file_path) + names = set() + + for obj in env.objects: + if obj.type.name not in SCAN_TYPES: + continue + try: + data = obj.read() + except Exception: + continue + + raw_name = getattr(data, "m_Name", None) + if not raw_name: + continue + + name = raw_name.strip().lower() + if not name: + continue + + ext = _get_asset_extension(obj.type.name) + if ext and not name.endswith(ext): + name += ext + + names.add(name) + + return sorted(names) + + +def _load_existing_cache(cache_path): + """Load existing index cache from disk. Returns dict or None.""" + try: + if os.path.isfile(cache_path): + with open(cache_path, "r", encoding="utf-8") as f: + data = json.load(f) + if data.get("schemaVersion") == INDEX_SCHEMA_VERSION: + return data + except Exception: + pass + return None + + +# --------------------------------------------------------------------------- +# Three-step scanning API +# --------------------------------------------------------------------------- + +def check_scan_needed(output_dir, bundle_list_json): + """ + Step 1: Compare the current bundle list against the cached index to + determine which bundles need scanning. + + Kotlin should call this first with the directory listing obtained via + Shizuku from the game's Shared/ directory. + + Args: + output_dir: Path where the index cache JSON lives (app-writable dir) + bundle_list_json: JSON string or list of objects: + [{"name": "bundleName", "hash": "hashDirName"}, ...] + + Returns: + JSON string of bundle names that need scanning: ["name1", "name2", ...] + Bundles whose hash matches the cache are skipped. + """ + global _scan_state + + bundle_list = json.loads(bundle_list_json) if isinstance(bundle_list_json, str) else bundle_list_json + + cache_path = os.path.join(output_dir, "local_bundle_index.json") + existing_cache = _load_existing_cache(cache_path) + cached_bundles = existing_cache.get("scannedBundles", {}) if existing_cache else {} + + needs_scan = [] + still_valid = {} + + for item in bundle_list: + name = item["name"] + hash_ = item["hash"] + cached = cached_bundles.get(name) + if cached and cached.get("hash") == hash_: + # Hash unchanged — reuse cached scan result + still_valid[name] = cached + else: + # New or updated — needs scanning + needs_scan.append(name) + + # Initialize/reset scan state + _scan_state = { + "output_dir": output_dir, + "all_bundle_hashes": {item["name"]: item["hash"] for item in bundle_list}, + "cached": still_valid, + "scanned": {}, + } + + return json.dumps(needs_scan) + + +def scan_single_bundle(bundle_name, bundle_hash, temp_data_path, progress_callback=None): + """ + Step 2: Scan a single bundle from a temporary file path. + + Kotlin should: + 1. Copy the __data file from the game directory to a temp path via Shizuku + 2. Call this function with the temp path + 3. Delete the temp file after this function returns + + This is called once per bundle that check_scan_needed() flagged. + + Args: + bundle_name: Bundle identifier (folder name under Shared/) + bundle_hash: Hash directory name (for cache key) + temp_data_path: Path to the __data file in app's accessible cache dir + progress_callback: Optional function(str) + + Returns: + Tuple (success: bool, asset_count: int, message: str) + """ + global _scan_state + + if _scan_state is None: + return False, 0, "No scan in progress. Call check_scan_needed first." + + try: + assets = _scan_bundle_file(temp_data_path) + _scan_state["scanned"][bundle_name] = { + "hash": bundle_hash, + "assets": assets, + } + if progress_callback: + progress_callback(f"Scanned {bundle_name}: {len(assets)} assets") + return True, len(assets), f"OK: {len(assets)} assets" + + except Exception as e: + _scan_state["scanned"][bundle_name] = { + "hash": bundle_hash, + "assets": [], + "error": str(e), + } + if progress_callback: + progress_callback(f"Failed {bundle_name}: {e}") + return False, 0, str(e) + + +def finalize_scan(output_dir, progress_callback=None): + """ + Step 3: Merge cached + newly scanned results and save the final index. + + Should be called after all scan_single_bundle() calls are complete + (or after deciding to stop scanning early — partial results are fine). + + Args: + output_dir: Path to save the index cache JSON + progress_callback: Optional function(str) + + Returns: + Tuple (success: bool, message: str) + """ + global _scan_state + + def report(msg): + if progress_callback: + progress_callback(msg) + print(msg) + + if _scan_state is None: + return False, "No scan in progress. Call check_scan_needed first." + + try: + # Merge cached and newly scanned bundles + all_bundles = {} + all_bundles.update(_scan_state["cached"]) + all_bundles.update(_scan_state["scanned"]) + + cached_count = len(_scan_state["cached"]) + scanned_count = len(_scan_state["scanned"]) + failed_count = sum( + 1 for info in _scan_state["scanned"].values() if info.get("error") + ) + + # Build the assetToBundles lookup table + asset_to_bundles = {} + for bundle_name, info in all_bundles.items(): + for asset_name in info.get("assets", []): + if asset_name not in asset_to_bundles: + asset_to_bundles[asset_name] = [] + if bundle_name not in asset_to_bundles[asset_name]: + asset_to_bundles[asset_name].append(bundle_name) + + # Save index to disk + os.makedirs(output_dir, exist_ok=True) + index = { + "schemaVersion": INDEX_SCHEMA_VERSION, + "scannedAt": int(time.time()), + "bundleCount": len(all_bundles), + "assetCount": len(asset_to_bundles), + "assetToBundles": asset_to_bundles, + "scannedBundles": all_bundles, + } + + cache_path = os.path.join(output_dir, "local_bundle_index.json") + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(index, f, ensure_ascii=False, separators=(",", ":")) + + msg = ( + f"Index saved: {len(all_bundles)} bundles, {len(asset_to_bundles)} assets " + f"(cached: {cached_count}, scanned: {scanned_count}, failed: {failed_count})" + ) + report(msg) + return True, msg + + except Exception as e: + import traceback + error_msg = traceback.format_exc() + report(f"Error finalizing scan: {error_msg}") + return False, error_msg + + finally: + # Always clear state, even on error + _scan_state = None + + +# --------------------------------------------------------------------------- +# Index loading (used by resolver) +# --------------------------------------------------------------------------- + +def load_local_index(output_dir): + """ + Load the local bundle index from disk cache. + + Returns: + The index dict, or None if no valid cache exists. + """ + cache_path = os.path.join(output_dir, "local_bundle_index.json") + return _load_existing_cache(cache_path) diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index 8cd53ded..a49b2d4c 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -11,7 +11,7 @@ from unpacker import unpack_bundle as unpacker_main import spine_merger import resolver -import catalog_indexer +import local_bundle_indexer import json from pathlib import Path @@ -32,29 +32,15 @@ def _prune_catalog_cache(keep_version=None): catalog_cache.pop(version, None) -def _load_valid_local_index(output_dir: str, quality: str, version: str = None): - output_path = Path(output_dir) - pattern = f"asset_index_{quality.lower()}_*.json" - candidates = sorted(output_path.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) - - for index_path in candidates: - try: - with open(index_path, 'r', encoding='utf-8') as f: - index = json.load(f) - if index.get('schemaVersion') != catalog_indexer.INDEX_SCHEMA_VERSION: - continue - if version and index.get('version') != version: - continue - return index - except Exception: - try: - index_path.unlink() - except Exception: - pass - return None - +# --------------------------------------------------------------------------- +# Character metadata (characters.json) — still uses CDN catalog +# --------------------------------------------------------------------------- -def _refresh_metadata(output_dir: str, quality: str = "HD", progress_callback=None): +def _refresh_character_data(output_dir, quality="HD", progress_callback=None): + """ + Download the catalog and rebuild characters.json only. + The asset index is now handled separately by local bundle scanning. + """ def report_progress(message): if progress_callback: progress_callback(message) @@ -67,7 +53,7 @@ def report_progress(message): report_progress(f"Fetching CDN version for {quality} quality...") latest_version = cdn_downloader.get_cdn_version(quality) if not latest_version: - return False, "Failed to get CDN version.", None, None + return False, "Failed to get CDN version.", None characters_json_path = output_path / "characters.json" stored_version = None @@ -79,144 +65,187 @@ def report_progress(message): except (json.JSONDecodeError, KeyError, TypeError): stored_version = None - existing_index = _load_valid_local_index(output_dir, quality, latest_version) - characters_up_to_date = stored_version == latest_version and characters_json_path.exists() - index_up_to_date = existing_index is not None - - if characters_up_to_date and index_up_to_date: - report_progress(f"Metadata already up to date for version {latest_version}.") - return True, "SKIPPED", latest_version, existing_index + if stored_version == latest_version and characters_json_path.exists(): + report_progress(f"characters.json already up to date for version {latest_version}.") + return True, "SKIPPED", latest_version - report_progress(f"Refreshing shared metadata for version {latest_version}...") + report_progress(f"Refreshing character data for version {latest_version}...") with catalog_cache_lock: _prune_catalog_cache(latest_version) catalog_content, error = cdn_downloader.download_catalog( output_dir, quality, latest_version, catalog_cache, catalog_cache_lock, progress_callback ) if error: - return False, error, None, None + return False, error, None - if not characters_up_to_date: - report_progress("Rebuilding characters.json from shared catalog...") - success, message = character_scraper.scrape_and_save_from_catalog(output_dir, latest_version, catalog_content) - if not success: - return False, message, None, None + report_progress("Rebuilding characters.json from catalog...") + success, message = character_scraper.scrape_and_save_from_catalog(output_dir, latest_version, catalog_content) + if not success: + return False, message, None - report_progress("Rebuilding/loading asset index from shared catalog...") - index = catalog_indexer.load_or_build_asset_index(output_dir, quality, latest_version, catalog_content) - return True, "SUCCESS", latest_version, index + return True, "SUCCESS", latest_version except Exception as e: import traceback error_message = traceback.format_exc() - report_progress(f"A critical error occurred while refreshing metadata: {error_message}") - return False, error_message, None, None + report_progress(f"Error refreshing character data: {error_message}") + return False, error_message, None -def unpack_bundle(bundle_path: str, output_dir: str, progress_callback=None): +def update_character_data(output_dir): """ - Entry point for Kotlin to unpack a bundle. - Returns a tuple: (success: Boolean, message: String) + Entry point for Kotlin to refresh character metadata (characters.json). + Returns a tuple: (status: String, message: String) + Status can be "SUCCESS", "SKIPPED", "FAILED" """ try: - success, message = unpacker_main( - bundle_path=bundle_path, - output_dir=output_dir, - progress_callback=progress_callback - ) - - print(message) - return success, message - + success, status, version = _refresh_character_data(output_dir, "HD") + if success: + if status == "SKIPPED": + return "SKIPPED", "characters.json is already up to date." + return "SUCCESS", f"Character data refreshed for version {version}." + return "FAILED", status except Exception as e: import traceback error_message = traceback.format_exc() - print(f"An error occurred during unpack: {error_message}") - if progress_callback: - progress_callback(f"An error occurred: {e}") - return False, error_message + print(f"An error occurred during character data refresh: {error_message}") + return "FAILED", error_message -def download_bundle(hashed_name, quality, output_dir, cache_key, progress_callback=None): +# --------------------------------------------------------------------------- +# Local bundle scanning — three-step API for Kotlin/Shizuku +# --------------------------------------------------------------------------- +# +# Usage from Kotlin: +# +# // Step 1: List Shared/ via Shizuku, build bundle list, check which need scanning +# val bundleListJson = buildBundleListJson() // [{"name":"...", "hash":"..."}, ...] +# val needsScanJson = mainScript.callAttr("check_scan_needed", outputDir, bundleListJson).toString() +# val needsScan = JSONArray(needsScanJson) +# +# // Step 2: For each bundle that needs scanning +# for (bundleName in needsScan) { +# val hash = bundleHashes[bundleName] +# val tempPath = copyBundleViaShizuku(bundleName, hash) // Copy __data to temp +# mainScript.callAttr("scan_single_bundle", bundleName, hash, tempPath, callback) +# File(tempPath).delete() // Clean up temp file +# } +# +# // Step 3: Finalize and save index +# mainScript.callAttr("finalize_scan", outputDir, callback) + + +def check_scan_needed(output_dir, bundle_list_json, progress_callback=None): """ - Entry point for Kotlin to download a bundle from the CDN. - Manages a shared in-memory cache for the catalog file to avoid redundant downloads. - Returns a tuple: (success: Boolean, message_or_path: String) - """ - global current_cache_key + Step 1: Check which bundles need scanning. - def report_progress(message): + Kotlin should call this with the directory listing from Shared/, + obtained via Shizuku. + + Args: + output_dir: App-writable directory for the index cache + bundle_list_json: JSON string of [{"name": "bundleName", "hash": "hashDir"}, ...] + progress_callback: Optional progress reporting function + + Returns: + JSON string of bundle names that need scanning: '["name1", "name2", ...]' + """ + def report(msg): if progress_callback: - progress_callback(message) - print(message) + progress_callback(msg) + print(msg) try: - with catalog_cache_lock: - if current_cache_key != cache_key: - report_progress("New batch installation detected, clearing catalog cache.") - catalog_cache.clear() - current_cache_key = cache_key + result = local_bundle_indexer.check_scan_needed(output_dir, bundle_list_json) + needs_scan = json.loads(result) + report(f"Scan check complete: {len(needs_scan)} bundles need scanning.") + return result + except Exception as e: + import traceback + error_msg = traceback.format_exc() + report(f"Error checking scan: {error_msg}") + return json.dumps([]) - 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." - 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 +def scan_single_bundle(bundle_name, bundle_hash, temp_data_path, progress_callback=None): + """ + Step 2: Scan one bundle from a temporary file. - report_progress(f"Searching for bundle {hashed_name} in catalog...") - output_file_path, error = cdn_downloader.find_and_download_bundle( - catalog_content=catalog_content, - version=version, - quality=quality, - hashed_name=hashed_name, - output_dir=output_dir, - progress_callback=progress_callback - ) + Kotlin should: + 1. Copy __data from game dir to a temp path via Shizuku + 2. Call this function + 3. Delete the temp file - if error: - return False, error + Args: + bundle_name: Bundle identifier (folder name under Shared/) + bundle_hash: Hash directory name + temp_data_path: Path to __data in app's cache dir + progress_callback: Optional progress reporting function - return True, output_file_path + Returns: + Tuple (success: Boolean, asset_count: int, message: String) + """ + return local_bundle_indexer.scan_single_bundle( + bundle_name, bundle_hash, temp_data_path, progress_callback + ) - except Exception as e: - import traceback - error_message = traceback.format_exc() - report_progress(f"A critical error occurred: {error_message}") - return False, error_message +def finalize_scan(output_dir, progress_callback=None): + """ + Step 3: Save the final index after all bundles have been scanned. + + Args: + output_dir: App-writable directory for the index cache + progress_callback: Optional progress reporting function + + Returns: + Tuple (success: Boolean, message: String) + """ + return local_bundle_indexer.finalize_scan(output_dir, progress_callback) + + +# --------------------------------------------------------------------------- +# Mod resolution — uses local bundle index +# --------------------------------------------------------------------------- -def ensure_asset_index(output_dir: str, quality: str = "HD", progress_callback=None): +def ensure_asset_index(output_dir, quality="HD", progress_callback=None): + """ + Load the local bundle index from disk. + + Returns a tuple: (success: Boolean, message_or_error: String, index: dict or None) + """ def report_progress(message): if progress_callback: progress_callback(message) print(message) try: - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - - index = _load_valid_local_index(output_dir, quality) + index = local_bundle_indexer.load_local_index(output_dir) if index is None: - return False, "Asset index missing. Refresh metadata first.", None + return False, "Local bundle index not found. Please scan local bundles first.", None - version = index.get('version') - report_progress(f"Using local asset index for version {version}.") - return True, version, index + asset_count = index.get('assetCount', 0) + bundle_count = index.get('bundleCount', 0) + report_progress(f"Local bundle index loaded: {bundle_count} bundles, {asset_count} assets.") + return True, "Local index loaded.", index except Exception as e: import traceback error_message = traceback.format_exc() - report_progress(f"A critical error occurred while loading asset index: {error_message}") + report_progress(f"Error loading local 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): +def resolve_mod_files(file_names_json, output_dir, quality="HD", progress_callback=None): + """ + Resolve mod files against the local bundle index. + + Args: + file_names_json: JSON string or list of mod file names + output_dir: Path where the local index cache is stored + quality: Ignored (kept for backward compatibility) + progress_callback: Optional progress reporting function + + Returns a tuple: (success: Boolean, result_json_or_error: String) + """ 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) @@ -230,7 +259,18 @@ def resolve_mod_files(file_names_json: str, output_dir: str, quality: str = "HD" return False, traceback.format_exc() -def resolve_mod_batch(mods_json: str, output_dir: str, quality: str = "HD", progress_callback=None): +def resolve_mod_batch(mods_json, output_dir, quality="HD", progress_callback=None): + """ + Resolve a batch of mods against the local bundle index. + + Args: + mods_json: JSON string or list of mod objects with 'id' and 'fileNames' + output_dir: Path where the local index cache is stored + quality: Ignored (kept for backward compatibility) + progress_callback: Optional progress reporting function + + Returns a tuple: (success: Boolean, results_json_or_error: String) + """ 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) @@ -252,27 +292,95 @@ def resolve_mod_batch(mods_json: str, output_dir: str, quality: str = "HD", prog return False, traceback.format_exc() -def update_character_data(output_dir: str): +# --------------------------------------------------------------------------- +# CDN bundle download — kept for backward compatibility +# --------------------------------------------------------------------------- + +def download_bundle(hashed_name, quality, output_dir, cache_key, progress_callback=None): """ - Entry point for Kotlin to refresh all shared metadata. - Returns a tuple: (status: String, message: String) - Status can be "SUCCESS", "SKIPPED", "FAILED" + Entry point for Kotlin to download a bundle from the CDN. + Manages a shared in-memory cache for the catalog file to avoid redundant downloads. + Returns a tuple: (success: Boolean, message_or_path: String) """ + global current_cache_key + + def report_progress(message): + if progress_callback: + progress_callback(message) + print(message) + try: - success, status, version, _index = _refresh_metadata(output_dir, "HD") - if success: - if status == "SKIPPED": - return "SKIPPED", "characters.json and asset index are already up to date." - return "SUCCESS", f"Metadata refreshed for version {version}." - return "FAILED", status + with catalog_cache_lock: + if current_cache_key != cache_key: + report_progress("New batch installation detected, clearing catalog cache.") + catalog_cache.clear() + current_cache_key = cache_key + + 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." + + 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 + + report_progress(f"Searching for bundle {hashed_name} in catalog...") + output_file_path, error = cdn_downloader.find_and_download_bundle( + catalog_content=catalog_content, + version=version, + quality=quality, + hashed_name=hashed_name, + output_dir=output_dir, + progress_callback=progress_callback + ) + + if error: + return False, error + + return True, output_file_path + except Exception as e: import traceback error_message = traceback.format_exc() - print(f"An error occurred during metadata refresh process: {error_message}") - return "FAILED", error_message + report_progress(f"A critical error occurred: {error_message}") + return False, error_message + + +# --------------------------------------------------------------------------- +# Unpacking, repacking, spine merge — unchanged +# --------------------------------------------------------------------------- + +def unpack_bundle(bundle_path, output_dir, progress_callback=None): + """ + Entry point for Kotlin to unpack a bundle. + Returns a tuple: (success: Boolean, message: String) + """ + try: + success, message = unpacker_main( + bundle_path=bundle_path, + output_dir=output_dir, + progress_callback=progress_callback + ) + + print(message) + return success, message + + except Exception as e: + import traceback + error_message = traceback.format_exc() + print(f"An error occurred during unpack: {error_message}") + if progress_callback: + progress_callback(f"An error occurred: {e}") + return False, error_message -def main(original_bundle_path: str, modded_assets_folder: str, output_path: str, use_astc: bool, progress_callback=None): +def main(original_bundle_path, modded_assets_folder, output_path, use_astc, progress_callback=None): """ Main entry point to be called from Kotlin. Returns a tuple: (success: Boolean, message: String) @@ -296,7 +404,7 @@ def main(original_bundle_path: str, modded_assets_folder: str, output_path: str, return False, error_message -def merge_spine_assets(mod_dir_path: str, progress_callback=None): +def merge_spine_assets(mod_dir_path, progress_callback=None): """ Entry point for Kotlin to run the spine merger script. Returns a tuple: (success: Boolean, message: String) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index a3cf0ed0..32ef8ad1 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -1,450 +1,231 @@ # -*- coding: utf-8 -*- +""" +Resolves mod file names against the local bundle index. + +This is a simplified replacement for the catalog-based resolver. Instead of +parsing catalog addressable keys and using complex bridge/prefab strategies, +it directly looks up asset names (m_Name) in the local bundle index. + +The matching is straightforward: + - Mod file "char000104.png" → looks up "char000104.png" in index + - Mod file "char000104.json" → also tries "char000104.skel" (JSON→skel conversion) + - Mod file "char000104.atlas.txt" → also tries "char000104.atlas" + +The return format is kept compatible with the Kotlin layer. +""" import re from pathlib import Path -from catalog_indexer import normalize_filename +def resolve_mod_folder(mod_file_names, local_index): + """ + Resolve a list of mod file names against the local bundle index. -def resolve_mod_folder(mod_file_names, asset_index): - unresolved_files = [] - file_matches = [] + Finds which bundle(s) contain the target assets, then identifies + the single common bundle that all mod files belong to. + + Args: + mod_file_names: list of mod file paths/names + local_index: dict with 'assetToBundles' key - assets_by_base = (asset_index or {}).get('assetsByBaseName', {}) - assets_by_exact = (asset_index or {}).get('assetsByExactKey', {}) + Returns: + A resolution result dict compatible with the Kotlin layer: + { + 'targetHash': str or None (bundle name), + 'resolvedFamilyKey': str or None, + 'resolvedTargets': list of match dicts, + 'unresolvedFiles': list of unmatched file names, + 'resolutionState': 'KNOWN' | 'UNKNOWN' | 'INVALID', + 'errorReason': str or None + } + """ + asset_to_bundles = (local_index or {}).get("assetToBundles", {}) + + unresolved = [] + file_matches = [] 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)) + matched_candidate = None + matched_bundles = set() 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) - matches = _filter_bridge_noise(base_name, matches) - - if matches: + bundles = asset_to_bundles.get(candidate) + if bundles: + if matched_candidate is None: + matched_candidate = candidate + matched_bundles.update(bundles) + + if matched_candidate and matched_bundles: file_matches.append({ 'fileName': base_name, 'candidates': candidates, - 'matches': matches, - 'targetHashes': {m.get('targetHash') for m in matches if m.get('targetHash')} + 'candidate': matched_candidate, + 'bundles': matched_bundles, }) else: - unresolved_files.append(base_name) - - _inherit_png_targets_from_spine_pairs(file_matches) + unresolved.append(base_name) - candidate_sets = [entry['targetHashes'] for entry in file_matches if entry['targetHashes']] - - if not candidate_sets: + # No matches at all + if not file_matches: return { 'targetHash': None, 'resolvedFamilyKey': None, 'resolvedTargets': [], - 'unresolvedFiles': unresolved_files, + 'unresolvedFiles': unresolved, 'resolutionState': 'UNKNOWN', - 'errorReason': 'No matching target could be resolved' + 'errorReason': 'No matching bundle found in local index' } + # Find common bundle across all matched files + candidate_sets = [entry['bundles'] for entry in file_matches] + intersection = set(candidate_sets[0]) - for target_set in candidate_sets[1:]: - intersection &= target_set + for s in candidate_sets[1:]: + intersection &= s union = set() - for target_set in candidate_sets: - union |= target_set + for s in candidate_sets: + union |= s + target_bundle = None if len(intersection) == 1: - target_hash = next(iter(intersection)) + target_bundle = next(iter(intersection)) + elif len(intersection) > 1: + # Multiple common bundles — pick the first alphabetically + target_bundle = sorted(intersection)[0] elif len(union) == 1: - target_hash = next(iter(union)) - else: + # No strict intersection but only one bundle total + target_bundle = next(iter(union)) + + if target_bundle is None: + # Files point to different bundles — can't determine a single target return { 'targetHash': None, 'resolvedFamilyKey': None, - 'resolvedTargets': _select_representative_matches(file_matches, None), - 'unresolvedFiles': unresolved_files, + 'resolvedTargets': [ + _build_target(entry) for entry in file_matches + ], + 'unresolvedFiles': unresolved, 'resolutionState': 'INVALID', - 'errorReason': 'Multiple targets detected in one mod folder' + 'errorReason': 'Mod files map to different bundles' } - 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')) - } + resolved_targets = [ + _build_target(entry, target_bundle) for entry in file_matches + ] + family_key = _compute_family_key(file_matches) return { - 'targetHash': target_hash, - 'resolvedFamilyKey': next(iter(family_keys)) if len(family_keys) == 1 else None, + 'targetHash': target_bundle, + 'resolvedFamilyKey': family_key, 'resolvedTargets': resolved_targets, - 'unresolvedFiles': unresolved_files, + 'unresolvedFiles': unresolved, 'resolutionState': 'KNOWN', 'errorReason': None } -def _expand_candidates(base_name: str): - candidates = list(dict.fromkeys(normalize_filename(base_name))) - - lowered_base = (base_name or '').strip().lower() - stem = _mod_asset_stem(lowered_base) - if stem: - if re.fullmatch(r'illust_dating\d+', stem, re.IGNORECASE): - candidates.extend([ - f'{stem}.prefab', - f'char/datingillust/{stem}.prefab', - ]) - - if re.fullmatch(r'char\d{6}', stem, re.IGNORECASE): - candidates.extend([ - f'illust_{stem}_01.prefab', - f'illust_{stem}_1.prefab', - ]) - - if re.fullmatch(r'npc\d{6}', stem, re.IGNORECASE): - candidates.extend([ - f'illust_{stem}_01.prefab', - f'illust_{stem}_1.prefab', - f'illust_{stem}_2.prefab', - ]) - - if _is_rhythm_hit_anim_stem(stem): - candidates.extend(_rhythm_bridge_candidates()) - - 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 _expand_candidates(base_name): + """ + Expand a mod filename to possible asset names in the bundle. -def _mod_asset_stem(asset_name: str): - lowered = (asset_name or '').strip().lower() + Handles extension mappings: + .json → also try .skel (mod JSON animation → bundle skel TextAsset) + .skel.bytes → also try .skel (catalog convention → actual m_Name) + .atlas.txt → also try .atlas (catalog convention → actual m_Name) + """ + lowered = (base_name or "").strip().lower() if not lowered: - return None - - for suffix in ('.skel.bytes', '.atlas.txt'): - if lowered.endswith(suffix): - return lowered[:-len(suffix)] - - return Path(lowered).stem - - - -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 _is_rhythm_hit_anim_stem(stem: str): - if not stem: - return False - return re.fullmatch(r'rhythmhitanim(?:_\d+)?', stem, re.IGNORECASE) is not None - - -def _rhythm_bridge_candidates(): - return [ - f'rhythm_char{n:03d}.prefab' - for n in range(1, 6) - ] - - -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 + candidates = [lowered] -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 - } - + # JSON animation → skel (user provides .json, bundle has .skel TextAsset) + if lowered.endswith('.json'): + candidates.append(lowered[:-5] + '.skel') -def _string_at(strings, index): - if index is None or index < 0 or index >= len(strings): - return None - return strings[index] + # .skel.bytes → .skel (catalog uses .skel.bytes, bundle m_Name is .skel) + if lowered.endswith('.skel.bytes'): + candidates.append(lowered[:-6]) # strip '.bytes' + # .atlas.txt → .atlas + if lowered.endswith('.atlas.txt'): + candidates.append(lowered[:-4]) # strip '.txt' -def _prefer_primary_hits(hits): - if not hits: - return [] + return list(dict.fromkeys(candidates)) - 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_target(entry, target_bundle=None): + """Build a resolved target dict for Kotlin compatibility.""" + bundle_name = target_bundle or (sorted(entry['bundles'])[0] if entry['bundles'] else None) + family_key = _extract_stem(entry['fileName']) -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, + 'originalFileName': entry['fileName'], + 'normalizedCandidates': entry.get('candidates', []), + 'resolvedAssetKey': entry['candidate'], + 'resolvedBundleName': bundle_name, + 'assetType': _infer_asset_type(entry['fileName']), + 'targetHash': bundle_name, '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 _filter_bridge_noise(base_name: str, matches): - if not matches: - return matches - - lowered = (base_name or '').strip().lower() - stem = _mod_asset_stem(lowered) - if not stem: - return matches - - if _is_rhythm_hit_anim_stem(stem): - pattern = re.compile(r'(^|.*/)rhythm_char\d{3}\.prefab$', re.IGNORECASE) - rhythm_matches = [] - for match in matches: - asset_key = (match.get('resolvedAssetKey') or '').lower() - if pattern.search(asset_key): - rhythm_matches.append(match) - - if not rhythm_matches: - return matches - - exact_char001 = [ - match for match in rhythm_matches - if (match.get('resolvedAssetKey') or '').lower().endswith('rhythm_char001.prefab') - ] - if exact_char001: - return exact_char001 - - return sorted( - rhythm_matches, - key=lambda match: (match.get('resolvedAssetKey') or '').lower() - )[:1] - - if not ( - lowered.endswith('.skel') - or lowered.endswith('.skel.bytes') - or lowered.endswith('.json') - or lowered.endswith('.atlas') - or lowered.endswith('.atlas.txt') - ): - return matches - - preferred = [] - - if re.fullmatch(r'char\d{6}', stem, re.IGNORECASE): - pattern = re.compile(rf'(^|.*/)illust_{re.escape(stem)}_\d+\.prefab$', re.IGNORECASE) - for match in matches: - asset_key = (match.get('resolvedAssetKey') or '').lower() - if pattern.search(asset_key): - preferred.append(match) - return preferred if preferred else matches - - if re.fullmatch(r'npc\d{6}', stem, re.IGNORECASE): - pattern = re.compile(rf'(^|.*/)illust_{re.escape(stem)}_\d+\.prefab$', re.IGNORECASE) - for match in matches: - asset_key = (match.get('resolvedAssetKey') or '').lower() - if pattern.search(asset_key): - preferred.append(match) - return preferred if preferred else matches - - return matches - - -def _inherit_png_targets_from_spine_pairs(file_matches): - if not file_matches: - return - - anchor_by_stem = {} - for entry in file_matches: - file_name = entry.get('fileName') or '' - lowered = file_name.lower() - if lowered.endswith('.png'): - continue - if not ( - lowered.endswith('.skel') - or lowered.endswith('.skel.bytes') - or lowered.endswith('.json') - or lowered.endswith('.atlas') - or lowered.endswith('.atlas.txt') - ): - continue - - target_hashes = list(entry.get('targetHashes') or []) - if len(target_hashes) != 1: - continue - - representative = _prefer_best_match(entry.get('matches') or []) - if not representative: - continue - - stem = _mod_asset_stem(lowered) - if not stem: - continue - - anchor_by_stem[stem] = { - 'targetHash': target_hashes[0], - 'match': representative, - } - - for entry in file_matches: - file_name = entry.get('fileName') or '' - lowered = file_name.lower() - if not lowered.endswith('.png'): - continue - - stem = _mod_asset_stem(lowered) - anchor = anchor_by_stem.get(stem) - if not anchor: - continue - - anchor_target_hash = anchor['targetHash'] - existing_matches = entry.get('matches') or [] - matching_target = [m for m in existing_matches if m.get('targetHash') == anchor_target_hash] - if matching_target: - entry['matches'] = matching_target - entry['targetHashes'] = {anchor_target_hash} - continue - - inherited = dict(anchor['match']) - inherited['originalFileName'] = file_name - inherited['assetType'] = _infer_asset_type(file_name) - inherited['matchStrategy'] = 'EXTENSION_MAPPING' - inherited['confidence'] = min(float(inherited.get('confidence') or 0.9), 0.9) - entry['matches'] = [inherited] - entry['targetHashes'] = {anchor_target_hash} - - - -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 + 'matchStrategy': 'LOCAL_SCAN', + 'confidence': 1.0, } - 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() +def _infer_asset_type(file_name): + """Infer the asset type from a mod file extension.""" + lowered = (file_name or "").lower() if lowered.endswith('.png'): return 'Texture2D' if lowered.endswith('.json'): return 'JsonSkeleton' - if lowered.endswith('.atlas') or lowered.endswith('.atlas.txt') or lowered.endswith('.skel') or lowered.endswith('.skel.txt') or lowered.endswith('.skel.bytes'): + if any(lowered.endswith(ext) for ext in ('.atlas', '.atlas.txt', '.skel', '.skel.txt', '.skel.bytes')): return 'TextAsset' return 'Unknown' -def _normalize_family_key(value: str): - if not value: +def _compute_family_key(file_matches): + """ + Compute a family key from the matched files. + The family key groups related assets (e.g., "char000104"). + """ + stems = set() + for entry in file_matches: + stem = _extract_stem(entry['fileName']) + if stem: + stems.add(stem) + + if len(stems) == 1: + return next(iter(stems)) + return None + + +def _extract_stem(file_name): + """ + Extract the base stem from a filename, removing extensions and numbered suffixes. + Examples: + "char000104.png" → "char000104" + "char000104_2.png" → "char000104" + "char000104.skel" → "char000104" + "char000104.skel.bytes" → "char000104" + "cutscene_char061002.atlas" → "cutscene_char061002" + """ + lowered = (file_name or "").strip().lower() + if not lowered: return None - lowered = value.lower() - lowered = re.sub(r'_(\d+)$', '', lowered) - return lowered + + # Remove known compound extensions first + for ext in ('.skel.bytes', '.atlas.txt', '.skel.txt'): + if lowered.endswith(ext): + lowered = lowered[:-len(ext)] + break + else: + lowered = Path(lowered).stem + + # Remove numbered suffix like _2, _3 + stem = re.sub(r'_\d+$', '', lowered) + return stem if stem else None From 345150362d8d8b95ca56f67725e8385088c149e6 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 15:42:32 +0800 Subject: [PATCH 02/25] fix(proguard): keep Gson TypeToken generic signatures for ModCacheInfo --- app/proguard-rules.pro | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8415ebfa..a79dfcc7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,4 +5,12 @@ -keep class com.chaquo.python.runtime.** { *; } # Shizuku --keep class com.example.bd2modmanager.service.ShizukuFileService { *; } \ No newline at end of file +-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.** { *; } \ No newline at end of file From c9676c93c1eae804ba3b6d1ecb62ca3936b1045b Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 16:03:23 +0800 Subject: [PATCH 03/25] feat: integrate local bundle scanning into Kotlin layer with Shizuku support --- .../example/bd2modmanager/IFileService.aidl | 1 + .../bd2modmanager/data/model/ModModels.kt | 1 + .../bd2modmanager/service/ModdingService.kt | 64 ++++++++++ .../service/ShizukuFileService.kt | 44 +++++++ .../bd2modmanager/service/ShizukuManager.kt | 110 ++++++++++++++++++ .../ui/viewmodel/MainViewModel.kt | 19 +++ 6 files changed, 239 insertions(+) diff --git a/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl b/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl index 12f7e541..38f0d021 100644 --- a/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl +++ b/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl @@ -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(); } 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 820ce6d4..6fa0142b 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 @@ -13,6 +13,7 @@ enum class MatchStrategy { EXACT, NORMALIZED, EXTENSION_MAPPING, + LOCAL_SCAN, FALLBACK, NONE } 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 387e1e29..176686c3 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt @@ -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 { + 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 { + 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.") + } + } } diff --git a/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt b/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt index 4df84315..a21630ef 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt @@ -4,6 +4,8 @@ import android.content.Context import com.example.bd2modmanager.IFileService import java.io.File import android.util.Log +import org.json.JSONArray +import org.json.JSONObject class ShizukuFileService(context: Context? = null) : IFileService.Stub() { @@ -59,6 +61,48 @@ class ShizukuFileService(context: Context? = null) : IFileService.Stub() { } } + /** + * List all bundles in the game's Shared/ directory. + * Returns a JSON array string: [{"name":"bundleName","hash":"hashDirName"}, ...] + * + * Expected directory structure: + * Shared/{bundleName}/{hash}/__data + */ + override fun listBundleDirectory(sharedDirPath: String): String { + return try { + val sharedDir = File(sharedDirPath) + if (!sharedDir.isDirectory) { + Log.w("ShizukuFileService", "Shared directory does not exist: $sharedDirPath") + return "[]" + } + + val result = JSONArray() + sharedDir.listFiles()?.forEach { bundleDir -> + if (!bundleDir.isDirectory) return@forEach + val bundleName = bundleDir.name + + // Find the first hash subdirectory containing __data + bundleDir.listFiles()?.forEach inner@{ hashDir -> + if (!hashDir.isDirectory) return@inner + val dataFile = File(hashDir, "__data") + if (dataFile.isFile) { + result.put(JSONObject().apply { + put("name", bundleName) + put("hash", hashDir.name) + }) + return@forEach // Only take the first valid hash directory + } + } + } + + Log.d("ShizukuFileService", "Listed ${result.length()} bundles from $sharedDirPath") + result.toString() + } catch (e: Exception) { + Log.e("ShizukuFileService", "Error listing bundle directory $sharedDirPath", e) + "[]" + } + } + override fun destroy() { System.exit(0) } diff --git a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt index f0d99510..5fbaf728 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import rikka.shizuku.Shizuku import android.util.Log +import org.json.JSONArray +import java.io.File object ShizukuManager { @@ -20,6 +22,8 @@ object ShizukuManager { "/storage/emulated/0/Android/data/com.neowizgames.game.browndust2/files/UnityCache/" private const val DOWNLOAD_SHARED_PATH = "/storage/emulated/0/Download/Shared" + private const val GAME_SHARED_PATH = + "/storage/emulated/0/Android/data/com.neowizgames.game.browndust2/files/UnityCache/Shared" private var fileService: IFileService? = null private val bindMutex = Mutex() @@ -168,6 +172,112 @@ object ShizukuManager { } } + /** + * 掃描遊戲本地快取的 bundle,建立 asset-to-bundle 索引。 + * + * 三步驟流程: + * 1. 透過 Shizuku 列舉 Shared/ 目錄取得 bundle 列表 + * 2. 比對快取,只掃描新增/更新的 bundle(逐一複製到 temp → Python 解析 → 刪除 temp) + * 3. 合併快取與新掃描結果,儲存最終索引 + * + * @param outputDir App 可寫入的目錄(通常是 context.filesDir) + * @param cacheDir App 的暫存目錄(通常是 context.cacheDir) + * @param onProgress 進度回報 + * @return Pair<成功, 訊息> + */ + suspend fun scanLocalBundles( + outputDir: String, + cacheDir: String, + onProgress: (String) -> Unit + ): Pair = withContext(Dispatchers.IO) { + try { + if (!isAvailable()) { + return@withContext Pair(false, "Shizuku is not available. Skipping local bundle scan.") + } + + val service = ensureServiceBound() + ?: return@withContext Pair(false, "Failed to connect to Shizuku file service.") + + // Step 1: List bundles via Shizuku + onProgress("Listing local game bundles...") + val bundleListJson = service.listBundleDirectory(GAME_SHARED_PATH) + + val bundleList = JSONArray(bundleListJson) + if (bundleList.length() == 0) { + onProgress("No bundles found in game directory.") + return@withContext Pair(false, "No bundles found. Is the game installed and has been played?") + } + + onProgress("Found ${bundleList.length()} bundles. Checking cache...") + + // Step 2: Check which bundles need scanning (Python side) + val needsScanJson = ModdingService.checkScanNeeded(outputDir, bundleListJson, onProgress) + val needsScan = JSONArray(needsScanJson) + + if (needsScan.length() == 0) { + onProgress("All bundles are up to date. Finalizing...") + val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir, onProgress) + return@withContext Pair(finalSuccess, finalMsg) + } + + onProgress("${needsScan.length()} bundles need scanning...") + + // Build hash lookup from the full bundle list + val hashMap = mutableMapOf() + for (i in 0 until bundleList.length()) { + val obj = bundleList.getJSONObject(i) + hashMap[obj.getString("name")] = obj.getString("hash") + } + + // Step 3: Scan each bundle one at a time + val tempDir = File(cacheDir, "scan_temp") + tempDir.mkdirs() + val tempDataFile = File(tempDir, "__data") + + var scannedCount = 0 + var failedCount = 0 + + for (i in 0 until needsScan.length()) { + val bundleName = needsScan.getString(i) + val bundleHash = hashMap[bundleName] ?: continue + + onProgress("Scanning ${i + 1}/${needsScan.length()}: $bundleName") + + // Copy __data from game dir to temp via Shizuku + val gamePath = "$GAME_SHARED_PATH/$bundleName/$bundleHash/__data" + val copySuccess = service.copyFile(gamePath, tempDataFile.absolutePath) + + if (!copySuccess) { + Log.w("ShizukuManager", "Failed to copy bundle $bundleName, skipping") + failedCount++ + continue + } + + // Scan via Python + val (scanSuccess, _, _) = ModdingService.scanSingleBundle( + bundleName, bundleHash, tempDataFile.absolutePath, onProgress + ) + + if (scanSuccess) scannedCount++ else failedCount++ + + // Clean up temp file immediately + tempDataFile.delete() + } + + // Clean up temp directory + tempDir.deleteRecursively() + + // Step 4: Finalize and save index + onProgress("Saving index... (scanned: $scannedCount, failed: $failedCount)") + val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir, onProgress) + Pair(finalSuccess, finalMsg) + + } catch (e: Exception) { + Log.e("ShizukuManager", "Error scanning local bundles", e) + Pair(false, "Error: ${e.message}") + } + } + /** * 解綁 UserService */ 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 0bae2912..e227ea38 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 @@ -7,6 +7,7 @@ import android.content.Intent import android.net.Uri import android.os.Environment import android.provider.MediaStore +import android.util.Log import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -131,11 +132,29 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( viewModelScope.launch { try { _isUpdatingCharacters.value = true + + // Step 1: Update characters.json from CDN characterRepository.updateCharacterData() + + // Step 2: Scan local bundles via Shizuku (incremental, fast if cache is up to date) + // This must complete before mod scanning so the asset index is available. + if (ShizukuManager.isAvailable()) { + withContext(Dispatchers.IO) { + ShizukuManager.scanLocalBundles( + outputDir = context.filesDir.absolutePath, + cacheDir = context.cacheDir.absolutePath + ) { progress -> + Log.d("MainViewModel", "Bundle scan: $progress") + } + } + } else { + Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") + } } finally { _isUpdatingCharacters.value = false } + // Step 3: Scan mod source directory (uses the local bundle index for resolution) modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } } From 7c571be7d5fe96553edc48cf89996f2e91b91b70 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 16:16:19 +0800 Subject: [PATCH 04/25] fix: use externalCacheDir for Shizuku temp files (shell UID cannot write to internal cache) --- .../com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 e227ea38..62641477 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 @@ -140,9 +140,12 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( // This must complete before mod scanning so the asset index is available. if (ShizukuManager.isAvailable()) { withContext(Dispatchers.IO) { + // Use externalCacheDir for temp files because Shizuku service runs + // as shell UID and cannot write to app's internal cacheDir (/data/data/...) + val shizukuCacheDir = (context.externalCacheDir ?: context.cacheDir).absolutePath ShizukuManager.scanLocalBundles( outputDir = context.filesDir.absolutePath, - cacheDir = context.cacheDir.absolutePath + cacheDir = shizukuCacheDir ) { progress -> Log.d("MainViewModel", "Bundle scan: $progress") } From 3ce883d3501860c9b60c94164c79611f9c534e35 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 16:57:59 +0800 Subject: [PATCH 05/25] perf: fast name extraction - read m_Name from raw bytes instead of full obj.read() deserialization --- app/src/main/python/local_bundle_indexer.py | 30 ++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py index c61fb4f6..7d942651 100644 --- a/app/src/main/python/local_bundle_indexer.py +++ b/app/src/main/python/local_bundle_indexer.py @@ -56,12 +56,34 @@ def _get_asset_extension(type_name): return _EXTENSION_MAP.get(type_name, "") +def _read_name_fast(obj): + """Read m_Name directly from raw object data, skipping full deserialization. + + For Texture2D, TextAsset, and Sprite, m_Name is the first serialized field. + Unity string format: [int32 length][UTF-8 bytes][padding to 4-byte align] + + By reading only the name, we skip parsing texture image data, script content, + sprite meshes, etc. — which is the vast majority of the data. + """ + try: + obj.reset() # seek reader to object's byte_start + return obj.reader.read_aligned_string() + except Exception: + # Fallback: full deserialization (slow but guaranteed correct) + try: + data = obj.read() + return getattr(data, "m_Name", None) + except Exception: + return None + + def _scan_bundle_file(file_path): """ Scan a single bundle file and return a sorted list of unique asset names. Only reads objects of types in SCAN_TYPES to minimize parsing overhead. - Asset names are normalized to lowercase with appropriate file extensions. + Uses fast name extraction (raw binary read) instead of full object + deserialization to avoid loading texture data, scripts, etc. """ unitypy = _ensure_unitypy() env = unitypy.load(file_path) @@ -70,12 +92,8 @@ def _scan_bundle_file(file_path): for obj in env.objects: if obj.type.name not in SCAN_TYPES: continue - try: - data = obj.read() - except Exception: - continue - raw_name = getattr(data, "m_Name", None) + raw_name = _read_name_fast(obj) if not raw_name: continue From 81e9fcc39c3c1824f8b9d27f63b4a64ae3f532f8 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 17:56:12 +0800 Subject: [PATCH 06/25] feat: Optimize local bundle scan UX with smart trigger and progress dialog --- .../com/example/bd2modmanager/MainActivity.kt | 8 + .../bd2modmanager/data/model/ModModels.kt | 17 ++ .../data/repository/CharacterRepository.kt | 29 ++- .../bd2modmanager/service/ShizukuManager.kt | 106 +++++++++ .../bd2modmanager/ui/dialogs/AppDialogs.kt | 210 ++++++++++++++++++ .../ui/viewmodel/MainViewModel.kt | 92 ++++++-- 6 files changed, 435 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt index 27447a7f..9a61be24 100644 --- a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt +++ b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt @@ -132,6 +132,14 @@ class MainActivity : ComponentActivity() { onDismiss = { viewModel.resetMergeState() } ) } + + val bundleScanState by viewModel.bundleScanState.collectAsState() + BundleScanDialog( + state = bundleScanState, + onConfirm = { viewModel.confirmBundleScan(this@MainActivity) }, + onSkip = { viewModel.skipBundleScan() }, + onDismiss = { viewModel.dismissBundleScanDialog() } + ) } } } 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 6fa0142b..0be92f7d 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 @@ -124,3 +124,20 @@ sealed class MergeState { data class Finished(val message: String) : MergeState() data class Failed(val error: String) : MergeState() } + +sealed class BundleScanState { + /** 初始狀態:等待使用者確認是否已更新遊戲 */ + object AwaitingConfirmation : BundleScanState() + /** 掃描中,帶有進度訊息和數值進度 */ + data class Scanning( + val progressMessage: String = "Initializing...", + val current: Int = 0, + val total: Int = 0 + ) : BundleScanState() + /** 掃描成功完成 */ + data class Finished(val message: String) : BundleScanState() + /** 掃描失敗 */ + data class Failed(val error: String) : BundleScanState() + /** 使用者選擇跳過或 Shizuku 不可用 */ + object Skipped : BundleScanState() +} diff --git a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt index 537186cd..17789390 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt @@ -15,20 +15,29 @@ class CharacterRepository(private val context: Context) { private const val MOD_CACHE_FILENAME = "mod_cache.json" } + enum class CharacterUpdateResult { + /** CDN 有新版本,characters.json 已更新 */ + UPDATED, + /** CDN 版本與本地相同,無需更新 */ + UP_TO_DATE, + /** 更新失敗 */ + FAILED + } + private var characterLut: Map> = emptyMap() - suspend fun updateCharacterData(): Boolean { + suspend fun updateCharacterData(): CharacterUpdateResult { return withContext(Dispatchers.IO) { - var success = false + var result = CharacterUpdateResult.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 message = result[1].toString() + val pyResult = mainScript.callAttr("update_character_data", context.filesDir.absolutePath).asList() + val status = pyResult[0].toString() // SUCCESS, SKIPPED, FAILED + val message = pyResult[1].toString() when (status) { "SUCCESS" -> { @@ -39,24 +48,24 @@ class CharacterRepository(private val context: Context) { cacheFile.delete() println("Deleted mod cache to force re-scan.") } - success = true + result = CharacterUpdateResult.UPDATED } "SKIPPED" -> { println("Scraper skipped: $message") - success = true + result = CharacterUpdateResult.UP_TO_DATE } "FAILED" -> { println("Scraper script failed: $message. Will use local version if available.") - success = false + result = CharacterUpdateResult.FAILED } } } catch (e: Exception) { e.printStackTrace() println("Failed to execute scraper python script, will use local version. Error: ${e.message}") - success = false + result = CharacterUpdateResult.FAILED } characterLut = parseCharacterJson() - success + result } } diff --git a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt index 5fbaf728..5cf131c2 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt @@ -278,6 +278,112 @@ object ShizukuManager { } } + /** + * 帶有詳細進度回報的 bundle 掃描。 + * onProgress 回報 (訊息, 目前進度, 總數),讓 UI 可顯示進度條。 + */ + suspend fun scanLocalBundlesWithProgress( + outputDir: String, + cacheDir: String, + onProgress: (String, Int, Int) -> Unit + ): Pair = withContext(Dispatchers.IO) { + try { + if (!isAvailable()) { + return@withContext Pair(false, "Shizuku is not available. Skipping local bundle scan.") + } + + val service = ensureServiceBound() + ?: return@withContext Pair(false, "Failed to connect to Shizuku file service.") + + // Step 1: List bundles via Shizuku + onProgress("Listing local game bundles...", 0, 0) + val bundleListJson = service.listBundleDirectory(GAME_SHARED_PATH) + + val bundleList = JSONArray(bundleListJson) + if (bundleList.length() == 0) { + onProgress("No bundles found in game directory.", 0, 0) + return@withContext Pair(false, "No bundles found. Is the game installed and has been played?") + } + + onProgress("Found ${bundleList.length()} bundles. Checking cache...", 0, bundleList.length()) + + // Step 2: Check which bundles need scanning (Python side) + val needsScanJson = ModdingService.checkScanNeeded(outputDir, bundleListJson) { msg -> + onProgress(msg, 0, bundleList.length()) + } + val needsScan = JSONArray(needsScanJson) + + if (needsScan.length() == 0) { + onProgress("All bundles are up to date. Finalizing...", bundleList.length(), bundleList.length()) + val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir) { msg -> + onProgress(msg, bundleList.length(), bundleList.length()) + } + return@withContext Pair(finalSuccess, finalMsg) + } + + onProgress("${needsScan.length()} bundles need scanning...", 0, needsScan.length()) + + // Build hash lookup from the full bundle list + val hashMap = mutableMapOf() + for (i in 0 until bundleList.length()) { + val obj = bundleList.getJSONObject(i) + hashMap[obj.getString("name")] = obj.getString("hash") + } + + // Step 3: Scan each bundle one at a time + val tempDir = File(cacheDir, "scan_temp") + tempDir.mkdirs() + val tempDataFile = File(tempDir, "__data") + + var scannedCount = 0 + var failedCount = 0 + val totalToScan = needsScan.length() + + for (i in 0 until totalToScan) { + val bundleName = needsScan.getString(i) + val bundleHash = hashMap[bundleName] ?: continue + + onProgress("Scanning: $bundleName", i + 1, totalToScan) + + // Copy __data from game dir to temp via Shizuku + val gamePath = "$GAME_SHARED_PATH/$bundleName/$bundleHash/__data" + val copySuccess = service.copyFile(gamePath, tempDataFile.absolutePath) + + if (!copySuccess) { + Log.w("ShizukuManager", "Failed to copy bundle $bundleName, skipping") + failedCount++ + continue + } + + // Scan via Python + val (scanSuccess, _, _) = ModdingService.scanSingleBundle( + bundleName, bundleHash, tempDataFile.absolutePath + ) { msg -> + onProgress(msg, i + 1, totalToScan) + } + + if (scanSuccess) scannedCount++ else failedCount++ + + // Clean up temp file immediately + tempDataFile.delete() + } + + // Clean up temp directory + tempDir.deleteRecursively() + + // Step 4: Finalize and save index + onProgress("Saving index... (scanned: $scannedCount, failed: $failedCount)", totalToScan, totalToScan) + val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir) { msg -> + onProgress(msg, totalToScan, totalToScan) + } + Pair(finalSuccess, finalMsg) + + } catch (e: Exception) { + Log.e("ShizukuManager", "Error scanning local bundles", e) + Pair(false, "Error: ${e.message}") + } + } + /** * 解綁 UserService */ 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 e393b9c8..57767471 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 @@ -525,3 +525,213 @@ fun MergeSpineDialog(state: MergeState, onDismiss: () -> Unit) { dismissButton = null ) } + +@Composable +fun BundleScanDialog( + state: BundleScanState, + onConfirm: () -> Unit, + onSkip: () -> Unit, + onDismiss: () -> Unit +) { + when (state) { + is BundleScanState.AwaitingConfirmation -> { + AlertDialog( + onDismissRequest = { /* 不允許點擊外部關閉 */ }, + icon = { + Icon( + Icons.Default.Update, + contentDescription = "Update", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp) + ) + }, + title = { + Text("Game Update Required") + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Before scanning local bundles, please make sure you have:", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(12.dp) + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.Top) { + Text("1. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + Text( + "Opened Brown Dust 2 and updated to the latest version", + style = MaterialTheme.typography.bodyMedium + ) + } + Row(verticalAlignment = Alignment.Top) { + Text("2. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + Text( + "Let the game fully download all the latest resources", + style = MaterialTheme.typography.bodyMedium + ) + } + Row(verticalAlignment = Alignment.Top) { + Text("3. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + Text( + "Entered the lobby to ensure all assets are cached", + style = MaterialTheme.typography.bodyMedium + ) + } + } + Spacer(Modifier.height(12.dp)) + Text( + "This ensures the local bundle index is accurate and up-to-date.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("I've Updated, Start Scan") + } + }, + dismissButton = { + TextButton(onClick = onSkip) { + Text("Skip") + } + } + ) + } + + is BundleScanState.Scanning -> { + AlertDialog( + onDismissRequest = { /* 掃描中不允許關閉 */ }, + icon = { + Icon( + Icons.Default.Scanner, + contentDescription = "Scanning", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp) + ) + }, + title = { + Text("Scanning Local Bundles") + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Please wait while scanning game bundles...", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + + if (state.total > 0) { + // 確定進度 + val progress = state.current.toFloat() / state.total.toFloat() + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Text( + "${state.current} / ${state.total}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + } else { + // 不確定進度 + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + Spacer(Modifier.height(8.dp)) + Text( + state.progressMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2 + ) + } + }, + confirmButton = { /* 掃描中無按鈕 */ }, + dismissButton = null + ) + } + + is BundleScanState.Finished -> { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Success", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp) + ) + }, + title = { Text("Scan Complete") }, + text = { + Text( + state.message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button(onClick = onDismiss) { Text("OK") } + }, + dismissButton = null + ) + } + + is BundleScanState.Failed -> { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Default.Error, + contentDescription = "Failed", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(36.dp) + ) + }, + title = { Text("Scan Failed") }, + text = { + Text( + state.error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button(onClick = onDismiss) { Text("OK") } + }, + dismissButton = null + ) + } + + // Skipped 狀態不顯示任何對話框 + else -> {} + } +} 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 62641477..b0ac90a5 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 @@ -118,6 +118,9 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() + private val _bundleScanState = MutableStateFlow(BundleScanState.Skipped) + val bundleScanState: StateFlow = _bundleScanState.asStateFlow() + private var initialized = false private var scanJob: Job? = null private var pendingScanUri: Uri? = null @@ -129,44 +132,99 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( characterRepository = CharacterRepository(context) modRepository = ModRepository(context, characterRepository) + // 先更新角色資料,再根據結果決定是否需要掃描 bundle viewModelScope.launch { + var updateResult: CharacterRepository.CharacterUpdateResult try { _isUpdatingCharacters.value = true - // Step 1: Update characters.json from CDN - characterRepository.updateCharacterData() + updateResult = characterRepository.updateCharacterData() + } catch (e: Exception) { + updateResult = CharacterRepository.CharacterUpdateResult.FAILED + } finally { + _isUpdatingCharacters.value = false + } + + // Step 2: 判斷是否需要掃描本地 bundle + // 條件: Shizuku 可用 + (CDN 有新版本 或 本地 index 不存在) + val indexFile = java.io.File(context.filesDir, "local_bundle_index.json") + val needsScan = updateResult == CharacterRepository.CharacterUpdateResult.UPDATED || !indexFile.exists() + + if (ShizukuManager.isAvailable() && needsScan) { + _bundleScanState.value = BundleScanState.AwaitingConfirmation + } else { + if (!ShizukuManager.isAvailable()) { + Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") + } else { + Log.d("MainViewModel", "No game update detected and index exists. Skipping bundle scan.") + } + _bundleScanState.value = BundleScanState.Skipped + // 直接掃描 mod 目錄 + modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } + } + } + + viewModelScope.launch { + installJobs.collect { jobs -> + if (jobs.isNotEmpty() && jobs.all { it.status is JobStatus.Finished || it.status is JobStatus.Failed }) { + summarizeResults() + } + } + } + } + + /** + * 使用者確認已更新遊戲後,開始掃描本地 bundle + */ + fun confirmBundleScan(context: Context) { + viewModelScope.launch { + _bundleScanState.value = BundleScanState.Scanning("Preparing scan...") + _isUpdatingCharacters.value = true - // Step 2: Scan local bundles via Shizuku (incremental, fast if cache is up to date) - // This must complete before mod scanning so the asset index is available. + try { if (ShizukuManager.isAvailable()) { withContext(Dispatchers.IO) { - // Use externalCacheDir for temp files because Shizuku service runs - // as shell UID and cannot write to app's internal cacheDir (/data/data/...) val shizukuCacheDir = (context.externalCacheDir ?: context.cacheDir).absolutePath - ShizukuManager.scanLocalBundles( + ShizukuManager.scanLocalBundlesWithProgress( outputDir = context.filesDir.absolutePath, cacheDir = shizukuCacheDir - ) { progress -> - Log.d("MainViewModel", "Bundle scan: $progress") + ) { message, current, total -> + viewModelScope.launch(Dispatchers.Main) { + _bundleScanState.value = BundleScanState.Scanning(message, current, total) + } } } + _bundleScanState.value = BundleScanState.Finished("Bundle scan completed!") } else { - Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") + _bundleScanState.value = BundleScanState.Failed("Shizuku is not available.") } + } catch (e: Exception) { + _bundleScanState.value = BundleScanState.Failed(e.message ?: "Unknown error during bundle scan.") } finally { _isUpdatingCharacters.value = false } - // Step 3: Scan mod source directory (uses the local bundle index for resolution) + // 掃描完成後自動掃描 mod 目錄 modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } } + } - viewModelScope.launch { - installJobs.collect { jobs -> - if (jobs.isNotEmpty() && jobs.all { it.status is JobStatus.Finished || it.status is JobStatus.Failed }) { - summarizeResults() - } - } + /** + * 使用者選擇跳過 bundle 掃描 + */ + fun skipBundleScan() { + _bundleScanState.value = BundleScanState.Skipped + // 直接掃描 mod 目錄(使用 cached index) + modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } + } + + /** + * 關閉 bundle 掃描對話框 + */ + fun dismissBundleScanDialog() { + val currentState = _bundleScanState.value + if (currentState is BundleScanState.Finished || currentState is BundleScanState.Failed) { + _bundleScanState.value = BundleScanState.Skipped } } From 1e504a30f515d331f216120a2af52544e5ea6183 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 18:18:18 +0800 Subject: [PATCH 07/25] fix: Keep module list loading during bundle scan and center dialog buttons --- .../bd2modmanager/ui/dialogs/AppDialogs.kt | 27 +++++++++++-------- .../ui/viewmodel/MainViewModel.kt | 11 +++++--- 2 files changed, 23 insertions(+), 15 deletions(-) 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 57767471..4b77123f 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 @@ -573,14 +573,14 @@ fun BundleScanDialog( Row(verticalAlignment = Alignment.Top) { Text("1. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) Text( - "Opened Brown Dust 2 and updated to the latest version", + "Updated BrownDust 2 to the latest version", style = MaterialTheme.typography.bodyMedium ) } Row(verticalAlignment = Alignment.Top) { Text("2. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) Text( - "Let the game fully download all the latest resources", + "Open BrownDust 2 and let the game fully download all the latest resources", style = MaterialTheme.typography.bodyMedium ) } @@ -602,17 +602,22 @@ fun BundleScanDialog( } }, confirmButton = { - Button(onClick = onConfirm) { - Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("I've Updated, Start Scan") + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = onConfirm) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("I've Updated, Start Scan") + } + TextButton(onClick = onSkip) { + Text("Skip") + } } }, - dismissButton = { - TextButton(onClick = onSkip) { - Text("Skip") - } - } + dismissButton = null ) } 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 b0ac90a5..17856462 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 @@ -55,9 +55,13 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _isUpdatingCharacters = MutableStateFlow(false) val isUpdatingCharacters: StateFlow = _isUpdatingCharacters.asStateFlow() + private val _bundleScanState = MutableStateFlow(BundleScanState.Skipped) + val bundleScanState: StateFlow = _bundleScanState.asStateFlow() + val showShimmer: StateFlow = - combine(_modsList, _isLoading, _isUpdatingCharacters) { mods, isScanning, isUpdating -> - (isScanning || isUpdating) && mods.isEmpty() + combine(_modsList, _isLoading, _isUpdatingCharacters, _bundleScanState) { mods, isScanning, isUpdating, scanState -> + val isBundleScanning = scanState is BundleScanState.AwaitingConfirmation || scanState is BundleScanState.Scanning + (isScanning || isUpdating || isBundleScanning) && mods.isEmpty() }.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), false) private val _selectedMods = MutableStateFlow>(emptySet()) @@ -118,8 +122,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() - private val _bundleScanState = MutableStateFlow(BundleScanState.Skipped) - val bundleScanState: StateFlow = _bundleScanState.asStateFlow() + private var initialized = false private var scanJob: Job? = null From ae78574f044c7ef68cfec10ec783588daa00f643 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 18:29:51 +0800 Subject: [PATCH 08/25] Revert "fix: Keep module list loading during bundle scan and center dialog buttons" This reverts commit 1e504a30f515d331f216120a2af52544e5ea6183. --- .../bd2modmanager/ui/dialogs/AppDialogs.kt | 27 ++++++++----------- .../ui/viewmodel/MainViewModel.kt | 11 +++----- 2 files changed, 15 insertions(+), 23 deletions(-) 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 4b77123f..57767471 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 @@ -573,14 +573,14 @@ fun BundleScanDialog( Row(verticalAlignment = Alignment.Top) { Text("1. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) Text( - "Updated BrownDust 2 to the latest version", + "Opened Brown Dust 2 and updated to the latest version", style = MaterialTheme.typography.bodyMedium ) } Row(verticalAlignment = Alignment.Top) { Text("2. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) Text( - "Open BrownDust 2 and let the game fully download all the latest resources", + "Let the game fully download all the latest resources", style = MaterialTheme.typography.bodyMedium ) } @@ -602,22 +602,17 @@ fun BundleScanDialog( } }, confirmButton = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button(onClick = onConfirm) { - Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("I've Updated, Start Scan") - } - TextButton(onClick = onSkip) { - Text("Skip") - } + Button(onClick = onConfirm) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("I've Updated, Start Scan") } }, - dismissButton = null + dismissButton = { + TextButton(onClick = onSkip) { + Text("Skip") + } + } ) } 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 17856462..b0ac90a5 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 @@ -55,13 +55,9 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _isUpdatingCharacters = MutableStateFlow(false) val isUpdatingCharacters: StateFlow = _isUpdatingCharacters.asStateFlow() - private val _bundleScanState = MutableStateFlow(BundleScanState.Skipped) - val bundleScanState: StateFlow = _bundleScanState.asStateFlow() - val showShimmer: StateFlow = - combine(_modsList, _isLoading, _isUpdatingCharacters, _bundleScanState) { mods, isScanning, isUpdating, scanState -> - val isBundleScanning = scanState is BundleScanState.AwaitingConfirmation || scanState is BundleScanState.Scanning - (isScanning || isUpdating || isBundleScanning) && mods.isEmpty() + combine(_modsList, _isLoading, _isUpdatingCharacters) { mods, isScanning, isUpdating -> + (isScanning || isUpdating) && mods.isEmpty() }.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), false) private val _selectedMods = MutableStateFlow>(emptySet()) @@ -122,7 +118,8 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() - + private val _bundleScanState = MutableStateFlow(BundleScanState.Skipped) + val bundleScanState: StateFlow = _bundleScanState.asStateFlow() private var initialized = false private var scanJob: Job? = null From 344f71aa43f14adcea2138c6b0ddad75ba33dbd2 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 18:29:51 +0800 Subject: [PATCH 09/25] Revert "feat: Optimize local bundle scan UX with smart trigger and progress dialog" This reverts commit 81e9fcc39c3c1824f8b9d27f63b4a64ae3f532f8. --- .../com/example/bd2modmanager/MainActivity.kt | 8 - .../bd2modmanager/data/model/ModModels.kt | 17 -- .../data/repository/CharacterRepository.kt | 29 +-- .../bd2modmanager/service/ShizukuManager.kt | 106 --------- .../bd2modmanager/ui/dialogs/AppDialogs.kt | 210 ------------------ .../ui/viewmodel/MainViewModel.kt | 92 ++------ 6 files changed, 27 insertions(+), 435 deletions(-) diff --git a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt index 9a61be24..27447a7f 100644 --- a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt +++ b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt @@ -132,14 +132,6 @@ class MainActivity : ComponentActivity() { onDismiss = { viewModel.resetMergeState() } ) } - - val bundleScanState by viewModel.bundleScanState.collectAsState() - BundleScanDialog( - state = bundleScanState, - onConfirm = { viewModel.confirmBundleScan(this@MainActivity) }, - onSkip = { viewModel.skipBundleScan() }, - onDismiss = { viewModel.dismissBundleScanDialog() } - ) } } } 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 0be92f7d..6fa0142b 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 @@ -124,20 +124,3 @@ sealed class MergeState { data class Finished(val message: String) : MergeState() data class Failed(val error: String) : MergeState() } - -sealed class BundleScanState { - /** 初始狀態:等待使用者確認是否已更新遊戲 */ - object AwaitingConfirmation : BundleScanState() - /** 掃描中,帶有進度訊息和數值進度 */ - data class Scanning( - val progressMessage: String = "Initializing...", - val current: Int = 0, - val total: Int = 0 - ) : BundleScanState() - /** 掃描成功完成 */ - data class Finished(val message: String) : BundleScanState() - /** 掃描失敗 */ - data class Failed(val error: String) : BundleScanState() - /** 使用者選擇跳過或 Shizuku 不可用 */ - object Skipped : BundleScanState() -} diff --git a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt index 17789390..537186cd 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt @@ -15,29 +15,20 @@ class CharacterRepository(private val context: Context) { private const val MOD_CACHE_FILENAME = "mod_cache.json" } - enum class CharacterUpdateResult { - /** CDN 有新版本,characters.json 已更新 */ - UPDATED, - /** CDN 版本與本地相同,無需更新 */ - UP_TO_DATE, - /** 更新失敗 */ - FAILED - } - private var characterLut: Map> = emptyMap() - suspend fun updateCharacterData(): CharacterUpdateResult { + suspend fun updateCharacterData(): Boolean { return withContext(Dispatchers.IO) { - var result = CharacterUpdateResult.FAILED + var success = false try { if (!Python.isStarted()) { Python.start(com.chaquo.python.android.AndroidPlatform(context)) } val py = Python.getInstance() val mainScript = py.getModule("main_script") - val pyResult = mainScript.callAttr("update_character_data", context.filesDir.absolutePath).asList() - val status = pyResult[0].toString() // SUCCESS, SKIPPED, FAILED - val message = pyResult[1].toString() + val result = mainScript.callAttr("update_character_data", context.filesDir.absolutePath).asList() + val status = result[0].toString() // SUCCESS, SKIPPED, FAILED + val message = result[1].toString() when (status) { "SUCCESS" -> { @@ -48,24 +39,24 @@ class CharacterRepository(private val context: Context) { cacheFile.delete() println("Deleted mod cache to force re-scan.") } - result = CharacterUpdateResult.UPDATED + success = true } "SKIPPED" -> { println("Scraper skipped: $message") - result = CharacterUpdateResult.UP_TO_DATE + success = true } "FAILED" -> { println("Scraper script failed: $message. Will use local version if available.") - result = CharacterUpdateResult.FAILED + success = false } } } catch (e: Exception) { e.printStackTrace() println("Failed to execute scraper python script, will use local version. Error: ${e.message}") - result = CharacterUpdateResult.FAILED + success = false } characterLut = parseCharacterJson() - result + success } } diff --git a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt index 5cf131c2..5fbaf728 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt @@ -278,112 +278,6 @@ object ShizukuManager { } } - /** - * 帶有詳細進度回報的 bundle 掃描。 - * onProgress 回報 (訊息, 目前進度, 總數),讓 UI 可顯示進度條。 - */ - suspend fun scanLocalBundlesWithProgress( - outputDir: String, - cacheDir: String, - onProgress: (String, Int, Int) -> Unit - ): Pair = withContext(Dispatchers.IO) { - try { - if (!isAvailable()) { - return@withContext Pair(false, "Shizuku is not available. Skipping local bundle scan.") - } - - val service = ensureServiceBound() - ?: return@withContext Pair(false, "Failed to connect to Shizuku file service.") - - // Step 1: List bundles via Shizuku - onProgress("Listing local game bundles...", 0, 0) - val bundleListJson = service.listBundleDirectory(GAME_SHARED_PATH) - - val bundleList = JSONArray(bundleListJson) - if (bundleList.length() == 0) { - onProgress("No bundles found in game directory.", 0, 0) - return@withContext Pair(false, "No bundles found. Is the game installed and has been played?") - } - - onProgress("Found ${bundleList.length()} bundles. Checking cache...", 0, bundleList.length()) - - // Step 2: Check which bundles need scanning (Python side) - val needsScanJson = ModdingService.checkScanNeeded(outputDir, bundleListJson) { msg -> - onProgress(msg, 0, bundleList.length()) - } - val needsScan = JSONArray(needsScanJson) - - if (needsScan.length() == 0) { - onProgress("All bundles are up to date. Finalizing...", bundleList.length(), bundleList.length()) - val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir) { msg -> - onProgress(msg, bundleList.length(), bundleList.length()) - } - return@withContext Pair(finalSuccess, finalMsg) - } - - onProgress("${needsScan.length()} bundles need scanning...", 0, needsScan.length()) - - // Build hash lookup from the full bundle list - val hashMap = mutableMapOf() - for (i in 0 until bundleList.length()) { - val obj = bundleList.getJSONObject(i) - hashMap[obj.getString("name")] = obj.getString("hash") - } - - // Step 3: Scan each bundle one at a time - val tempDir = File(cacheDir, "scan_temp") - tempDir.mkdirs() - val tempDataFile = File(tempDir, "__data") - - var scannedCount = 0 - var failedCount = 0 - val totalToScan = needsScan.length() - - for (i in 0 until totalToScan) { - val bundleName = needsScan.getString(i) - val bundleHash = hashMap[bundleName] ?: continue - - onProgress("Scanning: $bundleName", i + 1, totalToScan) - - // Copy __data from game dir to temp via Shizuku - val gamePath = "$GAME_SHARED_PATH/$bundleName/$bundleHash/__data" - val copySuccess = service.copyFile(gamePath, tempDataFile.absolutePath) - - if (!copySuccess) { - Log.w("ShizukuManager", "Failed to copy bundle $bundleName, skipping") - failedCount++ - continue - } - - // Scan via Python - val (scanSuccess, _, _) = ModdingService.scanSingleBundle( - bundleName, bundleHash, tempDataFile.absolutePath - ) { msg -> - onProgress(msg, i + 1, totalToScan) - } - - if (scanSuccess) scannedCount++ else failedCount++ - - // Clean up temp file immediately - tempDataFile.delete() - } - - // Clean up temp directory - tempDir.deleteRecursively() - - // Step 4: Finalize and save index - onProgress("Saving index... (scanned: $scannedCount, failed: $failedCount)", totalToScan, totalToScan) - val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir) { msg -> - onProgress(msg, totalToScan, totalToScan) - } - Pair(finalSuccess, finalMsg) - - } catch (e: Exception) { - Log.e("ShizukuManager", "Error scanning local bundles", e) - Pair(false, "Error: ${e.message}") - } - } - /** * 解綁 UserService */ 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 57767471..e393b9c8 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 @@ -525,213 +525,3 @@ fun MergeSpineDialog(state: MergeState, onDismiss: () -> Unit) { dismissButton = null ) } - -@Composable -fun BundleScanDialog( - state: BundleScanState, - onConfirm: () -> Unit, - onSkip: () -> Unit, - onDismiss: () -> Unit -) { - when (state) { - is BundleScanState.AwaitingConfirmation -> { - AlertDialog( - onDismissRequest = { /* 不允許點擊外部關閉 */ }, - icon = { - Icon( - Icons.Default.Update, - contentDescription = "Update", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) - }, - title = { - Text("Game Update Required") - }, - text = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - Text( - "Before scanning local bundles, please make sure you have:", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(12.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(12.dp) - ) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(verticalAlignment = Alignment.Top) { - Text("1. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) - Text( - "Opened Brown Dust 2 and updated to the latest version", - style = MaterialTheme.typography.bodyMedium - ) - } - Row(verticalAlignment = Alignment.Top) { - Text("2. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) - Text( - "Let the game fully download all the latest resources", - style = MaterialTheme.typography.bodyMedium - ) - } - Row(verticalAlignment = Alignment.Top) { - Text("3. ", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) - Text( - "Entered the lobby to ensure all assets are cached", - style = MaterialTheme.typography.bodyMedium - ) - } - } - Spacer(Modifier.height(12.dp)) - Text( - "This ensures the local bundle index is accurate and up-to-date.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - }, - confirmButton = { - Button(onClick = onConfirm) { - Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("I've Updated, Start Scan") - } - }, - dismissButton = { - TextButton(onClick = onSkip) { - Text("Skip") - } - } - ) - } - - is BundleScanState.Scanning -> { - AlertDialog( - onDismissRequest = { /* 掃描中不允許關閉 */ }, - icon = { - Icon( - Icons.Default.Scanner, - contentDescription = "Scanning", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) - }, - title = { - Text("Scanning Local Bundles") - }, - text = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - Text( - "Please wait while scanning game bundles...", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(16.dp)) - - if (state.total > 0) { - // 確定進度 - val progress = state.current.toFloat() / state.total.toFloat() - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - Text( - "${state.current} / ${state.total}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center - ) - } else { - // 不確定進度 - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - - Spacer(Modifier.height(8.dp)) - Text( - state.progressMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 2 - ) - } - }, - confirmButton = { /* 掃描中無按鈕 */ }, - dismissButton = null - ) - } - - is BundleScanState.Finished -> { - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Success", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) - }, - title = { Text("Scan Complete") }, - text = { - Text( - state.message, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - Button(onClick = onDismiss) { Text("OK") } - }, - dismissButton = null - ) - } - - is BundleScanState.Failed -> { - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - Icons.Default.Error, - contentDescription = "Failed", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(36.dp) - ) - }, - title = { Text("Scan Failed") }, - text = { - Text( - state.error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - Button(onClick = onDismiss) { Text("OK") } - }, - dismissButton = null - ) - } - - // Skipped 狀態不顯示任何對話框 - else -> {} - } -} 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 b0ac90a5..62641477 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 @@ -118,9 +118,6 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() - private val _bundleScanState = MutableStateFlow(BundleScanState.Skipped) - val bundleScanState: StateFlow = _bundleScanState.asStateFlow() - private var initialized = false private var scanJob: Job? = null private var pendingScanUri: Uri? = null @@ -132,99 +129,44 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( characterRepository = CharacterRepository(context) modRepository = ModRepository(context, characterRepository) - // 先更新角色資料,再根據結果決定是否需要掃描 bundle viewModelScope.launch { - var updateResult: CharacterRepository.CharacterUpdateResult try { _isUpdatingCharacters.value = true - // Step 1: Update characters.json from CDN - updateResult = characterRepository.updateCharacterData() - } catch (e: Exception) { - updateResult = CharacterRepository.CharacterUpdateResult.FAILED - } finally { - _isUpdatingCharacters.value = false - } - - // Step 2: 判斷是否需要掃描本地 bundle - // 條件: Shizuku 可用 + (CDN 有新版本 或 本地 index 不存在) - val indexFile = java.io.File(context.filesDir, "local_bundle_index.json") - val needsScan = updateResult == CharacterRepository.CharacterUpdateResult.UPDATED || !indexFile.exists() - - if (ShizukuManager.isAvailable() && needsScan) { - _bundleScanState.value = BundleScanState.AwaitingConfirmation - } else { - if (!ShizukuManager.isAvailable()) { - Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") - } else { - Log.d("MainViewModel", "No game update detected and index exists. Skipping bundle scan.") - } - _bundleScanState.value = BundleScanState.Skipped - // 直接掃描 mod 目錄 - modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } - } - } - - viewModelScope.launch { - installJobs.collect { jobs -> - if (jobs.isNotEmpty() && jobs.all { it.status is JobStatus.Finished || it.status is JobStatus.Failed }) { - summarizeResults() - } - } - } - } - /** - * 使用者確認已更新遊戲後,開始掃描本地 bundle - */ - fun confirmBundleScan(context: Context) { - viewModelScope.launch { - _bundleScanState.value = BundleScanState.Scanning("Preparing scan...") - _isUpdatingCharacters.value = true + // Step 1: Update characters.json from CDN + characterRepository.updateCharacterData() - try { + // Step 2: Scan local bundles via Shizuku (incremental, fast if cache is up to date) + // This must complete before mod scanning so the asset index is available. if (ShizukuManager.isAvailable()) { withContext(Dispatchers.IO) { + // Use externalCacheDir for temp files because Shizuku service runs + // as shell UID and cannot write to app's internal cacheDir (/data/data/...) val shizukuCacheDir = (context.externalCacheDir ?: context.cacheDir).absolutePath - ShizukuManager.scanLocalBundlesWithProgress( + ShizukuManager.scanLocalBundles( outputDir = context.filesDir.absolutePath, cacheDir = shizukuCacheDir - ) { message, current, total -> - viewModelScope.launch(Dispatchers.Main) { - _bundleScanState.value = BundleScanState.Scanning(message, current, total) - } + ) { progress -> + Log.d("MainViewModel", "Bundle scan: $progress") } } - _bundleScanState.value = BundleScanState.Finished("Bundle scan completed!") } else { - _bundleScanState.value = BundleScanState.Failed("Shizuku is not available.") + Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") } - } catch (e: Exception) { - _bundleScanState.value = BundleScanState.Failed(e.message ?: "Unknown error during bundle scan.") } finally { _isUpdatingCharacters.value = false } - // 掃描完成後自動掃描 mod 目錄 + // Step 3: Scan mod source directory (uses the local bundle index for resolution) modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } } - } - - /** - * 使用者選擇跳過 bundle 掃描 - */ - fun skipBundleScan() { - _bundleScanState.value = BundleScanState.Skipped - // 直接掃描 mod 目錄(使用 cached index) - modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } - } - /** - * 關閉 bundle 掃描對話框 - */ - fun dismissBundleScanDialog() { - val currentState = _bundleScanState.value - if (currentState is BundleScanState.Finished || currentState is BundleScanState.Failed) { - _bundleScanState.value = BundleScanState.Skipped + viewModelScope.launch { + installJobs.collect { jobs -> + if (jobs.isNotEmpty() && jobs.all { it.status is JobStatus.Finished || it.status is JobStatus.Failed }) { + summarizeResults() + } + } } } From 1be33d23914c5702d8226c24340d76ab2014d046 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 20:56:46 +0800 Subject: [PATCH 10/25] feat: add bundle scan confirmation and version mismatch dialogs --- .../com/example/bd2modmanager/MainActivity.kt | 14 ++ .../bd2modmanager/data/model/ModModels.kt | 24 +++ .../data/repository/CharacterRepository.kt | 28 ++- .../bd2modmanager/service/ShizukuManager.kt | 127 ++++++++---- .../bd2modmanager/ui/dialogs/AppDialogs.kt | 183 ++++++++++++++++++ .../ui/viewmodel/MainViewModel.kt | 110 +++++++++-- 6 files changed, 433 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt index 27447a7f..fbb48671 100644 --- a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt +++ b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt @@ -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() } + ) } } } 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 6fa0142b..3d8faa39 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 @@ -124,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, + val needsScanCount: Int +) diff --git a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt index 537186cd..9e9fee60 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt @@ -17,9 +17,15 @@ class CharacterRepository(private val context: Context) { private var characterLut: Map> = 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(): String { return withContext(Dispatchers.IO) { - var success = false + var status = "FAILED" try { if (!Python.isStarted()) { Python.start(com.chaquo.python.android.AndroidPlatform(context)) @@ -27,10 +33,10 @@ class CharacterRepository(private val context: 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 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. @@ -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> { return withContext(Dispatchers.IO) { val lut = mutableMapOf>() diff --git a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt index 5fbaf728..b94768c9 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt @@ -5,6 +5,7 @@ import android.content.ServiceConnection import android.content.pm.PackageManager import android.os.IBinder import com.example.bd2modmanager.IFileService +import com.example.bd2modmanager.data.model.BundleCheckResult import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -173,30 +174,28 @@ object ShizukuManager { } /** - * 掃描遊戲本地快取的 bundle,建立 asset-to-bundle 索引。 - * - * 三步驟流程: - * 1. 透過 Shizuku 列舉 Shared/ 目錄取得 bundle 列表 - * 2. 比對快取,只掃描新增/更新的 bundle(逐一複製到 temp → Python 解析 → 刪除 temp) - * 3. 合併快取與新掃描結果,儲存最終索引 + * Phase 1: 列舉遊戲目錄並檢查哪些 bundle 需要掃描。 + * 不執行實際掃描,只回傳結果讓呼叫端決定是否繼續。 * * @param outputDir App 可寫入的目錄(通常是 context.filesDir) - * @param cacheDir App 的暫存目錄(通常是 context.cacheDir) * @param onProgress 進度回報 - * @return Pair<成功, 訊息> + * @return BundleCheckResult? — null 表示失敗或無 bundle */ - suspend fun scanLocalBundles( + suspend fun checkLocalBundles( outputDir: String, - cacheDir: String, onProgress: (String) -> Unit - ): Pair = withContext(Dispatchers.IO) { + ): BundleCheckResult? = withContext(Dispatchers.IO) { try { if (!isAvailable()) { - return@withContext Pair(false, "Shizuku is not available. Skipping local bundle scan.") + onProgress("Shizuku is not available. Skipping local bundle scan.") + return@withContext null } val service = ensureServiceBound() - ?: return@withContext Pair(false, "Failed to connect to Shizuku file service.") + if (service == null) { + onProgress("Failed to connect to Shizuku file service.") + return@withContext null + } // Step 1: List bundles via Shizuku onProgress("Listing local game bundles...") @@ -205,7 +204,7 @@ object ShizukuManager { val bundleList = JSONArray(bundleListJson) if (bundleList.length() == 0) { onProgress("No bundles found in game directory.") - return@withContext Pair(false, "No bundles found. Is the game installed and has been played?") + return@withContext null } onProgress("Found ${bundleList.length()} bundles. Checking cache...") @@ -214,14 +213,6 @@ object ShizukuManager { val needsScanJson = ModdingService.checkScanNeeded(outputDir, bundleListJson, onProgress) val needsScan = JSONArray(needsScanJson) - if (needsScan.length() == 0) { - onProgress("All bundles are up to date. Finalizing...") - val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir, onProgress) - return@withContext Pair(finalSuccess, finalMsg) - } - - onProgress("${needsScan.length()} bundles need scanning...") - // Build hash lookup from the full bundle list val hashMap = mutableMapOf() for (i in 0 until bundleList.length()) { @@ -229,7 +220,48 @@ object ShizukuManager { hashMap[obj.getString("name")] = obj.getString("hash") } - // Step 3: Scan each bundle one at a time + BundleCheckResult( + bundleListJson = bundleListJson, + needsScanJson = needsScanJson, + hashMap = hashMap, + needsScanCount = needsScan.length() + ) + } catch (e: Exception) { + Log.e("ShizukuManager", "Error checking local bundles", e) + onProgress("Error: ${e.message}") + null + } + } + + /** + * Phase 2: 執行實際的 bundle 掃描並儲存索引。 + * 應在使用者確認後呼叫。 + * + * @param outputDir App 可寫入的目錄 + * @param cacheDir App 的暫存目錄 + * @param checkResult Phase 1 回傳的結果 + * @param onBundleProgress 每個 bundle 掃描時的進度 callback + * @return Triple<成功, 掃描數量, 失敗數量> + */ + suspend fun executeBundleScan( + outputDir: String, + cacheDir: String, + checkResult: BundleCheckResult, + onBundleProgress: (currentIndex: Int, total: Int, bundleName: String, message: String) -> Unit + ): Triple = withContext(Dispatchers.IO) { + try { + val service = ensureServiceBound() + ?: return@withContext Triple(false, 0, 0) + + val needsScan = JSONArray(checkResult.needsScanJson) + val total = needsScan.length() + + if (total == 0) { + // Nothing to scan, just finalize with cached data + ModdingService.finalizeScan(outputDir) { } + return@withContext Triple(true, 0, 0) + } + val tempDir = File(cacheDir, "scan_temp") tempDir.mkdirs() val tempDataFile = File(tempDir, "__data") @@ -237,11 +269,11 @@ object ShizukuManager { var scannedCount = 0 var failedCount = 0 - for (i in 0 until needsScan.length()) { + for (i in 0 until total) { val bundleName = needsScan.getString(i) - val bundleHash = hashMap[bundleName] ?: continue + val bundleHash = checkResult.hashMap[bundleName] ?: continue - onProgress("Scanning ${i + 1}/${needsScan.length()}: $bundleName") + onBundleProgress(i, total, bundleName, "Copying $bundleName...") // Copy __data from game dir to temp via Shizuku val gamePath = "$GAME_SHARED_PATH/$bundleName/$bundleHash/__data" @@ -253,10 +285,12 @@ object ShizukuManager { continue } + onBundleProgress(i, total, bundleName, "Scanning $bundleName...") + // Scan via Python val (scanSuccess, _, _) = ModdingService.scanSingleBundle( - bundleName, bundleHash, tempDataFile.absolutePath, onProgress - ) + bundleName, bundleHash, tempDataFile.absolutePath + ) { msg -> onBundleProgress(i, total, bundleName, msg) } if (scanSuccess) scannedCount++ else failedCount++ @@ -267,15 +301,42 @@ object ShizukuManager { // Clean up temp directory tempDir.deleteRecursively() - // Step 4: Finalize and save index - onProgress("Saving index... (scanned: $scannedCount, failed: $failedCount)") - val (finalSuccess, finalMsg) = ModdingService.finalizeScan(outputDir, onProgress) - Pair(finalSuccess, finalMsg) + // Finalize and save index + onBundleProgress(total, total, "", "Saving index...") + val (finalSuccess, _) = ModdingService.finalizeScan(outputDir) { msg -> + onBundleProgress(total, total, "", msg) + } + Triple(finalSuccess, scannedCount, failedCount) } catch (e: Exception) { Log.e("ShizukuManager", "Error scanning local bundles", e) - Pair(false, "Error: ${e.message}") + Triple(false, 0, 0) + } + } + + /** + * 便捷方法:一次完成列舉 + 掃描 + 儲存。 + * 保留向後兼容,內部使用分段 API。 + */ + suspend fun scanLocalBundles( + outputDir: String, + cacheDir: String, + onProgress: (String) -> Unit + ): Pair = withContext(Dispatchers.IO) { + val checkResult = checkLocalBundles(outputDir, onProgress) + ?: return@withContext Pair(false, "Failed to check local bundles.") + + if (checkResult.needsScanCount == 0) { + onProgress("All bundles are up to date. Finalizing...") + return@withContext ModdingService.finalizeScan(outputDir, onProgress) } + + onProgress("${checkResult.needsScanCount} bundles need scanning...") + val (success, scanned, failed) = executeBundleScan(outputDir, cacheDir, checkResult) { idx, total, name, msg -> + onProgress("Scanning ${idx + 1}/$total: $name - $msg") + } + + Pair(success, "Scanned: $scanned, Failed: $failed") } /** 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 e393b9c8..5c365487 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 @@ -525,3 +525,186 @@ fun MergeSpineDialog(state: MergeState, onDismiss: () -> Unit) { dismissButton = null ) } + +@Composable +fun VersionMismatchWarningDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Warning, contentDescription = "Warning", tint = MaterialTheme.colorScheme.error) }, + title = { Text("Game Resources Need Update") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + "A new game version has been detected, but the local game resources have not been updated yet.", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Please update the game and launch it at least once to download the latest resources, then restart this app.", + style = MaterialTheme.typography.bodyMedium + ) + } + }, + confirmButton = { + Button(onClick = onDismiss) { + Text("OK") + } + }, + dismissButton = null + ) +} + +@Composable +fun BundleScanDialog( + state: BundleScanState, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + if (state is BundleScanState.Idle) return + + AlertDialog( + onDismissRequest = { + if (state !is BundleScanState.Scanning) { + onDismiss() + } + }, + icon = { + when (state) { + is BundleScanState.Confirmation -> Icon(Icons.Default.FindInPage, contentDescription = "Scan") + is BundleScanState.Scanning -> Icon(Icons.Default.Search, contentDescription = "Scanning") + is BundleScanState.Finished -> Icon(Icons.Default.CheckCircle, contentDescription = "Done") + is BundleScanState.Failed -> Icon(Icons.Default.Error, contentDescription = "Error") + else -> {} + } + }, + title = { + val text = when (state) { + is BundleScanState.Confirmation -> "Bundle Scan Required" + is BundleScanState.Scanning -> "Scanning Bundles..." + is BundleScanState.Finished -> "Scan Complete" + is BundleScanState.Failed -> "Scan Failed" + else -> "" + } + Text(text) + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + when (state) { + is BundleScanState.Confirmation -> { + Text( + "${state.bundleCount} new or updated bundles have been detected and need to be scanned.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + "This process may take a while depending on the number of bundles. You can skip this and use the cached index instead.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + is BundleScanState.Scanning -> { + Text( + "Scanning ${state.currentIndex + 1} of ${state.totalCount}", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { (state.currentIndex.toFloat()) / state.totalCount.toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + if (state.currentBundle.isNotEmpty()) { + Text( + state.currentBundle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + if (state.progressMessage.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + state.progressMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } + is BundleScanState.Finished -> { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Success", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(16.dp)) + Text( + state.message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + if (state.failedCount > 0) { + Spacer(Modifier.height(8.dp)) + Text( + "${state.failedCount} bundle(s) failed to scan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + is BundleScanState.Failed -> { + Icon( + Icons.Default.Error, + contentDescription = "Failed", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(16.dp)) + Text( + state.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + } + else -> {} + } + } + }, + confirmButton = { + when (state) { + is BundleScanState.Confirmation -> { + Button(onClick = onConfirm) { + Text("Start Scan") + } + } + is BundleScanState.Finished, is BundleScanState.Failed -> { + Button(onClick = onDismiss) { + Text("OK") + } + } + else -> {} + } + }, + dismissButton = { + if (state is BundleScanState.Confirmation) { + TextButton(onClick = onDismiss) { + Text("Skip") + } + } + } + ) +} + 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 62641477..8b3aa7ae 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 @@ -118,6 +118,16 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() + private val _bundleScanState = MutableStateFlow(BundleScanState.Idle) + val bundleScanState: StateFlow = _bundleScanState.asStateFlow() + + private val _showVersionMismatchWarning = MutableStateFlow(false) + val showVersionMismatchWarning: StateFlow = _showVersionMismatchWarning.asStateFlow() + + // Stored for deferred scan execution after user confirmation + private var pendingCheckResult: BundleCheckResult? = null + private var appContext: Context? = null + private var initialized = false private var scanJob: Job? = null private var pendingScanUri: Uri? = null @@ -126,6 +136,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( if (initialized) return initialized = true + appContext = context.applicationContext characterRepository = CharacterRepository(context) modRepository = ModRepository(context, characterRepository) @@ -133,21 +144,39 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( try { _isUpdatingCharacters.value = true + // Check if local characters.json exists before update + val hadLocalCharacters = characterRepository.hasLocalCharactersJson() + // Step 1: Update characters.json from CDN - characterRepository.updateCharacterData() + val updateStatus = characterRepository.updateCharacterData() + val charactersWereRefreshed = (updateStatus == "SUCCESS" && hadLocalCharacters) - // Step 2: Scan local bundles via Shizuku (incremental, fast if cache is up to date) - // This must complete before mod scanning so the asset index is available. + // Step 2: Check local bundles via Shizuku (don't scan yet — ask user first) if (ShizukuManager.isAvailable()) { - withContext(Dispatchers.IO) { - // Use externalCacheDir for temp files because Shizuku service runs - // as shell UID and cannot write to app's internal cacheDir (/data/data/...) - val shizukuCacheDir = (context.externalCacheDir ?: context.cacheDir).absolutePath - ShizukuManager.scanLocalBundles( - outputDir = context.filesDir.absolutePath, - cacheDir = shizukuCacheDir + val checkResult = withContext(Dispatchers.IO) { + ShizukuManager.checkLocalBundles( + outputDir = context.filesDir.absolutePath ) { progress -> - Log.d("MainViewModel", "Bundle scan: $progress") + Log.d("MainViewModel", "Bundle check: $progress") + } + } + + if (checkResult != null) { + if (checkResult.needsScanCount > 0) { + // Bundles need scanning — show confirmation dialog + pendingCheckResult = checkResult + _bundleScanState.value = BundleScanState.Confirmation(checkResult.needsScanCount) + } else { + // No bundles need scanning — finalize with cached data + withContext(Dispatchers.IO) { + ModdingService.finalizeScan(context.filesDir.absolutePath) { } + } + + // If characters.json was just updated but no new bundles, + // the user likely hasn't updated the game yet. + if (charactersWereRefreshed) { + _showVersionMismatchWarning.value = true + } } } } else { @@ -182,6 +211,65 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( savedStateHandle["use_astc"] = useAstc } + // --- Bundle Scan Dialog Actions --- + + fun confirmBundleScan() { + val context = appContext ?: return + val checkResult = pendingCheckResult ?: return + + viewModelScope.launch { + try { + // Use externalCacheDir for temp files because Shizuku service runs + // as shell UID and cannot write to app's internal cacheDir (/data/data/...) + val shizukuCacheDir = (context.externalCacheDir ?: context.cacheDir).absolutePath + + val (success, scanned, failed) = withContext(Dispatchers.IO) { + ShizukuManager.executeBundleScan( + outputDir = context.filesDir.absolutePath, + cacheDir = shizukuCacheDir, + checkResult = checkResult + ) { currentIndex, total, bundleName, message -> + viewModelScope.launch(Dispatchers.Main) { + _bundleScanState.value = BundleScanState.Scanning( + currentIndex = currentIndex, + totalCount = total, + currentBundle = bundleName, + progressMessage = message + ) + } + } + } + + if (success) { + _bundleScanState.value = BundleScanState.Finished( + scannedCount = scanned, + failedCount = failed, + message = "Scan complete. $scanned scanned, $failed failed." + ) + } else { + _bundleScanState.value = BundleScanState.Failed("Bundle scan failed.") + } + } catch (e: Exception) { + _bundleScanState.value = BundleScanState.Failed(e.message ?: "Unknown error") + } finally { + pendingCheckResult = null + // Trigger mod rescan with updated index + modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } + } + } + } + + fun dismissBundleScan() { + // User cancelled — don't finalize, keep stale cache. + // Next app launch will re-check and prompt again. + pendingCheckResult = null + _bundleScanState.value = BundleScanState.Idle + } + + fun dismissVersionMismatchWarning() { + _showVersionMismatchWarning.value = false + } + fun setSearchActive(isActive: Boolean) { _isSearchActive.value = isActive if (!isActive) { From 2a23f4606e4fb951f62a308ef7dd615e3a194257 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 21:06:57 +0800 Subject: [PATCH 11/25] fix(scan): wait for user confirmation before ending initialization state --- .../ui/viewmodel/MainViewModel.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) 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 8b3aa7ae..345c14fc 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 @@ -141,6 +141,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( modRepository = ModRepository(context, characterRepository) viewModelScope.launch { + var requiresDeferredInitialization = false try { _isUpdatingCharacters.value = true @@ -166,6 +167,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( // Bundles need scanning — show confirmation dialog pendingCheckResult = checkResult _bundleScanState.value = BundleScanState.Confirmation(checkResult.needsScanCount) + requiresDeferredInitialization = true } else { // No bundles need scanning — finalize with cached data withContext(Dispatchers.IO) { @@ -182,12 +184,14 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } else { Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") } - } finally { - _isUpdatingCharacters.value = false + } catch (e: Exception) { + Log.e("MainViewModel", "Error during initialization", e) } - // Step 3: Scan mod source directory (uses the local bundle index for resolution) - modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } + if (!requiresDeferredInitialization) { + // Proceed immediately if no scan confirmation is needed + finishInitialization() + } } viewModelScope.launch { @@ -253,17 +257,27 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( _bundleScanState.value = BundleScanState.Failed(e.message ?: "Unknown error") } finally { pendingCheckResult = null - // Trigger mod rescan with updated index - modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } + // Finish initialization and trigger mod rescan with new index + finishInitialization() } } } fun dismissBundleScan() { - // User cancelled — don't finalize, keep stale cache. - // Next app launch will re-check and prompt again. + // User cancelled or dismissed results — don't finalize if cancelled. + val wasPending = pendingCheckResult != null pendingCheckResult = null _bundleScanState.value = BundleScanState.Idle + + // If user is skipping the confirmation dialog, we must finish initialization now + if (wasPending) { + finishInitialization() + } + } + + private fun finishInitialization() { + _isUpdatingCharacters.value = false + modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } } fun dismissVersionMismatchWarning() { From e378b9fc1505e6ea583515ebc6d7cbcd91bfdb23 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Mon, 13 Apr 2026 21:19:52 +0800 Subject: [PATCH 12/25] ui(scan): move Skip button to left, simplify scan log text --- .../bd2modmanager/ui/dialogs/AppDialogs.kt | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) 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 5c365487..49559883 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 @@ -623,17 +623,7 @@ fun BundleScanDialog( Spacer(Modifier.height(12.dp)) if (state.currentBundle.isNotEmpty()) { Text( - state.currentBundle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 1 - ) - } - if (state.progressMessage.isNotEmpty()) { - Spacer(Modifier.height(4.dp)) - Text( - state.progressMessage, + "Scanning: ${state.currentBundle}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -684,27 +674,33 @@ fun BundleScanDialog( } }, confirmButton = { - when (state) { - is BundleScanState.Confirmation -> { - Button(onClick = onConfirm) { - Text("Start Scan") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (state is BundleScanState.Confirmation) { + TextButton(onClick = onDismiss) { + Text("Skip") } + } else { + Spacer(Modifier.width(1.dp)) } - is BundleScanState.Finished, is BundleScanState.Failed -> { - Button(onClick = onDismiss) { - Text("OK") + when (state) { + is BundleScanState.Confirmation -> { + Button(onClick = onConfirm) { + Text("Start Scan") + } + } + is BundleScanState.Finished, is BundleScanState.Failed -> { + Button(onClick = onDismiss) { + Text("OK") + } } + else -> {} } - else -> {} } }, - dismissButton = { - if (state is BundleScanState.Confirmation) { - TextButton(onClick = onDismiss) { - Text("Skip") - } - } - } + dismissButton = null ) } From 753e1d008e2124fe10fb022b9e8c1f2699c001c4 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 00:07:11 +0800 Subject: [PATCH 13/25] feat(resolver): prioritize common-ui-prefabs-group bundles when resolving multiple matches --- app/src/main/python/resolver.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 32ef8ad1..0098e5b2 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -40,6 +40,16 @@ def resolve_mod_folder(mod_file_names, local_index): } """ asset_to_bundles = (local_index or {}).get("assetToBundles", {}) + scanned_bundles = (local_index or {}).get("scannedBundles", {}) + + def bundle_priority_key(bundle_name): + info = scanned_bundles.get(bundle_name, {}) + download_name = info.get("downloadName", "") + # Priority 0 is highest + if re.search(r'common-ui-prefabs-group\d+_assets_all', download_name): + return (0, bundle_name) + return (1, bundle_name) + unresolved = [] file_matches = [] @@ -94,8 +104,8 @@ def resolve_mod_folder(mod_file_names, local_index): if len(intersection) == 1: target_bundle = next(iter(intersection)) elif len(intersection) > 1: - # Multiple common bundles — pick the first alphabetically - target_bundle = sorted(intersection)[0] + # Multiple common bundles — prioritize common-ui-prefabs-group... + target_bundle = sorted(intersection, key=bundle_priority_key)[0] elif len(union) == 1: # No strict intersection but only one bundle total target_bundle = next(iter(union)) @@ -106,7 +116,7 @@ def resolve_mod_folder(mod_file_names, local_index): 'targetHash': None, 'resolvedFamilyKey': None, 'resolvedTargets': [ - _build_target(entry) for entry in file_matches + _build_target(entry, bundle_priority_key) for entry in file_matches ], 'unresolvedFiles': unresolved, 'resolutionState': 'INVALID', @@ -114,7 +124,7 @@ def resolve_mod_folder(mod_file_names, local_index): } resolved_targets = [ - _build_target(entry, target_bundle) for entry in file_matches + _build_target(entry, bundle_priority_key, target_bundle) for entry in file_matches ] family_key = _compute_family_key(file_matches) @@ -158,9 +168,12 @@ def _expand_candidates(base_name): return list(dict.fromkeys(candidates)) -def _build_target(entry, target_bundle=None): +def _build_target(entry, bundle_priority_key, target_bundle=None): """Build a resolved target dict for Kotlin compatibility.""" - bundle_name = target_bundle or (sorted(entry['bundles'])[0] if entry['bundles'] else None) + bundle_name = target_bundle + if not bundle_name and entry['bundles']: + bundle_name = sorted(entry['bundles'], key=bundle_priority_key)[0] + family_key = _extract_stem(entry['fileName']) return { From ae80664500e854915b099bbb4ef2274496d07b62 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 00:30:05 +0800 Subject: [PATCH 14/25] fix(indexer): correct entry_data byte offsets in catalog parsing for downloadName --- app/src/main/python/local_bundle_indexer.py | 88 ++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py index 7d942651..f5add126 100644 --- a/app/src/main/python/local_bundle_indexer.py +++ b/app/src/main/python/local_bundle_indexer.py @@ -15,11 +15,12 @@ import json import os import time +import glob # Lazy-loaded UnityPy reference _UnityPy = None -INDEX_SCHEMA_VERSION = 1 +INDEX_SCHEMA_VERSION = 2 # Only scan object types that carry meaningful m_Name for modding. # Skipping Transform, GameObject, Material, Shader, Mesh, etc. avoids @@ -223,6 +224,86 @@ def scan_single_bundle(bundle_name, bundle_hash, temp_data_path, progress_callba return False, 0, str(e) +def _get_bundle_to_download_name_map(output_dir): + """ + Attempts to read the latest catalog in output_dir to extract the + mapping of actual bundleName to Addressables Key (downloadName). + """ + try: + from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array + except ImportError: + return {} + import base64 + + catalogs = glob.glob(os.path.join(output_dir, "catalog_*.json")) + if not catalogs: + return {} + + # Use the most recently modified one if there are multiple + catalog_path = sorted(catalogs, key=os.path.getmtime, reverse=True)[0] + + try: + with open(catalog_path, 'r', encoding='utf-8') as f: + catalog = json.load(f) + + provider_ids = catalog.get("m_ProviderIds", []) + bundle_provider = "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider" + if bundle_provider not in provider_ids: + return {} + bundle_provider_index = provider_ids.index(bundle_provider) + + bucket_array = base64.b64decode(catalog["m_BucketDataString"]) + key_array = base64.b64decode(catalog["m_KeyDataString"]) + extra_data = base64.b64decode(catalog["m_ExtraDataString"]) + entry_data = base64.b64decode(catalog["m_EntryDataString"]) + + num_buckets = read_int32_from_byte_array(bucket_array, 0) + data_offsets = [] + index = 4 + for _ in range(num_buckets): + data_offsets.append(read_int32_from_byte_array(bucket_array, index)) + index += 4 + num_entries = read_int32_from_byte_array(bucket_array, index) + index += 4 + index += 4 * num_entries + + keys = [read_object_from_byte_array(key_array, offset) for offset in data_offsets] + + mapping = {} + number_of_entries = read_int32_from_byte_array(entry_data, 0) + index = 4 + for _ in range(number_of_entries): + # internal_id + index += 4 + # provider_index + provider_index = read_int32_from_byte_array(entry_data, index) + index += 4 + # dependency_key_index + index += 4 + # dependency_hash + index += 4 + # data_index (extra_data offset) + data_index = read_int32_from_byte_array(entry_data, index) + index += 4 + # primary_key_index + primary_key_index = read_int32_from_byte_array(entry_data, index) + index += 4 + # resource_type + index += 4 + + if provider_index == bundle_provider_index and data_index >= 0: + bundle_info = read_object_from_byte_array(extra_data, data_index) + if bundle_info and bundle_info.get("m_BundleName"): + bundle_name = str(bundle_info["m_BundleName"]) + download_name = str(keys[primary_key_index]) if primary_key_index < len(keys) else "" + mapping[bundle_name] = download_name + + return mapping + except Exception as e: + print(f"Error extracting downloadNames: {e}") + return {} + + def finalize_scan(output_dir, progress_callback=None): """ Step 3: Merge cached + newly scanned results and save the final index. @@ -268,6 +349,11 @@ def report(msg): if bundle_name not in asset_to_bundles[asset_name]: asset_to_bundles[asset_name].append(bundle_name) + # Enhance with downloadNames using the catalog mapping + download_names = _get_bundle_to_download_name_map(output_dir) + for b_name, b_info in all_bundles.items(): + b_info["downloadName"] = download_names.get(b_name, "") + # Save index to disk os.makedirs(output_dir, exist_ok=True) index = { From 230faf801141eaaf8e34d6f833f44b67ac608da0 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 00:36:53 +0800 Subject: [PATCH 15/25] fix(scan): correct catalog parsing and improve resolver priority logic --- app/src/main/python/resolver.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 0098e5b2..a358bd55 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -46,9 +46,11 @@ def bundle_priority_key(bundle_name): info = scanned_bundles.get(bundle_name, {}) download_name = info.get("downloadName", "") # Priority 0 is highest - if re.search(r'common-ui-prefabs-group\d+_assets_all', download_name): - return (0, bundle_name) - return (1, bundle_name) + match = re.search(r'common-ui-prefabs-group(\d+)_assets_all', download_name) + if match: + # Sort by priority 0 (highest), then by group number (smaller is better), then alphabetically + return (0, int(match.group(1)), bundle_name) + return (1, float('inf'), bundle_name) unresolved = [] From 5ad48d97e060e0bfa60c218c9d78ecdf2db5c368 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 01:20:18 +0800 Subject: [PATCH 16/25] feat: use catalog dependency chain for authoritative asset-to-bundle resolution - Replace _get_bundle_to_download_name_map with _parse_catalog_data - Add catalogAssetToBundle to local_bundle_index.json (schema v3) - Resolver now checks catalog first, falls back to scan-based lookup - Remove common-ui-prefabs-group priority rule (no longer needed) --- app/src/main/python/local_bundle_indexer.py | 153 +++++++++++++++----- app/src/main/python/resolver.py | 47 +++--- 2 files changed, 137 insertions(+), 63 deletions(-) diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py index f5add126..94aa0522 100644 --- a/app/src/main/python/local_bundle_indexer.py +++ b/app/src/main/python/local_bundle_indexer.py @@ -20,7 +20,7 @@ # Lazy-loaded UnityPy reference _UnityPy = None -INDEX_SCHEMA_VERSION = 2 +INDEX_SCHEMA_VERSION = 3 # Only scan object types that carry meaningful m_Name for modding. # Skipping Transform, GameObject, Material, Shader, Mesh, etc. avoids @@ -224,32 +224,35 @@ def scan_single_bundle(bundle_name, bundle_hash, temp_data_path, progress_callba return False, 0, str(e) -def _get_bundle_to_download_name_map(output_dir): +def _parse_catalog_data(output_dir): """ - Attempts to read the latest catalog in output_dir to extract the - mapping of actual bundleName to Addressables Key (downloadName). + Parse the catalog to extract: + 1. bundle_name → downloadName mapping + 2. asset_filename → bundle_name mapping (authoritative, from catalog dependency chain) + + Returns: + Tuple (download_names: dict, catalog_asset_to_bundle: dict) """ try: from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array except ImportError: - return {} + return {}, {} import base64 catalogs = glob.glob(os.path.join(output_dir, "catalog_*.json")) if not catalogs: - return {} - - # Use the most recently modified one if there are multiple + return {}, {} + catalog_path = sorted(catalogs, key=os.path.getmtime, reverse=True)[0] - + try: with open(catalog_path, 'r', encoding='utf-8') as f: catalog = json.load(f) - + provider_ids = catalog.get("m_ProviderIds", []) bundle_provider = "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider" if bundle_provider not in provider_ids: - return {} + return {}, {} bundle_provider_index = provider_ids.index(bundle_provider) bucket_array = base64.b64decode(catalog["m_BucketDataString"]) @@ -257,51 +260,118 @@ def _get_bundle_to_download_name_map(output_dir): extra_data = base64.b64decode(catalog["m_ExtraDataString"]) entry_data = base64.b64decode(catalog["m_EntryDataString"]) + # --- Parse buckets --- num_buckets = read_int32_from_byte_array(bucket_array, 0) + dependency_map = [None] * num_buckets data_offsets = [] - index = 4 - for _ in range(num_buckets): - data_offsets.append(read_int32_from_byte_array(bucket_array, index)) - index += 4 - num_entries = read_int32_from_byte_array(bucket_array, index) - index += 4 - index += 4 * num_entries + idx = 4 + for i in range(num_buckets): + data_offsets.append(read_int32_from_byte_array(bucket_array, idx)) + idx += 4 + num_entries = read_int32_from_byte_array(bucket_array, idx) + idx += 4 + entries_list = [] + for _ in range(num_entries): + entries_list.append(read_int32_from_byte_array(bucket_array, idx)) + idx += 4 + dependency_map[i] = entries_list keys = [read_object_from_byte_array(key_array, offset) for offset in data_offsets] - mapping = {} + # --- Parse entries --- number_of_entries = read_int32_from_byte_array(entry_data, 0) - index = 4 - for _ in range(number_of_entries): + idx = 4 + bundle_entries = {} # entry_index → bundle_name + download_names = {} # bundle_name → downloadName + all_entries = [] + + for m in range(number_of_entries): # internal_id - index += 4 + idx += 4 # provider_index - provider_index = read_int32_from_byte_array(entry_data, index) - index += 4 + provider_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 # dependency_key_index - index += 4 + dependency_key_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 # dependency_hash - index += 4 - # data_index (extra_data offset) - data_index = read_int32_from_byte_array(entry_data, index) - index += 4 + idx += 4 + # data_index + data_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 # primary_key_index - primary_key_index = read_int32_from_byte_array(entry_data, index) - index += 4 + primary_key_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 # resource_type - index += 4 + idx += 4 + + all_entries.append({ + 'dependency_index': dependency_key_index, + 'primary_key_index': primary_key_index, + }) if provider_index == bundle_provider_index and data_index >= 0: bundle_info = read_object_from_byte_array(extra_data, data_index) if bundle_info and bundle_info.get("m_BundleName"): bundle_name = str(bundle_info["m_BundleName"]) - download_name = str(keys[primary_key_index]) if primary_key_index < len(keys) else "" - mapping[bundle_name] = download_name + bundle_entries[m] = bundle_name + dn = str(keys[primary_key_index]) if primary_key_index < len(keys) else "" + download_names[bundle_name] = dn + + # --- Resolve asset → bundle via dependency chain --- + def resolve_bundle(entry_index): + if entry_index in bundle_entries: + return bundle_entries[entry_index] + if entry_index < 0 or entry_index >= len(all_entries): + return None + dep_idx = all_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: + if dep_entry in bundle_entries: + return bundle_entries[dep_entry] + return None + + # --- Build asset filename → bundle mapping --- + catalog_asset_to_bundle = {} + for i in range(len(all_entries)): + if i in bundle_entries: + continue # Skip bundle entries themselves + + pki = all_entries[i]['primary_key_index'] + if pki < 0 or pki >= len(keys): + continue + asset_key = keys[pki] + if not isinstance(asset_key, str): + continue + + bundle_name = resolve_bundle(i) + if not bundle_name: + continue + + # Extract filename from full path and normalize + filename = asset_key.rsplit('/', 1)[-1].strip().lower() + if not filename: + continue + + # Normalize extensions to match scanner m_Name conventions: + # .skel.bytes → .skel + # .atlas.txt → .atlas + if filename.endswith('.skel.bytes'): + filename = filename[:-6] # remove '.bytes' + elif filename.endswith('.atlas.txt'): + filename = filename[:-4] # remove '.txt' + + # First occurrence wins (primary asset entry) + if filename not in catalog_asset_to_bundle: + catalog_asset_to_bundle[filename] = bundle_name + + return download_names, catalog_asset_to_bundle - return mapping except Exception as e: - print(f"Error extracting downloadNames: {e}") - return {} + print(f"Error parsing catalog data: {e}") + return {}, {} def finalize_scan(output_dir, progress_callback=None): @@ -340,7 +410,7 @@ def report(msg): 1 for info in _scan_state["scanned"].values() if info.get("error") ) - # Build the assetToBundles lookup table + # Build the assetToBundles lookup table (scan-based, may have duplicates) asset_to_bundles = {} for bundle_name, info in all_bundles.items(): for asset_name in info.get("assets", []): @@ -349,11 +419,13 @@ def report(msg): if bundle_name not in asset_to_bundles[asset_name]: asset_to_bundles[asset_name].append(bundle_name) - # Enhance with downloadNames using the catalog mapping - download_names = _get_bundle_to_download_name_map(output_dir) + # Parse catalog for downloadNames and authoritative asset→bundle mapping + download_names, catalog_asset_to_bundle = _parse_catalog_data(output_dir) for b_name, b_info in all_bundles.items(): b_info["downloadName"] = download_names.get(b_name, "") + report(f"Catalog: {len(download_names)} downloadNames, {len(catalog_asset_to_bundle)} asset mappings") + # Save index to disk os.makedirs(output_dir, exist_ok=True) index = { @@ -362,6 +434,7 @@ def report(msg): "bundleCount": len(all_bundles), "assetCount": len(asset_to_bundles), "assetToBundles": asset_to_bundles, + "catalogAssetToBundle": catalog_asset_to_bundle, "scannedBundles": all_bundles, } diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index a358bd55..3666f422 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -24,9 +24,13 @@ def resolve_mod_folder(mod_file_names, local_index): Finds which bundle(s) contain the target assets, then identifies the single common bundle that all mod files belong to. + Uses the catalog-based authoritative mapping (catalogAssetToBundle) + as primary lookup. Falls back to scan-based assetToBundles if the + catalog doesn't have a mapping for a given asset. + Args: mod_file_names: list of mod file paths/names - local_index: dict with 'assetToBundles' key + local_index: dict with 'assetToBundles' and 'catalogAssetToBundle' keys Returns: A resolution result dict compatible with the Kotlin layer: @@ -40,18 +44,7 @@ def resolve_mod_folder(mod_file_names, local_index): } """ asset_to_bundles = (local_index or {}).get("assetToBundles", {}) - scanned_bundles = (local_index or {}).get("scannedBundles", {}) - - def bundle_priority_key(bundle_name): - info = scanned_bundles.get(bundle_name, {}) - download_name = info.get("downloadName", "") - # Priority 0 is highest - match = re.search(r'common-ui-prefabs-group(\d+)_assets_all', download_name) - if match: - # Sort by priority 0 (highest), then by group number (smaller is better), then alphabetically - return (0, int(match.group(1)), bundle_name) - return (1, float('inf'), bundle_name) - + catalog_asset_to_bundle = (local_index or {}).get("catalogAssetToBundle", {}) unresolved = [] file_matches = [] @@ -62,8 +55,18 @@ def bundle_priority_key(bundle_name): matched_candidate = None matched_bundles = set() + match_strategy = 'LOCAL_SCAN' for candidate in candidates: + # Check catalog first (authoritative, single bundle) + catalog_bundle = catalog_asset_to_bundle.get(candidate) + if catalog_bundle: + matched_candidate = candidate + matched_bundles = {catalog_bundle} + match_strategy = 'CATALOG' + break # Catalog is authoritative — no need to check further + + # Fall back to scan-based lookup (may have duplicates) bundles = asset_to_bundles.get(candidate) if bundles: if matched_candidate is None: @@ -76,6 +79,7 @@ def bundle_priority_key(bundle_name): 'candidates': candidates, 'candidate': matched_candidate, 'bundles': matched_bundles, + 'matchStrategy': match_strategy, }) else: unresolved.append(base_name) @@ -106,8 +110,8 @@ def bundle_priority_key(bundle_name): if len(intersection) == 1: target_bundle = next(iter(intersection)) elif len(intersection) > 1: - # Multiple common bundles — prioritize common-ui-prefabs-group... - target_bundle = sorted(intersection, key=bundle_priority_key)[0] + # Multiple common bundles — pick alphabetically + target_bundle = sorted(intersection)[0] elif len(union) == 1: # No strict intersection but only one bundle total target_bundle = next(iter(union)) @@ -118,7 +122,7 @@ def bundle_priority_key(bundle_name): 'targetHash': None, 'resolvedFamilyKey': None, 'resolvedTargets': [ - _build_target(entry, bundle_priority_key) for entry in file_matches + _build_target(entry) for entry in file_matches ], 'unresolvedFiles': unresolved, 'resolutionState': 'INVALID', @@ -126,7 +130,7 @@ def bundle_priority_key(bundle_name): } resolved_targets = [ - _build_target(entry, bundle_priority_key, target_bundle) for entry in file_matches + _build_target(entry, target_bundle) for entry in file_matches ] family_key = _compute_family_key(file_matches) @@ -170,12 +174,9 @@ def _expand_candidates(base_name): return list(dict.fromkeys(candidates)) -def _build_target(entry, bundle_priority_key, target_bundle=None): +def _build_target(entry, target_bundle=None): """Build a resolved target dict for Kotlin compatibility.""" - bundle_name = target_bundle - if not bundle_name and entry['bundles']: - bundle_name = sorted(entry['bundles'], key=bundle_priority_key)[0] - + bundle_name = target_bundle or (sorted(entry['bundles'])[0] if entry['bundles'] else None) family_key = _extract_stem(entry['fileName']) return { @@ -186,7 +187,7 @@ def _build_target(entry, bundle_priority_key, target_bundle=None): 'assetType': _infer_asset_type(entry['fileName']), 'targetHash': bundle_name, 'familyKey': family_key, - 'matchStrategy': 'LOCAL_SCAN', + 'matchStrategy': entry.get('matchStrategy', 'LOCAL_SCAN'), 'confidence': 1.0, } From d34374e073a05ac897eac6608c000b0229de03c2 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 01:23:27 +0800 Subject: [PATCH 17/25] refactor(resolver): use catalog to filter scan results instead of bypassing them --- app/src/main/python/resolver.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 3666f422..0cb6ee8d 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -58,21 +58,21 @@ def resolve_mod_folder(mod_file_names, local_index): match_strategy = 'LOCAL_SCAN' for candidate in candidates: - # Check catalog first (authoritative, single bundle) - catalog_bundle = catalog_asset_to_bundle.get(candidate) - if catalog_bundle: - matched_candidate = candidate - matched_bundles = {catalog_bundle} - match_strategy = 'CATALOG' - break # Catalog is authoritative — no need to check further - - # Fall back to scan-based lookup (may have duplicates) bundles = asset_to_bundles.get(candidate) if bundles: if matched_candidate is None: matched_candidate = candidate matched_bundles.update(bundles) + # Use catalog to narrow down if multiple bundles matched + if len(matched_bundles) > 1: + for candidate in candidates: + catalog_bundle = catalog_asset_to_bundle.get(candidate) + if catalog_bundle and catalog_bundle in matched_bundles: + matched_bundles = {catalog_bundle} + match_strategy = 'CATALOG_FILTERED' + break + if matched_candidate and matched_bundles: file_matches.append({ 'fileName': base_name, From b790aa95aa8a90cd5fe75610acd43eab6c40ff79 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 01:36:26 +0800 Subject: [PATCH 18/25] fix: refine catalog-based bundle resolution with multi-variant keys and stem lookup --- app/src/main/python/local_bundle_indexer.py | 31 +++++++++++++++------ app/src/main/python/resolver.py | 17 ++++++++--- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py index 94aa0522..e1e99f83 100644 --- a/app/src/main/python/local_bundle_indexer.py +++ b/app/src/main/python/local_bundle_indexer.py @@ -355,17 +355,30 @@ def resolve_bundle(entry_index): if not filename: continue - # Normalize extensions to match scanner m_Name conventions: - # .skel.bytes → .skel - # .atlas.txt → .atlas + # Store multiple key variants to maximize matching with scanned m_Names: + names_to_store = set() + + # 1. Original filename as-is (e.g., "char000104.skel.bytes", "char000104.png") + names_to_store.add(filename) + + # 2. Normalized extensions to match scanner m_Name conventions if filename.endswith('.skel.bytes'): - filename = filename[:-6] # remove '.bytes' + names_to_store.add(filename[:-6]) # "char000104.skel" elif filename.endswith('.atlas.txt'): - filename = filename[:-4] # remove '.txt' - - # First occurrence wins (primary asset entry) - if filename not in catalog_asset_to_bundle: - catalog_asset_to_bundle[filename] = bundle_name + names_to_store.add(filename[:-4]) # "char000104.atlas" + + # 3. Stem without any extension (e.g., "char000104") + # Handles catalog keys that are just labels with no extension + stem = filename.rsplit('.', 1)[0] if '.' in filename else filename + # For compound extensions like "char000104.skel", also try removing + for ext in ('.skel', '.atlas'): + if stem.endswith(ext): + names_to_store.add(stem[:-len(ext)]) + + # First occurrence per key wins + for name in names_to_store: + if name and name not in catalog_asset_to_bundle: + catalog_asset_to_bundle[name] = bundle_name return download_names, catalog_asset_to_bundle diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 0cb6ee8d..d359f390 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -67,10 +67,19 @@ def resolve_mod_folder(mod_file_names, local_index): # Use catalog to narrow down if multiple bundles matched if len(matched_bundles) > 1: for candidate in candidates: - catalog_bundle = catalog_asset_to_bundle.get(candidate) - if catalog_bundle and catalog_bundle in matched_bundles: - matched_bundles = {catalog_bundle} - match_strategy = 'CATALOG_FILTERED' + # Try exact match first, then stem (no extension) + lookup_keys = [candidate] + stem = candidate.rsplit('.', 1)[0] if '.' in candidate else candidate + if stem != candidate: + lookup_keys.append(stem) + + for key in lookup_keys: + catalog_bundle = catalog_asset_to_bundle.get(key) + if catalog_bundle and catalog_bundle in matched_bundles: + matched_bundles = {catalog_bundle} + match_strategy = 'CATALOG_FILTERED' + break + if match_strategy == 'CATALOG_FILTERED': break if matched_candidate and matched_bundles: From 86e4033e9bd9f8f06bd95d0ec7a883cbeb99a1bf Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 01:46:01 +0800 Subject: [PATCH 19/25] feat: use bundle-level reverse-lookup with similarity scoring for catalog disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of matching catalog asset keys to scanned m_Names by filename, build a bundle→keys mapping from catalog, then for each ambiguous asset in assetToBundles, score each candidate bundle's catalog keys against the m_Name. catalogAssetToBundle is now keyed by exact m_Names. --- app/src/main/python/local_bundle_indexer.py | 96 +++++++++++++-------- app/src/main/python/resolver.py | 17 +--- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py index e1e99f83..2f0c6dc9 100644 --- a/app/src/main/python/local_bundle_indexer.py +++ b/app/src/main/python/local_bundle_indexer.py @@ -228,10 +228,10 @@ def _parse_catalog_data(output_dir): """ Parse the catalog to extract: 1. bundle_name → downloadName mapping - 2. asset_filename → bundle_name mapping (authoritative, from catalog dependency chain) + 2. bundle_name → list of asset keys (for reverse-lookup disambiguation) Returns: - Tuple (download_names: dict, catalog_asset_to_bundle: dict) + Tuple (download_names: dict, catalog_bundle_to_keys: dict) """ try: from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array @@ -333,8 +333,8 @@ def resolve_bundle(entry_index): return bundle_entries[dep_entry] return None - # --- Build asset filename → bundle mapping --- - catalog_asset_to_bundle = {} + # --- Build bundle → asset keys mapping --- + catalog_bundle_to_keys = {} for i in range(len(all_entries)): if i in bundle_entries: continue # Skip bundle entries themselves @@ -350,41 +350,42 @@ def resolve_bundle(entry_index): if not bundle_name: continue - # Extract filename from full path and normalize - filename = asset_key.rsplit('/', 1)[-1].strip().lower() - if not filename: - continue + if bundle_name not in catalog_bundle_to_keys: + catalog_bundle_to_keys[bundle_name] = [] + catalog_bundle_to_keys[bundle_name].append(asset_key.lower()) - # Store multiple key variants to maximize matching with scanned m_Names: - names_to_store = set() + return download_names, catalog_bundle_to_keys - # 1. Original filename as-is (e.g., "char000104.skel.bytes", "char000104.png") - names_to_store.add(filename) + except Exception as e: + print(f"Error parsing catalog data: {e}") + return {}, {} - # 2. Normalized extensions to match scanner m_Name conventions - if filename.endswith('.skel.bytes'): - names_to_store.add(filename[:-6]) # "char000104.skel" - elif filename.endswith('.atlas.txt'): - names_to_store.add(filename[:-4]) # "char000104.atlas" - # 3. Stem without any extension (e.g., "char000104") - # Handles catalog keys that are just labels with no extension - stem = filename.rsplit('.', 1)[0] if '.' in filename else filename - # For compound extensions like "char000104.skel", also try removing - for ext in ('.skel', '.atlas'): - if stem.endswith(ext): - names_to_store.add(stem[:-len(ext)]) +def _score_bundle_for_asset(asset_name, catalog_keys): + """ + Score how well a bundle's catalog asset keys match a scanned m_Name. - # First occurrence per key wins - for name in names_to_store: - if name and name not in catalog_asset_to_bundle: - catalog_asset_to_bundle[name] = bundle_name + Returns an integer score (higher = better match, 0 = no match). + """ + # Strip extension from m_Name for stem matching + stem = asset_name.rsplit('.', 1)[0] if '.' in asset_name else asset_name - return download_names, catalog_asset_to_bundle + best_score = 0 + for key in catalog_keys: + # Exact filename match (highest priority) + key_filename = key.rsplit('/', 1)[-1] + if key_filename == asset_name: + return 100 # Perfect match, return immediately - except Exception as e: - print(f"Error parsing catalog data: {e}") - return {}, {} + # Stem appears in the key's filename portion + if stem in key_filename: + best_score = max(best_score, 50 + len(stem)) + + # Stem appears anywhere in the full key path + elif stem in key: + best_score = max(best_score, 10 + len(stem)) + + return best_score def finalize_scan(output_dir, progress_callback=None): @@ -432,12 +433,37 @@ def report(msg): if bundle_name not in asset_to_bundles[asset_name]: asset_to_bundles[asset_name].append(bundle_name) - # Parse catalog for downloadNames and authoritative asset→bundle mapping - download_names, catalog_asset_to_bundle = _parse_catalog_data(output_dir) + # Parse catalog for downloadNames and bundle→keys mapping + download_names, catalog_bundle_to_keys = _parse_catalog_data(output_dir) for b_name, b_info in all_bundles.items(): b_info["downloadName"] = download_names.get(b_name, "") - report(f"Catalog: {len(download_names)} downloadNames, {len(catalog_asset_to_bundle)} asset mappings") + # Build catalogAssetToBundle by cross-referencing: + # For each ambiguous asset (multiple bundles), score each bundle's + # catalog asset keys against the m_Name and pick the best match. + catalog_asset_to_bundle = {} + ambiguous_count = 0 + resolved_count = 0 + for asset_name, bundle_list in asset_to_bundles.items(): + if len(bundle_list) <= 1: + continue # Only one bundle — no ambiguity + ambiguous_count += 1 + + best_bundle = None + best_score = 0 + for bundle_name in bundle_list: + cat_keys = catalog_bundle_to_keys.get(bundle_name, []) + score = _score_bundle_for_asset(asset_name, cat_keys) + if score > best_score: + best_score = score + best_bundle = bundle_name + + if best_bundle: + catalog_asset_to_bundle[asset_name] = best_bundle + resolved_count += 1 + + report(f"Catalog: {len(download_names)} downloadNames, " + f"{ambiguous_count} ambiguous assets, {resolved_count} resolved") # Save index to disk os.makedirs(output_dir, exist_ok=True) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index d359f390..0cb6ee8d 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -67,19 +67,10 @@ def resolve_mod_folder(mod_file_names, local_index): # Use catalog to narrow down if multiple bundles matched if len(matched_bundles) > 1: for candidate in candidates: - # Try exact match first, then stem (no extension) - lookup_keys = [candidate] - stem = candidate.rsplit('.', 1)[0] if '.' in candidate else candidate - if stem != candidate: - lookup_keys.append(stem) - - for key in lookup_keys: - catalog_bundle = catalog_asset_to_bundle.get(key) - if catalog_bundle and catalog_bundle in matched_bundles: - matched_bundles = {catalog_bundle} - match_strategy = 'CATALOG_FILTERED' - break - if match_strategy == 'CATALOG_FILTERED': + catalog_bundle = catalog_asset_to_bundle.get(candidate) + if catalog_bundle and catalog_bundle in matched_bundles: + matched_bundles = {catalog_bundle} + match_strategy = 'CATALOG_FILTERED' break if matched_candidate and matched_bundles: From 66df96442a5428464f1b85c349656e050bb2e74a Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 09:31:54 +0800 Subject: [PATCH 20/25] fix(ModRepository): invalidate mod cache if local_bundle_index.json is updated --- .../example/bd2modmanager/data/repository/ModRepository.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 630fbb76..53b53353 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 @@ -217,6 +217,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>() {}.type From b975137948ae320aae0edaa94ee66e77b3239398 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 09:40:03 +0800 Subject: [PATCH 21/25] perf(ModRepository): replace DocumentFile with batch ContentResolver queries --- .../data/repository/ModRepository.kt | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) 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 53b53353..d2136cc8 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 @@ -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 @@ -51,12 +51,36 @@ class ModRepository( 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 } - .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) { @@ -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, @@ -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()) { @@ -269,12 +293,24 @@ class ModRepository( val fileNames = mutableListOf() 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) { From 31fefb17533d6519f7cc683d28d82d6dcd549129 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 10:08:29 +0800 Subject: [PATCH 22/25] perf: parallelize init, skip redundant finalizeScan, cache loaded index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Run CDN character update and Shizuku bundle check in parallel instead of sequentially (saves ~1-2 seconds of network latency). 2. Skip finalizeScan() when 0 bundles need scanning — the existing local_bundle_index.json is already valid. This avoids expensive catalog parsing and JSON rewrite on every app launch. 3. Add in-memory caching to load_local_index() keyed by file mtime, so resolve_mod_batch doesn't re-read the large JSON from disk. --- .../ui/viewmodel/MainViewModel.kt | 55 ++++++++++--------- app/src/main/python/local_bundle_indexer.py | 25 ++++++++- 2 files changed, 52 insertions(+), 28 deletions(-) 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 345c14fc..60450741 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 @@ -148,41 +148,44 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( // Check if local characters.json exists before update val hadLocalCharacters = characterRepository.hasLocalCharactersJson() - // Step 1: Update characters.json from CDN - val updateStatus = characterRepository.updateCharacterData() - val charactersWereRefreshed = (updateStatus == "SUCCESS" && hadLocalCharacters) - - // Step 2: Check local bundles via Shizuku (don't scan yet — ask user first) - if (ShizukuManager.isAvailable()) { - val checkResult = withContext(Dispatchers.IO) { + // Run CDN update and Shizuku check in PARALLEL — they're independent + val characterUpdateJob = async(Dispatchers.IO) { + characterRepository.updateCharacterData() + } + val bundleCheckJob = async(Dispatchers.IO) { + if (ShizukuManager.isAvailable()) { ShizukuManager.checkLocalBundles( outputDir = context.filesDir.absolutePath ) { progress -> Log.d("MainViewModel", "Bundle check: $progress") } + } else { + Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") + null } + } - if (checkResult != null) { - if (checkResult.needsScanCount > 0) { - // Bundles need scanning — show confirmation dialog - pendingCheckResult = checkResult - _bundleScanState.value = BundleScanState.Confirmation(checkResult.needsScanCount) - requiresDeferredInitialization = true - } else { - // No bundles need scanning — finalize with cached data - withContext(Dispatchers.IO) { - ModdingService.finalizeScan(context.filesDir.absolutePath) { } - } - - // If characters.json was just updated but no new bundles, - // the user likely hasn't updated the game yet. - if (charactersWereRefreshed) { - _showVersionMismatchWarning.value = true - } + val updateStatus = characterUpdateJob.await() + val charactersWereRefreshed = (updateStatus == "SUCCESS" && hadLocalCharacters) + val checkResult = bundleCheckJob.await() + + if (checkResult != null) { + if (checkResult.needsScanCount > 0) { + // Bundles need scanning — show confirmation dialog + pendingCheckResult = checkResult + _bundleScanState.value = BundleScanState.Confirmation(checkResult.needsScanCount) + requiresDeferredInitialization = true + } else { + // No bundles need scanning — existing local_bundle_index.json + // is already valid from the last session. Skip the expensive + // finalizeScan() which would re-parse the catalog for nothing. + + // If characters.json was just updated but no new bundles, + // the user likely hasn't updated the game yet. + if (charactersWereRefreshed) { + _showVersionMismatchWarning.value = true } } - } else { - Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") } } catch (e: Exception) { Log.e("MainViewModel", "Error during initialization", e) diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py index 2f0c6dc9..0aadd19a 100644 --- a/app/src/main/python/local_bundle_indexer.py +++ b/app/src/main/python/local_bundle_indexer.py @@ -503,12 +503,33 @@ def report(msg): # Index loading (used by resolver) # --------------------------------------------------------------------------- +# In-memory cache: avoids re-reading and re-parsing the large JSON on every resolve call. +_index_cache = None # cached dict +_index_cache_mtime = 0 # mtime of the file when it was cached + def load_local_index(output_dir): """ - Load the local bundle index from disk cache. + Load the local bundle index from disk cache, with in-memory caching. Returns: The index dict, or None if no valid cache exists. """ + global _index_cache, _index_cache_mtime + cache_path = os.path.join(output_dir, "local_bundle_index.json") - return _load_existing_cache(cache_path) + + try: + current_mtime = os.path.getmtime(cache_path) + except OSError: + return None + + # Return cached version if file hasn't changed + if _index_cache is not None and current_mtime == _index_cache_mtime: + return _index_cache + + data = _load_existing_cache(cache_path) + if data is not None: + _index_cache = data + _index_cache_mtime = current_mtime + + return data From 8d6577f42dbff2d3dc980cbb39bf57beb8f02dc5 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 10:12:41 +0800 Subject: [PATCH 23/25] fix: revert to sequential init to avoid Python startup race condition --- .../ui/viewmodel/MainViewModel.kt | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) 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 60450741..b488b518 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 @@ -148,44 +148,41 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( // Check if local characters.json exists before update val hadLocalCharacters = characterRepository.hasLocalCharactersJson() - // Run CDN update and Shizuku check in PARALLEL — they're independent - val characterUpdateJob = async(Dispatchers.IO) { - characterRepository.updateCharacterData() - } - val bundleCheckJob = async(Dispatchers.IO) { - if (ShizukuManager.isAvailable()) { + // Step 1: Update characters.json from CDN (also starts Python runtime) + val updateStatus = characterRepository.updateCharacterData() + val charactersWereRefreshed = (updateStatus == "SUCCESS" && hadLocalCharacters) + + // Step 2: Check local bundles via Shizuku + // Must run after Step 1 because both use Python, and Python.start() is not thread-safe + if (ShizukuManager.isAvailable()) { + val checkResult = withContext(Dispatchers.IO) { ShizukuManager.checkLocalBundles( outputDir = context.filesDir.absolutePath ) { progress -> Log.d("MainViewModel", "Bundle check: $progress") } - } else { - Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") - null } - } - val updateStatus = characterUpdateJob.await() - val charactersWereRefreshed = (updateStatus == "SUCCESS" && hadLocalCharacters) - val checkResult = bundleCheckJob.await() - - if (checkResult != null) { - if (checkResult.needsScanCount > 0) { - // Bundles need scanning — show confirmation dialog - pendingCheckResult = checkResult - _bundleScanState.value = BundleScanState.Confirmation(checkResult.needsScanCount) - requiresDeferredInitialization = true - } else { - // No bundles need scanning — existing local_bundle_index.json - // is already valid from the last session. Skip the expensive - // finalizeScan() which would re-parse the catalog for nothing. - - // If characters.json was just updated but no new bundles, - // the user likely hasn't updated the game yet. - if (charactersWereRefreshed) { - _showVersionMismatchWarning.value = true + if (checkResult != null) { + if (checkResult.needsScanCount > 0) { + // Bundles need scanning — show confirmation dialog + pendingCheckResult = checkResult + _bundleScanState.value = BundleScanState.Confirmation(checkResult.needsScanCount) + requiresDeferredInitialization = true + } else { + // No bundles need scanning — existing local_bundle_index.json + // is already valid from the last session. Skip the expensive + // finalizeScan() which would re-parse the catalog for nothing. + + // If characters.json was just updated but no new bundles, + // the user likely hasn't updated the game yet. + if (charactersWereRefreshed) { + _showVersionMismatchWarning.value = true + } } } + } else { + Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") } } catch (e: Exception) { Log.e("MainViewModel", "Error during initialization", e) From 9009322238f978c1e3467cfdcd7afb8e8b75b03e Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 11:20:23 +0800 Subject: [PATCH 24/25] feat: add global quality selector (SD/HD/FHD) and persist settings - Added a Dropdown quality selector above the Repack FAB in ModScreen - Switched useAstc and selectedQuality to use SharedPreferences instead of SavedStateHandle so they persist across process deaths and full reboots - Injected selectedQuality down to updateCharacterData and downloadBundle in Kotlin - Updated main_script.py _refresh_character_data to consume quality dynamically --- .../data/repository/CharacterRepository.kt | 4 +-- .../data/repository/ModRepository.kt | 5 +++- .../bd2modmanager/ui/screens/ModScreen.kt | 29 +++++++++++++++++++ .../ui/viewmodel/MainViewModel.kt | 24 +++++++++++---- app/src/main/python/main_script.py | 4 +-- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt index 9e9fee60..05019406 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt @@ -23,7 +23,7 @@ class CharacterRepository(private val context: Context) { * - "SKIPPED" means the local version was already up to date. * - "FAILED" means the update attempt failed. */ - suspend fun updateCharacterData(): String { + suspend fun updateCharacterData(quality: String): String { return withContext(Dispatchers.IO) { var status = "FAILED" try { @@ -32,7 +32,7 @@ class CharacterRepository(private val context: Context) { } val py = Python.getInstance() val mainScript = py.getModule("main_script") - val result = mainScript.callAttr("update_character_data", context.filesDir.absolutePath).asList() + 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() 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 d2136cc8..40573b24 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 @@ -137,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() 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 f4af6ff0..84a9ad00 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 @@ -82,6 +82,35 @@ fun ModScreen( Scaffold( floatingActionButton = { Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { + val selectedQuality by viewModel.selectedQuality.collectAsState() + var qualityExpanded by remember { mutableStateOf(false) } + + AnimatedVisibility(visible = selectedMods.isNotEmpty()) { + Box { + ExtendedFloatingActionButton( + onClick = { qualityExpanded = true }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + icon = { Icon(Icons.Default.HighQuality, contentDescription = "Quality") }, + text = { Text("Quality: $selectedQuality") } + ) + DropdownMenu( + expanded = qualityExpanded, + onDismissRequest = { qualityExpanded = false } + ) { + listOf("SD", "HD", "FHD").forEach { q -> + DropdownMenuItem( + text = { Text(q, fontWeight = if (selectedQuality == q) FontWeight.Bold else FontWeight.Normal) }, + onClick = { + viewModel.setSelectedQuality(q) + qualityExpanded = false + } + ) + } + } + } + } + AnimatedVisibility(visible = selectedMods.size == 1) { ExtendedFloatingActionButton( onClick = onMergeRequest, 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 b488b518..62a89bb8 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 @@ -63,7 +63,11 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _selectedMods = MutableStateFlow>(emptySet()) val selectedMods: StateFlow> = _selectedMods.asStateFlow() - val useAstc: StateFlow = savedStateHandle.getStateFlow("use_astc", false) + private val _useAstc = MutableStateFlow(false) + val useAstc: StateFlow = _useAstc.asStateFlow() + + private val _selectedQuality = MutableStateFlow("HD") + val selectedQuality: StateFlow = _selectedQuality.asStateFlow() private val _isSearchActive = MutableStateFlow(false) val isSearchActive: StateFlow = _isSearchActive.asStateFlow() @@ -137,6 +141,10 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( initialized = true appContext = context.applicationContext + val prefs = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE) + _useAstc.value = prefs.getBoolean("use_astc", false) + _selectedQuality.value = prefs.getString("selected_quality", "HD") ?: "HD" + characterRepository = CharacterRepository(context) modRepository = ModRepository(context, characterRepository) @@ -149,7 +157,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( val hadLocalCharacters = characterRepository.hasLocalCharactersJson() // Step 1: Update characters.json from CDN (also starts Python runtime) - val updateStatus = characterRepository.updateCharacterData() + val updateStatus = characterRepository.updateCharacterData(_selectedQuality.value) val charactersWereRefreshed = (updateStatus == "SUCCESS" && hadLocalCharacters) // Step 2: Check local bundles via Shizuku @@ -212,7 +220,13 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } fun setUseAstc(useAstc: Boolean) { - savedStateHandle["use_astc"] = useAstc + _useAstc.value = useAstc + appContext?.getSharedPreferences("app_settings", Context.MODE_PRIVATE)?.edit()?.putBoolean("use_astc", useAstc)?.apply() + } + + fun setSelectedQuality(quality: String) { + _selectedQuality.value = quality + appContext?.getSharedPreferences("app_settings", Context.MODE_PRIVATE)?.edit()?.putString("selected_quality", quality)?.apply() } // --- Bundle Scan Dialog Actions --- @@ -386,7 +400,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( try { updateJobStatus(hashedName, JobStatus.Downloading("Starting download...")) - val (downloadSuccess, messageOrPath) = ModdingService.downloadBundle(hashedName, "HD", context.cacheDir.absolutePath, cacheKey) { progress -> + val (downloadSuccess, messageOrPath) = ModdingService.downloadBundle(hashedName, selectedQuality.value, context.cacheDir.absolutePath, cacheKey) { progress -> updateJobStatus(hashedName, JobStatus.Downloading(progress)) } @@ -540,7 +554,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( Python.start(com.chaquo.python.android.AndroidPlatform(context)) } val cacheKey = "uninstall_${System.currentTimeMillis()}" - ModdingService.downloadBundle(hashedName, "HD", context.cacheDir.absolutePath, cacheKey) { progress -> + ModdingService.downloadBundle(hashedName, selectedQuality.value, context.cacheDir.absolutePath, cacheKey) { progress -> viewModelScope.launch(Dispatchers.Main) { _uninstallState.value = UninstallState.Downloading(hashedName, progress) } diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index a49b2d4c..ba6763ad 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -91,14 +91,14 @@ def report_progress(message): return False, error_message, None -def update_character_data(output_dir): +def update_character_data(output_dir, quality="HD"): """ Entry point for Kotlin to refresh character metadata (characters.json). Returns a tuple: (status: String, message: String) Status can be "SUCCESS", "SKIPPED", "FAILED" """ try: - success, status, version = _refresh_character_data(output_dir, "HD") + success, status, version = _refresh_character_data(output_dir, quality) if success: if status == "SKIPPED": return "SKIPPED", "characters.json is already up to date." From a7578f7801dac5f9fef982719ab62ff69bc59d2f Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 14 Apr 2026 11:31:32 +0800 Subject: [PATCH 25/25] ui: remove Quality: prefix from quality selector button --- .../main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 84a9ad00..1f156aa3 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 @@ -92,7 +92,7 @@ fun ModScreen( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, icon = { Icon(Icons.Default.HighQuality, contentDescription = "Quality") }, - text = { Text("Quality: $selectedQuality") } + text = { Text(selectedQuality) } ) DropdownMenu( expanded = qualityExpanded,