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 <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2025-11-29 09:20:40 +10:00
parent 261bab49a7
commit 9c250aabda
4 changed files with 177 additions and 40 deletions

View File

@@ -470,12 +470,14 @@ object NativeLibrary {
* Dumps the RomFS from a game to the dump directory * Dumps the RomFS from a game to the dump directory
* @param gamePath Path to the game file * @param gamePath Path to the game file
* @param programId String representation of the game's program ID * @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) * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long)
* @return true if successful, false otherwise * @return true if successful, false otherwise
*/ */
external fun dumpRomFS( external fun dumpRomFS(
gamePath: String, gamePath: String,
programId: String, programId: String,
dumpPath: String?,
callback: (max: Long, progress: Long) -> Boolean callback: (max: Long, progress: Long) -> Boolean
): Boolean ): Boolean
@@ -483,12 +485,14 @@ object NativeLibrary {
* Dumps the ExeFS from a game to the dump directory * Dumps the ExeFS from a game to the dump directory
* @param gamePath Path to the game file * @param gamePath Path to the game file
* @param programId String representation of the game's program ID * @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) * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long)
* @return true if successful, false otherwise * @return true if successful, false otherwise
*/ */
external fun dumpExeFS( external fun dumpExeFS(
gamePath: String, gamePath: String,
programId: String, programId: String,
dumpPath: String?,
callback: (max: Long, progress: Long) -> Boolean callback: (max: Long, progress: Long) -> Boolean
): Boolean ): Boolean
} }

View File

@@ -40,6 +40,7 @@ import org.citron.citron_emu.model.InstallableProperty
import org.citron.citron_emu.model.SubmenuProperty import org.citron.citron_emu.model.SubmenuProperty
import org.citron.citron_emu.model.TaskState import org.citron.citron_emu.model.TaskState
import org.citron.citron_emu.utils.DirectoryInitialization 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.FileUtil
import org.citron.citron_emu.utils.GameIconUtils import org.citron.citron_emu.utils.GameIconUtils
import org.citron.citron_emu.utils.GpuDriverHelper 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.marquee
import org.citron.citron_emu.utils.ViewUtils.updateMargins import org.citron.citron_emu.utils.ViewUtils.updateMargins
import org.citron.citron_emu.utils.collect import org.citron.citron_emu.utils.collect
import androidx.documentfile.provider.DocumentFile
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
@@ -283,25 +285,23 @@ class GamePropertiesFragment : Fragment() {
R.string.dump_romfs_description, R.string.dump_romfs_description,
R.drawable.ic_save R.drawable.ic_save
) { ) {
ProgressDialogFragment.newInstance( // Show dialog to select dump location or use default
MessageDialogFragment.newInstance(
requireActivity(), requireActivity(),
R.string.dump_romfs_extracting, titleId = R.string.dump_romfs,
false descriptionId = R.string.select_dump_location_description,
) { _, _ -> positiveButtonTitleId = R.string.select_location,
val success = NativeLibrary.dumpRomFS( negativeButtonTitleId = R.string.use_default_location,
args.game.path, positiveAction = {
args.game.programIdHex, // User wants to select a custom location
{ max, progress -> pendingDumpType = "romfs"
// Progress callback - return true to cancel selectDumpDirectory.launch(null)
false },
} negativeAction = {
) // Use default location
if (success) { performRomFSDump(null)
getString(R.string.dump_success)
} else {
getString(R.string.dump_failed)
} }
}.show(parentFragmentManager, ProgressDialogFragment.TAG) ).show(parentFragmentManager, MessageDialogFragment.TAG)
} }
) )
@@ -311,25 +311,23 @@ class GamePropertiesFragment : Fragment() {
R.string.dump_exefs_description, R.string.dump_exefs_description,
R.drawable.ic_save R.drawable.ic_save
) { ) {
ProgressDialogFragment.newInstance( // Show dialog to select dump location or use default
MessageDialogFragment.newInstance(
requireActivity(), requireActivity(),
R.string.dump_exefs_extracting, titleId = R.string.dump_exefs,
false descriptionId = R.string.select_dump_location_description,
) { _, _ -> positiveButtonTitleId = R.string.select_location,
val success = NativeLibrary.dumpExeFS( negativeButtonTitleId = R.string.use_default_location,
args.game.path, positiveAction = {
args.game.programIdHex, // User wants to select a custom location
{ max, progress -> pendingDumpType = "exefs"
// Progress callback - return true to cancel selectDumpDirectory.launch(null)
false },
} negativeAction = {
) // Use default location
if (success) { performExeFSDump(null)
getString(R.string.dump_success)
} else {
getString(R.string.dump_failed)
} }
}.show(parentFragmentManager, ProgressDialogFragment.TAG) ).show(parentFragmentManager, MessageDialogFragment.TAG)
} }
) )
} }
@@ -387,6 +385,22 @@ class GamePropertiesFragment : Fragment() {
windowInsets 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 = private val importSaves =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null) { if (result == null) {
@@ -480,4 +494,87 @@ class GamePropertiesFragment : Fragment() {
} }
}.show(parentFragmentManager, ProgressDialogFragment.TAG) }.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)
}
} }

View File

@@ -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, jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobject jobj,
jstring jgamePath, jstring jprogramId, jstring jgamePath, jstring jprogramId,
jobject jcallback) { jstring jdumpPath, jobject jcallback) {
const auto game_path = Common::Android::GetJString(env, jgamePath); const auto game_path = Common::Android::GetJString(env, jgamePath);
const auto program_id = EmulationSession::GetProgramId(env, jprogramId); 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; 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 romfs_dir = fmt::format("{:016X}/romfs", title_id);
const auto path = dump_dir / romfs_dir; 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, jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpExeFS(JNIEnv* env, jobject jobj,
jstring jgamePath, jstring jprogramId, jstring jgamePath, jstring jprogramId,
jobject jcallback) { jstring jdumpPath, jobject jcallback) {
const auto game_path = Common::Android::GetJString(env, jgamePath); const auto game_path = Common::Android::GetJString(env, jgamePath);
const auto program_id = EmulationSession::GetProgramId(env, jprogramId); 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; return false;
} }
// Get dump directory // Use custom dump path if provided, otherwise use default
const auto dump_dir = system.GetFileSystemController().GetModificationDumpRoot(title_id); 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<FileSys::RealVfsDirectory>(
std::filesystem::path(custom_path));
if (custom_dir && custom_dir->IsWritable()) {
dump_dir = custom_dir;
}
}
}
if (!dump_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"); const auto exefs_dir = FileSys::GetOrCreateDirectoryRelative(dump_dir, "/exefs");

View File

@@ -153,6 +153,9 @@
<string name="dump_exefs_extracting">Extracting ExeFS…</string> <string name="dump_exefs_extracting">Extracting ExeFS…</string>
<string name="dump_success">Extraction completed successfully</string> <string name="dump_success">Extraction completed successfully</string>
<string name="dump_failed">Extraction failed</string> <string name="dump_failed">Extraction failed</string>
<string name="select_dump_location_description">Choose where to save the extracted files. Select a location or use the default dump directory.</string>
<string name="select_location">Select Location</string>
<string name="use_default_location">Use Default</string>
<!-- Applet launcher strings --> <!-- Applet launcher strings -->
<string name="applets">Applet launcher</string> <string name="applets">Applet launcher</string>