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] 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 0000000000..ab657a7b95
--- /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 0000000000..dec2b7cf16
--- /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 0000000000..b6c2e4635b
--- /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 0000000000..341a37fdb2
--- /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 4720daec4c..3addc2e63b 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 c66bb635ab..c4277735da 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 0000000000..274bc1c7bc
--- /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 8512ed17c5..752d98c102 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 756f767216..251b5a6676 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 bd2f4cd256..745901e197 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 8c3268e9c8..bbe7bfa922 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 e6aca6b44f..55010dc595 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 87e579fa7a..f4e1bb13fc 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 3041c25c9b..767d8ea839 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 e679392fd0..f490be016a 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 37bc339183..fc05232064 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 960abf95ad..a56ed56629 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 b761589281..855649efaf 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 8e81816e52..763b2164c5 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 0000000000..4e0c04b6b5
--- /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 0000000000..01f95e8687
--- /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 0000000000..248d048cb4
--- /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 0000000000..74f2f3754d
--- /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 6d4c1f86d8..cf70b4bc42 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 ef855ea6fb..380d142138 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 471af87959..fa9b153b6f 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 7474cb0f9d..1a0491c2c1 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) {