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
import android.text.TextUtils
import android.widget.Toast
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.utils.NativeConfig
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 {
Android,
Audio,

@ -19,12 +19,13 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.navArgs
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import org.yuzu.yuzu_emu.R
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.fragments.ResetSettingsDialogFragment
import org.yuzu.yuzu_emu.model.SettingsViewModel
@ -53,10 +54,6 @@ class SettingsActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
if (savedInstanceState != null) {
settingsViewModel.shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
}
if (InsetsHelper.getSystemGestureType(applicationContext) !=
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() {
super.onStart()
// 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() {
super.onStop()
if (isFinishing && settingsViewModel.shouldSave) {
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
Settings.saveSettings()
CoroutineScope(Dispatchers.IO).launch {
NativeConfig.saveSettings()
}
}
@ -160,9 +145,6 @@ class SettingsActivity : AppCompatActivity() {
}
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
NativeConfig.unloadConfig()
val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
@ -194,8 +176,4 @@ class SettingsActivity : AppCompatActivity() {
windowInsets
}
}
companion object {
private const val KEY_SHOULD_SAVE = "should_save"
}
}

@ -105,7 +105,6 @@ class SettingsAdapter(
fun onBooleanClick(item: SwitchSetting, checked: Boolean) {
item.checked = checked
settingsViewModel.setShouldReloadSettingsList(true)
settingsViewModel.shouldSave = true
}
fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) {
@ -161,7 +160,6 @@ class SettingsAdapter(
epochTime += timePicker.hour.toLong() * 60 * 60
epochTime += timePicker.minute.toLong() * 60
if (item.value != epochTime) {
settingsViewModel.shouldSave = true
notifyItemChanged(position)
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(
HomeSetting(
R.string.select_games_folder,
R.string.manage_game_folders,
R.string.select_games_folder_description,
R.drawable.ic_add,
{
mainActivity.getGamesDirectory.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data
)
},
{ true },
0,
0,
homeViewModel.gamesDir
binding.root.findNavController()
.navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment)
}
)
)
add(

@ -52,7 +52,6 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
settingsViewModel.clickedItem!!.setting.reset()
settingsViewModel.setAdapterItemChanged(position)
settingsViewModel.shouldSave = true
}
.setNegativeButton(android.R.string.cancel, null)
.create()
@ -137,24 +136,17 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener
is SingleChoiceSetting -> {
val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting
val value = getValueForSingleChoiceSelection(scSetting, which)
if (scSetting.selectedValue != value) {
settingsViewModel.shouldSave = true
}
scSetting.selectedValue = value
}
is StringSingleChoiceSetting -> {
val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting
val value = scSetting.getValueAt(which)
if (scSetting.selectedValue != value) settingsViewModel.shouldSave = true
scSetting.selectedValue = value
}
is SliderSetting -> {
val sliderSetting = settingsViewModel.clickedItem as SliderSetting
if (sliderSetting.selectedValue != settingsViewModel.sliderProgress.value) {
settingsViewModel.shouldSave = true
}
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.ui.main.MainActivity
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
class SetupFragment : Fragment() {
@ -184,11 +184,7 @@ class SetupFragment : Fragment() {
R.string.add_games_warning_description,
R.string.add_games_warning_help,
{
val preferences =
PreferenceManager.getDefaultSharedPreferences(
YuzuApplication.appContext
)
if (preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()) {
if (NativeConfig.getGameDirs().isNotEmpty()) {
StepState.COMPLETE
} else {
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.utils.GameHelper
import org.yuzu.yuzu_emu.utils.GameMetadata
import org.yuzu.yuzu_emu.utils.NativeConfig
class GamesViewModel : ViewModel() {
val games: StateFlow<List<Game>> get() = _games
@ -40,6 +42,9 @@ class GamesViewModel : ViewModel() {
val searchFocused: StateFlow<Boolean> get() = _searchFocused
private val _searchFocused = MutableStateFlow(false)
private val _folders = MutableStateFlow(mutableListOf<GameDir>())
val folders = _folders.asStateFlow()
init {
// Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys()
@ -50,6 +55,7 @@ class GamesViewModel : ViewModel() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
getGameDirs()
if (storedGames!!.isNotEmpty()) {
val deserializedGames = mutableSetOf<Game>()
storedGames.forEach {
@ -104,7 +110,7 @@ class GamesViewModel : ViewModel() {
_searchFocused.value = searchFocused
}
fun reloadGames(directoryChanged: Boolean) {
fun reloadGames(directoriesChanged: Boolean) {
if (isReloading.value) {
return
}
@ -116,10 +122,61 @@ class GamesViewModel : ViewModel() {
setGames(GameHelper.getGames())
_isReloading.value = false
if (directoryChanged) {
if (directoriesChanged) {
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
import android.net.Uri
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.utils.GameHelper
class HomeViewModel : ViewModel() {
val navigationVisible: StateFlow<Pair<Boolean, Boolean>> get() = _navigationVisible
@ -23,14 +17,6 @@ class HomeViewModel : ViewModel() {
val shouldPageForward: StateFlow<Boolean> get() = _shouldPageForward
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
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
@ -50,9 +36,4 @@ class HomeViewModel : ViewModel() {
fun setShouldPageForward(pageForward: Boolean) {
_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() {
var game: Game? = null
var shouldSave = false
var clickedItem: SettingsItem? = null
val shouldRecreate: StateFlow<Boolean> get() = _shouldRecreate
@ -73,6 +71,5 @@ class SettingsViewModel : ViewModel() {
fun clear() {
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.databinding.ActivityMainBinding
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.MessageDialogFragment
import org.yuzu.yuzu_emu.getPublicFilesDir
@ -252,6 +253,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
super.onResume()
}
override fun onStop() {
super.onStop()
CoroutineScope(Dispatchers.IO).launch {
NativeConfig.saveSettings()
}
}
override fun onDestroy() {
EmulationActivity.stopForegroundService(this)
super.onDestroy()
@ -293,20 +301,19 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
// When a new directory is picked, we currently will reset the existing games
// database. This effectively means that only one game directory is supported.
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
.putString(GameHelper.KEY_GAME_PATH, result.toString())
.apply()
val uriString = result.toString()
val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString }
if (folder != null) {
Toast.makeText(
applicationContext,
R.string.folder_already_added,
Toast.LENGTH_SHORT
).show()
return
}
Toast.makeText(
applicationContext,
R.string.games_dir_selected,
Toast.LENGTH_LONG
).show()
gamesViewModel.reloadGames(true)
homeViewModel.setGamesDir(this, result.path!!)
AddGameFolderDialogFragment.newInstance(uriString)
.show(supportFragmentManager, AddGameFolderDialogFragment.TAG)
}
val getProdKey =

@ -364,6 +364,27 @@ object FileUtil {
.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)
fun getStringFromFile(file: File): String =
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.YuzuApplication
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.MinimalDocumentFile
object GameHelper {
const val KEY_GAME_PATH = "game_path"
private const val KEY_OLD_GAME_PATH = "game_path"
const val KEY_GAMES = "Games"
private lateinit var preferences: SharedPreferences
@ -22,15 +23,43 @@ object GameHelper {
fun getGames(): List<Game> {
val games = mutableListOf<Game>()
val context = YuzuApplication.appContext
val gamesDir =
PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
val gamesUri = Uri.parse(gamesDir)
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.
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
val serializedGames = mutableSetOf<String>()

@ -3,6 +3,8 @@
package org.yuzu.yuzu_emu.utils
import org.yuzu.yuzu_emu.model.GameDir
object NativeConfig {
/**
* Creates a Config object and opens the emulation config.
@ -54,4 +56,22 @@ object NativeConfig {
external fun getConfigHeader(category: Int): 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() {
if (global) {
ReadAndroidUIValues();
ReadUIValues();
}
}
@ -45,9 +46,35 @@ void AndroidConfig::ReadAndroidUIValues() {
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() {
if (global) {
SaveAndroidUIValues();
SaveUIValues();
}
WriteToIni();
@ -61,6 +88,29 @@ void AndroidConfig::SaveAndroidUIValues() {
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) {
auto& map = Settings::values.linkage.by_category;
if (map.contains(category)) {

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

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

@ -13,6 +13,8 @@ static JavaVM* s_java_vm;
static jclass s_native_library_class;
static jclass s_disk_cache_progress_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_disk_cache_load_progress;
static jmethodID s_on_emulation_started;
@ -53,6 +55,14 @@ jclass GetDiskCacheLoadCallbackStageClass() {
return s_load_callback_stage_class;
}
jclass GetGameDirClass() {
return s_game_dir_class;
}
jmethodID GetGameDirConstructor() {
return s_game_dir_constructor;
}
jmethodID GetExitEmulationActivity() {
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(
"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
s_exit_emulation_activity =
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_disk_cache_progress_class);
env->DeleteGlobalRef(s_load_callback_stage_class);
env->DeleteGlobalRef(s_game_dir_class);
// UnInitialize applets
SoftwareKeyboard::CleanupJNI(env);

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

@ -11,6 +11,7 @@
#include "common/settings.h"
#include "frontend_common/config.h"
#include "jni/android_common/android_common.h"
#include "jni/id_cache.h"
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());
}
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"

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

@ -13,7 +13,7 @@
<dimen name="menu_width">256dp</dimen>
<dimen name="card_width">165dp</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="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="search_and_filter_games">Search and filter games</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="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>
@ -124,6 +125,11 @@
<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="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 -->
<string name="applets">Applet launcher</string>
@ -257,6 +263,7 @@
<string name="cancelling">Cancelling</string>
<string name="install">Install</string>
<string name="delete">Delete</string>
<string name="edit">Edit</string>
<string name="export_success">Exported successfully</string>
<!-- 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
// network drives so we have to watch for that here
#ifndef ANDROID
if (string.substr(0, 2) == "//") {
boost::replace_all(adjusted_string, "//", "/");
adjusted_string.insert(0, "/");
} else {
boost::replace_all(adjusted_string, "//", "/");
}
#endif
// Needed for backwards compatibility with QSettings deserialization
for (const auto& special_character : special_characters) {