[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>
This commit is contained in:
kleidis 2025-12-31 21:20:30 +01:00 committed by crueter
parent 18af560a43
commit b0cd47c005
No known key found for this signature in database
GPG Key ID: 425ACD2D4830EBC6
28 changed files with 867 additions and 24 deletions

View File

@ -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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <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.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 <application
android:name="org.yuzu.yuzu_emu.YuzuApplication" android:name="org.yuzu.yuzu_emu.YuzuApplication"

View File

@ -25,6 +25,7 @@ object Settings {
SECTION_INPUT_PLAYER_SEVEN, SECTION_INPUT_PLAYER_SEVEN,
SECTION_INPUT_PLAYER_EIGHT, SECTION_INPUT_PLAYER_EIGHT,
SECTION_APP_SETTINGS(R.string.app_settings), SECTION_APP_SETTINGS(R.string.app_settings),
SECTION_CUSTOM_PATHS(R.string.preferences_custom_paths),
SECTION_DEBUG(R.string.preferences_debug), SECTION_DEBUG(R.string.preferences_debug),
SECTION_APPLETS(R.string.applets_menu); SECTION_APPLETS(R.string.applets_menu);
} }

View File

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

View File

@ -98,6 +98,7 @@ abstract class SettingsItem(
const val TYPE_STRING_INPUT = 11 const val TYPE_STRING_INPUT = 11
const val TYPE_SPINBOX = 12 const val TYPE_SPINBOX = 12
const val TYPE_LAUNCHABLE = 13 const val TYPE_LAUNCHABLE = 13
const val TYPE_PATH = 14
const val FASTMEM_COMBINED = "fastmem_combined" const val FASTMEM_COMBINED = "fastmem_combined"

View File

@ -96,6 +96,10 @@ class SettingsAdapter(
SettingsItem.TYPE_LAUNCHABLE -> { SettingsItem.TYPE_LAUNCHABLE -> {
LaunchableViewHolder(ListItemSettingBinding.inflate(inflater), this) LaunchableViewHolder(ListItemSettingBinding.inflate(inflater), this)
} }
SettingsItem.TYPE_PATH -> {
PathViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
else -> { else -> {
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
} }
@ -450,6 +454,18 @@ class SettingsAdapter(
settingsViewModel.setShouldReloadSettingsList(true) 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>() { private class DiffCallback : DiffUtil.ItemCallback<SettingsItem>() {
override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean { override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean {
return oldItem.setting.key == newItem.setting.key return oldItem.setting.key == newItem.setting.key

View File

@ -7,10 +7,16 @@
package org.yuzu.yuzu_emu.features.settings.ui package org.yuzu.yuzu_emu.features.settings.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.Settings as AndroidSettings
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -19,14 +25,19 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
import org.yuzu.yuzu_emu.features.input.NativeInput 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.Settings
import org.yuzu.yuzu_emu.features.settings.model.view.PathSetting
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment 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.ViewUtils.updateMargins
import org.yuzu.yuzu_emu.utils.* import org.yuzu.yuzu_emu.utils.*
import java.io.File
import androidx.core.net.toUri
class SettingsFragment : Fragment() { class SettingsFragment : Fragment() {
private lateinit var presenter: SettingsFragmentPresenter private lateinit var presenter: SettingsFragmentPresenter
@ -39,6 +50,20 @@ class SettingsFragment : Fragment() {
private val settingsViewModel: SettingsViewModel by activityViewModels() 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) 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) { if (args.menuTag == Settings.MenuTag.SECTION_ROOT) {
binding.toolbarSettings.inflateMenu(R.menu.menu_settings) binding.toolbarSettings.inflateMenu(R.menu.menu_settings)
binding.toolbarSettings.setOnMenuItemClickListener { binding.toolbarSettings.setOnMenuItemClickListener {
@ -184,4 +227,199 @@ class SettingsFragment : Fragment() {
windowInsets 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()
}
} }

View File

@ -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.features.settings.model.view.*
import org.yuzu.yuzu_emu.utils.InputHandler import org.yuzu.yuzu_emu.utils.InputHandler
import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.NativeConfig
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import androidx.core.content.edit import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
@ -109,6 +110,7 @@ class SettingsFragmentPresenter(
MenuTag.SECTION_APP_SETTINGS -> addThemeSettings(sl) MenuTag.SECTION_APP_SETTINGS -> addThemeSettings(sl)
MenuTag.SECTION_DEBUG -> addDebugSettings(sl) MenuTag.SECTION_DEBUG -> addDebugSettings(sl)
MenuTag.SECTION_APPLETS -> addAppletSettings(sl) MenuTag.SECTION_APPLETS -> addAppletSettings(sl)
MenuTag.SECTION_CUSTOM_PATHS -> addCustomPathsSettings(sl)
} }
settingsList = sl settingsList = sl
adapter.submitList(settingsList) { adapter.submitList(settingsList) {
@ -187,6 +189,16 @@ class SettingsFragmentPresenter(
menuKey = MenuTag.SECTION_APPLETS 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( add(
RunnableSetting( RunnableSetting(
titleId = R.string.reset_to_default, titleId = R.string.reset_to_default,
@ -1182,4 +1194,42 @@ class SettingsFragmentPresenter(
add(IntSetting.DEBUG_KNOBS.key) 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) }
)
)
}
}
} }

View File

@ -59,6 +59,16 @@ class SettingsViewModel : ViewModel() {
private val _shouldRecreateForLanguageChange = MutableStateFlow(false) private val _shouldRecreateForLanguageChange = MutableStateFlow(false)
val shouldRecreateForLanguageChange = _shouldRecreateForLanguageChange.asStateFlow() 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) { fun setShouldRecreate(value: Boolean) {
_shouldRecreate.value = value _shouldRecreate.value = value
} }
@ -112,6 +122,18 @@ class SettingsViewModel : ViewModel() {
_shouldRecreateForLanguageChange.value = value _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 = fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage =
try { try {
InputHandler.registeredControllers[currentDevice] InputHandler.registeredControllers[currentDevice]

View File

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

View File

@ -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 // SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.fragments 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.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.FileUtil 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.ViewUtils.updateMargins
import org.yuzu.yuzu_emu.utils.collect import org.yuzu.yuzu_emu.utils.collect
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
@ -99,11 +100,11 @@ class InstallableFragment : Fragment() {
}, },
export = { export = {
val oldSaveDataFolder = File( val oldSaveDataFolder = File(
"${DirectoryInitialization.userDirectory}/nand" + NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(false) NativeLibrary.getDefaultProfileSaveDataRoot(false)
) )
val futureSaveDataFolder = File( val futureSaveDataFolder = File(
"${DirectoryInitialization.userDirectory}/nand" + NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(true) NativeLibrary.getDefaultProfileSaveDataRoot(true)
) )
if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) { if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) {
@ -213,7 +214,7 @@ class InstallableFragment : Fragment() {
} }
val internalSaveFolder = File( val internalSaveFolder = File(
"${DirectoryInitialization.userDirectory}/nand$baseSaveDir" "${NativeConfig.getSaveDir()}$baseSaveDir"
) )
internalSaveFolder.deleteRecursively() internalSaveFolder.deleteRecursively()
internalSaveFolder.mkdir() internalSaveFolder.mkdir()
@ -290,7 +291,7 @@ class InstallableFragment : Fragment() {
cacheSaveDir.mkdir() cacheSaveDir.mkdir()
val oldSaveDataFolder = File( val oldSaveDataFolder = File(
"${DirectoryInitialization.userDirectory}/nand" + NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(false) NativeLibrary.getDefaultProfileSaveDataRoot(false)
) )
if (oldSaveDataFolder.exists()) { if (oldSaveDataFolder.exists()) {
@ -298,7 +299,7 @@ class InstallableFragment : Fragment() {
} }
val futureSaveDataFolder = File( val futureSaveDataFolder = File(
"${DirectoryInitialization.userDirectory}/nand" + NativeConfig.getSaveDir() +
NativeLibrary.getDefaultProfileSaveDataRoot(true) NativeLibrary.getDefaultProfileSaveDataRoot(true)
) )
if (futureSaveDataFolder.exists()) { if (futureSaveDataFolder.exists()) {

View File

@ -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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // 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.activities.EmulationActivity
import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.FileUtil import org.yuzu.yuzu_emu.utils.FileUtil
import org.yuzu.yuzu_emu.utils.NativeConfig
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -57,8 +61,7 @@ class Game(
}.zip" }.zip"
val saveDir: String val saveDir: String
get() = DirectoryInitialization.userDirectory + "/nand" + get() = NativeConfig.getSaveDir() + NativeLibrary.getSavePath(programId)
NativeLibrary.getSavePath(programId)
val addonDir: String val addonDir: String
get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/" get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/"

View File

@ -456,7 +456,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
val firmwarePath = val firmwarePath =
File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") File(NativeConfig.getNandDir() + "/system/Contents/registered/")
val cacheFirmwareDir = File("${cacheDir.path}/registered/") val cacheFirmwareDir = File("${cacheDir.path}/registered/")
ProgressDialogFragment.newInstance( ProgressDialogFragment.newInstance(
@ -499,7 +499,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
fun uninstallFirmware() { fun uninstallFirmware() {
val firmwarePath = val firmwarePath =
File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") File(NativeConfig.getNandDir() + "/system/Contents/registered/")
ProgressDialogFragment.newInstance( ProgressDialogFragment.newInstance(
this, this,
R.string.firmware_uninstalling R.string.firmware_uninstalling

View File

@ -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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -183,4 +186,22 @@ object NativeConfig {
*/ */
@Synchronized @Synchronized
external fun saveControlPlayerValues() 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)
} }

View File

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

View File

@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project // SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
#include <common/fs/path_util.h>
#include <common/logging/log.h> #include <common/logging/log.h>
#include <input_common/main.h> #include <input_common/main.h>
#include "android_config.h" #include "android_config.h"
@ -68,6 +69,24 @@ void AndroidConfig::ReadPathValues() {
} }
EndArray(); 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(); EndGroup();
} }
@ -222,6 +241,26 @@ void AndroidConfig::SavePathValues() {
} }
EndArray(); 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(); EndGroup();
} }

View File

@ -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)); const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
ASSERT(user_id); ASSERT(user_id);
const auto nandDir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir); const auto saveDir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir);
auto vfsNandDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(nandDir), auto vfsSaveDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(saveDir),
FileSys::OpenMode::Read); FileSys::OpenMode::Read);
const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath( 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); user_id->AsU128(), 0);
return Common::Android::ToJString(env, user_save_data_path); return Common::Android::ToJString(env, user_save_data_path);
} }

View File

@ -4,6 +4,7 @@
#include <string> #include <string>
#include <jni.h> #include <jni.h>
#include <common/fs/path_util.h>
#include "android_config.h" #include "android_config.h"
#include "android_settings.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" } // extern "C"

View File

@ -639,6 +639,7 @@
<!-- Miscellaneous --> <!-- Miscellaneous -->
<string name="slider_default">Default</string> <string name="slider_default">Default</string>
<string name="default_string">Default</string>
<string name="loading">Loading…</string> <string name="loading">Loading…</string>
<string name="shutting_down">Shutting down…</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> <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_player">Player %d</string>
<string name="preferences_debug">Debug</string> <string name="preferences_debug">Debug</string>
<string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</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 --> <!-- Game properties -->
<string name="info">Info</string> <string name="info">Info</string>

View File

@ -160,6 +160,7 @@ public:
GenerateEdenPath(EdenPath::LogDir, eden_path / LOG_DIR); GenerateEdenPath(EdenPath::LogDir, eden_path / LOG_DIR);
GenerateEdenPath(EdenPath::NANDDir, eden_path / NAND_DIR); GenerateEdenPath(EdenPath::NANDDir, eden_path / NAND_DIR);
GenerateEdenPath(EdenPath::PlayTimeDir, eden_path / PLAY_TIME_DIR); GenerateEdenPath(EdenPath::PlayTimeDir, eden_path / PLAY_TIME_DIR);
GenerateEdenPath(EdenPath::SaveDir, eden_path / NAND_DIR);
GenerateEdenPath(EdenPath::ScreenshotsDir, eden_path / SCREENSHOTS_DIR); GenerateEdenPath(EdenPath::ScreenshotsDir, eden_path / SCREENSHOTS_DIR);
GenerateEdenPath(EdenPath::SDMCDir, eden_path / SDMC_DIR); GenerateEdenPath(EdenPath::SDMCDir, eden_path / SDMC_DIR);
GenerateEdenPath(EdenPath::ShaderDir, eden_path / SHADER_DIR); GenerateEdenPath(EdenPath::ShaderDir, eden_path / SHADER_DIR);

View File

@ -25,6 +25,7 @@ enum class EdenPath {
LogDir, // Where log files are stored. LogDir, // Where log files are stored.
NANDDir, // Where the emulated NAND is stored. NANDDir, // Where the emulated NAND is stored.
PlayTimeDir, // Where play time data is stored. PlayTimeDir, // Where play time data is stored.
SaveDir, // Where save data is stored.
ScreenshotsDir, // Where yuzu screenshots are stored. ScreenshotsDir, // Where yuzu screenshots are stored.
SDMCDir, // Where the emulated SDMC is stored. SDMCDir, // Where the emulated SDMC is stored.
ShaderDir, // Where shaders are stored. ShaderDir, // Where shaders are stored.

View File

@ -138,6 +138,7 @@ void LogSettings() {
log_path("DataStorage_ConfigDir", Common::FS::GetEdenPath(Common::FS::EdenPath::ConfigDir)); log_path("DataStorage_ConfigDir", Common::FS::GetEdenPath(Common::FS::EdenPath::ConfigDir));
log_path("DataStorage_LoadDir", Common::FS::GetEdenPath(Common::FS::EdenPath::LoadDir)); log_path("DataStorage_LoadDir", Common::FS::GetEdenPath(Common::FS::EdenPath::LoadDir));
log_path("DataStorage_NANDDir", Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir)); log_path("DataStorage_NANDDir", Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir));
log_path("DataStorage_SaveDir", Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir));
log_path("DataStorage_SDMCDir", Common::FS::GetEdenPath(Common::FS::EdenPath::SDMCDir)); log_path("DataStorage_SDMCDir", Common::FS::GetEdenPath(Common::FS::EdenPath::SDMCDir));
} }

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -350,10 +353,10 @@ std::shared_ptr<FileSys::SaveDataFactory> FileSystemController::CreateSaveDataFa
const auto rw_mode = FileSys::OpenMode::ReadWrite; const auto rw_mode = FileSys::OpenMode::ReadWrite;
auto vfs = system.GetFilesystem(); auto vfs = system.GetFilesystem();
const auto nand_directory = const auto save_directory =
vfs->OpenDirectory(Common::FS::GetEdenPathString(EdenPath::NANDDir), rw_mode); vfs->OpenDirectory(Common::FS::GetEdenPathString(EdenPath::SaveDir), rw_mode);
return std::make_shared<FileSys::SaveDataFactory>(system, program_id, return std::make_shared<FileSys::SaveDataFactory>(system, program_id,
std::move(nand_directory)); std::move(save_directory));
} }
Result FileSystemController::OpenSDMC(FileSys::VirtualDir* out_sdmc) const { Result FileSystemController::OpenSDMC(FileSys::VirtualDir* out_sdmc) const {

View File

@ -290,6 +290,13 @@ void Config::ReadDataStorageValues() {
setPath(EdenPath::DumpDir, "dump_directory"); setPath(EdenPath::DumpDir, "dump_directory");
setPath(EdenPath::TASDir, "tas_directory"); setPath(EdenPath::TASDir, "tas_directory");
const auto save_dir_setting = ReadStringSetting(std::string("save_directory"));
if (save_dir_setting.empty()) {
SetEdenPath(EdenPath::SaveDir, GetEdenPathString(EdenPath::NANDDir));
} else {
SetEdenPath(EdenPath::SaveDir, save_dir_setting);
}
ReadCategory(Settings::Category::DataStorage); ReadCategory(Settings::Category::DataStorage);
EndGroup(); EndGroup();
@ -583,6 +590,16 @@ void Config::SaveDataStorageValues() {
writePath("dump_directory", EdenPath::DumpDir); writePath("dump_directory", EdenPath::DumpDir);
writePath("tas_directory", EdenPath::TASDir); writePath("tas_directory", EdenPath::TASDir);
const auto save_path = FS::GetEdenPathString(EdenPath::SaveDir);
const auto nand_path = FS::GetEdenPathString(EdenPath::NANDDir);
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("")));
}
WriteCategory(Settings::Category::DataStorage); WriteCategory(Settings::Category::DataStorage);
EndGroup(); EndGroup();

View File

@ -13,10 +13,11 @@ namespace fs = std::filesystem;
const fs::path GetDataDir(DataDir dir, const std::string &user_id) const fs::path GetDataDir(DataDir dir, const std::string &user_id)
{ {
const fs::path nand_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir); const fs::path nand_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir);
const fs::path save_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir);
switch (dir) { switch (dir) {
case DataDir::Saves: case DataDir::Saves:
return (nand_dir / "user" / "save" / "0000000000000000" / user_id).string(); return (save_dir / "user" / "save" / "0000000000000000" / user_id).string();
case DataDir::UserNand: case DataDir::UserNand:
return (nand_dir / "user" / "Contents" / "registered").string(); return (nand_dir / "user" / "Contents" / "registered").string();
case DataDir::SysNand: case DataDir::SysNand:

View File

@ -5,8 +5,10 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
#include "yuzu/configuration/configure_filesystem.h" #include "yuzu/configuration/configure_filesystem.h"
#include <filesystem>
#include <QFileDialog> #include <QFileDialog>
#include <QMessageBox> #include <QMessageBox>
#include <QProgressDialog>
#include "common/fs/fs.h" #include "common/fs/fs.h"
#include "common/fs/path_util.h" #include "common/fs/path_util.h"
#include "common/settings.h" #include "common/settings.h"
@ -24,6 +26,8 @@ ConfigureFilesystem::ConfigureFilesystem(QWidget* parent)
[this] { SetDirectory(DirectoryTarget::NAND, ui->nand_directory_edit); }); [this] { SetDirectory(DirectoryTarget::NAND, ui->nand_directory_edit); });
connect(ui->sdmc_directory_button, &QToolButton::pressed, this, connect(ui->sdmc_directory_button, &QToolButton::pressed, this,
[this] { SetDirectory(DirectoryTarget::SD, ui->sdmc_directory_edit); }); [this] { SetDirectory(DirectoryTarget::SD, ui->sdmc_directory_edit); });
connect(ui->save_directory_button, &QToolButton::pressed, this,
[this] { SetSaveDirectory(); });
connect(ui->gamecard_path_button, &QToolButton::pressed, this, connect(ui->gamecard_path_button, &QToolButton::pressed, this,
[this] { SetDirectory(DirectoryTarget::Gamecard, ui->gamecard_path_edit); }); [this] { SetDirectory(DirectoryTarget::Gamecard, ui->gamecard_path_edit); });
connect(ui->dump_path_button, &QToolButton::pressed, this, connect(ui->dump_path_button, &QToolButton::pressed, this,
@ -55,6 +59,8 @@ void ConfigureFilesystem::SetConfiguration() {
QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir))); QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir)));
ui->sdmc_directory_edit->setText( ui->sdmc_directory_edit->setText(
QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir))); QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir)));
ui->save_directory_edit->setText(
QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir)));
ui->gamecard_path_edit->setText( ui->gamecard_path_edit->setText(
QString::fromStdString(Settings::values.gamecard_path.GetValue())); QString::fromStdString(Settings::values.gamecard_path.GetValue()));
ui->dump_path_edit->setText( ui->dump_path_edit->setText(
@ -77,6 +83,8 @@ void ConfigureFilesystem::ApplyConfiguration() {
ui->nand_directory_edit->text().toStdString()); ui->nand_directory_edit->text().toStdString());
Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir,
ui->sdmc_directory_edit->text().toStdString()); ui->sdmc_directory_edit->text().toStdString());
Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir,
ui->save_directory_edit->text().toStdString());
Common::FS::SetEdenPath(Common::FS::EdenPath::DumpDir, Common::FS::SetEdenPath(Common::FS::EdenPath::DumpDir,
ui->dump_path_edit->text().toStdString()); ui->dump_path_edit->text().toStdString());
Common::FS::SetEdenPath(Common::FS::EdenPath::LoadDir, Common::FS::SetEdenPath(Common::FS::EdenPath::LoadDir,
@ -100,6 +108,9 @@ void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit)
case DirectoryTarget::SD: case DirectoryTarget::SD:
caption = tr("Select Emulated SD Directory..."); caption = tr("Select Emulated SD Directory...");
break; break;
case DirectoryTarget::Save:
caption = tr("Select Save Data Directory...");
break;
case DirectoryTarget::Gamecard: case DirectoryTarget::Gamecard:
caption = tr("Select Gamecard Path..."); caption = tr("Select Gamecard Path...");
break; break;
@ -130,6 +141,131 @@ void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit)
edit->setText(str); edit->setText(str);
} }
void ConfigureFilesystem::SetSaveDirectory() {
const QString current_path = ui->save_directory_edit->text();
const QString nand_path = ui->nand_directory_edit->text();
QMessageBox msgBox(this);
msgBox.setWindowTitle(tr("Save Data Directory"));
msgBox.setText(tr("Choose an action for the save data directory:"));
QPushButton* customButton = msgBox.addButton(tr("Set Custom Path"), QMessageBox::ActionRole);
QPushButton* resetButton = msgBox.addButton(tr("Reset to NAND"), QMessageBox::ActionRole);
msgBox.addButton(QMessageBox::Cancel);
msgBox.exec();
if (msgBox.clickedButton() == customButton) {
QString new_path = QFileDialog::getExistingDirectory(
this, tr("Select Save Data Directory..."), current_path);
if (new_path.isNull() || new_path.isEmpty()) {
return;
}
if (new_path.back() != QChar::fromLatin1('/')) {
new_path.append(QChar::fromLatin1('/'));
}
if (new_path != current_path) {
PromptSaveMigration(current_path, new_path);
ui->save_directory_edit->setText(new_path);
}
} else if (msgBox.clickedButton() == resetButton) {
if (current_path != nand_path) {
PromptSaveMigration(current_path, nand_path);
ui->save_directory_edit->setText(nand_path);
}
}
}
void ConfigureFilesystem::PromptSaveMigration(const QString& from_path, const QString& to_path) {
namespace fs = std::filesystem;
const fs::path source_save_dir = fs::path(from_path.toStdString()) / "user" / "save";
const fs::path dest_save_dir = fs::path(to_path.toStdString()) / "user" / "save";
std::error_code ec;
bool source_has_saves = false;
if (Common::FS::Exists(source_save_dir)) {
bool source_empty = fs::is_empty(source_save_dir, ec);
source_has_saves = !ec && !source_empty;
}
// Check if destination already has saves
bool dest_has_saves = false;
if (Common::FS::Exists(dest_save_dir)) {
bool dest_empty = fs::is_empty(dest_save_dir, ec);
dest_has_saves = !ec && !dest_empty;
}
if (!source_has_saves) {
return;
}
QString message;
if (dest_has_saves) {
message = tr("Save data exists in both the old and new locations.\n\n"
"Old: %1\n"
"New: %2\n\n"
"Would you like to migrate saves from the old location?\n"
"WARNING: This will overwrite any conflicting saves in the new location!")
.arg(QString::fromStdString(source_save_dir.string()))
.arg(QString::fromStdString(dest_save_dir.string()));
} else {
message = tr("Would you like to migrate your save data to the new location?\n\n"
"From: %1\n"
"To: %2")
.arg(QString::fromStdString(source_save_dir.string()))
.arg(QString::fromStdString(dest_save_dir.string()));
}
QMessageBox::StandardButton reply = QMessageBox::question(
this, tr("Migrate Save Data"), message,
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (reply != QMessageBox::Yes) {
return;
}
QProgressDialog progress(tr("Migrating save data..."), tr("Cancel"), 0, 0, this);
progress.setWindowModality(Qt::WindowModal);
progress.setMinimumDuration(0);
progress.show();
if (!Common::FS::Exists(dest_save_dir)) {
if (!Common::FS::CreateDirs(dest_save_dir)) {
progress.close();
QMessageBox::warning(this, tr("Migration Failed"),
tr("Failed to create destination directory."));
return;
}
}
fs::copy(source_save_dir, dest_save_dir,
fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec);
progress.close();
if (ec) {
QMessageBox::warning(this, tr("Migration Failed"),
tr("Failed to migrate save data:\n%1")
.arg(QString::fromStdString(ec.message())));
return;
}
QMessageBox::StandardButton deleteReply = QMessageBox::question(
this, tr("Migration Complete"),
tr("Save data has been migrated successfully.\n\n"
"Would you like to delete the old save data?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (deleteReply == QMessageBox::Yes) {
Common::FS::RemoveDirRecursively(source_save_dir);
}
}
void ConfigureFilesystem::ResetMetadata() { void ConfigureFilesystem::ResetMetadata() {
QtCommon::Game::ResetMetadata(); QtCommon::Game::ResetMetadata();
} }

View File

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -30,12 +33,15 @@ private:
enum class DirectoryTarget { enum class DirectoryTarget {
NAND, NAND,
SD, SD,
Save,
Gamecard, Gamecard,
Dump, Dump,
Load, Load,
}; };
void SetDirectory(DirectoryTarget target, QLineEdit* edit); void SetDirectory(DirectoryTarget target, QLineEdit* edit);
void SetSaveDirectory();
void PromptSaveMigration(const QString& from_path, const QString& to_path);
void ResetMetadata(); void ResetMetadata();
void UpdateEnabledControls(); void UpdateEnabledControls();

View File

@ -59,6 +59,23 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0">
<widget class="QLabel" name="label_save">
<property name="text">
<string>Save Data</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QLineEdit" name="save_directory_edit"/>
</item>
<item row="2" column="3">
<widget class="QToolButton" name="save_directory_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item row="0" column="1"> <item row="0" column="1">
<spacer name="horizontalSpacer"> <spacer name="horizontalSpacer">
<property name="orientation"> <property name="orientation">

View File

@ -2331,9 +2331,9 @@ void MainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target,
switch (target) { switch (target) {
case GameListOpenTarget::SaveData: { case GameListOpenTarget::SaveData: {
open_target = tr("Save Data"); open_target = tr("Save Data");
const auto nand_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir); const auto save_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir);
auto vfs_nand_dir = auto vfs_save_dir =
QtCommon::vfs->OpenDirectory(Common::FS::PathToUTF8String(nand_dir), FileSys::OpenMode::Read); QtCommon::vfs->OpenDirectory(Common::FS::PathToUTF8String(save_dir), FileSys::OpenMode::Read);
if (has_user_save) { if (has_user_save) {
// User save data // User save data
@ -2341,17 +2341,17 @@ void MainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target,
assert(user_id); assert(user_id);
const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath( const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
{}, vfs_nand_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, {}, vfs_save_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account,
program_id, user_id->AsU128(), 0); program_id, user_id->AsU128(), 0);
path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path); path = Common::FS::ConcatPathSafe(save_dir, user_save_data_path);
} else { } else {
// Device save data // Device save data
const auto device_save_data_path = FileSys::SaveDataFactory::GetFullPath( const auto device_save_data_path = FileSys::SaveDataFactory::GetFullPath(
{}, vfs_nand_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account, {}, vfs_save_dir, FileSys::SaveDataSpaceId::User, FileSys::SaveDataType::Account,
program_id, {}, 0); program_id, {}, 0);
path = Common::FS::ConcatPathSafe(nand_dir, device_save_data_path); path = Common::FS::ConcatPathSafe(save_dir, device_save_data_path);
} }
if (!Common::FS::CreateDirs(path)) { if (!Common::FS::CreateDirs(path)) {