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