From b0cd47c005d80d07f6924fd514dd582afa27b25d Mon Sep 17 00:00:00 2001 From: kleidis Date: Wed, 31 Dec 2025 21:20:30 +0100 Subject: [PATCH] [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 Reviewed-by: MaranBr Co-authored-by: kleidis Co-committed-by: kleidis --- src/android/app/src/main/AndroidManifest.xml | 3 + .../features/settings/model/Settings.kt | 1 + .../settings/model/view/PathSetting.kt | 40 +++ .../settings/model/view/SettingsItem.kt | 1 + .../features/settings/ui/SettingsAdapter.kt | 16 ++ .../features/settings/ui/SettingsFragment.kt | 238 ++++++++++++++++++ .../settings/ui/SettingsFragmentPresenter.kt | 50 ++++ .../features/settings/ui/SettingsViewModel.kt | 22 ++ .../settings/ui/viewholder/PathViewHolder.kt | 64 +++++ .../yuzu_emu/fragments/InstallableFragment.kt | 13 +- .../main/java/org/yuzu/yuzu_emu/model/Game.kt | 7 +- .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 4 +- .../org/yuzu/yuzu_emu/utils/NativeConfig.kt | 21 ++ .../java/org/yuzu/yuzu_emu/utils/PathUtil.kt | 97 +++++++ .../app/src/main/jni/android_config.cpp | 39 +++ src/android/app/src/main/jni/native.cpp | 6 +- .../app/src/main/jni/native_config.cpp | 36 +++ .../app/src/main/res/values/strings.xml | 28 +++ src/common/fs/path_util.cpp | 1 + src/common/fs/path_util.h | 1 + src/common/settings.cpp | 1 + .../hle/service/filesystem/filesystem.cpp | 9 +- src/frontend_common/config.cpp | 17 ++ src/frontend_common/data_manager.cpp | 3 +- .../configuration/configure_filesystem.cpp | 136 ++++++++++ src/yuzu/configuration/configure_filesystem.h | 6 + .../configuration/configure_filesystem.ui | 17 ++ src/yuzu/main_window.cpp | 14 +- 28 files changed, 867 insertions(+), 24 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/PathSetting.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt 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 + + + 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() { 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) { + 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 #include #include #include "android_config.h" @@ -68,6 +69,24 @@ void AndroidConfig::ReadPathValues() { } EndArray(); + const auto nand_dir_setting = ReadStringSetting(std::string("nand_directory")); + if (!nand_dir_setting.empty()) { + Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, nand_dir_setting); + } + + const auto sdmc_dir_setting = ReadStringSetting(std::string("sdmc_directory")); + if (!sdmc_dir_setting.empty()) { + Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, sdmc_dir_setting); + } + + const auto save_dir_setting = ReadStringSetting(std::string("save_directory")); + if (save_dir_setting.empty()) { + Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir, + Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir)); + } else { + Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir, save_dir_setting); + } + EndGroup(); } @@ -222,6 +241,26 @@ void AndroidConfig::SavePathValues() { } EndArray(); + // Save custom NAND directory + const auto nand_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir); + WriteStringSetting(std::string("nand_directory"), nand_path, + std::make_optional(std::string(""))); + + // Save custom SDMC directory + const auto sdmc_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir); + WriteStringSetting(std::string("sdmc_directory"), sdmc_path, + std::make_optional(std::string(""))); + + // Save custom save directory + const auto save_path = Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir); + if (save_path == nand_path) { + WriteStringSetting(std::string("save_directory"), std::string(""), + std::make_optional(std::string(""))); + } else { + WriteStringSetting(std::string("save_directory"), save_path, + std::make_optional(std::string(""))); + } + EndGroup(); } diff --git a/src/android/app/src/main/jni/native.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(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 #include +#include #include "android_config.h" #include "android_settings.h" @@ -545,4 +546,39 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_saveControlPlayerValues(JNIEnv* } } +jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getSaveDir(JNIEnv* env, jobject obj) { + return Common::Android::ToJString(env, + Common::FS::GetEdenPathString(Common::FS::EdenPath::SaveDir)); +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getDefaultSaveDir(JNIEnv* env, jobject obj) { + return Common::Android::ToJString(env, + Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir)); +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSaveDir(JNIEnv* env, jobject obj, jstring jpath) { + auto path = Common::Android::GetJString(env, jpath); + Common::FS::SetEdenPath(Common::FS::EdenPath::SaveDir, path); +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getNandDir(JNIEnv* env, jobject obj) { + return Common::Android::ToJString(env, + Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir)); +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setNandDir(JNIEnv* env, jobject obj, jstring jpath) { + auto path = Common::Android::GetJString(env, jpath); + Common::FS::SetEdenPath(Common::FS::EdenPath::NANDDir, path); +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getSdmcDir(JNIEnv* env, jobject obj) { + return Common::Android::ToJString(env, + Common::FS::GetEdenPathString(Common::FS::EdenPath::SDMCDir)); +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setSdmcDir(JNIEnv* env, jobject obj, jstring jpath) { + auto path = Common::Android::GetJString(env, jpath); + Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, path); +} + } // extern "C" diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 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 @@ Default + Default Loading… Shutting down… Do you want to reset this setting back to its default value? @@ -714,6 +715,33 @@ Player %d Debug CPU/GPU debugging, graphics API, fastmem + Custom Paths + Save data directory + + + Save Data Directory + Set a custom path for save data storage + Select Directory + Choose an action for the save directory: + Set Custom Path + Reset to Default + Migrate Save Data + Do you want to migrate existing save data to the new location? + This will copy your save files from the old location to the new one. + Migrating save data… + Save data migrated successfully + Save data migration failed + Save directory set + Save directory reset to default + The destination already contains data. Do you want to overwrite it? + All Files Access permission is required for custom paths + Grant Permission + NAND Directory + Set a custom path for NAND storage + SD Card Directory + Set a custom path for virtual SD card storage + Path set successfully + Skip Info diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp index 8f1fe1402e..9b57fda295 100644 --- a/src/common/fs/path_util.cpp +++ b/src/common/fs/path_util.cpp @@ -160,6 +160,7 @@ public: GenerateEdenPath(EdenPath::LogDir, eden_path / LOG_DIR); GenerateEdenPath(EdenPath::NANDDir, eden_path / NAND_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::SDMCDir, eden_path / SDMC_DIR); GenerateEdenPath(EdenPath::ShaderDir, eden_path / SHADER_DIR); diff --git a/src/common/fs/path_util.h b/src/common/fs/path_util.h index dc800b2892..9f597232a5 100644 --- a/src/common/fs/path_util.h +++ b/src/common/fs/path_util.h @@ -25,6 +25,7 @@ enum class EdenPath { LogDir, // Where log files are stored. NANDDir, // Where the emulated NAND is stored. PlayTimeDir, // Where play time data is stored. + SaveDir, // Where save data is stored. ScreenshotsDir, // Where yuzu screenshots are stored. SDMCDir, // Where the emulated SDMC is stored. ShaderDir, // Where shaders are stored. diff --git a/src/common/settings.cpp b/src/common/settings.cpp index b4eafe5d22..e961e1d2d7 100644 --- a/src/common/settings.cpp +++ b/src/common/settings.cpp @@ -138,6 +138,7 @@ void LogSettings() { 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_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)); } diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 9d7de4242e..95a32c1250 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -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-License-Identifier: GPL-2.0-or-later @@ -350,10 +353,10 @@ std::shared_ptr FileSystemController::CreateSaveDataFa const auto rw_mode = FileSys::OpenMode::ReadWrite; auto vfs = system.GetFilesystem(); - const auto nand_directory = - vfs->OpenDirectory(Common::FS::GetEdenPathString(EdenPath::NANDDir), rw_mode); + const auto save_directory = + vfs->OpenDirectory(Common::FS::GetEdenPathString(EdenPath::SaveDir), rw_mode); return std::make_shared(system, program_id, - std::move(nand_directory)); + std::move(save_directory)); } Result FileSystemController::OpenSDMC(FileSys::VirtualDir* out_sdmc) const { diff --git a/src/frontend_common/config.cpp b/src/frontend_common/config.cpp index 94ec349651..669e578b33 100644 --- a/src/frontend_common/config.cpp +++ b/src/frontend_common/config.cpp @@ -290,6 +290,13 @@ void Config::ReadDataStorageValues() { setPath(EdenPath::DumpDir, "dump_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); EndGroup(); @@ -583,6 +590,16 @@ void Config::SaveDataStorageValues() { writePath("dump_directory", EdenPath::DumpDir); 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); EndGroup(); diff --git a/src/frontend_common/data_manager.cpp b/src/frontend_common/data_manager.cpp index e5f376720b..1dfbbb0808 100644 --- a/src/frontend_common/data_manager.cpp +++ b/src/frontend_common/data_manager.cpp @@ -13,10 +13,11 @@ namespace fs = std::filesystem; 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 save_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir); switch (dir) { case DataDir::Saves: - return (nand_dir / "user" / "save" / "0000000000000000" / user_id).string(); + return (save_dir / "user" / "save" / "0000000000000000" / user_id).string(); case DataDir::UserNand: return (nand_dir / "user" / "Contents" / "registered").string(); case DataDir::SysNand: diff --git a/src/yuzu/configuration/configure_filesystem.cpp b/src/yuzu/configuration/configure_filesystem.cpp index aae954d21e..545032eee3 100644 --- a/src/yuzu/configuration/configure_filesystem.cpp +++ b/src/yuzu/configuration/configure_filesystem.cpp @@ -5,8 +5,10 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "yuzu/configuration/configure_filesystem.h" +#include #include #include +#include #include "common/fs/fs.h" #include "common/fs/path_util.h" #include "common/settings.h" @@ -24,6 +26,8 @@ ConfigureFilesystem::ConfigureFilesystem(QWidget* parent) [this] { SetDirectory(DirectoryTarget::NAND, ui->nand_directory_edit); }); connect(ui->sdmc_directory_button, &QToolButton::pressed, this, [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, [this] { SetDirectory(DirectoryTarget::Gamecard, ui->gamecard_path_edit); }); connect(ui->dump_path_button, &QToolButton::pressed, this, @@ -55,6 +59,8 @@ void ConfigureFilesystem::SetConfiguration() { QString::fromStdString(Common::FS::GetEdenPathString(Common::FS::EdenPath::NANDDir))); ui->sdmc_directory_edit->setText( 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( QString::fromStdString(Settings::values.gamecard_path.GetValue())); ui->dump_path_edit->setText( @@ -77,6 +83,8 @@ void ConfigureFilesystem::ApplyConfiguration() { ui->nand_directory_edit->text().toStdString()); Common::FS::SetEdenPath(Common::FS::EdenPath::SDMCDir, 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, ui->dump_path_edit->text().toStdString()); Common::FS::SetEdenPath(Common::FS::EdenPath::LoadDir, @@ -100,6 +108,9 @@ void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit) case DirectoryTarget::SD: caption = tr("Select Emulated SD Directory..."); break; + case DirectoryTarget::Save: + caption = tr("Select Save Data Directory..."); + break; case DirectoryTarget::Gamecard: caption = tr("Select Gamecard Path..."); break; @@ -130,6 +141,131 @@ void ConfigureFilesystem::SetDirectory(DirectoryTarget target, QLineEdit* edit) 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() { QtCommon::Game::ResetMetadata(); } diff --git a/src/yuzu/configuration/configure_filesystem.h b/src/yuzu/configuration/configure_filesystem.h index 31d2f1d56d..d8c26a783a 100644 --- a/src/yuzu/configuration/configure_filesystem.h +++ b/src/yuzu/configuration/configure_filesystem.h @@ -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-License-Identifier: GPL-2.0-or-later @@ -30,12 +33,15 @@ private: enum class DirectoryTarget { NAND, SD, + Save, Gamecard, Dump, Load, }; void SetDirectory(DirectoryTarget target, QLineEdit* edit); + void SetSaveDirectory(); + void PromptSaveMigration(const QString& from_path, const QString& to_path); void ResetMetadata(); void UpdateEnabledControls(); diff --git a/src/yuzu/configuration/configure_filesystem.ui b/src/yuzu/configuration/configure_filesystem.ui index 2f6030b5c4..75c61c74a6 100644 --- a/src/yuzu/configuration/configure_filesystem.ui +++ b/src/yuzu/configuration/configure_filesystem.ui @@ -59,6 +59,23 @@ + + + + Save Data + + + + + + + + + + ... + + + diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index ed3d0f8466..6d5d6fc03e 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -2331,9 +2331,9 @@ void MainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target, switch (target) { case GameListOpenTarget::SaveData: { open_target = tr("Save Data"); - const auto nand_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir); - auto vfs_nand_dir = - QtCommon::vfs->OpenDirectory(Common::FS::PathToUTF8String(nand_dir), FileSys::OpenMode::Read); + const auto save_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::SaveDir); + auto vfs_save_dir = + QtCommon::vfs->OpenDirectory(Common::FS::PathToUTF8String(save_dir), FileSys::OpenMode::Read); if (has_user_save) { // User save data @@ -2341,17 +2341,17 @@ void MainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target, assert(user_id); 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); - path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path); + path = Common::FS::ConcatPathSafe(save_dir, user_save_data_path); } else { // Device save data 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); - 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)) {