Merge pull request #12204 from t895/config-migration

android: Multi directory UI
master
liamwhite 2023-11-29 12:34:09 +07:00 committed by GitHub
commit aded28f276
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 848 additions and 122 deletions

@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.adapters
import android.net.Uri
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.fragment.app.FragmentActivity
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.yuzu.yuzu_emu.databinding.CardFolderBinding
import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel
class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) :
ListAdapter<GameDir, FolderAdapter.FolderViewHolder>(
AsyncDifferConfig.Builder(DiffCallback()).build()
) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): FolderAdapter.FolderViewHolder {
CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
.also { return FolderViewHolder(it) }
}
override fun onBindViewHolder(holder: FolderAdapter.FolderViewHolder, position: Int) =
holder.bind(currentList[position])
inner class FolderViewHolder(val binding: CardFolderBinding) :
RecyclerView.ViewHolder(binding.root) {
private lateinit var gameDir: GameDir
fun bind(gameDir: GameDir) {
this.gameDir = gameDir
binding.apply {
path.text = Uri.parse(gameDir.uriString).path
path.postDelayed(
{
path.isSelected = true
path.ellipsize = TextUtils.TruncateAt.MARQUEE
},
3000
)
buttonEdit.setOnClickListener {
GameFolderPropertiesDialogFragment.newInstance(this@FolderViewHolder.gameDir)
.show(
activity.supportFragmentManager,
GameFolderPropertiesDialogFragment.TAG
)
}
buttonDelete.setOnClickListener {
gamesViewModel.removeFolder(this@FolderViewHolder.gameDir)
}
}
}
}
private class DiffCallback : DiffUtil.ItemCallback<GameDir>() {
override fun areItemsTheSame(oldItem: GameDir, newItem: GameDir): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: GameDir, newItem: GameDir): Boolean {
return oldItem == newItem
}
}
}

@ -3,33 +3,9 @@
package org.yuzu.yuzu_emu.features.settings.model package org.yuzu.yuzu_emu.features.settings.model
import android.text.TextUtils
import android.widget.Toast
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.utils.NativeConfig
object Settings { object Settings {
private val context get() = YuzuApplication.appContext
fun saveSettings(gameId: String = "") {
if (TextUtils.isEmpty(gameId)) {
Toast.makeText(
context,
context.getString(R.string.ini_saved),
Toast.LENGTH_SHORT
).show()
NativeConfig.saveSettings()
} else {
// TODO: Save custom game settings
Toast.makeText(
context,
context.getString(R.string.gameid_saved, gameId),
Toast.LENGTH_SHORT
).show()
}
}
enum class Category { enum class Category {
Android, Android,
Audio, Audio,

@ -19,12 +19,13 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.navArgs import androidx.navigation.navArgs
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment
import org.yuzu.yuzu_emu.model.SettingsViewModel import org.yuzu.yuzu_emu.model.SettingsViewModel
@ -53,10 +54,6 @@ class SettingsActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
if (savedInstanceState != null) {
settingsViewModel.shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
}
if (InsetsHelper.getSystemGestureType(applicationContext) != if (InsetsHelper.getSystemGestureType(applicationContext) !=
InsetsHelper.GESTURE_NAVIGATION InsetsHelper.GESTURE_NAVIGATION
) { ) {
@ -127,12 +124,6 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
override fun onSaveInstanceState(outState: Bundle) {
// Critical: If super method is not called, rotations will be busted.
super.onSaveInstanceState(outState)
outState.putBoolean(KEY_SHOULD_SAVE, settingsViewModel.shouldSave)
}
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
// TODO: Load custom settings contextually // TODO: Load custom settings contextually
@ -141,16 +132,10 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
/**
* If this is called, the user has left the settings screen (potentially through the
* home button) and will expect their changes to be persisted. So we kick off an
* IntentService which will do so on a background thread.
*/
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (isFinishing && settingsViewModel.shouldSave) { CoroutineScope(Dispatchers.IO).launch {
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...") NativeConfig.saveSettings()
Settings.saveSettings()
} }
} }
@ -160,9 +145,6 @@ class SettingsActivity : AppCompatActivity() {
} }
fun onSettingsReset() { fun onSettingsReset() {
// Prevents saving to a non-existent settings file
settingsViewModel.shouldSave = false
// Delete settings file because the user may have changed values that do not exist in the UI // Delete settings file because the user may have changed values that do not exist in the UI
NativeConfig.unloadConfig() NativeConfig.unloadConfig()
val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
@ -194,8 +176,4 @@ class SettingsActivity : AppCompatActivity() {
windowInsets windowInsets
} }
} }
companion object {
private const val KEY_SHOULD_SAVE = "should_save"
}
} }

@ -105,7 +105,6 @@ class SettingsAdapter(
fun onBooleanClick(item: SwitchSetting, checked: Boolean) { fun onBooleanClick(item: SwitchSetting, checked: Boolean) {
item.checked = checked item.checked = checked
settingsViewModel.setShouldReloadSettingsList(true) settingsViewModel.setShouldReloadSettingsList(true)
settingsViewModel.shouldSave = true
} }
fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) {
@ -161,7 +160,6 @@ class SettingsAdapter(
epochTime += timePicker.hour.toLong() * 60 * 60 epochTime += timePicker.hour.toLong() * 60 * 60
epochTime += timePicker.minute.toLong() * 60 epochTime += timePicker.minute.toLong() * 60
if (item.value != epochTime) { if (item.value != epochTime) {
settingsViewModel.shouldSave = true
notifyItemChanged(position) notifyItemChanged(position)
item.value = epochTime item.value = epochTime
} }

@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
import android.content.DialogInterface
import android.net.Uri
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.DialogAddFolderBinding
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel
class AddGameFolderDialogFragment : DialogFragment() {
private val gamesViewModel: GamesViewModel by activityViewModels()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = DialogAddFolderBinding.inflate(layoutInflater)
val folderUriString = requireArguments().getString(FOLDER_URI_STRING)
if (folderUriString == null) {
dismiss()
}
binding.path.text = Uri.parse(folderUriString).path
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.add_game_folder)
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked)
gamesViewModel.addFolder(newGameDir)
}
.setNegativeButton(android.R.string.cancel, null)
.setView(binding.root)
.show()
}
companion object {
const val TAG = "AddGameFolderDialogFragment"
private const val FOLDER_URI_STRING = "FolderUriString"
fun newInstance(folderUriString: String): AddGameFolderDialogFragment {
val args = Bundle()
args.putString(FOLDER_URI_STRING, folderUriString)
val fragment = AddGameFolderDialogFragment()
fragment.arguments = args
return fragment
}
}
}

@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
class GameFolderPropertiesDialogFragment : DialogFragment() {
private val gamesViewModel: GamesViewModel by activityViewModels()
private var deepScan = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = DialogFolderPropertiesBinding.inflate(layoutInflater)
val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!!
// Restore checkbox state
binding.deepScanSwitch.isChecked =
savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan
// Ensure that we can get the checkbox state even if the view is destroyed
deepScan = binding.deepScanSwitch.isChecked
binding.deepScanSwitch.setOnClickListener {
deepScan = binding.deepScanSwitch.isChecked
}
return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root)
.setTitle(R.string.game_folder_properties)
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
val folderIndex = gamesViewModel.folders.value.indexOf(gameDir)
if (folderIndex != -1) {
gamesViewModel.folders.value[folderIndex].deepScan =
binding.deepScanSwitch.isChecked
gamesViewModel.updateGameDirs()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(DEEP_SCAN, deepScan)
}
companion object {
const val TAG = "GameFolderPropertiesDialogFragment"
private const val GAME_DIR = "GameDir"
private const val DEEP_SCAN = "DeepScan"
fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment {
val args = Bundle()
args.putParcelable(GAME_DIR, gameDir)
val fragment = GameFolderPropertiesDialogFragment()
fragment.arguments = args
return fragment
}
}
}

@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import kotlinx.coroutines.launch
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.FolderAdapter
import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.ui.main.MainActivity
class GameFoldersFragment : Fragment() {
private var _binding: FragmentFoldersBinding? = null
private val binding get() = _binding!!
private val homeViewModel: HomeViewModel by activityViewModels()
private val gamesViewModel: GamesViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
gamesViewModel.onOpenGameFoldersFragment()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentFoldersBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
homeViewModel.setNavigationVisibility(visible = false, animated = true)
homeViewModel.setStatusBarShadeVisibility(visible = false)
binding.toolbarFolders.setNavigationOnClickListener {
binding.root.findNavController().popBackStack()
}
binding.listFolders.apply {
layoutManager = GridLayoutManager(
requireContext(),
resources.getInteger(R.integer.grid_columns)
)
adapter = FolderAdapter(requireActivity(), gamesViewModel)
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
gamesViewModel.folders.collect {
(binding.listFolders.adapter as FolderAdapter).submitList(it)
}
}
}
val mainActivity = requireActivity() as MainActivity
binding.buttonAdd.setOnClickListener {
mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
}
setInsets()
}
override fun onStop() {
super.onStop()
gamesViewModel.onCloseGameFoldersFragment()
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(
binding.root
) { _: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val leftInsets = barInsets.left + cutoutInsets.left
val rightInsets = barInsets.right + cutoutInsets.right
val mlpToolbar = binding.toolbarFolders.layoutParams as ViewGroup.MarginLayoutParams
mlpToolbar.leftMargin = leftInsets
mlpToolbar.rightMargin = rightInsets
binding.toolbarFolders.layoutParams = mlpToolbar
val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
val mlpFab =
binding.buttonAdd.layoutParams as ViewGroup.MarginLayoutParams
mlpFab.leftMargin = leftInsets + fabSpacing
mlpFab.rightMargin = rightInsets + fabSpacing
mlpFab.bottomMargin = barInsets.bottom + fabSpacing
binding.buttonAdd.layoutParams = mlpFab
val mlpListFolders = binding.listFolders.layoutParams as ViewGroup.MarginLayoutParams
mlpListFolders.leftMargin = leftInsets
mlpListFolders.rightMargin = rightInsets
binding.listFolders.layoutParams = mlpListFolders
binding.listFolders.updatePadding(
bottom = barInsets.bottom +
resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
)
windowInsets
}
}

@ -127,18 +127,13 @@ class HomeSettingsFragment : Fragment() {
) )
add( add(
HomeSetting( HomeSetting(
R.string.select_games_folder, R.string.manage_game_folders,
R.string.select_games_folder_description, R.string.select_games_folder_description,
R.drawable.ic_add, R.drawable.ic_add,
{ {
mainActivity.getGamesDirectory.launch( binding.root.findNavController()
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data .navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment)
) }
},
{ true },
0,
0,
homeViewModel.gamesDir
) )
) )
add( add(

@ -52,7 +52,6 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
settingsViewModel.clickedItem!!.setting.reset() settingsViewModel.clickedItem!!.setting.reset()
settingsViewModel.setAdapterItemChanged(position) settingsViewModel.setAdapterItemChanged(position)
settingsViewModel.shouldSave = true
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.create() .create()
@ -137,24 +136,17 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener
is SingleChoiceSetting -> { is SingleChoiceSetting -> {
val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting
val value = getValueForSingleChoiceSelection(scSetting, which) val value = getValueForSingleChoiceSelection(scSetting, which)
if (scSetting.selectedValue != value) {
settingsViewModel.shouldSave = true
}
scSetting.selectedValue = value scSetting.selectedValue = value
} }
is StringSingleChoiceSetting -> { is StringSingleChoiceSetting -> {
val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting
val value = scSetting.getValueAt(which) val value = scSetting.getValueAt(which)
if (scSetting.selectedValue != value) settingsViewModel.shouldSave = true
scSetting.selectedValue = value scSetting.selectedValue = value
} }
is SliderSetting -> { is SliderSetting -> {
val sliderSetting = settingsViewModel.clickedItem as SliderSetting val sliderSetting = settingsViewModel.clickedItem as SliderSetting
if (sliderSetting.selectedValue != settingsViewModel.sliderProgress.value) {
settingsViewModel.shouldSave = true
}
sliderSetting.selectedValue = settingsViewModel.sliderProgress.value sliderSetting.selectedValue = settingsViewModel.sliderProgress.value
} }
} }

@ -42,7 +42,7 @@ import org.yuzu.yuzu_emu.model.SetupPage
import org.yuzu.yuzu_emu.model.StepState import org.yuzu.yuzu_emu.model.StepState
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.GameHelper import org.yuzu.yuzu_emu.utils.NativeConfig
import org.yuzu.yuzu_emu.utils.ViewUtils import org.yuzu.yuzu_emu.utils.ViewUtils
class SetupFragment : Fragment() { class SetupFragment : Fragment() {
@ -184,11 +184,7 @@ class SetupFragment : Fragment() {
R.string.add_games_warning_description, R.string.add_games_warning_description,
R.string.add_games_warning_help, R.string.add_games_warning_help,
{ {
val preferences = if (NativeConfig.getGameDirs().isNotEmpty()) {
PreferenceManager.getDefaultSharedPreferences(
YuzuApplication.appContext
)
if (preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()) {
StepState.COMPLETE StepState.COMPLETE
} else { } else {
StepState.INCOMPLETE StepState.INCOMPLETE

@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class GameDir(
val uriString: String,
var deepScan: Boolean
) : Parcelable

@ -12,6 +12,7 @@ import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -20,6 +21,7 @@ import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.utils.GameHelper import org.yuzu.yuzu_emu.utils.GameHelper
import org.yuzu.yuzu_emu.utils.GameMetadata import org.yuzu.yuzu_emu.utils.GameMetadata
import org.yuzu.yuzu_emu.utils.NativeConfig
class GamesViewModel : ViewModel() { class GamesViewModel : ViewModel() {
val games: StateFlow<List<Game>> get() = _games val games: StateFlow<List<Game>> get() = _games
@ -40,6 +42,9 @@ class GamesViewModel : ViewModel() {
val searchFocused: StateFlow<Boolean> get() = _searchFocused val searchFocused: StateFlow<Boolean> get() = _searchFocused
private val _searchFocused = MutableStateFlow(false) private val _searchFocused = MutableStateFlow(false)
private val _folders = MutableStateFlow(mutableListOf<GameDir>())
val folders = _folders.asStateFlow()
init { init {
// Ensure keys are loaded so that ROM metadata can be decrypted. // Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys() NativeLibrary.reloadKeys()
@ -50,6 +55,7 @@ class GamesViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
getGameDirs()
if (storedGames!!.isNotEmpty()) { if (storedGames!!.isNotEmpty()) {
val deserializedGames = mutableSetOf<Game>() val deserializedGames = mutableSetOf<Game>()
storedGames.forEach { storedGames.forEach {
@ -104,7 +110,7 @@ class GamesViewModel : ViewModel() {
_searchFocused.value = searchFocused _searchFocused.value = searchFocused
} }
fun reloadGames(directoryChanged: Boolean) { fun reloadGames(directoriesChanged: Boolean) {
if (isReloading.value) { if (isReloading.value) {
return return
} }
@ -116,10 +122,61 @@ class GamesViewModel : ViewModel() {
setGames(GameHelper.getGames()) setGames(GameHelper.getGames())
_isReloading.value = false _isReloading.value = false
if (directoryChanged) { if (directoriesChanged) {
setShouldSwapData(true) setShouldSwapData(true)
} }
} }
} }
} }
fun addFolder(gameDir: GameDir) =
viewModelScope.launch {
withContext(Dispatchers.IO) {
NativeConfig.addGameDir(gameDir)
getGameDirs()
}
}
fun removeFolder(gameDir: GameDir) =
viewModelScope.launch {
withContext(Dispatchers.IO) {
val gameDirs = _folders.value.toMutableList()
val removedDirIndex = gameDirs.indexOf(gameDir)
if (removedDirIndex != -1) {
gameDirs.removeAt(removedDirIndex)
NativeConfig.setGameDirs(gameDirs.toTypedArray())
getGameDirs()
}
}
}
fun updateGameDirs() =
viewModelScope.launch {
withContext(Dispatchers.IO) {
NativeConfig.setGameDirs(_folders.value.toTypedArray())
getGameDirs()
}
}
fun onOpenGameFoldersFragment() =
viewModelScope.launch {
withContext(Dispatchers.IO) {
getGameDirs()
}
}
fun onCloseGameFoldersFragment() =
viewModelScope.launch {
withContext(Dispatchers.IO) {
getGameDirs(true)
}
}
private fun getGameDirs(reloadList: Boolean = false) {
val gameDirs = NativeConfig.getGameDirs()
_folders.value = gameDirs.toMutableList()
if (reloadList) {
reloadGames(true)
}
}
} }

@ -3,15 +3,9 @@
package org.yuzu.yuzu_emu.model package org.yuzu.yuzu_emu.model
import android.net.Uri
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.utils.GameHelper
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
val navigationVisible: StateFlow<Pair<Boolean, Boolean>> get() = _navigationVisible val navigationVisible: StateFlow<Pair<Boolean, Boolean>> get() = _navigationVisible
@ -23,14 +17,6 @@ class HomeViewModel : ViewModel() {
val shouldPageForward: StateFlow<Boolean> get() = _shouldPageForward val shouldPageForward: StateFlow<Boolean> get() = _shouldPageForward
private val _shouldPageForward = MutableStateFlow(false) private val _shouldPageForward = MutableStateFlow(false)
val gamesDir: StateFlow<String> get() = _gamesDir
private val _gamesDir = MutableStateFlow(
Uri.parse(
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
.getString(GameHelper.KEY_GAME_PATH, "")
).path ?: ""
)
var navigatedToSetup = false var navigatedToSetup = false
fun setNavigationVisibility(visible: Boolean, animated: Boolean) { fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
@ -50,9 +36,4 @@ class HomeViewModel : ViewModel() {
fun setShouldPageForward(pageForward: Boolean) { fun setShouldPageForward(pageForward: Boolean) {
_shouldPageForward.value = pageForward _shouldPageForward.value = pageForward
} }
fun setGamesDir(activity: FragmentActivity, dir: String) {
ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
_gamesDir.value = dir
}
} }

@ -13,8 +13,6 @@ import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
class SettingsViewModel : ViewModel() { class SettingsViewModel : ViewModel() {
var game: Game? = null var game: Game? = null
var shouldSave = false
var clickedItem: SettingsItem? = null var clickedItem: SettingsItem? = null
val shouldRecreate: StateFlow<Boolean> get() = _shouldRecreate val shouldRecreate: StateFlow<Boolean> get() = _shouldRecreate
@ -73,6 +71,5 @@ class SettingsViewModel : ViewModel() {
fun clear() { fun clear() {
game = null game = null
shouldSave = false
} }
} }

@ -40,6 +40,7 @@ import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment
import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
import org.yuzu.yuzu_emu.getPublicFilesDir import org.yuzu.yuzu_emu.getPublicFilesDir
@ -252,6 +253,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
super.onResume() super.onResume()
} }
override fun onStop() {
super.onStop()
CoroutineScope(Dispatchers.IO).launch {
NativeConfig.saveSettings()
}
}
override fun onDestroy() { override fun onDestroy() {
EmulationActivity.stopForegroundService(this) EmulationActivity.stopForegroundService(this)
super.onDestroy() super.onDestroy()
@ -293,20 +301,19 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
Intent.FLAG_GRANT_READ_URI_PERMISSION Intent.FLAG_GRANT_READ_URI_PERMISSION
) )
// When a new directory is picked, we currently will reset the existing games val uriString = result.toString()
// database. This effectively means that only one game directory is supported. val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString }
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit() if (folder != null) {
.putString(GameHelper.KEY_GAME_PATH, result.toString()) Toast.makeText(
.apply() applicationContext,
R.string.folder_already_added,
Toast.LENGTH_SHORT
).show()
return
}
Toast.makeText( AddGameFolderDialogFragment.newInstance(uriString)
applicationContext, .show(supportFragmentManager, AddGameFolderDialogFragment.TAG)
R.string.games_dir_selected,
Toast.LENGTH_LONG
).show()
gamesViewModel.reloadGames(true)
homeViewModel.setGamesDir(this, result.path!!)
} }
val getProdKey = val getProdKey =

@ -364,6 +364,27 @@ object FileUtil {
.lowercase() .lowercase()
} }
fun isTreeUriValid(uri: Uri): Boolean {
val resolver = context.contentResolver
val columns = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
return try {
val docId: String = if (isRootTreeUri(uri)) {
DocumentsContract.getTreeDocumentId(uri)
} else {
DocumentsContract.getDocumentId(uri)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
resolver.query(childrenUri, columns, null, null, null)
true
} catch (_: Exception) {
false
}
}
@Throws(IOException::class) @Throws(IOException::class)
fun getStringFromFile(file: File): String = fun getStringFromFile(file: File): String =
String(file.readBytes(), StandardCharsets.UTF_8) String(file.readBytes(), StandardCharsets.UTF_8)

@ -11,10 +11,11 @@ import kotlinx.serialization.json.Json
import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.MinimalDocumentFile import org.yuzu.yuzu_emu.model.MinimalDocumentFile
object GameHelper { object GameHelper {
const val KEY_GAME_PATH = "game_path" private const val KEY_OLD_GAME_PATH = "game_path"
const val KEY_GAMES = "Games" const val KEY_GAMES = "Games"
private lateinit var preferences: SharedPreferences private lateinit var preferences: SharedPreferences
@ -22,15 +23,43 @@ object GameHelper {
fun getGames(): List<Game> { fun getGames(): List<Game> {
val games = mutableListOf<Game>() val games = mutableListOf<Game>()
val context = YuzuApplication.appContext val context = YuzuApplication.appContext
val gamesDir =
PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
val gamesUri = Uri.parse(gamesDir)
preferences = PreferenceManager.getDefaultSharedPreferences(context) preferences = PreferenceManager.getDefaultSharedPreferences(context)
val gameDirs = mutableListOf<GameDir>()
val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: ""
if (oldGamesDir.isNotEmpty()) {
gameDirs.add(GameDir(oldGamesDir, true))
preferences.edit().remove(KEY_OLD_GAME_PATH).apply()
}
gameDirs.addAll(NativeConfig.getGameDirs())
// Ensure keys are loaded so that ROM metadata can be decrypted. // Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys() NativeLibrary.reloadKeys()
addGamesRecursive(games, FileUtil.listFiles(gamesUri), 3) val badDirs = mutableListOf<Int>()
gameDirs.forEachIndexed { index: Int, gameDir: GameDir ->
val gameDirUri = Uri.parse(gameDir.uriString)
val isValid = FileUtil.isTreeUriValid(gameDirUri)
if (isValid) {
addGamesRecursive(
games,
FileUtil.listFiles(gameDirUri),
if (gameDir.deepScan) 3 else 1
)
} else {
badDirs.add(index)
}
}
// Remove all game dirs with insufficient permissions from config
if (badDirs.isNotEmpty()) {
var offset = 0
badDirs.forEach {
gameDirs.removeAt(it - offset)
offset++
}
}
NativeConfig.setGameDirs(gameDirs.toTypedArray())
// Cache list of games found on disk // Cache list of games found on disk
val serializedGames = mutableSetOf<String>() val serializedGames = mutableSetOf<String>()

@ -3,6 +3,8 @@
package org.yuzu.yuzu_emu.utils package org.yuzu.yuzu_emu.utils
import org.yuzu.yuzu_emu.model.GameDir
object NativeConfig { object NativeConfig {
/** /**
* Creates a Config object and opens the emulation config. * Creates a Config object and opens the emulation config.
@ -54,4 +56,22 @@ object NativeConfig {
external fun getConfigHeader(category: Int): String external fun getConfigHeader(category: Int): String
external fun getPairedSettingKey(key: String): String external fun getPairedSettingKey(key: String): String
/**
* Gets every [GameDir] in AndroidSettings::values.game_dirs
*/
@Synchronized
external fun getGameDirs(): Array<GameDir>
/**
* Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array
*/
@Synchronized
external fun setGameDirs(dirs: Array<GameDir>)
/**
* Adds a single [GameDir] to the AndroidSettings::values.game_dirs array
*/
@Synchronized
external fun addGameDir(dir: GameDir)
} }

@ -34,6 +34,7 @@ void AndroidConfig::SaveAllValues() {
void AndroidConfig::ReadAndroidValues() { void AndroidConfig::ReadAndroidValues() {
if (global) { if (global) {
ReadAndroidUIValues(); ReadAndroidUIValues();
ReadUIValues();
} }
} }
@ -45,9 +46,35 @@ void AndroidConfig::ReadAndroidUIValues() {
EndGroup(); EndGroup();
} }
void AndroidConfig::ReadUIValues() {
BeginGroup(Settings::TranslateCategory(Settings::Category::Ui));
ReadPathValues();
EndGroup();
}
void AndroidConfig::ReadPathValues() {
BeginGroup(Settings::TranslateCategory(Settings::Category::Paths));
const int gamedirs_size = BeginArray(std::string("gamedirs"));
for (int i = 0; i < gamedirs_size; ++i) {
SetArrayIndex(i);
AndroidSettings::GameDir game_dir;
game_dir.path = ReadStringSetting(std::string("path"));
game_dir.deep_scan =
ReadBooleanSetting(std::string("deep_scan"), std::make_optional(false));
AndroidSettings::values.game_dirs.push_back(game_dir);
}
EndArray();
EndGroup();
}
void AndroidConfig::SaveAndroidValues() { void AndroidConfig::SaveAndroidValues() {
if (global) { if (global) {
SaveAndroidUIValues(); SaveAndroidUIValues();
SaveUIValues();
} }
WriteToIni(); WriteToIni();
@ -61,6 +88,29 @@ void AndroidConfig::SaveAndroidUIValues() {
EndGroup(); EndGroup();
} }
void AndroidConfig::SaveUIValues() {
BeginGroup(Settings::TranslateCategory(Settings::Category::Ui));
SavePathValues();
EndGroup();
}
void AndroidConfig::SavePathValues() {
BeginGroup(Settings::TranslateCategory(Settings::Category::Paths));
BeginArray(std::string("gamedirs"));
for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) {
SetArrayIndex(i);
const auto& game_dir = AndroidSettings::values.game_dirs[i];
WriteSetting(std::string("path"), game_dir.path);
WriteSetting(std::string("deep_scan"), game_dir.deep_scan, std::make_optional(false));
}
EndArray();
EndGroup();
}
std::vector<Settings::BasicSetting*>& AndroidConfig::FindRelevantList(Settings::Category category) { std::vector<Settings::BasicSetting*>& AndroidConfig::FindRelevantList(Settings::Category category) {
auto& map = Settings::values.linkage.by_category; auto& map = Settings::values.linkage.by_category;
if (map.contains(category)) { if (map.contains(category)) {

@ -19,9 +19,9 @@ protected:
void ReadAndroidUIValues(); void ReadAndroidUIValues();
void ReadHidbusValues() override {} void ReadHidbusValues() override {}
void ReadDebugControlValues() override {} void ReadDebugControlValues() override {}
void ReadPathValues() override {} void ReadPathValues() override;
void ReadShortcutValues() override {} void ReadShortcutValues() override {}
void ReadUIValues() override {} void ReadUIValues() override;
void ReadUIGamelistValues() override {} void ReadUIGamelistValues() override {}
void ReadUILayoutValues() override {} void ReadUILayoutValues() override {}
void ReadMultiplayerValues() override {} void ReadMultiplayerValues() override {}
@ -30,9 +30,9 @@ protected:
void SaveAndroidUIValues(); void SaveAndroidUIValues();
void SaveHidbusValues() override {} void SaveHidbusValues() override {}
void SaveDebugControlValues() override {} void SaveDebugControlValues() override {}
void SavePathValues() override {} void SavePathValues() override;
void SaveShortcutValues() override {} void SaveShortcutValues() override {}
void SaveUIValues() override {} void SaveUIValues() override;
void SaveUIGamelistValues() override {} void SaveUIGamelistValues() override {}
void SaveUILayoutValues() override {} void SaveUILayoutValues() override {}
void SaveMultiplayerValues() override {} void SaveMultiplayerValues() override {}

@ -9,9 +9,17 @@
namespace AndroidSettings { namespace AndroidSettings {
struct GameDir {
std::string path;
bool deep_scan = false;
};
struct Values { struct Values {
Settings::Linkage linkage; Settings::Linkage linkage;
// Path settings
std::vector<GameDir> game_dirs;
// Android // Android
Settings::Setting<bool> picture_in_picture{linkage, false, "picture_in_picture", Settings::Setting<bool> picture_in_picture{linkage, false, "picture_in_picture",
Settings::Category::Android}; Settings::Category::Android};

@ -13,6 +13,8 @@ static JavaVM* s_java_vm;
static jclass s_native_library_class; static jclass s_native_library_class;
static jclass s_disk_cache_progress_class; static jclass s_disk_cache_progress_class;
static jclass s_load_callback_stage_class; static jclass s_load_callback_stage_class;
static jclass s_game_dir_class;
static jmethodID s_game_dir_constructor;
static jmethodID s_exit_emulation_activity; static jmethodID s_exit_emulation_activity;
static jmethodID s_disk_cache_load_progress; static jmethodID s_disk_cache_load_progress;
static jmethodID s_on_emulation_started; static jmethodID s_on_emulation_started;
@ -53,6 +55,14 @@ jclass GetDiskCacheLoadCallbackStageClass() {
return s_load_callback_stage_class; return s_load_callback_stage_class;
} }
jclass GetGameDirClass() {
return s_game_dir_class;
}
jmethodID GetGameDirConstructor() {
return s_game_dir_constructor;
}
jmethodID GetExitEmulationActivity() { jmethodID GetExitEmulationActivity() {
return s_exit_emulation_activity; return s_exit_emulation_activity;
} }
@ -90,6 +100,11 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
s_load_callback_stage_class = reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass( s_load_callback_stage_class = reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass(
"org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage"))); "org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage")));
const jclass game_dir_class = env->FindClass("org/yuzu/yuzu_emu/model/GameDir");
s_game_dir_class = reinterpret_cast<jclass>(env->NewGlobalRef(game_dir_class));
s_game_dir_constructor = env->GetMethodID(game_dir_class, "<init>", "(Ljava/lang/String;Z)V");
env->DeleteLocalRef(game_dir_class);
// Initialize methods // Initialize methods
s_exit_emulation_activity = s_exit_emulation_activity =
env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V");
@ -120,6 +135,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
env->DeleteGlobalRef(s_native_library_class); env->DeleteGlobalRef(s_native_library_class);
env->DeleteGlobalRef(s_disk_cache_progress_class); env->DeleteGlobalRef(s_disk_cache_progress_class);
env->DeleteGlobalRef(s_load_callback_stage_class); env->DeleteGlobalRef(s_load_callback_stage_class);
env->DeleteGlobalRef(s_game_dir_class);
// UnInitialize applets // UnInitialize applets
SoftwareKeyboard::CleanupJNI(env); SoftwareKeyboard::CleanupJNI(env);

@ -13,6 +13,8 @@ JNIEnv* GetEnvForThread();
jclass GetNativeLibraryClass(); jclass GetNativeLibraryClass();
jclass GetDiskCacheProgressClass(); jclass GetDiskCacheProgressClass();
jclass GetDiskCacheLoadCallbackStageClass(); jclass GetDiskCacheLoadCallbackStageClass();
jclass GetGameDirClass();
jmethodID GetGameDirConstructor();
jmethodID GetExitEmulationActivity(); jmethodID GetExitEmulationActivity();
jmethodID GetDiskCacheLoadProgress(); jmethodID GetDiskCacheLoadProgress();
jmethodID GetOnEmulationStarted(); jmethodID GetOnEmulationStarted();

@ -11,6 +11,7 @@
#include "common/settings.h" #include "common/settings.h"
#include "frontend_common/config.h" #include "frontend_common/config.h"
#include "jni/android_common/android_common.h" #include "jni/android_common/android_common.h"
#include "jni/id_cache.h"
std::unique_ptr<AndroidConfig> config; std::unique_ptr<AndroidConfig> config;
@ -253,4 +254,55 @@ jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getPairedSettingKey(JNIEnv* e
return ToJString(env, setting->PairedSetting()->GetLabel()); return ToJString(env, setting->PairedSetting()->GetLabel());
} }
jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getGameDirs(JNIEnv* env, jobject obj) {
jclass gameDirClass = IDCache::GetGameDirClass();
jmethodID gameDirConstructor = IDCache::GetGameDirConstructor();
jobjectArray jgameDirArray =
env->NewObjectArray(AndroidSettings::values.game_dirs.size(), gameDirClass, nullptr);
for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) {
jobject jgameDir =
env->NewObject(gameDirClass, gameDirConstructor,
ToJString(env, AndroidSettings::values.game_dirs[i].path),
static_cast<jboolean>(AndroidSettings::values.game_dirs[i].deep_scan));
env->SetObjectArrayElement(jgameDirArray, i, jgameDir);
}
return jgameDirArray;
}
void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setGameDirs(JNIEnv* env, jobject obj,
jobjectArray gameDirs) {
AndroidSettings::values.game_dirs.clear();
int size = env->GetArrayLength(gameDirs);
if (size == 0) {
return;
}
jobject dir = env->GetObjectArrayElement(gameDirs, 0);
jclass gameDirClass = IDCache::GetGameDirClass();
jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;");
jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z");
for (int i = 0; i < size; ++i) {
dir = env->GetObjectArrayElement(gameDirs, i);
jstring juriString = static_cast<jstring>(env->GetObjectField(dir, uriStringField));
jboolean jdeepScanBoolean = env->GetBooleanField(dir, deepScanBooleanField);
std::string uriString = GetJString(env, juriString);
AndroidSettings::values.game_dirs.push_back(
AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)});
}
}
void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject obj,
jobject gameDir) {
jclass gameDirClass = IDCache::GetGameDirClass();
jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;");
jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z");
jstring juriString = static_cast<jstring>(env->GetObjectField(gameDir, uriStringField));
jboolean jdeepScanBoolean = env->GetBooleanField(gameDir, deepScanBooleanField);
std::string uriString = GetJString(env, juriString);
AndroidSettings::values.game_dirs.push_back(
AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)});
}
} // extern "C" } // extern "C"

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?attr/materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="12dp"
android:focusable="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:layout_gravity="center_vertical"
android:animateLayoutChanges="true">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/path"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|start"
android:ellipsize="none"
android:marqueeRepeatLimit="marquee_forever"
android:requiresFadingEdge="horizontal"
android:singleLine="true"
android:textAlignment="viewStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@string/select_gpu_driver_default" />
<LinearLayout
android:id="@+id/button_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<Button
android:id="@+id/button_edit"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/delete"
android:tooltipText="@string/edit"
app:icon="@drawable/ic_edit"
app:iconTint="?attr/colorControlNormal" />
<Button
android:id="@+id/button_delete"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/delete"
android:tooltipText="@string/delete"
app:icon="@drawable/ic_delete"
app:iconTint="?attr/colorControlNormal" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="24dp"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/path"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="center_vertical|start"
android:layout_weight="1"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:requiresFadingEdge="horizontal"
android:singleLine="true"
android:textAlignment="viewStart"
tools:text="folder/folder/folder/folder" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="8dp">
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|start"
android:layout_weight="1"
android:text="@string/deep_scan"
android:textAlignment="viewStart" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/deep_scan_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="24dp"
android:orientation="vertical">
<LinearLayout
android:id="@+id/deep_scan_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|start"
android:layout_weight="1"
android:text="@string/deep_scan"
android:textAlignment="viewStart" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/deep_scan_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/coordinator_folders"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_folders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:liftOnScrollTargetViewId="@id/list_folders">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar_folders"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_back"
app:title="@string/game_folders" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_folders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/button_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:contentDescription="@string/add_games"
app:srcCompat="@drawable/ic_add"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -28,6 +28,9 @@
<action <action
android:id="@+id/action_homeSettingsFragment_to_appletLauncherFragment" android:id="@+id/action_homeSettingsFragment_to_appletLauncherFragment"
app:destination="@id/appletLauncherFragment" /> app:destination="@id/appletLauncherFragment" />
<action
android:id="@+id/action_homeSettingsFragment_to_gameFoldersFragment"
app:destination="@id/gameFoldersFragment" />
</fragment> </fragment>
<fragment <fragment
@ -117,5 +120,9 @@
android:id="@+id/cabinetLauncherDialogFragment" android:id="@+id/cabinetLauncherDialogFragment"
android:name="org.yuzu.yuzu_emu.fragments.CabinetLauncherDialogFragment" android:name="org.yuzu.yuzu_emu.fragments.CabinetLauncherDialogFragment"
android:label="CabinetLauncherDialogFragment" /> android:label="CabinetLauncherDialogFragment" />
<fragment
android:id="@+id/gameFoldersFragment"
android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment"
android:label="GameFoldersFragment" />
</navigation> </navigation>

@ -13,7 +13,7 @@
<dimen name="menu_width">256dp</dimen> <dimen name="menu_width">256dp</dimen>
<dimen name="card_width">165dp</dimen> <dimen name="card_width">165dp</dimen>
<dimen name="icon_inset">24dp</dimen> <dimen name="icon_inset">24dp</dimen>
<dimen name="spacing_bottom_list_fab">72dp</dimen> <dimen name="spacing_bottom_list_fab">76dp</dimen>
<dimen name="spacing_fab">24dp</dimen> <dimen name="spacing_fab">24dp</dimen>
<dimen name="dialog_margin">20dp</dimen> <dimen name="dialog_margin">20dp</dimen>

@ -38,6 +38,7 @@
<string name="empty_gamelist">No files were found or no game directory has been selected yet.</string> <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string>
<string name="search_and_filter_games">Search and filter games</string> <string name="search_and_filter_games">Search and filter games</string>
<string name="select_games_folder">Select games folder</string> <string name="select_games_folder">Select games folder</string>
<string name="manage_game_folders">Manage game folders</string>
<string name="select_games_folder_description">Allows yuzu to populate the games list</string> <string name="select_games_folder_description">Allows yuzu to populate the games list</string>
<string name="add_games_warning">Skip selecting games folder?</string> <string name="add_games_warning">Skip selecting games folder?</string>
<string name="add_games_warning_description">Games won\'t be displayed in the Games list if a folder isn\'t selected.</string> <string name="add_games_warning_description">Games won\'t be displayed in the Games list if a folder isn\'t selected.</string>
@ -124,6 +125,11 @@
<string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string> <string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string>
<string name="share_save_file">Share save file</string> <string name="share_save_file">Share save file</string>
<string name="export_save_failed">Failed to export save</string> <string name="export_save_failed">Failed to export save</string>
<string name="game_folders">Game folders</string>
<string name="deep_scan">Deep scan</string>
<string name="add_game_folder">Add game folder</string>
<string name="folder_already_added">This folder was already added!</string>
<string name="game_folder_properties">Game folder properties</string>
<!-- Applet launcher strings --> <!-- Applet launcher strings -->
<string name="applets">Applet launcher</string> <string name="applets">Applet launcher</string>
@ -257,6 +263,7 @@
<string name="cancelling">Cancelling</string> <string name="cancelling">Cancelling</string>
<string name="install">Install</string> <string name="install">Install</string>
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="edit">Edit</string>
<string name="export_success">Exported successfully</string> <string name="export_success">Exported successfully</string>
<!-- GPU driver installation --> <!-- GPU driver installation -->

@ -924,12 +924,14 @@ std::string Config::AdjustOutputString(const std::string& string) {
// Windows requires that two forward slashes are used at the start of a path for unmapped // Windows requires that two forward slashes are used at the start of a path for unmapped
// network drives so we have to watch for that here // network drives so we have to watch for that here
#ifndef ANDROID
if (string.substr(0, 2) == "//") { if (string.substr(0, 2) == "//") {
boost::replace_all(adjusted_string, "//", "/"); boost::replace_all(adjusted_string, "//", "/");
adjusted_string.insert(0, "/"); adjusted_string.insert(0, "/");
} else { } else {
boost::replace_all(adjusted_string, "//", "/"); boost::replace_all(adjusted_string, "//", "/");
} }
#endif
// Needed for backwards compatibility with QSettings deserialization // Needed for backwards compatibility with QSettings deserialization
for (const auto& special_character : special_characters) { for (const auto& special_character : special_characters) {