From b8f66c9412e2306691ffb9f8a9d232198ebd13f4 Mon Sep 17 00:00:00 2001
From: t895 <clombardo169@gmail.com>
Date: Mon, 27 Nov 2023 14:56:25 -0500
Subject: [PATCH 1/2] android: Multi directory UI

---
 .../yuzu/yuzu_emu/adapters/FolderAdapter.kt   |  76 +++++++++++
 .../fragments/AddGameFolderDialogFragment.kt  |  53 ++++++++
 .../GameFolderPropertiesDialogFragment.kt     |  72 ++++++++++
 .../yuzu_emu/fragments/GameFoldersFragment.kt | 128 ++++++++++++++++++
 .../fragments/HomeSettingsFragment.kt         |  13 +-
 .../yuzu/yuzu_emu/fragments/SetupFragment.kt  |   8 +-
 .../java/org/yuzu/yuzu_emu/model/GameDir.kt   |  13 ++
 .../org/yuzu/yuzu_emu/model/GamesViewModel.kt |  61 ++++++++-
 .../org/yuzu/yuzu_emu/model/HomeViewModel.kt  |  19 ---
 .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt |  26 ++--
 .../java/org/yuzu/yuzu_emu/utils/FileUtil.kt  |  21 +++
 .../org/yuzu/yuzu_emu/utils/GameHelper.kt     |  39 +++++-
 .../org/yuzu/yuzu_emu/utils/NativeConfig.kt   |  20 +++
 .../app/src/main/jni/android_config.cpp       |  50 +++++++
 src/android/app/src/main/jni/android_config.h |   8 +-
 .../app/src/main/jni/android_settings.h       |   8 ++
 src/android/app/src/main/jni/id_cache.cpp     |  16 +++
 src/android/app/src/main/jni/id_cache.h       |   2 +
 .../app/src/main/jni/native_config.cpp        |  52 +++++++
 .../app/src/main/res/layout/card_folder.xml   |  70 ++++++++++
 .../src/main/res/layout/dialog_add_folder.xml |  45 ++++++
 .../res/layout/dialog_folder_properties.xml   |  30 ++++
 .../src/main/res/layout/fragment_folders.xml  |  48 +++++++
 .../main/res/navigation/home_navigation.xml   |   7 +
 .../app/src/main/res/values/dimens.xml        |   2 +-
 .../app/src/main/res/values/strings.xml       |   7 +
 src/frontend_common/config.cpp                |   2 +
 27 files changed, 837 insertions(+), 59 deletions(-)
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt
 create mode 100644 src/android/app/src/main/res/layout/card_folder.xml
 create mode 100644 src/android/app/src/main/res/layout/dialog_add_folder.xml
 create mode 100644 src/android/app/src/main/res/layout/dialog_folder_properties.xml
 create mode 100644 src/android/app/src/main/res/layout/fragment_folders.xml

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 @@
+// 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
+        }
+    }
+}
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 @@
+// 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
+        }
+    }
+}
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 @@
+// 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
+        }
+    }
+}
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 @@
+// 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
+        }
+}
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() {
             )
             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(
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
 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
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 @@
+// 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
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
 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)
+        }
+    }
 }
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 @@
 
 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
-    }
 }
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..745901e19 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
 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
@@ -293,20 +294,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 =
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 {
             .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)
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
 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>()
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 @@
 
 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)
 }
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() {
 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)) {
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:
     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 {}
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 @@
 
 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};
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;
 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);
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();
 jclass GetNativeLibraryClass();
 jclass GetDiskCacheProgressClass();
 jclass GetDiskCacheLoadCallbackStageClass();
+jclass GetGameDirClass();
+jmethodID GetGameDirConstructor();
 jmethodID GetExitEmulationActivity();
 jmethodID GetDiskCacheLoadProgress();
 jmethodID 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 @@
 #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"
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 @@
+<?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>
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 @@
+<?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>
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 @@
+<?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>
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 @@
+<?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>
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 @@
         <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>
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 @@
     <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>
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 @@
     <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 -->
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) {
 
     // 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) {

From 7dddf5cb3cbc09ce32c108d2fb1343ce43d7641c Mon Sep 17 00:00:00 2001
From: t895 <clombardo169@gmail.com>
Date: Mon, 27 Nov 2023 15:04:03 -0500
Subject: [PATCH 2/2] android: Save global settings in onStop

---
 .../features/settings/model/Settings.kt       | 24 ---------------
 .../features/settings/ui/SettingsActivity.kt  | 30 +++----------------
 .../features/settings/ui/SettingsAdapter.kt   |  2 --
 .../fragments/SettingsDialogFragment.kt       |  8 -----
 .../yuzu/yuzu_emu/model/SettingsViewModel.kt  |  3 --
 .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt |  7 +++++
 6 files changed, 11 insertions(+), 63 deletions(-)

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 @@
 
 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,
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
 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"
-    }
 }
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(
     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
             }
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
                     .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
             }
         }
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
 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
     }
 }
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 745901e19..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
@@ -253,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()