[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:
parent
18af560a43
commit
b0cd47c005
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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 + "/"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue