Browse Source

[android] Implement custom path settings

pull/3154/head
Kleidis 2 weeks ago
committed by crueter
parent
commit
d8b7c7de96
  1. 3
      src/android/app/src/main/AndroidManifest.xml
  2. 3
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
  3. 40
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/PathSetting.kt
  4. 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
  5. 16
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
  6. 236
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
  7. 50
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
  8. 22
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt
  9. 64
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/PathViewHolder.kt
  10. 18
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
  11. 92
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt
  12. 39
      src/android/app/src/main/jni/android_config.cpp
  13. 36
      src/android/app/src/main/jni/native_config.cpp
  14. 28
      src/android/app/src/main/res/values/strings.xml

3
src/android/app/src/main/AndroidManifest.xml

@ -29,6 +29,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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"

3
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt

@ -27,7 +27,8 @@ object Settings {
SECTION_APP_SETTINGS(R.string.app_settings), SECTION_APP_SETTINGS(R.string.app_settings),
SECTION_DEBUG(R.string.preferences_debug), SECTION_DEBUG(R.string.preferences_debug),
SECTION_EDEN_VEIL(R.string.eden_veil), SECTION_EDEN_VEIL(R.string.eden_veil),
SECTION_APPLETS(R.string.applets_menu);
SECTION_APPLETS(R.string.applets_menu),
SECTION_CUSTOM_PATHS(R.string.preferences_custom_paths);
} }
fun getPlayerString(player: Int): String = fun getPlayerString(player: Int): String =

40
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/PathSetting.kt

@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
class PathSetting(
@StringRes titleId: Int = 0,
titleString: String = "",
@StringRes descriptionId: Int = 0,
descriptionString: String = "",
@DrawableRes val iconId: Int = 0,
val pathType: PathType,
val defaultPathGetter: () -> String,
val currentPathGetter: () -> String,
val pathSetter: (String) -> Unit
) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) {
override val type = TYPE_PATH
enum class PathType {
SAVE_DATA,
NAND,
SDMC
}
fun getCurrentPath(): String = currentPathGetter()
fun getDefaultPath(): String = defaultPathGetter()
fun setPath(path: String) = pathSetter(path)
fun isUsingDefaultPath(): Boolean = getCurrentPath() == getDefaultPath()
companion object {
const val TYPE_PATH = 13
}
}

1
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt

@ -97,6 +97,7 @@ abstract class SettingsItem(
const val TYPE_INPUT_PROFILE = 10 const val TYPE_INPUT_PROFILE = 10
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_PATH = 13
const val FASTMEM_COMBINED = "fastmem_combined" const val FASTMEM_COMBINED = "fastmem_combined"

16
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt

@ -93,6 +93,10 @@ class SettingsAdapter(
StringInputViewHolder(ListItemSettingBinding.inflate(inflater), this) StringInputViewHolder(ListItemSettingBinding.inflate(inflater), this)
} }
SettingsItem.TYPE_PATH -> {
PathViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
else -> { else -> {
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
} }
@ -442,6 +446,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

236
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 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,197 @@ 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 realPath = PathUtil.getPathFromUri(uri)
if (realPath != null) {
handleSelectedPath(pathSetting, realPath)
} else {
Toast.makeText(
requireContext(),
R.string.invalid_directory,
Toast.LENGTH_SHORT
).show()
}
}
}
private fun handleSelectedPath(pathSetting: PathSetting, path: String) {
if (!PathUtil.validateDirectory(path)) {
Toast.makeText(
requireContext(),
R.string.invalid_directory,
Toast.LENGTH_SHORT
).show()
return
}
if (pathSetting.pathType == PathSetting.PathType.SAVE_DATA) {
val oldPath = pathSetting.getCurrentPath()
if (oldPath != path) {
promptSaveMigration(pathSetting, oldPath, path)
}
} else {
setPathAndNotify(pathSetting, path)
}
}
private fun promptSaveMigration(pathSetting: PathSetting, fromPath: String, toPath: String) {
val sourceSavePath = "$fromPath/user/save"
val destSavePath = "$toPath/user/save"
val sourceSaveDir = File(sourceSavePath)
val destSaveDir = File(destSavePath)
val sourceHasSaves = PathUtil.hasContent(sourceSavePath)
val destHasSaves = PathUtil.hasContent(destSavePath)
if (!sourceHasSaves) {
setPathAndNotify(pathSetting, toPath)
return
}
if (destHasSaves) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.migrate_save_data)
.setMessage(R.string.destination_has_saves)
.setPositiveButton(R.string.confirm) { _, _ ->
migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, toPath)
}
.setNegativeButton(R.string.skip_migration) { _, _ ->
setPathAndNotify(pathSetting, toPath)
}
.setNeutralButton(R.string.cancel, null)
.show()
} else {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.migrate_save_data)
.setMessage(R.string.migrate_save_data_question)
.setPositiveButton(R.string.confirm) { _, _ ->
migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, toPath)
}
.setNegativeButton(R.string.skip_migration) { _, _ ->
setPathAndNotify(pathSetting, toPath)
}
.setNeutralButton(R.string.cancel, null)
.show()
}
}
private fun migrateSaveData(
pathSetting: PathSetting,
sourceDir: File,
destDir: File,
newPath: String
) {
Thread {
val success = PathUtil.copyDirectory(sourceDir, destDir, overwrite = true)
requireActivity().runOnUiThread {
if (success) {
setPathAndNotify(pathSetting, newPath)
Toast.makeText(
requireContext(),
R.string.save_migration_complete,
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
requireContext(),
R.string.save_migration_failed,
Toast.LENGTH_SHORT
).show()
}
}
}.start()
}
private fun setPathAndNotify(pathSetting: PathSetting, path: String) {
pathSetting.setPath(path)
NativeConfig.saveGlobalConfig()
val messageResId = if (pathSetting.pathType == PathSetting.PathType.SAVE_DATA) {
R.string.save_directory_set
} else {
R.string.path_set
}
Toast.makeText(
requireContext(),
messageResId,
Toast.LENGTH_SHORT
).show()
val position = settingsViewModel.pathSettingPosition.value
if (position >= 0) {
settingsAdapter?.notifyItemChanged(position)
}
}
private fun showPathResetDialog() {
val pathSetting = settingsViewModel.clickedItem as? PathSetting ?: return
if (pathSetting.isUsingDefaultPath()) {
return
}
val currentPath = pathSetting.getCurrentPath()
val defaultPath = pathSetting.getDefaultPath()
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.reset_to_nand)
.setMessage(R.string.migrate_save_data_question)
.setPositiveButton(R.string.confirm) { _, _ ->
val sourceSaveDir = File(currentPath, "user/save")
val destSaveDir = File(defaultPath, "user/save")
if (sourceSaveDir.exists() && sourceSaveDir.listFiles()?.isNotEmpty() == true) {
migrateSaveData(pathSetting, sourceSaveDir, destSaveDir, defaultPath)
} else {
setPathAndNotify(pathSetting, defaultPath)
}
}
.setNegativeButton(R.string.cancel) { _, _ ->
setPathAndNotify(pathSetting, defaultPath)
}
.show()
}
} }

50
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt

@ -28,6 +28,7 @@ import org.yuzu.yuzu_emu.features.settings.model.StringSetting
import org.yuzu.yuzu_emu.features.settings.model.view.* import org.yuzu.yuzu_emu.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_DEBUG -> addDebugSettings(sl) MenuTag.SECTION_DEBUG -> addDebugSettings(sl)
MenuTag.SECTION_EDEN_VEIL -> addEdenVeilSettings(sl) MenuTag.SECTION_EDEN_VEIL -> addEdenVeilSettings(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) {
@ -195,6 +197,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,
@ -1172,4 +1184,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) }
)
)
}
}
} }

22
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) 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]

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

18
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt

@ -183,4 +183,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)
} }

92
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PathUtil.kt

@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.utils
import android.net.Uri
import android.provider.DocumentsContract
import java.io.File
object PathUtil {
/**
* Converts a content:// URI from the Storage Access Framework to a real filesystem path.
*
*/
fun getPathFromUri(uri: Uri): String? {
val docId = try {
DocumentsContract.getTreeDocumentId(uri)
} catch (_: Exception) {
return null
}
if (docId.startsWith("primary:")) {
val relativePath = docId.removePrefix("primary:")
return "/storage/emulated/0/$relativePath"
}
// external SD cards and other volumes)
val split = docId.split(":")
if (split.size >= 2) {
val volumeId = split[0]
val relativePath = split.getOrElse(1) { "" }
val possiblePaths = listOf(
"/storage/$volumeId/$relativePath",
"/mnt/media_rw/$volumeId/$relativePath"
)
for (path in possiblePaths) {
val file = File(path)
if (file.exists() && file.isDirectory) {
return path
}
}
}
return null
}
/**
* Validates that a path is a valid, writable directory.
* Creates the directory if it doesn't exist.
*/
fun validateDirectory(path: String): Boolean {
val dir = File(path)
if (!dir.exists()) {
if (!dir.mkdirs()) {
return false
}
}
return dir.isDirectory && dir.canWrite()
}
/**
* Copies a directory recursively from source to destination.
*/
fun copyDirectory(source: File, destination: File, overwrite: Boolean = true): Boolean {
return try {
source.copyRecursively(destination, overwrite)
true
} catch (_: Exception) {
false
}
}
/**
* Checks if a directory has any content.
*/
fun hasContent(path: String): Boolean {
val dir = File(path)
return dir.exists() && dir.listFiles()?.isNotEmpty() == true
}
fun truncatePathForDisplay(path: String, maxLength: Int = 40): String {
return if (path.length > maxLength) {
"...${path.takeLast(maxLength - 3)}"
} else {
path
}
}
}

39
src/android/app/src/main/jni/android_config.cpp

@ -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();
} }

36
src/android/app/src/main/jni/native_config.cpp

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

28
src/android/app/src/main/res/values/strings.xml

@ -621,6 +621,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>
@ -695,6 +696,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 save 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>

Loading…
Cancel
Save