summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar liamwhite2023-11-29 12:34:09 -0500
committerGravatar GitHub2023-11-29 12:34:09 -0500
commitaded28f276554f7f654ca3f6391c991b872862ac (patch)
treecfae0437bd8667186718e5f2e37a825f16d8f33a
parentMerge pull request #12203 from liamwhite/crash-fix (diff)
parentandroid: Save global settings in onStop (diff)
downloadyuzu-aded28f276554f7f654ca3f6391c991b872862ac.tar.gz
yuzu-aded28f276554f7f654ca3f6391c991b872862ac.tar.xz
yuzu-aded28f276554f7f654ca3f6391c991b872862ac.zip
Merge pull request #12204 from t895/config-migration
android: Multi directory UI
Diffstat (limited to '')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt76
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt24
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt30
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt53
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt72
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt128
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt61
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt19
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt3
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt33
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt21
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt39
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt20
-rw-r--r--src/android/app/src/main/jni/android_config.cpp50
-rw-r--r--src/android/app/src/main/jni/android_config.h8
-rw-r--r--src/android/app/src/main/jni/android_settings.h8
-rw-r--r--src/android/app/src/main/jni/id_cache.cpp16
-rw-r--r--src/android/app/src/main/jni/id_cache.h2
-rw-r--r--src/android/app/src/main/jni/native_config.cpp52
-rw-r--r--src/android/app/src/main/res/layout/card_folder.xml70
-rw-r--r--src/android/app/src/main/res/layout/dialog_add_folder.xml45
-rw-r--r--src/android/app/src/main/res/layout/dialog_folder_properties.xml30
-rw-r--r--src/android/app/src/main/res/layout/fragment_folders.xml48
-rw-r--r--src/android/app/src/main/res/navigation/home_navigation.xml7
-rw-r--r--src/android/app/src/main/res/values/dimens.xml2
-rw-r--r--src/android/app/src/main/res/values/strings.xml7
-rw-r--r--src/frontend_common/config.cpp2
32 files changed, 848 insertions, 122 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt
new file mode 100644
index 000000000..ab657a7b9
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt
@@ -0,0 +1,76 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.adapters
5
6import android.net.Uri
7import android.text.TextUtils
8import android.view.LayoutInflater
9import android.view.ViewGroup
10import androidx.fragment.app.FragmentActivity
11import androidx.recyclerview.widget.AsyncDifferConfig
12import androidx.recyclerview.widget.DiffUtil
13import androidx.recyclerview.widget.ListAdapter
14import androidx.recyclerview.widget.RecyclerView
15import org.yuzu.yuzu_emu.databinding.CardFolderBinding
16import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment
17import org.yuzu.yuzu_emu.model.GameDir
18import org.yuzu.yuzu_emu.model.GamesViewModel
19
20class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) :
21 ListAdapter<GameDir, FolderAdapter.FolderViewHolder>(
22 AsyncDifferConfig.Builder(DiffCallback()).build()
23 ) {
24 override fun onCreateViewHolder(
25 parent: ViewGroup,
26 viewType: Int
27 ): FolderAdapter.FolderViewHolder {
28 CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
29 .also { return FolderViewHolder(it) }
30 }
31
32 override fun onBindViewHolder(holder: FolderAdapter.FolderViewHolder, position: Int) =
33 holder.bind(currentList[position])
34
35 inner class FolderViewHolder(val binding: CardFolderBinding) :
36 RecyclerView.ViewHolder(binding.root) {
37 private lateinit var gameDir: GameDir
38
39 fun bind(gameDir: GameDir) {
40 this.gameDir = gameDir
41
42 binding.apply {
43 path.text = Uri.parse(gameDir.uriString).path
44 path.postDelayed(
45 {
46 path.isSelected = true
47 path.ellipsize = TextUtils.TruncateAt.MARQUEE
48 },
49 3000
50 )
51
52 buttonEdit.setOnClickListener {
53 GameFolderPropertiesDialogFragment.newInstance(this@FolderViewHolder.gameDir)
54 .show(
55 activity.supportFragmentManager,
56 GameFolderPropertiesDialogFragment.TAG
57 )
58 }
59
60 buttonDelete.setOnClickListener {
61 gamesViewModel.removeFolder(this@FolderViewHolder.gameDir)
62 }
63 }
64 }
65 }
66
67 private class DiffCallback : DiffUtil.ItemCallback<GameDir>() {
68 override fun areItemsTheSame(oldItem: GameDir, newItem: GameDir): Boolean {
69 return oldItem == newItem
70 }
71
72 override fun areContentsTheSame(oldItem: GameDir, newItem: GameDir): Boolean {
73 return oldItem == newItem
74 }
75 }
76}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
index d005c656e..e3cd66185 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
@@ -3,33 +3,9 @@
3 3
4package org.yuzu.yuzu_emu.features.settings.model 4package org.yuzu.yuzu_emu.features.settings.model
5 5
6import android.text.TextUtils
7import android.widget.Toast
8import org.yuzu.yuzu_emu.R 6import org.yuzu.yuzu_emu.R
9import org.yuzu.yuzu_emu.YuzuApplication
10import org.yuzu.yuzu_emu.utils.NativeConfig
11 7
12object Settings { 8object Settings {
13 private val context get() = YuzuApplication.appContext
14
15 fun saveSettings(gameId: String = "") {
16 if (TextUtils.isEmpty(gameId)) {
17 Toast.makeText(
18 context,
19 context.getString(R.string.ini_saved),
20 Toast.LENGTH_SHORT
21 ).show()
22 NativeConfig.saveSettings()
23 } else {
24 // TODO: Save custom game settings
25 Toast.makeText(
26 context,
27 context.getString(R.string.gameid_saved, gameId),
28 Toast.LENGTH_SHORT
29 ).show()
30 }
31 }
32
33 enum class Category { 9 enum class Category {
34 Android, 10 Android,
35 Audio, 11 Audio,
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
index 48bdbdd75..64bfc6dd0 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
@@ -19,12 +19,13 @@ import androidx.lifecycle.repeatOnLifecycle
19import androidx.navigation.fragment.NavHostFragment 19import androidx.navigation.fragment.NavHostFragment
20import androidx.navigation.navArgs 20import androidx.navigation.navArgs
21import com.google.android.material.color.MaterialColors 21import com.google.android.material.color.MaterialColors
22import kotlinx.coroutines.CoroutineScope
23import kotlinx.coroutines.Dispatchers
22import kotlinx.coroutines.flow.collectLatest 24import kotlinx.coroutines.flow.collectLatest
23import kotlinx.coroutines.launch 25import kotlinx.coroutines.launch
24import java.io.IOException 26import java.io.IOException
25import org.yuzu.yuzu_emu.R 27import org.yuzu.yuzu_emu.R
26import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding 28import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
27import org.yuzu.yuzu_emu.features.settings.model.Settings
28import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile 29import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
29import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment 30import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment
30import org.yuzu.yuzu_emu.model.SettingsViewModel 31import org.yuzu.yuzu_emu.model.SettingsViewModel
@@ -53,10 +54,6 @@ class SettingsActivity : AppCompatActivity() {
53 54
54 WindowCompat.setDecorFitsSystemWindows(window, false) 55 WindowCompat.setDecorFitsSystemWindows(window, false)
55 56
56 if (savedInstanceState != null) {
57 settingsViewModel.shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
58 }
59
60 if (InsetsHelper.getSystemGestureType(applicationContext) != 57 if (InsetsHelper.getSystemGestureType(applicationContext) !=
61 InsetsHelper.GESTURE_NAVIGATION 58 InsetsHelper.GESTURE_NAVIGATION
62 ) { 59 ) {
@@ -127,12 +124,6 @@ class SettingsActivity : AppCompatActivity() {
127 } 124 }
128 } 125 }
129 126
130 override fun onSaveInstanceState(outState: Bundle) {
131 // Critical: If super method is not called, rotations will be busted.
132 super.onSaveInstanceState(outState)
133 outState.putBoolean(KEY_SHOULD_SAVE, settingsViewModel.shouldSave)
134 }
135
136 override fun onStart() { 127 override fun onStart() {
137 super.onStart() 128 super.onStart()
138 // TODO: Load custom settings contextually 129 // TODO: Load custom settings contextually
@@ -141,16 +132,10 @@ class SettingsActivity : AppCompatActivity() {
141 } 132 }
142 } 133 }
143 134
144 /**
145 * If this is called, the user has left the settings screen (potentially through the
146 * home button) and will expect their changes to be persisted. So we kick off an
147 * IntentService which will do so on a background thread.
148 */
149 override fun onStop() { 135 override fun onStop() {
150 super.onStop() 136 super.onStop()
151 if (isFinishing && settingsViewModel.shouldSave) { 137 CoroutineScope(Dispatchers.IO).launch {
152 Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...") 138 NativeConfig.saveSettings()
153 Settings.saveSettings()
154 } 139 }
155 } 140 }
156 141
@@ -160,9 +145,6 @@ class SettingsActivity : AppCompatActivity() {
160 } 145 }
161 146
162 fun onSettingsReset() { 147 fun onSettingsReset() {
163 // Prevents saving to a non-existent settings file
164 settingsViewModel.shouldSave = false
165
166 // Delete settings file because the user may have changed values that do not exist in the UI 148 // Delete settings file because the user may have changed values that do not exist in the UI
167 NativeConfig.unloadConfig() 149 NativeConfig.unloadConfig()
168 val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) 150 val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
@@ -194,8 +176,4 @@ class SettingsActivity : AppCompatActivity() {
194 windowInsets 176 windowInsets
195 } 177 }
196 } 178 }
197
198 companion object {
199 private const val KEY_SHOULD_SAVE = "should_save"
200 }
201} 179}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
index a7a029fc1..af2c1e582 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
@@ -105,7 +105,6 @@ class SettingsAdapter(
105 fun onBooleanClick(item: SwitchSetting, checked: Boolean) { 105 fun onBooleanClick(item: SwitchSetting, checked: Boolean) {
106 item.checked = checked 106 item.checked = checked
107 settingsViewModel.setShouldReloadSettingsList(true) 107 settingsViewModel.setShouldReloadSettingsList(true)
108 settingsViewModel.shouldSave = true
109 } 108 }
110 109
111 fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { 110 fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) {
@@ -161,7 +160,6 @@ class SettingsAdapter(
161 epochTime += timePicker.hour.toLong() * 60 * 60 160 epochTime += timePicker.hour.toLong() * 60 * 60
162 epochTime += timePicker.minute.toLong() * 60 161 epochTime += timePicker.minute.toLong() * 60
163 if (item.value != epochTime) { 162 if (item.value != epochTime) {
164 settingsViewModel.shouldSave = true
165 notifyItemChanged(position) 163 notifyItemChanged(position)
166 item.value = epochTime 164 item.value = epochTime
167 } 165 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt
new file mode 100644
index 000000000..dec2b7cf1
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt
@@ -0,0 +1,53 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.app.Dialog
7import android.content.DialogInterface
8import android.net.Uri
9import android.os.Bundle
10import androidx.fragment.app.DialogFragment
11import androidx.fragment.app.activityViewModels
12import com.google.android.material.dialog.MaterialAlertDialogBuilder
13import org.yuzu.yuzu_emu.R
14import org.yuzu.yuzu_emu.databinding.DialogAddFolderBinding
15import org.yuzu.yuzu_emu.model.GameDir
16import org.yuzu.yuzu_emu.model.GamesViewModel
17
18class AddGameFolderDialogFragment : DialogFragment() {
19 private val gamesViewModel: GamesViewModel by activityViewModels()
20
21 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
22 val binding = DialogAddFolderBinding.inflate(layoutInflater)
23 val folderUriString = requireArguments().getString(FOLDER_URI_STRING)
24 if (folderUriString == null) {
25 dismiss()
26 }
27 binding.path.text = Uri.parse(folderUriString).path
28
29 return MaterialAlertDialogBuilder(requireContext())
30 .setTitle(R.string.add_game_folder)
31 .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
32 val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked)
33 gamesViewModel.addFolder(newGameDir)
34 }
35 .setNegativeButton(android.R.string.cancel, null)
36 .setView(binding.root)
37 .show()
38 }
39
40 companion object {
41 const val TAG = "AddGameFolderDialogFragment"
42
43 private const val FOLDER_URI_STRING = "FolderUriString"
44
45 fun newInstance(folderUriString: String): AddGameFolderDialogFragment {
46 val args = Bundle()
47 args.putString(FOLDER_URI_STRING, folderUriString)
48 val fragment = AddGameFolderDialogFragment()
49 fragment.arguments = args
50 return fragment
51 }
52 }
53}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt
new file mode 100644
index 000000000..b6c2e4635
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt
@@ -0,0 +1,72 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.app.Dialog
7import android.content.DialogInterface
8import android.os.Bundle
9import androidx.fragment.app.DialogFragment
10import androidx.fragment.app.activityViewModels
11import com.google.android.material.dialog.MaterialAlertDialogBuilder
12import org.yuzu.yuzu_emu.R
13import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding
14import org.yuzu.yuzu_emu.model.GameDir
15import org.yuzu.yuzu_emu.model.GamesViewModel
16import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
17
18class GameFolderPropertiesDialogFragment : DialogFragment() {
19 private val gamesViewModel: GamesViewModel by activityViewModels()
20
21 private var deepScan = false
22
23 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
24 val binding = DialogFolderPropertiesBinding.inflate(layoutInflater)
25 val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!!
26
27 // Restore checkbox state
28 binding.deepScanSwitch.isChecked =
29 savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan
30
31 // Ensure that we can get the checkbox state even if the view is destroyed
32 deepScan = binding.deepScanSwitch.isChecked
33 binding.deepScanSwitch.setOnClickListener {
34 deepScan = binding.deepScanSwitch.isChecked
35 }
36
37 return MaterialAlertDialogBuilder(requireContext())
38 .setView(binding.root)
39 .setTitle(R.string.game_folder_properties)
40 .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
41 val folderIndex = gamesViewModel.folders.value.indexOf(gameDir)
42 if (folderIndex != -1) {
43 gamesViewModel.folders.value[folderIndex].deepScan =
44 binding.deepScanSwitch.isChecked
45 gamesViewModel.updateGameDirs()
46 }
47 }
48 .setNegativeButton(android.R.string.cancel, null)
49 .show()
50 }
51
52 override fun onSaveInstanceState(outState: Bundle) {
53 super.onSaveInstanceState(outState)
54 outState.putBoolean(DEEP_SCAN, deepScan)
55 }
56
57 companion object {
58 const val TAG = "GameFolderPropertiesDialogFragment"
59
60 private const val GAME_DIR = "GameDir"
61
62 private const val DEEP_SCAN = "DeepScan"
63
64 fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment {
65 val args = Bundle()
66 args.putParcelable(GAME_DIR, gameDir)
67 val fragment = GameFolderPropertiesDialogFragment()
68 fragment.arguments = args
69 return fragment
70 }
71 }
72}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt
new file mode 100644
index 000000000..341a37fdb
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt
@@ -0,0 +1,128 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.content.Intent
7import android.os.Bundle
8import android.view.LayoutInflater
9import android.view.View
10import android.view.ViewGroup
11import androidx.core.view.ViewCompat
12import androidx.core.view.WindowInsetsCompat
13import androidx.core.view.updatePadding
14import androidx.fragment.app.Fragment
15import androidx.fragment.app.activityViewModels
16import androidx.lifecycle.Lifecycle
17import androidx.lifecycle.lifecycleScope
18import androidx.lifecycle.repeatOnLifecycle
19import androidx.navigation.findNavController
20import androidx.recyclerview.widget.GridLayoutManager
21import com.google.android.material.transition.MaterialSharedAxis
22import kotlinx.coroutines.launch
23import org.yuzu.yuzu_emu.R
24import org.yuzu.yuzu_emu.adapters.FolderAdapter
25import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding
26import org.yuzu.yuzu_emu.model.GamesViewModel
27import org.yuzu.yuzu_emu.model.HomeViewModel
28import org.yuzu.yuzu_emu.ui.main.MainActivity
29
30class GameFoldersFragment : Fragment() {
31 private var _binding: FragmentFoldersBinding? = null
32 private val binding get() = _binding!!
33
34 private val homeViewModel: HomeViewModel by activityViewModels()
35 private val gamesViewModel: GamesViewModel by activityViewModels()
36
37 override fun onCreate(savedInstanceState: Bundle?) {
38 super.onCreate(savedInstanceState)
39 enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
40 returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
41 reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
42
43 gamesViewModel.onOpenGameFoldersFragment()
44 }
45
46 override fun onCreateView(
47 inflater: LayoutInflater,
48 container: ViewGroup?,
49 savedInstanceState: Bundle?
50 ): View {
51 _binding = FragmentFoldersBinding.inflate(inflater)
52 return binding.root
53 }
54
55 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
56 super.onViewCreated(view, savedInstanceState)
57 homeViewModel.setNavigationVisibility(visible = false, animated = true)
58 homeViewModel.setStatusBarShadeVisibility(visible = false)
59
60 binding.toolbarFolders.setNavigationOnClickListener {
61 binding.root.findNavController().popBackStack()
62 }
63
64 binding.listFolders.apply {
65 layoutManager = GridLayoutManager(
66 requireContext(),
67 resources.getInteger(R.integer.grid_columns)
68 )
69 adapter = FolderAdapter(requireActivity(), gamesViewModel)
70 }
71
72 viewLifecycleOwner.lifecycleScope.launch {
73 repeatOnLifecycle(Lifecycle.State.CREATED) {
74 gamesViewModel.folders.collect {
75 (binding.listFolders.adapter as FolderAdapter).submitList(it)
76 }
77 }
78 }
79
80 val mainActivity = requireActivity() as MainActivity
81 binding.buttonAdd.setOnClickListener {
82 mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
83 }
84
85 setInsets()
86 }
87
88 override fun onStop() {
89 super.onStop()
90 gamesViewModel.onCloseGameFoldersFragment()
91 }
92
93 private fun setInsets() =
94 ViewCompat.setOnApplyWindowInsetsListener(
95 binding.root
96 ) { _: View, windowInsets: WindowInsetsCompat ->
97 val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
98 val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
99
100 val leftInsets = barInsets.left + cutoutInsets.left
101 val rightInsets = barInsets.right + cutoutInsets.right
102
103 val mlpToolbar = binding.toolbarFolders.layoutParams as ViewGroup.MarginLayoutParams
104 mlpToolbar.leftMargin = leftInsets
105 mlpToolbar.rightMargin = rightInsets
106 binding.toolbarFolders.layoutParams = mlpToolbar
107
108 val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
109 val mlpFab =
110 binding.buttonAdd.layoutParams as ViewGroup.MarginLayoutParams
111 mlpFab.leftMargin = leftInsets + fabSpacing
112 mlpFab.rightMargin = rightInsets + fabSpacing
113 mlpFab.bottomMargin = barInsets.bottom + fabSpacing
114 binding.buttonAdd.layoutParams = mlpFab
115
116 val mlpListFolders = binding.listFolders.layoutParams as ViewGroup.MarginLayoutParams
117 mlpListFolders.leftMargin = leftInsets
118 mlpListFolders.rightMargin = rightInsets
119 binding.listFolders.layoutParams = mlpListFolders
120
121 binding.listFolders.updatePadding(
122 bottom = barInsets.bottom +
123 resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
124 )
125
126 windowInsets
127 }
128}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
index 4720daec4..3addc2e63 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
@@ -127,18 +127,13 @@ class HomeSettingsFragment : Fragment() {
127 ) 127 )
128 add( 128 add(
129 HomeSetting( 129 HomeSetting(
130 R.string.select_games_folder, 130 R.string.manage_game_folders,
131 R.string.select_games_folder_description, 131 R.string.select_games_folder_description,
132 R.drawable.ic_add, 132 R.drawable.ic_add,
133 { 133 {
134 mainActivity.getGamesDirectory.launch( 134 binding.root.findNavController()
135 Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data 135 .navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment)
136 ) 136 }
137 },
138 { true },
139 0,
140 0,
141 homeViewModel.gamesDir
142 ) 137 )
143 ) 138 )
144 add( 139 add(
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt
index d18ec6974..b88d2c038 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt
@@ -52,7 +52,6 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener
52 .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> 52 .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
53 settingsViewModel.clickedItem!!.setting.reset() 53 settingsViewModel.clickedItem!!.setting.reset()
54 settingsViewModel.setAdapterItemChanged(position) 54 settingsViewModel.setAdapterItemChanged(position)
55 settingsViewModel.shouldSave = true
56 } 55 }
57 .setNegativeButton(android.R.string.cancel, null) 56 .setNegativeButton(android.R.string.cancel, null)
58 .create() 57 .create()
@@ -137,24 +136,17 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener
137 is SingleChoiceSetting -> { 136 is SingleChoiceSetting -> {
138 val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting 137 val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting
139 val value = getValueForSingleChoiceSelection(scSetting, which) 138 val value = getValueForSingleChoiceSelection(scSetting, which)
140 if (scSetting.selectedValue != value) {
141 settingsViewModel.shouldSave = true
142 }
143 scSetting.selectedValue = value 139 scSetting.selectedValue = value
144 } 140 }
145 141
146 is StringSingleChoiceSetting -> { 142 is StringSingleChoiceSetting -> {
147 val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting 143 val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting
148 val value = scSetting.getValueAt(which) 144 val value = scSetting.getValueAt(which)
149 if (scSetting.selectedValue != value) settingsViewModel.shouldSave = true
150 scSetting.selectedValue = value 145 scSetting.selectedValue = value
151 } 146 }
152 147
153 is SliderSetting -> { 148 is SliderSetting -> {
154 val sliderSetting = settingsViewModel.clickedItem as SliderSetting 149 val sliderSetting = settingsViewModel.clickedItem as SliderSetting
155 if (sliderSetting.selectedValue != settingsViewModel.sliderProgress.value) {
156 settingsViewModel.shouldSave = true
157 }
158 sliderSetting.selectedValue = settingsViewModel.sliderProgress.value 150 sliderSetting.selectedValue = settingsViewModel.sliderProgress.value
159 } 151 }
160 } 152 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
index c66bb635a..c4277735d 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
@@ -42,7 +42,7 @@ import org.yuzu.yuzu_emu.model.SetupPage
42import org.yuzu.yuzu_emu.model.StepState 42import org.yuzu.yuzu_emu.model.StepState
43import org.yuzu.yuzu_emu.ui.main.MainActivity 43import org.yuzu.yuzu_emu.ui.main.MainActivity
44import org.yuzu.yuzu_emu.utils.DirectoryInitialization 44import org.yuzu.yuzu_emu.utils.DirectoryInitialization
45import org.yuzu.yuzu_emu.utils.GameHelper 45import org.yuzu.yuzu_emu.utils.NativeConfig
46import org.yuzu.yuzu_emu.utils.ViewUtils 46import org.yuzu.yuzu_emu.utils.ViewUtils
47 47
48class SetupFragment : Fragment() { 48class SetupFragment : Fragment() {
@@ -184,11 +184,7 @@ class SetupFragment : Fragment() {
184 R.string.add_games_warning_description, 184 R.string.add_games_warning_description,
185 R.string.add_games_warning_help, 185 R.string.add_games_warning_help,
186 { 186 {
187 val preferences = 187 if (NativeConfig.getGameDirs().isNotEmpty()) {
188 PreferenceManager.getDefaultSharedPreferences(
189 YuzuApplication.appContext
190 )
191 if (preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()) {
192 StepState.COMPLETE 188 StepState.COMPLETE
193 } else { 189 } else {
194 StepState.INCOMPLETE 190 StepState.INCOMPLETE
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt
new file mode 100644
index 000000000..274bc1c7b
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt
@@ -0,0 +1,13 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.model
5
6import android.os.Parcelable
7import kotlinx.parcelize.Parcelize
8
9@Parcelize
10data class GameDir(
11 val uriString: String,
12 var deepScan: Boolean
13) : Parcelable
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
index 8512ed17c..752d98c10 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
@@ -12,6 +12,7 @@ import java.util.Locale
12import kotlinx.coroutines.Dispatchers 12import kotlinx.coroutines.Dispatchers
13import kotlinx.coroutines.flow.MutableStateFlow 13import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.StateFlow 14import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.launch 16import kotlinx.coroutines.launch
16import kotlinx.coroutines.withContext 17import kotlinx.coroutines.withContext
17import kotlinx.serialization.decodeFromString 18import kotlinx.serialization.decodeFromString
@@ -20,6 +21,7 @@ import org.yuzu.yuzu_emu.NativeLibrary
20import org.yuzu.yuzu_emu.YuzuApplication 21import org.yuzu.yuzu_emu.YuzuApplication
21import org.yuzu.yuzu_emu.utils.GameHelper 22import org.yuzu.yuzu_emu.utils.GameHelper
22import org.yuzu.yuzu_emu.utils.GameMetadata 23import org.yuzu.yuzu_emu.utils.GameMetadata
24import org.yuzu.yuzu_emu.utils.NativeConfig
23 25
24class GamesViewModel : ViewModel() { 26class GamesViewModel : ViewModel() {
25 val games: StateFlow<List<Game>> get() = _games 27 val games: StateFlow<List<Game>> get() = _games
@@ -40,6 +42,9 @@ class GamesViewModel : ViewModel() {
40 val searchFocused: StateFlow<Boolean> get() = _searchFocused 42 val searchFocused: StateFlow<Boolean> get() = _searchFocused
41 private val _searchFocused = MutableStateFlow(false) 43 private val _searchFocused = MutableStateFlow(false)
42 44
45 private val _folders = MutableStateFlow(mutableListOf<GameDir>())
46 val folders = _folders.asStateFlow()
47
43 init { 48 init {
44 // Ensure keys are loaded so that ROM metadata can be decrypted. 49 // Ensure keys are loaded so that ROM metadata can be decrypted.
45 NativeLibrary.reloadKeys() 50 NativeLibrary.reloadKeys()
@@ -50,6 +55,7 @@ class GamesViewModel : ViewModel() {
50 55
51 viewModelScope.launch { 56 viewModelScope.launch {
52 withContext(Dispatchers.IO) { 57 withContext(Dispatchers.IO) {
58 getGameDirs()
53 if (storedGames!!.isNotEmpty()) { 59 if (storedGames!!.isNotEmpty()) {
54 val deserializedGames = mutableSetOf<Game>() 60 val deserializedGames = mutableSetOf<Game>()
55 storedGames.forEach { 61 storedGames.forEach {
@@ -104,7 +110,7 @@ class GamesViewModel : ViewModel() {
104 _searchFocused.value = searchFocused 110 _searchFocused.value = searchFocused
105 } 111 }
106 112
107 fun reloadGames(directoryChanged: Boolean) { 113 fun reloadGames(directoriesChanged: Boolean) {
108 if (isReloading.value) { 114 if (isReloading.value) {
109 return 115 return
110 } 116 }
@@ -116,10 +122,61 @@ class GamesViewModel : ViewModel() {
116 setGames(GameHelper.getGames()) 122 setGames(GameHelper.getGames())
117 _isReloading.value = false 123 _isReloading.value = false
118 124
119 if (directoryChanged) { 125 if (directoriesChanged) {
120 setShouldSwapData(true) 126 setShouldSwapData(true)
121 } 127 }
122 } 128 }
123 } 129 }
124 } 130 }
131
132 fun addFolder(gameDir: GameDir) =
133 viewModelScope.launch {
134 withContext(Dispatchers.IO) {
135 NativeConfig.addGameDir(gameDir)
136 getGameDirs()
137 }
138 }
139
140 fun removeFolder(gameDir: GameDir) =
141 viewModelScope.launch {
142 withContext(Dispatchers.IO) {
143 val gameDirs = _folders.value.toMutableList()
144 val removedDirIndex = gameDirs.indexOf(gameDir)
145 if (removedDirIndex != -1) {
146 gameDirs.removeAt(removedDirIndex)
147 NativeConfig.setGameDirs(gameDirs.toTypedArray())
148 getGameDirs()
149 }
150 }
151 }
152
153 fun updateGameDirs() =
154 viewModelScope.launch {
155 withContext(Dispatchers.IO) {
156 NativeConfig.setGameDirs(_folders.value.toTypedArray())
157 getGameDirs()
158 }
159 }
160
161 fun onOpenGameFoldersFragment() =
162 viewModelScope.launch {
163 withContext(Dispatchers.IO) {
164 getGameDirs()
165 }
166 }
167
168 fun onCloseGameFoldersFragment() =
169 viewModelScope.launch {
170 withContext(Dispatchers.IO) {
171 getGameDirs(true)
172 }
173 }
174
175 private fun getGameDirs(reloadList: Boolean = false) {
176 val gameDirs = NativeConfig.getGameDirs()
177 _folders.value = gameDirs.toMutableList()
178 if (reloadList) {
179 reloadGames(true)
180 }
181 }
125} 182}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
index 756f76721..251b5a667 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
@@ -3,15 +3,9 @@
3 3
4package org.yuzu.yuzu_emu.model 4package org.yuzu.yuzu_emu.model
5 5
6import android.net.Uri
7import androidx.fragment.app.FragmentActivity
8import androidx.lifecycle.ViewModel 6import androidx.lifecycle.ViewModel
9import androidx.lifecycle.ViewModelProvider
10import androidx.preference.PreferenceManager
11import kotlinx.coroutines.flow.MutableStateFlow 7import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow 8import kotlinx.coroutines.flow.StateFlow
13import org.yuzu.yuzu_emu.YuzuApplication
14import org.yuzu.yuzu_emu.utils.GameHelper
15 9
16class HomeViewModel : ViewModel() { 10class HomeViewModel : ViewModel() {
17 val navigationVisible: StateFlow<Pair<Boolean, Boolean>> get() = _navigationVisible 11 val navigationVisible: StateFlow<Pair<Boolean, Boolean>> get() = _navigationVisible
@@ -23,14 +17,6 @@ class HomeViewModel : ViewModel() {
23 val shouldPageForward: StateFlow<Boolean> get() = _shouldPageForward 17 val shouldPageForward: StateFlow<Boolean> get() = _shouldPageForward
24 private val _shouldPageForward = MutableStateFlow(false) 18 private val _shouldPageForward = MutableStateFlow(false)
25 19
26 val gamesDir: StateFlow<String> get() = _gamesDir
27 private val _gamesDir = MutableStateFlow(
28 Uri.parse(
29 PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
30 .getString(GameHelper.KEY_GAME_PATH, "")
31 ).path ?: ""
32 )
33
34 var navigatedToSetup = false 20 var navigatedToSetup = false
35 21
36 fun setNavigationVisibility(visible: Boolean, animated: Boolean) { 22 fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
@@ -50,9 +36,4 @@ class HomeViewModel : ViewModel() {
50 fun setShouldPageForward(pageForward: Boolean) { 36 fun setShouldPageForward(pageForward: Boolean) {
51 _shouldPageForward.value = pageForward 37 _shouldPageForward.value = pageForward
52 } 38 }
53
54 fun setGamesDir(activity: FragmentActivity, dir: String) {
55 ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
56 _gamesDir.value = dir
57 }
58} 39}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt
index 6f947674e..ccc981e95 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt
@@ -13,8 +13,6 @@ import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
13class SettingsViewModel : ViewModel() { 13class SettingsViewModel : ViewModel() {
14 var game: Game? = null 14 var game: Game? = null
15 15
16 var shouldSave = false
17
18 var clickedItem: SettingsItem? = null 16 var clickedItem: SettingsItem? = null
19 17
20 val shouldRecreate: StateFlow<Boolean> get() = _shouldRecreate 18 val shouldRecreate: StateFlow<Boolean> get() = _shouldRecreate
@@ -73,6 +71,5 @@ class SettingsViewModel : ViewModel() {
73 71
74 fun clear() { 72 fun clear() {
75 game = null 73 game = null
76 shouldSave = false
77 } 74 }
78} 75}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index bd2f4cd25..16323a316 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -40,6 +40,7 @@ import org.yuzu.yuzu_emu.R
40import org.yuzu.yuzu_emu.activities.EmulationActivity 40import org.yuzu.yuzu_emu.activities.EmulationActivity
41import org.yuzu.yuzu_emu.databinding.ActivityMainBinding 41import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
42import org.yuzu.yuzu_emu.features.settings.model.Settings 42import org.yuzu.yuzu_emu.features.settings.model.Settings
43import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment
43import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment 44import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
44import org.yuzu.yuzu_emu.fragments.MessageDialogFragment 45import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
45import org.yuzu.yuzu_emu.getPublicFilesDir 46import org.yuzu.yuzu_emu.getPublicFilesDir
@@ -252,6 +253,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
252 super.onResume() 253 super.onResume()
253 } 254 }
254 255
256 override fun onStop() {
257 super.onStop()
258 CoroutineScope(Dispatchers.IO).launch {
259 NativeConfig.saveSettings()
260 }
261 }
262
255 override fun onDestroy() { 263 override fun onDestroy() {
256 EmulationActivity.stopForegroundService(this) 264 EmulationActivity.stopForegroundService(this)
257 super.onDestroy() 265 super.onDestroy()
@@ -293,20 +301,19 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
293 Intent.FLAG_GRANT_READ_URI_PERMISSION 301 Intent.FLAG_GRANT_READ_URI_PERMISSION
294 ) 302 )
295 303
296 // When a new directory is picked, we currently will reset the existing games 304 val uriString = result.toString()
297 // database. This effectively means that only one game directory is supported. 305 val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString }
298 PreferenceManager.getDefaultSharedPreferences(applicationContext).edit() 306 if (folder != null) {
299 .putString(GameHelper.KEY_GAME_PATH, result.toString()) 307 Toast.makeText(
300 .apply() 308 applicationContext,
301 309 R.string.folder_already_added,
302 Toast.makeText( 310 Toast.LENGTH_SHORT
303 applicationContext, 311 ).show()
304 R.string.games_dir_selected, 312 return
305 Toast.LENGTH_LONG 313 }
306 ).show()
307 314
308 gamesViewModel.reloadGames(true) 315 AddGameFolderDialogFragment.newInstance(uriString)
309 homeViewModel.setGamesDir(this, result.path!!) 316 .show(supportFragmentManager, AddGameFolderDialogFragment.TAG)
310 } 317 }
311 318
312 val getProdKey = 319 val getProdKey =
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
index 8c3268e9c..bbe7bfa92 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
@@ -364,6 +364,27 @@ object FileUtil {
364 .lowercase() 364 .lowercase()
365 } 365 }
366 366
367 fun isTreeUriValid(uri: Uri): Boolean {
368 val resolver = context.contentResolver
369 val columns = arrayOf(
370 DocumentsContract.Document.COLUMN_DOCUMENT_ID,
371 DocumentsContract.Document.COLUMN_DISPLAY_NAME,
372 DocumentsContract.Document.COLUMN_MIME_TYPE
373 )
374 return try {
375 val docId: String = if (isRootTreeUri(uri)) {
376 DocumentsContract.getTreeDocumentId(uri)
377 } else {
378 DocumentsContract.getDocumentId(uri)
379 }
380 val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
381 resolver.query(childrenUri, columns, null, null, null)
382 true
383 } catch (_: Exception) {
384 false
385 }
386 }
387
367 @Throws(IOException::class) 388 @Throws(IOException::class)
368 fun getStringFromFile(file: File): String = 389 fun getStringFromFile(file: File): String =
369 String(file.readBytes(), StandardCharsets.UTF_8) 390 String(file.readBytes(), StandardCharsets.UTF_8)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
index e6aca6b44..55010dc59 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
@@ -11,10 +11,11 @@ import kotlinx.serialization.json.Json
11import org.yuzu.yuzu_emu.NativeLibrary 11import org.yuzu.yuzu_emu.NativeLibrary
12import org.yuzu.yuzu_emu.YuzuApplication 12import org.yuzu.yuzu_emu.YuzuApplication
13import org.yuzu.yuzu_emu.model.Game 13import org.yuzu.yuzu_emu.model.Game
14import org.yuzu.yuzu_emu.model.GameDir
14import org.yuzu.yuzu_emu.model.MinimalDocumentFile 15import org.yuzu.yuzu_emu.model.MinimalDocumentFile
15 16
16object GameHelper { 17object GameHelper {
17 const val KEY_GAME_PATH = "game_path" 18 private const val KEY_OLD_GAME_PATH = "game_path"
18 const val KEY_GAMES = "Games" 19 const val KEY_GAMES = "Games"
19 20
20 private lateinit var preferences: SharedPreferences 21 private lateinit var preferences: SharedPreferences
@@ -22,15 +23,43 @@ object GameHelper {
22 fun getGames(): List<Game> { 23 fun getGames(): List<Game> {
23 val games = mutableListOf<Game>() 24 val games = mutableListOf<Game>()
24 val context = YuzuApplication.appContext 25 val context = YuzuApplication.appContext
25 val gamesDir =
26 PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
27 val gamesUri = Uri.parse(gamesDir)
28 preferences = PreferenceManager.getDefaultSharedPreferences(context) 26 preferences = PreferenceManager.getDefaultSharedPreferences(context)
29 27
28 val gameDirs = mutableListOf<GameDir>()
29 val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: ""
30 if (oldGamesDir.isNotEmpty()) {
31 gameDirs.add(GameDir(oldGamesDir, true))
32 preferences.edit().remove(KEY_OLD_GAME_PATH).apply()
33 }
34 gameDirs.addAll(NativeConfig.getGameDirs())
35
30 // Ensure keys are loaded so that ROM metadata can be decrypted. 36 // Ensure keys are loaded so that ROM metadata can be decrypted.
31 NativeLibrary.reloadKeys() 37 NativeLibrary.reloadKeys()
32 38
33 addGamesRecursive(games, FileUtil.listFiles(gamesUri), 3) 39 val badDirs = mutableListOf<Int>()
40 gameDirs.forEachIndexed { index: Int, gameDir: GameDir ->
41 val gameDirUri = Uri.parse(gameDir.uriString)
42 val isValid = FileUtil.isTreeUriValid(gameDirUri)
43 if (isValid) {
44 addGamesRecursive(
45 games,
46 FileUtil.listFiles(gameDirUri),
47 if (gameDir.deepScan) 3 else 1
48 )
49 } else {
50 badDirs.add(index)
51 }
52 }
53
54 // Remove all game dirs with insufficient permissions from config
55 if (badDirs.isNotEmpty()) {
56 var offset = 0
57 badDirs.forEach {
58 gameDirs.removeAt(it - offset)
59 offset++
60 }
61 }
62 NativeConfig.setGameDirs(gameDirs.toTypedArray())
34 63
35 // Cache list of games found on disk 64 // Cache list of games found on disk
36 val serializedGames = mutableSetOf<String>() 65 val serializedGames = mutableSetOf<String>()
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
index 87e579fa7..f4e1bb13f 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
@@ -3,6 +3,8 @@
3 3
4package org.yuzu.yuzu_emu.utils 4package org.yuzu.yuzu_emu.utils
5 5
6import org.yuzu.yuzu_emu.model.GameDir
7
6object NativeConfig { 8object NativeConfig {
7 /** 9 /**
8 * Creates a Config object and opens the emulation config. 10 * Creates a Config object and opens the emulation config.
@@ -54,4 +56,22 @@ object NativeConfig {
54 external fun getConfigHeader(category: Int): String 56 external fun getConfigHeader(category: Int): String
55 57
56 external fun getPairedSettingKey(key: String): String 58 external fun getPairedSettingKey(key: String): String
59
60 /**
61 * Gets every [GameDir] in AndroidSettings::values.game_dirs
62 */
63 @Synchronized
64 external fun getGameDirs(): Array<GameDir>
65
66 /**
67 * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array
68 */
69 @Synchronized
70 external fun setGameDirs(dirs: Array<GameDir>)
71
72 /**
73 * Adds a single [GameDir] to the AndroidSettings::values.game_dirs array
74 */
75 @Synchronized
76 external fun addGameDir(dir: GameDir)
57} 77}
diff --git a/src/android/app/src/main/jni/android_config.cpp b/src/android/app/src/main/jni/android_config.cpp
index 3041c25c9..767d8ea83 100644
--- a/src/android/app/src/main/jni/android_config.cpp
+++ b/src/android/app/src/main/jni/android_config.cpp
@@ -34,6 +34,7 @@ void AndroidConfig::SaveAllValues() {
34void AndroidConfig::ReadAndroidValues() { 34void AndroidConfig::ReadAndroidValues() {
35 if (global) { 35 if (global) {
36 ReadAndroidUIValues(); 36 ReadAndroidUIValues();
37 ReadUIValues();
37 } 38 }
38} 39}
39 40
@@ -45,9 +46,35 @@ void AndroidConfig::ReadAndroidUIValues() {
45 EndGroup(); 46 EndGroup();
46} 47}
47 48
49void AndroidConfig::ReadUIValues() {
50 BeginGroup(Settings::TranslateCategory(Settings::Category::Ui));
51
52 ReadPathValues();
53
54 EndGroup();
55}
56
57void AndroidConfig::ReadPathValues() {
58 BeginGroup(Settings::TranslateCategory(Settings::Category::Paths));
59
60 const int gamedirs_size = BeginArray(std::string("gamedirs"));
61 for (int i = 0; i < gamedirs_size; ++i) {
62 SetArrayIndex(i);
63 AndroidSettings::GameDir game_dir;
64 game_dir.path = ReadStringSetting(std::string("path"));
65 game_dir.deep_scan =
66 ReadBooleanSetting(std::string("deep_scan"), std::make_optional(false));
67 AndroidSettings::values.game_dirs.push_back(game_dir);
68 }
69 EndArray();
70
71 EndGroup();
72}
73
48void AndroidConfig::SaveAndroidValues() { 74void AndroidConfig::SaveAndroidValues() {
49 if (global) { 75 if (global) {
50 SaveAndroidUIValues(); 76 SaveAndroidUIValues();
77 SaveUIValues();
51 } 78 }
52 79
53 WriteToIni(); 80 WriteToIni();
@@ -61,6 +88,29 @@ void AndroidConfig::SaveAndroidUIValues() {
61 EndGroup(); 88 EndGroup();
62} 89}
63 90
91void AndroidConfig::SaveUIValues() {
92 BeginGroup(Settings::TranslateCategory(Settings::Category::Ui));
93
94 SavePathValues();
95
96 EndGroup();
97}
98
99void AndroidConfig::SavePathValues() {
100 BeginGroup(Settings::TranslateCategory(Settings::Category::Paths));
101
102 BeginArray(std::string("gamedirs"));
103 for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) {
104 SetArrayIndex(i);
105 const auto& game_dir = AndroidSettings::values.game_dirs[i];
106 WriteSetting(std::string("path"), game_dir.path);
107 WriteSetting(std::string("deep_scan"), game_dir.deep_scan, std::make_optional(false));
108 }
109 EndArray();
110
111 EndGroup();
112}
113
64std::vector<Settings::BasicSetting*>& AndroidConfig::FindRelevantList(Settings::Category category) { 114std::vector<Settings::BasicSetting*>& AndroidConfig::FindRelevantList(Settings::Category category) {
65 auto& map = Settings::values.linkage.by_category; 115 auto& map = Settings::values.linkage.by_category;
66 if (map.contains(category)) { 116 if (map.contains(category)) {
diff --git a/src/android/app/src/main/jni/android_config.h b/src/android/app/src/main/jni/android_config.h
index e679392fd..f490be016 100644
--- a/src/android/app/src/main/jni/android_config.h
+++ b/src/android/app/src/main/jni/android_config.h
@@ -19,9 +19,9 @@ protected:
19 void ReadAndroidUIValues(); 19 void ReadAndroidUIValues();
20 void ReadHidbusValues() override {} 20 void ReadHidbusValues() override {}
21 void ReadDebugControlValues() override {} 21 void ReadDebugControlValues() override {}
22 void ReadPathValues() override {} 22 void ReadPathValues() override;
23 void ReadShortcutValues() override {} 23 void ReadShortcutValues() override {}
24 void ReadUIValues() override {} 24 void ReadUIValues() override;
25 void ReadUIGamelistValues() override {} 25 void ReadUIGamelistValues() override {}
26 void ReadUILayoutValues() override {} 26 void ReadUILayoutValues() override {}
27 void ReadMultiplayerValues() override {} 27 void ReadMultiplayerValues() override {}
@@ -30,9 +30,9 @@ protected:
30 void SaveAndroidUIValues(); 30 void SaveAndroidUIValues();
31 void SaveHidbusValues() override {} 31 void SaveHidbusValues() override {}
32 void SaveDebugControlValues() override {} 32 void SaveDebugControlValues() override {}
33 void SavePathValues() override {} 33 void SavePathValues() override;
34 void SaveShortcutValues() override {} 34 void SaveShortcutValues() override {}
35 void SaveUIValues() override {} 35 void SaveUIValues() override;
36 void SaveUIGamelistValues() override {} 36 void SaveUIGamelistValues() override {}
37 void SaveUILayoutValues() override {} 37 void SaveUILayoutValues() override {}
38 void SaveMultiplayerValues() override {} 38 void SaveMultiplayerValues() override {}
diff --git a/src/android/app/src/main/jni/android_settings.h b/src/android/app/src/main/jni/android_settings.h
index 37bc33918..fc0523206 100644
--- a/src/android/app/src/main/jni/android_settings.h
+++ b/src/android/app/src/main/jni/android_settings.h
@@ -9,9 +9,17 @@
9 9
10namespace AndroidSettings { 10namespace AndroidSettings {
11 11
12struct GameDir {
13 std::string path;
14 bool deep_scan = false;
15};
16
12struct Values { 17struct Values {
13 Settings::Linkage linkage; 18 Settings::Linkage linkage;
14 19
20 // Path settings
21 std::vector<GameDir> game_dirs;
22
15 // Android 23 // Android
16 Settings::Setting<bool> picture_in_picture{linkage, false, "picture_in_picture", 24 Settings::Setting<bool> picture_in_picture{linkage, false, "picture_in_picture",
17 Settings::Category::Android}; 25 Settings::Category::Android};
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index 960abf95a..a56ed5662 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -13,6 +13,8 @@ static JavaVM* s_java_vm;
13static jclass s_native_library_class; 13static jclass s_native_library_class;
14static jclass s_disk_cache_progress_class; 14static jclass s_disk_cache_progress_class;
15static jclass s_load_callback_stage_class; 15static jclass s_load_callback_stage_class;
16static jclass s_game_dir_class;
17static jmethodID s_game_dir_constructor;
16static jmethodID s_exit_emulation_activity; 18static jmethodID s_exit_emulation_activity;
17static jmethodID s_disk_cache_load_progress; 19static jmethodID s_disk_cache_load_progress;
18static jmethodID s_on_emulation_started; 20static jmethodID s_on_emulation_started;
@@ -53,6 +55,14 @@ jclass GetDiskCacheLoadCallbackStageClass() {
53 return s_load_callback_stage_class; 55 return s_load_callback_stage_class;
54} 56}
55 57
58jclass GetGameDirClass() {
59 return s_game_dir_class;
60}
61
62jmethodID GetGameDirConstructor() {
63 return s_game_dir_constructor;
64}
65
56jmethodID GetExitEmulationActivity() { 66jmethodID GetExitEmulationActivity() {
57 return s_exit_emulation_activity; 67 return s_exit_emulation_activity;
58} 68}
@@ -90,6 +100,11 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
90 s_load_callback_stage_class = reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass( 100 s_load_callback_stage_class = reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass(
91 "org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage"))); 101 "org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage")));
92 102
103 const jclass game_dir_class = env->FindClass("org/yuzu/yuzu_emu/model/GameDir");
104 s_game_dir_class = reinterpret_cast<jclass>(env->NewGlobalRef(game_dir_class));
105 s_game_dir_constructor = env->GetMethodID(game_dir_class, "<init>", "(Ljava/lang/String;Z)V");
106 env->DeleteLocalRef(game_dir_class);
107
93 // Initialize methods 108 // Initialize methods
94 s_exit_emulation_activity = 109 s_exit_emulation_activity =
95 env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); 110 env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V");
@@ -120,6 +135,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
120 env->DeleteGlobalRef(s_native_library_class); 135 env->DeleteGlobalRef(s_native_library_class);
121 env->DeleteGlobalRef(s_disk_cache_progress_class); 136 env->DeleteGlobalRef(s_disk_cache_progress_class);
122 env->DeleteGlobalRef(s_load_callback_stage_class); 137 env->DeleteGlobalRef(s_load_callback_stage_class);
138 env->DeleteGlobalRef(s_game_dir_class);
123 139
124 // UnInitialize applets 140 // UnInitialize applets
125 SoftwareKeyboard::CleanupJNI(env); 141 SoftwareKeyboard::CleanupJNI(env);
diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h
index b76158928..855649efa 100644
--- a/src/android/app/src/main/jni/id_cache.h
+++ b/src/android/app/src/main/jni/id_cache.h
@@ -13,6 +13,8 @@ JNIEnv* GetEnvForThread();
13jclass GetNativeLibraryClass(); 13jclass GetNativeLibraryClass();
14jclass GetDiskCacheProgressClass(); 14jclass GetDiskCacheProgressClass();
15jclass GetDiskCacheLoadCallbackStageClass(); 15jclass GetDiskCacheLoadCallbackStageClass();
16jclass GetGameDirClass();
17jmethodID GetGameDirConstructor();
16jmethodID GetExitEmulationActivity(); 18jmethodID GetExitEmulationActivity();
17jmethodID GetDiskCacheLoadProgress(); 19jmethodID GetDiskCacheLoadProgress();
18jmethodID GetOnEmulationStarted(); 20jmethodID GetOnEmulationStarted();
diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp
index 8e81816e5..763b2164c 100644
--- a/src/android/app/src/main/jni/native_config.cpp
+++ b/src/android/app/src/main/jni/native_config.cpp
@@ -11,6 +11,7 @@
11#include "common/settings.h" 11#include "common/settings.h"
12#include "frontend_common/config.h" 12#include "frontend_common/config.h"
13#include "jni/android_common/android_common.h" 13#include "jni/android_common/android_common.h"
14#include "jni/id_cache.h"
14 15
15std::unique_ptr<AndroidConfig> config; 16std::unique_ptr<AndroidConfig> config;
16 17
@@ -253,4 +254,55 @@ jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getPairedSettingKey(JNIEnv* e
253 return ToJString(env, setting->PairedSetting()->GetLabel()); 254 return ToJString(env, setting->PairedSetting()->GetLabel());
254} 255}
255 256
257jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getGameDirs(JNIEnv* env, jobject obj) {
258 jclass gameDirClass = IDCache::GetGameDirClass();
259 jmethodID gameDirConstructor = IDCache::GetGameDirConstructor();
260 jobjectArray jgameDirArray =
261 env->NewObjectArray(AndroidSettings::values.game_dirs.size(), gameDirClass, nullptr);
262 for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) {
263 jobject jgameDir =
264 env->NewObject(gameDirClass, gameDirConstructor,
265 ToJString(env, AndroidSettings::values.game_dirs[i].path),
266 static_cast<jboolean>(AndroidSettings::values.game_dirs[i].deep_scan));
267 env->SetObjectArrayElement(jgameDirArray, i, jgameDir);
268 }
269 return jgameDirArray;
270}
271
272void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setGameDirs(JNIEnv* env, jobject obj,
273 jobjectArray gameDirs) {
274 AndroidSettings::values.game_dirs.clear();
275 int size = env->GetArrayLength(gameDirs);
276
277 if (size == 0) {
278 return;
279 }
280
281 jobject dir = env->GetObjectArrayElement(gameDirs, 0);
282 jclass gameDirClass = IDCache::GetGameDirClass();
283 jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;");
284 jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z");
285 for (int i = 0; i < size; ++i) {
286 dir = env->GetObjectArrayElement(gameDirs, i);
287 jstring juriString = static_cast<jstring>(env->GetObjectField(dir, uriStringField));
288 jboolean jdeepScanBoolean = env->GetBooleanField(dir, deepScanBooleanField);
289 std::string uriString = GetJString(env, juriString);
290 AndroidSettings::values.game_dirs.push_back(
291 AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)});
292 }
293}
294
295void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject obj,
296 jobject gameDir) {
297 jclass gameDirClass = IDCache::GetGameDirClass();
298 jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;");
299 jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z");
300
301 jstring juriString = static_cast<jstring>(env->GetObjectField(gameDir, uriStringField));
302 jboolean jdeepScanBoolean = env->GetBooleanField(gameDir, deepScanBooleanField);
303 std::string uriString = GetJString(env, juriString);
304 AndroidSettings::values.game_dirs.push_back(
305 AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)});
306}
307
256} // extern "C" 308} // extern "C"
diff --git a/src/android/app/src/main/res/layout/card_folder.xml b/src/android/app/src/main/res/layout/card_folder.xml
new file mode 100644
index 000000000..4e0c04b6b
--- /dev/null
+++ b/src/android/app/src/main/res/layout/card_folder.xml
@@ -0,0 +1,70 @@
1<?xml version="1.0" encoding="utf-8"?>
2<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:app="http://schemas.android.com/apk/res-auto"
4 xmlns:tools="http://schemas.android.com/tools"
5 style="?attr/materialCardViewOutlinedStyle"
6 android:layout_width="match_parent"
7 android:layout_height="wrap_content"
8 android:layout_marginHorizontal="16dp"
9 android:layout_marginVertical="12dp"
10 android:focusable="true">
11
12 <androidx.constraintlayout.widget.ConstraintLayout
13 android:layout_width="match_parent"
14 android:layout_height="wrap_content"
15 android:orientation="horizontal"
16 android:padding="16dp"
17 android:layout_gravity="center_vertical"
18 android:animateLayoutChanges="true">
19
20 <com.google.android.material.textview.MaterialTextView
21 android:id="@+id/path"
22 style="@style/TextAppearance.Material3.BodyLarge"
23 android:layout_width="0dp"
24 android:layout_height="wrap_content"
25 android:layout_gravity="center_vertical|start"
26 android:ellipsize="none"
27 android:marqueeRepeatLimit="marquee_forever"
28 android:requiresFadingEdge="horizontal"
29 android:singleLine="true"
30 android:textAlignment="viewStart"
31 app:layout_constraintBottom_toBottomOf="parent"
32 app:layout_constraintEnd_toStartOf="@+id/button_layout"
33 app:layout_constraintStart_toStartOf="parent"
34 app:layout_constraintTop_toTopOf="parent"
35 tools:text="@string/select_gpu_driver_default" />
36
37 <LinearLayout
38 android:id="@+id/button_layout"
39 android:layout_width="wrap_content"
40 android:layout_height="wrap_content"
41 android:orientation="horizontal"
42 app:layout_constraintBottom_toBottomOf="parent"
43 app:layout_constraintEnd_toEndOf="parent"
44 app:layout_constraintTop_toTopOf="parent">
45
46 <Button
47 android:id="@+id/button_edit"
48 style="@style/Widget.Material3.Button.IconButton"
49 android:layout_width="wrap_content"
50 android:layout_height="wrap_content"
51 android:contentDescription="@string/delete"
52 android:tooltipText="@string/edit"
53 app:icon="@drawable/ic_edit"
54 app:iconTint="?attr/colorControlNormal" />
55
56 <Button
57 android:id="@+id/button_delete"
58 style="@style/Widget.Material3.Button.IconButton"
59 android:layout_width="wrap_content"
60 android:layout_height="wrap_content"
61 android:contentDescription="@string/delete"
62 android:tooltipText="@string/delete"
63 app:icon="@drawable/ic_delete"
64 app:iconTint="?attr/colorControlNormal" />
65
66 </LinearLayout>
67
68 </androidx.constraintlayout.widget.ConstraintLayout>
69
70</com.google.android.material.card.MaterialCardView>
diff --git a/src/android/app/src/main/res/layout/dialog_add_folder.xml b/src/android/app/src/main/res/layout/dialog_add_folder.xml
new file mode 100644
index 000000000..01f95e868
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_add_folder.xml
@@ -0,0 +1,45 @@
1<?xml version="1.0" encoding="utf-8"?>
2<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:tools="http://schemas.android.com/tools"
4 android:layout_width="match_parent"
5 android:layout_height="wrap_content"
6 android:padding="24dp"
7 android:orientation="vertical">
8
9 <com.google.android.material.textview.MaterialTextView
10 android:id="@+id/path"
11 style="@style/TextAppearance.Material3.BodyLarge"
12 android:layout_width="match_parent"
13 android:layout_height="0dp"
14 android:layout_gravity="center_vertical|start"
15 android:layout_weight="1"
16 android:ellipsize="marquee"
17 android:marqueeRepeatLimit="marquee_forever"
18 android:requiresFadingEdge="horizontal"
19 android:singleLine="true"
20 android:textAlignment="viewStart"
21 tools:text="folder/folder/folder/folder" />
22
23 <LinearLayout
24 android:layout_width="match_parent"
25 android:layout_height="wrap_content"
26 android:orientation="horizontal"
27 android:paddingTop="8dp">
28
29 <com.google.android.material.textview.MaterialTextView
30 style="@style/TextAppearance.Material3.BodyMedium"
31 android:layout_width="0dp"
32 android:layout_height="wrap_content"
33 android:layout_gravity="center_vertical|start"
34 android:layout_weight="1"
35 android:text="@string/deep_scan"
36 android:textAlignment="viewStart" />
37
38 <com.google.android.material.checkbox.MaterialCheckBox
39 android:id="@+id/deep_scan_switch"
40 android:layout_width="wrap_content"
41 android:layout_height="wrap_content" />
42
43 </LinearLayout>
44
45</LinearLayout>
diff --git a/src/android/app/src/main/res/layout/dialog_folder_properties.xml b/src/android/app/src/main/res/layout/dialog_folder_properties.xml
new file mode 100644
index 000000000..248d048cb
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_folder_properties.xml
@@ -0,0 +1,30 @@
1<?xml version="1.0" encoding="utf-8"?>
2<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 android:layout_width="match_parent"
4 android:layout_height="wrap_content"
5 android:padding="24dp"
6 android:orientation="vertical">
7
8 <LinearLayout
9 android:id="@+id/deep_scan_layout"
10 android:layout_width="match_parent"
11 android:layout_height="wrap_content"
12 android:orientation="horizontal">
13
14 <com.google.android.material.textview.MaterialTextView
15 style="@style/TextAppearance.Material3.BodyMedium"
16 android:layout_width="0dp"
17 android:layout_height="wrap_content"
18 android:layout_gravity="center_vertical|start"
19 android:layout_weight="1"
20 android:text="@string/deep_scan"
21 android:textAlignment="viewStart" />
22
23 <com.google.android.material.checkbox.MaterialCheckBox
24 android:id="@+id/deep_scan_switch"
25 android:layout_width="wrap_content"
26 android:layout_height="wrap_content" />
27
28 </LinearLayout>
29
30</LinearLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_folders.xml b/src/android/app/src/main/res/layout/fragment_folders.xml
new file mode 100644
index 000000000..74f2f3754
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_folders.xml
@@ -0,0 +1,48 @@
1<?xml version="1.0" encoding="utf-8"?>
2<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:app="http://schemas.android.com/apk/res-auto"
4 android:id="@+id/coordinator_folders"
5 android:layout_width="match_parent"
6 android:layout_height="match_parent"
7 android:background="?attr/colorSurface">
8
9 <androidx.coordinatorlayout.widget.CoordinatorLayout
10 android:layout_width="match_parent"
11 android:layout_height="match_parent">
12
13 <com.google.android.material.appbar.AppBarLayout
14 android:id="@+id/appbar_folders"
15 android:layout_width="match_parent"
16 android:layout_height="wrap_content"
17 android:fitsSystemWindows="true"
18 app:liftOnScrollTargetViewId="@id/list_folders">
19
20 <com.google.android.material.appbar.MaterialToolbar
21 android:id="@+id/toolbar_folders"
22 android:layout_width="match_parent"
23 android:layout_height="?attr/actionBarSize"
24 app:navigationIcon="@drawable/ic_back"
25 app:title="@string/game_folders" />
26
27 </com.google.android.material.appbar.AppBarLayout>
28
29 <androidx.recyclerview.widget.RecyclerView
30 android:id="@+id/list_folders"
31 android:layout_width="match_parent"
32 android:layout_height="wrap_content"
33 android:clipToPadding="false"
34 app:layout_behavior="@string/appbar_scrolling_view_behavior" />
35
36 </androidx.coordinatorlayout.widget.CoordinatorLayout>
37
38 <com.google.android.material.floatingactionbutton.FloatingActionButton
39 android:id="@+id/button_add"
40 android:layout_width="wrap_content"
41 android:layout_height="wrap_content"
42 android:layout_gravity="bottom|end"
43 android:contentDescription="@string/add_games"
44 app:srcCompat="@drawable/ic_add"
45 app:layout_constraintBottom_toBottomOf="parent"
46 app:layout_constraintEnd_toEndOf="parent" />
47
48</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml
index 6d4c1f86d..cf70b4bc4 100644
--- a/src/android/app/src/main/res/navigation/home_navigation.xml
+++ b/src/android/app/src/main/res/navigation/home_navigation.xml
@@ -28,6 +28,9 @@
28 <action 28 <action
29 android:id="@+id/action_homeSettingsFragment_to_appletLauncherFragment" 29 android:id="@+id/action_homeSettingsFragment_to_appletLauncherFragment"
30 app:destination="@id/appletLauncherFragment" /> 30 app:destination="@id/appletLauncherFragment" />
31 <action
32 android:id="@+id/action_homeSettingsFragment_to_gameFoldersFragment"
33 app:destination="@id/gameFoldersFragment" />
31 </fragment> 34 </fragment>
32 35
33 <fragment 36 <fragment
@@ -117,5 +120,9 @@
117 android:id="@+id/cabinetLauncherDialogFragment" 120 android:id="@+id/cabinetLauncherDialogFragment"
118 android:name="org.yuzu.yuzu_emu.fragments.CabinetLauncherDialogFragment" 121 android:name="org.yuzu.yuzu_emu.fragments.CabinetLauncherDialogFragment"
119 android:label="CabinetLauncherDialogFragment" /> 122 android:label="CabinetLauncherDialogFragment" />
123 <fragment
124 android:id="@+id/gameFoldersFragment"
125 android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment"
126 android:label="GameFoldersFragment" />
120 127
121</navigation> 128</navigation>
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
index ef855ea6f..380d14213 100644
--- a/src/android/app/src/main/res/values/dimens.xml
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -13,7 +13,7 @@
13 <dimen name="menu_width">256dp</dimen> 13 <dimen name="menu_width">256dp</dimen>
14 <dimen name="card_width">165dp</dimen> 14 <dimen name="card_width">165dp</dimen>
15 <dimen name="icon_inset">24dp</dimen> 15 <dimen name="icon_inset">24dp</dimen>
16 <dimen name="spacing_bottom_list_fab">72dp</dimen> 16 <dimen name="spacing_bottom_list_fab">76dp</dimen>
17 <dimen name="spacing_fab">24dp</dimen> 17 <dimen name="spacing_fab">24dp</dimen>
18 18
19 <dimen name="dialog_margin">20dp</dimen> 19 <dimen name="dialog_margin">20dp</dimen>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 471af8795..fa9b153b6 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -38,6 +38,7 @@
38 <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string> 38 <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string>
39 <string name="search_and_filter_games">Search and filter games</string> 39 <string name="search_and_filter_games">Search and filter games</string>
40 <string name="select_games_folder">Select games folder</string> 40 <string name="select_games_folder">Select games folder</string>
41 <string name="manage_game_folders">Manage game folders</string>
41 <string name="select_games_folder_description">Allows yuzu to populate the games list</string> 42 <string name="select_games_folder_description">Allows yuzu to populate the games list</string>
42 <string name="add_games_warning">Skip selecting games folder?</string> 43 <string name="add_games_warning">Skip selecting games folder?</string>
43 <string name="add_games_warning_description">Games won\'t be displayed in the Games list if a folder isn\'t selected.</string> 44 <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 @@
124 <string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string> 125 <string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string>
125 <string name="share_save_file">Share save file</string> 126 <string name="share_save_file">Share save file</string>
126 <string name="export_save_failed">Failed to export save</string> 127 <string name="export_save_failed">Failed to export save</string>
128 <string name="game_folders">Game folders</string>
129 <string name="deep_scan">Deep scan</string>
130 <string name="add_game_folder">Add game folder</string>
131 <string name="folder_already_added">This folder was already added!</string>
132 <string name="game_folder_properties">Game folder properties</string>
127 133
128 <!-- Applet launcher strings --> 134 <!-- Applet launcher strings -->
129 <string name="applets">Applet launcher</string> 135 <string name="applets">Applet launcher</string>
@@ -257,6 +263,7 @@
257 <string name="cancelling">Cancelling</string> 263 <string name="cancelling">Cancelling</string>
258 <string name="install">Install</string> 264 <string name="install">Install</string>
259 <string name="delete">Delete</string> 265 <string name="delete">Delete</string>
266 <string name="edit">Edit</string>
260 <string name="export_success">Exported successfully</string> 267 <string name="export_success">Exported successfully</string>
261 268
262 <!-- GPU driver installation --> 269 <!-- GPU driver installation -->
diff --git a/src/frontend_common/config.cpp b/src/frontend_common/config.cpp
index 7474cb0f9..1a0491c2c 100644
--- a/src/frontend_common/config.cpp
+++ b/src/frontend_common/config.cpp
@@ -924,12 +924,14 @@ std::string Config::AdjustOutputString(const std::string& string) {
924 924
925 // Windows requires that two forward slashes are used at the start of a path for unmapped 925 // Windows requires that two forward slashes are used at the start of a path for unmapped
926 // network drives so we have to watch for that here 926 // network drives so we have to watch for that here
927#ifndef ANDROID
927 if (string.substr(0, 2) == "//") { 928 if (string.substr(0, 2) == "//") {
928 boost::replace_all(adjusted_string, "//", "/"); 929 boost::replace_all(adjusted_string, "//", "/");
929 adjusted_string.insert(0, "/"); 930 adjusted_string.insert(0, "/");
930 } else { 931 } else {
931 boost::replace_all(adjusted_string, "//", "/"); 932 boost::replace_all(adjusted_string, "//", "/");
932 } 933 }
934#endif
933 935
934 // Needed for backwards compatibility with QSettings deserialization 936 // Needed for backwards compatibility with QSettings deserialization
935 for (const auto& special_character : special_characters) { 937 for (const auto& special_character : special_characters) {