aboutsummaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorkleidis <kleidis1@protonmail.com>2025-12-31 21:20:30 +0100
committercrueter <crueter@eden-emu.dev>2025-12-31 21:20:30 +0100
commitb0cd47c005d80d07f6924fd514dd582afa27b25d (patch)
treedb5b27ac9ed4880a272bdf21b45017872b5af7be /src/android
parent18af560a43ba0d2862ce8781fe1566766bc0dd0a (diff)
[qt, android] Implement custom save path setting and migration + Implement custom path settings for Android (#3154)
Needs careful review and especially testing Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3154 Reviewed-by: DraVee <dravee@eden-emu.dev> Reviewed-by: MaranBr <maranbr@eden-emu.dev> Co-authored-by: kleidis <kleidis1@protonmail.com> Co-committed-by: kleidis <kleidis1@protonmail.com>
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/src/main/AndroidManifest.xml3
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt1
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/PathSetting.kt40
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt1
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt16
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt238
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt50
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt22
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt64
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt7
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt21
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt97
-rw-r--r--src/android/app/src/main/jni/android_config.cpp39
-rw-r--r--src/android/app/src/main/jni/native.cpp6
-rw-r--r--src/android/app/src/main/jni/native_config.cpp36
-rw-r--r--src/android/app/src/main/res/values/strings.xml28
18 files changed, 673 insertions, 13 deletions
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 8136df60e8..13007f10e4 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
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
+ <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<application
android:name="org.yuzu.yuzu_emu.YuzuApplication"
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
index d979171334..df96b17bec 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
@@ -25,6 +25,7 @@ object Settings {
SECTION_INPUT_PLAYER_SEVEN,
SECTION_INPUT_PLAYER_EIGHT,
SECTION_APP_SETTINGS(R.string.app_settings),
+ SECTION_CUSTOM_PATHS(R.string.preferences_custom_paths),
SECTION_DEBUG(R.string.preferences_debug),
SECTION_APPLETS(R.string.applets_menu);
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/PathSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/PathSetting.kt
new file mode 100644
index 0000000000..b5407f0cc0
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/PathSetting.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+
+class PathSetting(
+ @StringRes titleId: Int = 0,
+ titleString: String = "",
+ @StringRes descriptionId: Int = 0,
+ descriptionString: String = "",
+ @DrawableRes val iconId: Int = 0,
+ val pathType: PathType,
+ val defaultPathGetter: () -> 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 = 14
+ }
+}
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 ba92ce21ba..da606274eb 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
@@ -98,6 +98,7 @@ abstract class SettingsItem(
const val TYPE_STRING_INPUT = 11
const val TYPE_SPINBOX = 12
const val TYPE_LAUNCHABLE = 13
+ const val TYPE_PATH = 14
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 576af8ece8..fac67dbb64 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
@@ -96,6 +96,10 @@ class SettingsAdapter(
SettingsItem.TYPE_LAUNCHABLE -> {
LaunchableViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
+
+ SettingsItem.TYPE_PATH -> {
+ PathViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
else -> {
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
}
@@ -450,6 +454,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<SettingsItem>() {
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..2f527b5f62 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,199 @@ 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 rawPath = PathUtil.getPathFromUri(uri)
+ if (rawPath != null) {
+ handleSelectedPath(pathSetting, rawPath)
+ } 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()
+
+ NativeConfig.reloadGlobalConfig()
+
+ 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) { _, _ ->
+ // just dismiss
+ }
+ .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 ca5df58fe8..80b75aed96 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
@@ -29,6 +29,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_APP_SETTINGS -> addThemeSettings(sl)
MenuTag.SECTION_DEBUG -> addDebugSettings(sl)
MenuTag.SECTION_APPLETS -> addAppletSettings(sl)
+ MenuTag.SECTION_CUSTOM_PATHS -> addCustomPathsSettings(sl)
}
settingsList = sl
adapter.submitList(settingsList) {
@@ -187,6 +189,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,
@@ -1182,4 +1194,42 @@ class SettingsFragmentPresenter(
add(IntSetting.DEBUG_KNOBS.key)
}
}
+
+ private fun addCustomPathsSettings(sl: ArrayList<SettingsItem>) {
+ 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/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
index c02411d1bb..1b94d5f1a6 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: 2025 Eden Emulator Project
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.fragments
@@ -31,6 +31,7 @@ import org.yuzu.yuzu_emu.model.TaskState
import org.yuzu.yuzu_emu.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.NativeConfig
import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins
import org.yuzu.yuzu_emu.utils.collect
import java.io.BufferedOutputStream
@@ -99,11 +100,11 @@ class InstallableFragment : Fragment() {
},
export = {
val oldSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(false)
)
val futureSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(true)
)
if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) {
@@ -213,7 +214,7 @@ class InstallableFragment : Fragment() {
}
val internalSaveFolder = File(
- "${DirectoryInitialization.userDirectory}/nand$baseSaveDir"
+ "${NativeConfig.getSaveDir()}$baseSaveDir"
)
internalSaveFolder.deleteRecursively()
internalSaveFolder.mkdir()
@@ -290,7 +291,7 @@ class InstallableFragment : Fragment() {
cacheSaveDir.mkdir()
val oldSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(false)
)
if (oldSaveDataFolder.exists()) {
@@ -298,7 +299,7 @@ class InstallableFragment : Fragment() {
}
val futureSaveDataFolder = File(
- "${DirectoryInitialization.userDirectory}/nand" +
+ NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(true)
)
if (futureSaveDataFolder.exists()) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
index 6859b77806..799708dfa7 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -15,6 +18,7 @@ import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.NativeConfig
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -57,8 +61,7 @@ class Game(
}.zip"
val saveDir: String
- get() = DirectoryInitialization.userDirectory + "/nand" +
- NativeLibrary.getSavePath(programId)
+ get() = NativeConfig.getSaveDir() + NativeLibrary.getSavePath(programId)
val addonDir: String
get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/"
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index 538d8f6e49..23716ac5a5 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -456,7 +456,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
val firmwarePath =
- File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
+ File(NativeConfig.getNandDir() + "/system/Contents/registered/")
val cacheFirmwareDir = File("${cacheDir.path}/registered/")
ProgressDialogFragment.newInstance(
@@ -499,7 +499,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
fun uninstallFirmware() {
val firmwarePath =
- File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
+ File(NativeConfig.getNandDir() + "/system/Contents/registered/")
ProgressDialogFragment.newInstance(
this,
R.string.firmware_uninstalling
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..d53672af26 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
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -183,4 +186,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..a840b3b846
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt
@@ -0,0 +1,97 @@
+// 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.substringAfter(":")
+ val primaryStoragePath = android.os.Environment.getExternalStorageDirectory().absolutePath
+ return "$primaryStoragePath/$relativePath"
+ }
+
+ // external SD cards and other volumes)
+ val storageIdString = docId.substringBefore(":")
+ val removablePath = getRemovableStoragePath(storageIdString)
+ if (removablePath != null) {
+ return "$removablePath/${docId.substringAfter(":")}"
+ }
+
+ 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
+ }
+ }
+
+ // This really shouldn't be necessary, but the Android API seemingly
+ // doesn't have a way of doing this?
+ // Apparently, on certain devices the mount location can vary, so add
+ // extra cases here if we discover any new ones.
+ fun getRemovableStoragePath(idString: String): String? {
+ var pathFile: File
+
+ pathFile = File("/mnt/media_rw/$idString");
+ if (pathFile.exists()) {
+ return pathFile.absolutePath
+ }
+
+ return null
+ }
+}
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 <common/fs/path_util.h>
#include <common/logging/log.h>
#include <input_common/main.h>
#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.cpp b/src/android/app/src/main/jni/native.cpp
index 9d2a76566c..d2daef4eb2 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -1389,12 +1389,12 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject j
const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
ASSERT(user_id);
- const auto nandDir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir);
- auto vfsNandDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(nandDir),
+ const auto saveDir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir);
+ auto vfsSaveDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(saveDir),
FileSys::OpenMode::Read);
const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
- {}, vfsNandDir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, program_id,
+ {}, vfsSaveDir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, program_id,
user_id->AsU128(), 0);
return Common::Android::ToJString(env, user_save_data_path);
}
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 <string>
#include <jni.h>
+#include <common/fs/path_util.h>
#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 f0fef3b1bb..117e7397e0 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -639,6 +639,7 @@
<!-- Miscellaneous -->
<string name="slider_default">Default</string>
+ <string name="default_string">Default</string>
<string name="loading">Loading…</string>
<string name="shutting_down">Shutting down…</string>
<string name="reset_setting_confirmation">Do you want to reset this setting back to its default value?</string>
@@ -714,6 +715,33 @@
<string name="preferences_player">Player %d</string>
<string name="preferences_debug">Debug</string>
<string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string>
+ <string name="preferences_custom_paths">Custom Paths</string>
+ <string name="preferences_custom_paths_description">Save data directory</string>
+
+ <!-- Custom Paths settings -->
+ <string name="custom_save_directory">Save Data Directory</string>
+ <string name="custom_save_directory_description">Set a custom path for save data storage</string>
+ <string name="select_directory">Select Directory</string>
+ <string name="choose_save_directory_action">Choose an action for the save directory:</string>
+ <string name="set_custom_path">Set Custom Path</string>
+ <string name="reset_to_nand">Reset to Default</string>
+ <string name="migrate_save_data">Migrate Save Data</string>
+ <string name="migrate_save_data_question">Do you want to migrate existing save data to the new location?</string>
+ <string name="migrate_save_data_description">This will copy your save files from the old location to the new one.</string>
+ <string name="migrating_save_data">Migrating save data…</string>
+ <string name="save_migration_complete">Save data migrated successfully</string>
+ <string name="save_migration_failed">Save data migration failed</string>
+ <string name="save_directory_set">Save directory set</string>
+ <string name="save_directory_reset">Save directory reset to default</string>
+ <string name="destination_has_saves">The destination already contains data. Do you want to overwrite it?</string>
+ <string name="all_files_permission_required">All Files Access permission is required for custom paths</string>
+ <string name="grant_permission">Grant Permission</string>
+ <string name="custom_nand_directory">NAND Directory</string>
+ <string name="custom_nand_directory_description">Set a custom path for NAND storage</string>
+ <string name="custom_sdmc_directory">SD Card Directory</string>
+ <string name="custom_sdmc_directory_description">Set a custom path for virtual SD card storage</string>
+ <string name="path_set">Path set successfully</string>
+ <string name="skip_migration">Skip</string>
<!-- Game properties -->
<string name="info">Info</string>