From 9c250aabdacd519a02d6357d52407783b07ea0ed Mon Sep 17 00:00:00 2001 From: Zephyron Date: Sat, 29 Nov 2025 09:20:40 +1000 Subject: [PATCH] feat(android): add custom dump location selection for RomFS/ExeFS Allow users to specify a custom directory for dumping game files via the Android document picker, with fallback to default dump directory. Adds optional dumpPath parameter to both dump functions and UI dialog for location selection. Signed-off-by: Zephyron --- .../org/citron/citron_emu/NativeLibrary.kt | 4 + .../fragments/GamePropertiesFragment.kt | 165 ++++++++++++++---- src/android/app/src/main/jni/native.cpp | 45 ++++- .../app/src/main/res/values/strings.xml | 3 + 4 files changed, 177 insertions(+), 40 deletions(-) diff --git a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt index e778947f5..b6d6d77ca 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt @@ -470,12 +470,14 @@ object NativeLibrary { * Dumps the RomFS from a game to the dump directory * @param gamePath Path to the game file * @param programId String representation of the game's program ID + * @param dumpPath Optional custom dump path. If null or empty, uses default dump directory * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long) * @return true if successful, false otherwise */ external fun dumpRomFS( gamePath: String, programId: String, + dumpPath: String?, callback: (max: Long, progress: Long) -> Boolean ): Boolean @@ -483,12 +485,14 @@ object NativeLibrary { * Dumps the ExeFS from a game to the dump directory * @param gamePath Path to the game file * @param programId String representation of the game's program ID + * @param dumpPath Optional custom dump path. If null or empty, uses default dump directory * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long) * @return true if successful, false otherwise */ external fun dumpExeFS( gamePath: String, programId: String, + dumpPath: String?, callback: (max: Long, progress: Long) -> Boolean ): Boolean } diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt index 4645afe89..6356820f5 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt @@ -40,6 +40,7 @@ import org.citron.citron_emu.model.InstallableProperty import org.citron.citron_emu.model.SubmenuProperty import org.citron.citron_emu.model.TaskState import org.citron.citron_emu.utils.DirectoryInitialization +import org.citron.citron_emu.utils.DocumentsTree import org.citron.citron_emu.utils.FileUtil import org.citron.citron_emu.utils.GameIconUtils import org.citron.citron_emu.utils.GpuDriverHelper @@ -47,6 +48,7 @@ import org.citron.citron_emu.utils.MemoryUtil import org.citron.citron_emu.utils.ViewUtils.marquee import org.citron.citron_emu.utils.ViewUtils.updateMargins import org.citron.citron_emu.utils.collect +import androidx.documentfile.provider.DocumentFile import java.io.BufferedOutputStream import java.io.File @@ -283,25 +285,23 @@ class GamePropertiesFragment : Fragment() { R.string.dump_romfs_description, R.drawable.ic_save ) { - ProgressDialogFragment.newInstance( + // Show dialog to select dump location or use default + MessageDialogFragment.newInstance( requireActivity(), - R.string.dump_romfs_extracting, - false - ) { _, _ -> - val success = NativeLibrary.dumpRomFS( - args.game.path, - args.game.programIdHex, - { max, progress -> - // Progress callback - return true to cancel - false - } - ) - if (success) { - getString(R.string.dump_success) - } else { - getString(R.string.dump_failed) + titleId = R.string.dump_romfs, + descriptionId = R.string.select_dump_location_description, + positiveButtonTitleId = R.string.select_location, + negativeButtonTitleId = R.string.use_default_location, + positiveAction = { + // User wants to select a custom location + pendingDumpType = "romfs" + selectDumpDirectory.launch(null) + }, + negativeAction = { + // Use default location + performRomFSDump(null) } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) + ).show(parentFragmentManager, MessageDialogFragment.TAG) } ) @@ -311,25 +311,23 @@ class GamePropertiesFragment : Fragment() { R.string.dump_exefs_description, R.drawable.ic_save ) { - ProgressDialogFragment.newInstance( + // Show dialog to select dump location or use default + MessageDialogFragment.newInstance( requireActivity(), - R.string.dump_exefs_extracting, - false - ) { _, _ -> - val success = NativeLibrary.dumpExeFS( - args.game.path, - args.game.programIdHex, - { max, progress -> - // Progress callback - return true to cancel - false - } - ) - if (success) { - getString(R.string.dump_success) - } else { - getString(R.string.dump_failed) + titleId = R.string.dump_exefs, + descriptionId = R.string.select_dump_location_description, + positiveButtonTitleId = R.string.select_location, + negativeButtonTitleId = R.string.use_default_location, + positiveAction = { + // User wants to select a custom location + pendingDumpType = "exefs" + selectDumpDirectory.launch(null) + }, + negativeAction = { + // Use default location + performExeFSDump(null) } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) + ).show(parentFragmentManager, MessageDialogFragment.TAG) } ) } @@ -387,6 +385,22 @@ class GamePropertiesFragment : Fragment() { windowInsets } + private var pendingDumpType: String? = null // "romfs" or "exefs" + + private val selectDumpDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + // Store the selected directory URI and perform the dump + val selectedUri = result.toString() + when (pendingDumpType) { + "romfs" -> performRomFSDump(selectedUri) + "exefs" -> performExeFSDump(selectedUri) + } + pendingDumpType = null + } + private val importSaves = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> if (result == null) { @@ -480,4 +494,87 @@ class GamePropertiesFragment : Fragment() { } }.show(parentFragmentManager, ProgressDialogFragment.TAG) } + + private fun performRomFSDump(dumpPathUri: String?) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.dump_romfs_extracting, + false + ) { _, _ -> + // Convert URI to file path if needed + val dumpPathString = dumpPathUri?.let { uriString -> + try { + val uri = android.net.Uri.parse(uriString) + // For document tree URIs, try to get the actual file path + if (DocumentsTree.isNativePath(uriString)) { + uriString + } else { + // Try to extract file path from document URI + // For document tree URIs, we can't easily get a native path + // So we'll pass the URI and let the native code handle it + // or extract path using DocumentFile + val docFile = DocumentFile.fromTreeUri(requireContext(), uri) + docFile?.uri?.path ?: uriString + } + } catch (e: Exception) { + null + } + } + + val success = NativeLibrary.dumpRomFS( + args.game.path, + args.game.programIdHex, + dumpPathString, + { max, progress -> + // Progress callback - return true to cancel + false + } + ) + if (success) { + getString(R.string.dump_success) + } else { + getString(R.string.dump_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + private fun performExeFSDump(dumpPathUri: String?) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.dump_exefs_extracting, + false + ) { _, _ -> + // Convert URI to file path if needed + val dumpPathString = dumpPathUri?.let { uriString -> + try { + val uri = android.net.Uri.parse(uriString) + // For document tree URIs, try to get the actual file path + if (DocumentsTree.isNativePath(uriString)) { + uriString + } else { + // Try to extract file path from document URI + val docFile = DocumentFile.fromTreeUri(requireContext(), uri) + docFile?.uri?.path ?: uriString + } + } catch (e: Exception) { + null + } + } + + val success = NativeLibrary.dumpExeFS( + args.game.path, + args.game.programIdHex, + dumpPathString, + { max, progress -> + // Progress callback - return true to cancel + false + } + ) + if (success) { + getString(R.string.dump_success) + } else { + getString(R.string.dump_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } } diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index c56021497..18a0241b2 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -883,7 +883,7 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, j jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobject jobj, jstring jgamePath, jstring jprogramId, - jobject jcallback) { + jstring jdumpPath, jobject jcallback) { const auto game_path = Common::Android::GetJString(env, jgamePath); const auto program_id = EmulationSession::GetProgramId(env, jprogramId); @@ -935,7 +935,25 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobjec return false; } - const auto dump_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::DumpDir); + // Use custom dump path if provided, otherwise use default + std::filesystem::path dump_dir; + if (jdumpPath != nullptr) { + const auto custom_path = Common::Android::GetJString(env, jdumpPath); + if (!custom_path.empty()) { + // Check if it's a native path (starts with /) or try to use it as-is + if (custom_path[0] == '/') { + dump_dir = std::filesystem::path(custom_path); + } else { + // Try to parse as URI and extract path + // For document tree URIs, we can't easily get a native path + // So fall back to default + dump_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::DumpDir); + } + } + } + if (dump_dir.empty()) { + dump_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::DumpDir); + } const auto romfs_dir = fmt::format("{:016X}/romfs", title_id); const auto path = dump_dir / romfs_dir; @@ -1014,7 +1032,7 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobjec jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpExeFS(JNIEnv* env, jobject jobj, jstring jgamePath, jstring jprogramId, - jobject jcallback) { + jstring jdumpPath, jobject jcallback) { const auto game_path = Common::Android::GetJString(env, jgamePath); const auto program_id = EmulationSession::GetProgramId(env, jprogramId); @@ -1069,10 +1087,25 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpExeFS(JNIEnv* env, jobjec return false; } - // Get dump directory - const auto dump_dir = system.GetFileSystemController().GetModificationDumpRoot(title_id); + // Use custom dump path if provided, otherwise use default + FileSys::VirtualDir dump_dir; + if (jdumpPath != nullptr) { + const auto custom_path = Common::Android::GetJString(env, jdumpPath); + if (!custom_path.empty() && custom_path[0] == '/') { + // Create a real VFS directory from the custom path (native path only) + const auto custom_dir = std::make_shared( + std::filesystem::path(custom_path)); + if (custom_dir && custom_dir->IsWritable()) { + dump_dir = custom_dir; + } + } + } if (!dump_dir) { - return false; + // Fall back to default dump directory + dump_dir = system.GetFileSystemController().GetModificationDumpRoot(title_id); + if (!dump_dir) { + return false; + } } const auto exefs_dir = FileSys::GetOrCreateDirectoryRelative(dump_dir, "/exefs"); diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 74e307a9b..461436a3d 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -153,6 +153,9 @@ Extracting ExeFS… Extraction completed successfully Extraction failed + Choose where to save the extracted files. Select a location or use the default dump directory. + Select Location + Use Default Applet launcher