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
* @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
}

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.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)
}
}

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,
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<FileSys::RealVfsDirectory>(
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");

View File

@@ -153,6 +153,9 @@
<string name="dump_exefs_extracting">Extracting ExeFS…</string>
<string name="dump_success">Extraction completed successfully</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 -->
<string name="applets">Applet launcher</string>