diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 3b236c8765..052d274814 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -29,6 +29,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
+
+
+
String,
+ val currentPathGetter: () -> String,
+ val pathSetter: (String) -> Unit
+) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) {
+
+ override val type = TYPE_PATH
+
+ enum class PathType {
+ SAVE_DATA,
+ NAND,
+ SDMC
+ }
+
+ fun getCurrentPath(): String = currentPathGetter()
+
+ fun getDefaultPath(): String = defaultPathGetter()
+
+ fun setPath(path: String) = pathSetter(path)
+
+ fun isUsingDefaultPath(): Boolean = getCurrentPath() == getDefaultPath()
+
+ companion object {
+ const val TYPE_PATH = 13
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
index b1fe56a866..25c0747d95 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
@@ -97,6 +97,7 @@ abstract class SettingsItem(
const val TYPE_INPUT_PROFILE = 10
const val TYPE_STRING_INPUT = 11
const val TYPE_SPINBOX = 12
+ const val TYPE_PATH = 13
const val FASTMEM_COMBINED = "fastmem_combined"
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
index 71a3e54cb3..248b748e58 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
@@ -93,6 +93,10 @@ class SettingsAdapter(
StringInputViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
+ SettingsItem.TYPE_PATH -> {
+ PathViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
else -> {
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
}
@@ -442,6 +446,18 @@ class SettingsAdapter(
settingsViewModel.setShouldReloadSettingsList(true)
}
+ fun onPathClick(item: PathSetting, position: Int) {
+ settingsViewModel.clickedItem = item
+ settingsViewModel.setPathSettingPosition(position)
+ settingsViewModel.setShouldShowPathPicker(true)
+ }
+
+ fun onPathReset(item: PathSetting, position: Int) {
+ settingsViewModel.clickedItem = item
+ settingsViewModel.setPathSettingPosition(position)
+ settingsViewModel.setShouldShowPathResetDialog(true)
+ }
+
private class DiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean {
return oldItem.setting.key == newItem.setting.key
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
index b2fde638db..fd0db1618f 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
@@ -7,10 +7,16 @@
package org.yuzu.yuzu_emu.features.settings.ui
import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Build
import android.os.Bundle
+import android.os.Environment
+import android.provider.Settings as AndroidSettings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
@@ -19,14 +25,19 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
import org.yuzu.yuzu_emu.features.input.NativeInput
import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.view.PathSetting
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
+import org.yuzu.yuzu_emu.utils.PathUtil
import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins
import org.yuzu.yuzu_emu.utils.*
+import java.io.File
+import androidx.core.net.toUri
class SettingsFragment : Fragment() {
private lateinit var presenter: SettingsFragmentPresenter
@@ -39,6 +50,20 @@ class SettingsFragment : Fragment() {
private val settingsViewModel: SettingsViewModel by activityViewModels()
+ private val requestAllFilesPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ if (hasAllFilesPermission()) {
+ showPathPickerDialog()
+ } else {
+ Toast.makeText(
+ requireContext(),
+ R.string.all_files_permission_required,
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
@@ -134,6 +159,24 @@ class SettingsFragment : Fragment() {
}
}
+ settingsViewModel.shouldShowPathPicker.collect(
+ viewLifecycleOwner,
+ resetState = { settingsViewModel.setShouldShowPathPicker(false) }
+ ) {
+ if (it) {
+ handlePathPickerRequest()
+ }
+ }
+
+ settingsViewModel.shouldShowPathResetDialog.collect(
+ viewLifecycleOwner,
+ resetState = { settingsViewModel.setShouldShowPathResetDialog(false) }
+ ) {
+ if (it) {
+ showPathResetDialog()
+ }
+ }
+
if (args.menuTag == Settings.MenuTag.SECTION_ROOT) {
binding.toolbarSettings.inflateMenu(R.menu.menu_settings)
binding.toolbarSettings.setOnMenuItemClickListener {
@@ -184,4 +227,197 @@ class SettingsFragment : Fragment() {
windowInsets
}
}
+
+ private fun hasAllFilesPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Environment.isExternalStorageManager()
+ } else {
+ true
+ }
+ }
+
+ private fun requestAllFilesPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val intent = Intent(AndroidSettings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
+ intent.data = "package:${requireContext().packageName}".toUri()
+ requestAllFilesPermissionLauncher.launch(intent)
+ }
+ }
+
+ private fun handlePathPickerRequest() {
+ if (!hasAllFilesPermission()) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.all_files_permission_required)
+ .setMessage(R.string.all_files_permission_required)
+ .setPositiveButton(R.string.grant_permission) { _, _ ->
+ requestAllFilesPermission()
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ return
+ }
+ showPathPickerDialog()
+ }
+
+ private fun showPathPickerDialog() {
+ directoryPickerLauncher.launch(null)
+ }
+
+ private val directoryPickerLauncher = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) { uri ->
+ if (uri != null) {
+ val pathSetting = settingsViewModel.clickedItem as? PathSetting ?: return@registerForActivityResult
+ val realPath = PathUtil.getPathFromUri(uri)
+ if (realPath != null) {
+ handleSelectedPath(pathSetting, realPath)
+ } else {
+ Toast.makeText(
+ requireContext(),
+ R.string.invalid_directory,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+
+ private fun handleSelectedPath(pathSetting: PathSetting, path: String) {
+ if (!PathUtil.validateDirectory(path)) {
+ Toast.makeText(
+ requireContext(),
+ R.string.invalid_directory,
+ Toast.LENGTH_SHORT
+ ).show()
+ return
+ }
+
+ if (pathSetting.pathType == PathSetting.PathType.SAVE_DATA) {
+ val oldPath = pathSetting.getCurrentPath()
+ if (oldPath != path) {
+ promptSaveMigration(pathSetting, oldPath, path)
+ }
+ } else {
+ setPathAndNotify(pathSetting, path)
+ }
+ }
+
+ private fun promptSaveMigration(pathSetting: PathSetting, fromPath: String, toPath: String) {
+ val sourceSavePath = "$fromPath/user/save"
+ val destSavePath = "$toPath/user/save"
+ val sourceSaveDir = File(sourceSavePath)
+ val destSaveDir = File(destSavePath)
+
+ val sourceHasSaves = PathUtil.hasContent(sourceSavePath)
+ val destHasSaves = PathUtil.hasContent(destSavePath)
+
+ if (!sourceHasSaves) {
+ setPathAndNotify(pathSetting, toPath)
+ return
+ }
+
+ if (destHasSaves) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.migrate_save_data)
+ .setMessage(R.string.destination_has_saves)
+ .setPositiveButton(R.string.confirm) { _, _ ->
+ migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, toPath)
+ }
+ .setNegativeButton(R.string.skip_migration) { _, _ ->
+ setPathAndNotify(pathSetting, toPath)
+ }
+ .setNeutralButton(R.string.cancel, null)
+ .show()
+ } else {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.migrate_save_data)
+ .setMessage(R.string.migrate_save_data_question)
+ .setPositiveButton(R.string.confirm) { _, _ ->
+ migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, toPath)
+ }
+ .setNegativeButton(R.string.skip_migration) { _, _ ->
+ setPathAndNotify(pathSetting, toPath)
+ }
+ .setNeutralButton(R.string.cancel, null)
+ .show()
+ }
+ }
+
+ private fun migrateSaveData(
+ pathSetting: PathSetting,
+ sourceDir: File,
+ destDir: File,
+ newPath: String
+ ) {
+ Thread {
+ val success = PathUtil.copyDirectory(sourceDir, destDir, overwrite = true)
+
+ requireActivity().runOnUiThread {
+ if (success) {
+ setPathAndNotify(pathSetting, newPath)
+ Toast.makeText(
+ requireContext(),
+ R.string.save_migration_complete,
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ requireContext(),
+ R.string.save_migration_failed,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }.start()
+ }
+
+ private fun setPathAndNotify(pathSetting: PathSetting, path: String) {
+ pathSetting.setPath(path)
+ NativeConfig.saveGlobalConfig()
+
+ val messageResId = if (pathSetting.pathType == PathSetting.PathType.SAVE_DATA) {
+ R.string.save_directory_set
+ } else {
+ R.string.path_set
+ }
+
+ Toast.makeText(
+ requireContext(),
+ messageResId,
+ Toast.LENGTH_SHORT
+ ).show()
+
+ val position = settingsViewModel.pathSettingPosition.value
+ if (position >= 0) {
+ settingsAdapter?.notifyItemChanged(position)
+ }
+ }
+
+ private fun showPathResetDialog() {
+ val pathSetting = settingsViewModel.clickedItem as? PathSetting ?: return
+
+ if (pathSetting.isUsingDefaultPath()) {
+ return
+ }
+
+ val currentPath = pathSetting.getCurrentPath()
+ val defaultPath = pathSetting.getDefaultPath()
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.reset_to_nand)
+ .setMessage(R.string.migrate_save_data_question)
+ .setPositiveButton(R.string.confirm) { _, _ ->
+ val sourceSaveDir = File(currentPath, "user/save")
+ val destSaveDir = File(defaultPath, "user/save")
+
+ if (sourceSaveDir.exists() && sourceSaveDir.listFiles()?.isNotEmpty() == true) {
+ migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, defaultPath)
+ } else {
+ setPathAndNotify(pathSetting, defaultPath)
+ }
+ }
+ .setNegativeButton(R.string.cancel) { _, _ ->
+ setPathAndNotify(pathSetting, defaultPath)
+ }
+ .show()
+ }
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
index 80b6ddb7b2..a334e50d4e 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
@@ -28,6 +28,7 @@ import org.yuzu.yuzu_emu.features.settings.model.StringSetting
import org.yuzu.yuzu_emu.features.settings.model.view.*
import org.yuzu.yuzu_emu.utils.InputHandler
import org.yuzu.yuzu_emu.utils.NativeConfig
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
@@ -109,6 +110,7 @@ class SettingsFragmentPresenter(
MenuTag.SECTION_DEBUG -> addDebugSettings(sl)
MenuTag.SECTION_EDEN_VEIL -> addEdenVeilSettings(sl)
MenuTag.SECTION_APPLETS -> addAppletSettings(sl)
+ MenuTag.SECTION_CUSTOM_PATHS -> addCustomPathsSettings(sl)
}
settingsList = sl
adapter.submitList(settingsList) {
@@ -195,6 +197,16 @@ class SettingsFragmentPresenter(
menuKey = MenuTag.SECTION_APPLETS
)
)
+ if (!NativeConfig.isPerGameConfigLoaded()) {
+ add(
+ SubmenuSetting(
+ titleId = R.string.preferences_custom_paths,
+ descriptionId = R.string.preferences_custom_paths_description,
+ iconId = R.drawable.ic_folder_open,
+ menuKey = MenuTag.SECTION_CUSTOM_PATHS
+ )
+ )
+ }
add(
RunnableSetting(
titleId = R.string.reset_to_default,
@@ -1172,4 +1184,42 @@ class SettingsFragmentPresenter(
add(IntSetting.DEBUG_KNOBS.key)
}
}
+
+ private fun addCustomPathsSettings(sl: ArrayList) {
+ sl.apply {
+ add(
+ PathSetting(
+ titleId = R.string.custom_save_directory,
+ descriptionId = R.string.custom_save_directory_description,
+ iconId = R.drawable.ic_save,
+ pathType = PathSetting.PathType.SAVE_DATA,
+ defaultPathGetter = { NativeConfig.getDefaultSaveDir() },
+ currentPathGetter = { NativeConfig.getSaveDir() },
+ pathSetter = { path -> NativeConfig.setSaveDir(path) }
+ )
+ )
+ add(
+ PathSetting(
+ titleId = R.string.custom_nand_directory,
+ descriptionId = R.string.custom_nand_directory_description,
+ iconId = R.drawable.ic_folder_open,
+ pathType = PathSetting.PathType.NAND,
+ defaultPathGetter = { DirectoryInitialization.userDirectory + "/nand" },
+ currentPathGetter = { NativeConfig.getNandDir() },
+ pathSetter = { path -> NativeConfig.setNandDir(path) }
+ )
+ )
+ add(
+ PathSetting(
+ titleId = R.string.custom_sdmc_directory,
+ descriptionId = R.string.custom_sdmc_directory_description,
+ iconId = R.drawable.ic_folder_open,
+ pathType = PathSetting.PathType.SDMC,
+ defaultPathGetter = { DirectoryInitialization.userDirectory + "/sdmc" },
+ currentPathGetter = { NativeConfig.getSdmcDir() },
+ pathSetter = { path -> NativeConfig.setSdmcDir(path) }
+ )
+ )
+ }
+ }
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt
index d47e33244e..b1914c3169 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt
@@ -59,6 +59,16 @@ class SettingsViewModel : ViewModel() {
private val _shouldRecreateForLanguageChange = MutableStateFlow(false)
val shouldRecreateForLanguageChange = _shouldRecreateForLanguageChange.asStateFlow()
+
+ private val _shouldShowPathPicker = MutableStateFlow(false)
+ val shouldShowPathPicker = _shouldShowPathPicker.asStateFlow()
+
+ private val _shouldShowPathResetDialog = MutableStateFlow(false)
+ val shouldShowPathResetDialog = _shouldShowPathResetDialog.asStateFlow()
+
+ private val _pathSettingPosition = MutableStateFlow(-1)
+ val pathSettingPosition = _pathSettingPosition.asStateFlow()
+
fun setShouldRecreate(value: Boolean) {
_shouldRecreate.value = value
}
@@ -112,6 +122,18 @@ class SettingsViewModel : ViewModel() {
_shouldRecreateForLanguageChange.value = value
}
+ fun setShouldShowPathPicker(value: Boolean) {
+ _shouldShowPathPicker.value = value
+ }
+
+ fun setShouldShowPathResetDialog(value: Boolean) {
+ _shouldShowPathResetDialog.value = value
+ }
+
+ fun setPathSettingPosition(value: Int) {
+ _pathSettingPosition.value = value
+ }
+
fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage =
try {
InputHandler.registeredControllers[currentDevice]
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt
new file mode 100644
index 0000000000..7e0517a6dd
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import androidx.core.content.res.ResourcesCompat
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.PathSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import org.yuzu.yuzu_emu.utils.PathUtil
+import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible
+
+class PathViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+
+ private lateinit var setting: PathSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as PathSetting
+ binding.icon.setVisible(setting.iconId != 0)
+ if (setting.iconId != 0) {
+ binding.icon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ binding.icon.resources,
+ setting.iconId,
+ binding.icon.context.theme
+ )
+ )
+ }
+
+ binding.textSettingName.text = setting.title
+ binding.textSettingDescription.setVisible(setting.description.isNotEmpty())
+ binding.textSettingDescription.text = setting.description
+
+ val currentPath = setting.getCurrentPath()
+ val displayPath = PathUtil.truncatePathForDisplay(currentPath)
+
+ binding.textSettingValue.setVisible(true)
+ binding.textSettingValue.text = if (setting.isUsingDefaultPath()) {
+ binding.root.context.getString(R.string.default_string)
+ } else {
+ displayPath
+ }
+
+ binding.buttonClear.setVisible(!setting.isUsingDefaultPath())
+ binding.buttonClear.text = binding.root.context.getString(R.string.reset_to_default)
+ binding.buttonClear.setOnClickListener {
+ adapter.onPathReset(setting, bindingAdapterPosition)
+ }
+
+ setStyle(true, binding)
+ }
+
+ override fun onClick(clicked: View) {
+ adapter.onPathClick(setting, bindingAdapterPosition)
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
index 7228f25d24..8ac9964f57 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
@@ -183,4 +183,22 @@ object NativeConfig {
*/
@Synchronized
external fun saveControlPlayerValues()
+
+ /**
+ * Directory paths getters and setters
+ */
+ @Synchronized
+ external fun getSaveDir(): String
+ @Synchronized
+ external fun getDefaultSaveDir(): String
+ @Synchronized
+ external fun setSaveDir(path: String)
+ @Synchronized
+ external fun getNandDir(): String
+ @Synchronized
+ external fun setNandDir(path: String)
+ @Synchronized
+ external fun getSdmcDir(): String
+ @Synchronized
+ external fun setSdmcDir(path: String)
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt
new file mode 100644
index 0000000000..cc97723f80
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt
@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.net.Uri
+import android.provider.DocumentsContract
+import java.io.File
+
+object PathUtil {
+
+ /**
+ * Converts a content:// URI from the Storage Access Framework to a real filesystem path.
+ *
+ */
+ fun getPathFromUri(uri: Uri): String? {
+ val docId = try {
+ DocumentsContract.getTreeDocumentId(uri)
+ } catch (_: Exception) {
+ return null
+ }
+
+ if (docId.startsWith("primary:")) {
+ val relativePath = docId.removePrefix("primary:")
+ return "/storage/emulated/0/$relativePath"
+ }
+
+ // external SD cards and other volumes)
+ val split = docId.split(":")
+ if (split.size >= 2) {
+ val volumeId = split[0]
+ val relativePath = split.getOrElse(1) { "" }
+ val possiblePaths = listOf(
+ "/storage/$volumeId/$relativePath",
+ "/mnt/media_rw/$volumeId/$relativePath"
+ )
+ for (path in possiblePaths) {
+ val file = File(path)
+ if (file.exists() && file.isDirectory) {
+ return path
+ }
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Validates that a path is a valid, writable directory.
+ * Creates the directory if it doesn't exist.
+ */
+ fun validateDirectory(path: String): Boolean {
+ val dir = File(path)
+
+ if (!dir.exists()) {
+ if (!dir.mkdirs()) {
+ return false
+ }
+ }
+
+ return dir.isDirectory && dir.canWrite()
+ }
+
+ /**
+ * Copies a directory recursively from source to destination.
+ */
+ fun copyDirectory(source: File, destination: File, overwrite: Boolean = true): Boolean {
+ return try {
+ source.copyRecursively(destination, overwrite)
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ /**
+ * Checks if a directory has any content.
+ */
+ fun hasContent(path: String): Boolean {
+ val dir = File(path)
+ return dir.exists() && dir.listFiles()?.isNotEmpty() == true
+ }
+
+
+ fun truncatePathForDisplay(path: String, maxLength: Int = 40): String {
+ return if (path.length > maxLength) {
+ "...${path.takeLast(maxLength - 3)}"
+ } else {
+ path
+ }
+ }
+}
diff --git a/src/android/app/src/main/jni/android_config.cpp b/src/android/app/src/main/jni/android_config.cpp
index 41ac680d6b..7345a1893f 100644
--- a/src/android/app/src/main/jni/android_config.cpp
+++ b/src/android/app/src/main/jni/android_config.cpp
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
+#include
#include
#include
#include "android_config.h"
@@ -68,6 +69,24 @@ void AndroidConfig::ReadPathValues() {
}
EndArray();
+ const auto nand_dir_setting = ReadStringSetting(std::string("nand_directory"));
+ if (!nand_dir_setting.empty()) {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting);
+ }
+
+ const auto sdmc_dir_setting = ReadStringSetting(std::string("sdmc_directory"));
+ if (!sdmc_dir_setting.empty()) {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, sdmc_dir_setting);
+ }
+
+ const auto save_dir_setting = ReadStringSetting(std::string("save_directory"));
+ if (save_dir_setting.empty()) {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir));
+ } else {
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir, save_dir_setting);
+ }
+
EndGroup();
}
@@ -222,6 +241,26 @@ void AndroidConfig::SavePathValues() {
}
EndArray();
+ // Save custom NAND directory
+ const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir);
+ WriteStringSetting(std::string("nand_directory"), nand_path,
+ std::make_optional(std::string("")));
+
+ // Save custom SDMC directory
+ const auto sdmc_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir);
+ WriteStringSetting(std::string("sdmc_directory"), sdmc_path,
+ std::make_optional(std::string("")));
+
+ // Save custom save directory
+ const auto save_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir);
+ if (save_path == nand_path) {
+ WriteStringSetting(std::string("save_directory"), std::string(""),
+ std::make_optional(std::string("")));
+ } else {
+ WriteStringSetting(std::string("save_directory"), save_path,
+ std::make_optional(std::string("")));
+ }
+
EndGroup();
}
diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp
index e6021ed217..800f3e4569 100644
--- a/src/android/app/src/main/jni/native_config.cpp
+++ b/src/android/app/src/main/jni/native_config.cpp
@@ -4,6 +4,7 @@
#include
#include
+#include
#include "android_config.h"
#include "android_settings.h"
@@ -545,4 +546,39 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_saveControlPlayerValues(JNIEnv*
}
}
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getSaveDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir));
+}
+
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getDefaultSaveDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir));
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSaveDir(JNIEnv* env, jobject obj, jstring jpath) {
+ auto path = Common::Android::GetJString(env, jpath);
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir, path);
+}
+
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getNandDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir));
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setNandDir(JNIEnv* env, jobject obj, jstring jpath) {
+ auto path = Common::Android::GetJString(env, jpath);
+ Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, path);
+}
+
+jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getSdmcDir(JNIEnv* env, jobject obj) {
+ return Common::Android::ToJString(env,
+ Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir));
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSdmcDir(JNIEnv* env, jobject obj, jstring jpath) {
+ auto path = Common::Android::GetJString(env, jpath);
+ Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, path);
+}
+
} // extern "C"
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index ef8082a849..e1fb4bcb2a 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -621,6 +621,7 @@
Default
+ Default
Loading…
Shutting down…
Do you want to reset this setting back to its default value?
@@ -695,6 +696,33 @@
Player %d
Debug
CPU/GPU debugging, graphics API, fastmem
+ Custom Paths
+ Save data directory
+
+
+ Save Data Directory
+ Set a custom path for save data storage
+ Select Directory
+ Choose an action for the save directory:
+ Set Custom Path
+ Reset to Default
+ Migrate Save Data
+ Do you want to migrate existing save data to the new location?
+ This will copy your save files from the old location to the new one.
+ Migrating save data…
+ Save data migrated successfully
+ Save data migration failed
+ Save directory set
+ Save directory reset to default
+ The destination already contains save data. Do you want to overwrite it?
+ All Files Access permission is required for custom paths
+ Grant Permission
+ NAND Directory
+ Set a custom path for NAND storage
+ SD Card Directory
+ Set a custom path for virtual SD card storage
+ Path set successfully
+ Skip
Info