Просмотр исходного кода

android: Add Game properties

This commit has the UI for viewing a game's properties on long-press and some links to useful tools like
- Game info
- Shortcut to settings (global in this commit)
- Addon manager with installer
- Save data manager
- Option to clear all save data
- Option to clear shader cache
t895 2 лет назад
Родитель
Сommit
e975f3cde9
40 измененных файлов с 2227 добавлено и 253 удалено
  1. 22 13
      src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
  2. 52 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
  3. 3 3
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
  4. 12 4
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
  5. 133 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
  6. 214 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
  7. 68 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt
  8. 148 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt
  9. 418 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
  10. 1 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
  11. 61 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt
  12. 20 17
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
  13. 3 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
  14. 10 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
  15. 83 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
  16. 41 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
  17. 34 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt
  18. 15 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
  19. 2 2
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
  20. 1 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
  21. 4 7
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
  22. 137 190
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
  23. 8 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt
  24. 32 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
  25. 19 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
  26. 40 0
      src/android/app/src/main/jni/id_cache.cpp
  27. 6 0
      src/android/app/src/main/jni/id_cache.h
  28. 98 10
      src/android/app/src/main/jni/native.cpp
  29. 2 0
      src/android/app/src/main/jni/native.h
  30. 26 0
      src/android/app/src/main/jni/native_config.cpp
  31. 99 0
      src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
  32. 2 1
      src/android/app/src/main/res/layout/card_installable.xml
  33. 19 1
      src/android/app/src/main/res/layout/card_simple_outlined.xml
  34. 47 0
      src/android/app/src/main/res/layout/fragment_addons.xml
  35. 125 0
      src/android/app/src/main/res/layout/fragment_game_info.xml
  36. 86 0
      src/android/app/src/main/res/layout/fragment_game_properties.xml
  37. 57 0
      src/android/app/src/main/res/layout/list_item_addon.xml
  38. 33 0
      src/android/app/src/main/res/navigation/home_navigation.xml
  39. 1 1
      src/android/app/src/main/res/values/dimens.xml
  40. 45 0
      src/android/app/src/main/res/values/strings.xml

+ 22 - 13
src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt

@@ -230,8 +230,6 @@ object NativeLibrary {
      */
     external fun onTouchReleased(finger_id: Int)
 
-    external fun initGameIni(gameID: String?)
-
     external fun setAppDirectory(directory: String)
 
     /**
@@ -241,6 +239,8 @@ object NativeLibrary {
      */
     external fun installFileToNand(filename: String, extension: String): Int
 
+    external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean
+
     external fun initializeGpuDriver(
         hookLibDir: String?,
         customDriverDir: String?,
@@ -252,18 +252,11 @@ object NativeLibrary {
 
     external fun initializeSystem(reload: Boolean)
 
-    external fun defaultCPUCore(): Int
-
     /**
      * Begins emulation.
      */
     external fun run(path: String?)
 
-    /**
-     * Begins emulation from the specified savestate.
-     */
-    external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean)
-
     // Surface Handling
     external fun surfaceChanged(surf: Surface?)
 
@@ -304,10 +297,9 @@ object NativeLibrary {
      */
     external fun getCpuBackend(): String
 
-    /**
-     * Notifies the core emulation that the orientation has changed.
-     */
-    external fun notifyOrientationChange(layout_option: Int, rotation: Int)
+    external fun applySettings()
+
+    external fun logSettings()
 
     enum class CoreError {
         ErrorSystemFiles,
@@ -538,6 +530,23 @@ object NativeLibrary {
      */
     external fun isFirmwareAvailable(): Boolean
 
+    /**
+     * Checks the PatchManager for any addons that are available
+     *
+     * @param path Path to game file. Can be a [Uri].
+     * @param programId String representation of a game's program ID
+     * @return Array of pairs where the first value is the name of an addon and the second is the version
+     */
+    external fun getAddonsForFile(path: String, programId: String): Array<Pair<String, String>>?
+
+    /**
+     * Gets the save location for a specific game
+     *
+     * @param programId String representation of a game's program ID
+     * @return Save data path that may not exist yet
+     */
+    external fun getSavePath(programId: String): String
+
     /**
      * Button type for use in onTouchEvent
      */

+ 52 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt

@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+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.ListItemAddonBinding
+import org.yuzu.yuzu_emu.model.Addon
+
+class AddonAdapter : ListAdapter<Addon, AddonAdapter.AddonViewHolder>(
+    AsyncDifferConfig.Builder(DiffCallback()).build()
+) {
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder {
+        ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+            .also { return AddonViewHolder(it) }
+    }
+
+    override fun getItemCount(): Int = currentList.size
+
+    override fun onBindViewHolder(holder: AddonViewHolder, position: Int) =
+        holder.bind(currentList[position])
+
+    inner class AddonViewHolder(val binding: ListItemAddonBinding) :
+        RecyclerView.ViewHolder(binding.root) {
+        fun bind(addon: Addon) {
+            binding.root.setOnClickListener {
+                binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked
+            }
+            binding.title.text = addon.title
+            binding.version.text = addon.version
+            binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
+                addon.enabled = checked
+            }
+            binding.addonSwitch.isChecked = addon.enabled
+        }
+    }
+
+    private class DiffCallback : DiffUtil.ItemCallback<Addon>() {
+        override fun areItemsTheSame(oldItem: Addon, newItem: Addon): Boolean {
+            return oldItem == newItem
+        }
+
+        override fun areContentsTheSame(oldItem: Addon, newItem: Addon): Boolean {
+            return oldItem == newItem
+        }
+    }
+}

+ 3 - 3
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt

@@ -15,7 +15,7 @@ import org.yuzu.yuzu_emu.HomeNavigationDirections
 import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.YuzuApplication
-import org.yuzu.yuzu_emu.databinding.CardAppletOptionBinding
+import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
 import org.yuzu.yuzu_emu.model.Applet
 import org.yuzu.yuzu_emu.model.AppletInfo
 import org.yuzu.yuzu_emu.model.Game
@@ -28,7 +28,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List<Applet>) :
         parent: ViewGroup,
         viewType: Int
     ): AppletAdapter.AppletViewHolder {
-        CardAppletOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false)
             .apply { root.setOnClickListener(this@AppletAdapter) }
             .also { return AppletViewHolder(it) }
     }
@@ -65,7 +65,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List<Applet>) :
         view.findNavController().navigate(action)
     }
 
-    inner class AppletViewHolder(val binding: CardAppletOptionBinding) :
+    inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) :
         RecyclerView.ViewHolder(binding.root) {
         lateinit var applet: Applet
 

+ 12 - 4
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt

@@ -44,19 +44,20 @@ import org.yuzu.yuzu_emu.utils.GameIconUtils
 
 class GameAdapter(private val activity: AppCompatActivity) :
     ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
-    View.OnClickListener {
+    View.OnClickListener,
+    View.OnLongClickListener {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
         // Create a new view.
         val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
         binding.cardGame.setOnClickListener(this)
+        binding.cardGame.setOnLongClickListener(this)
 
         // Use that view to create a ViewHolder.
         return GameViewHolder(binding)
     }
 
-    override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
+    override fun onBindViewHolder(holder: GameViewHolder, position: Int) =
         holder.bind(currentList[position])
-    }
 
     override fun getItemCount(): Int = currentList.size
 
@@ -125,8 +126,15 @@ class GameAdapter(private val activity: AppCompatActivity) :
             }
         }
 
-        val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game)
+        val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game, true)
+        view.findNavController().navigate(action)
+    }
+
+    override fun onLongClick(view: View): Boolean {
+        val holder = view.tag as GameViewHolder
+        val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(holder.game)
         view.findNavController().navigate(action)
+        return true
     }
 
     inner class GameViewHolder(val binding: CardGameBinding) :

+ 133 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt

@@ -0,0 +1,133 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.res.ResourcesCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.databinding.CardInstallableBinding
+import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
+import org.yuzu.yuzu_emu.model.GameProperty
+import org.yuzu.yuzu_emu.model.InstallableProperty
+import org.yuzu.yuzu_emu.model.SubmenuProperty
+
+class GamePropertiesAdapter(
+    private val viewLifecycle: LifecycleOwner,
+    private var properties: List<GameProperty>
+) :
+    RecyclerView.Adapter<GamePropertiesAdapter.GamePropertyViewHolder>() {
+    override fun onCreateViewHolder(
+        parent: ViewGroup,
+        viewType: Int
+    ): GamePropertyViewHolder {
+        val inflater = LayoutInflater.from(parent.context)
+        return when (viewType) {
+            PropertyType.Submenu.ordinal -> {
+                SubmenuPropertyViewHolder(
+                    CardSimpleOutlinedBinding.inflate(
+                        inflater,
+                        parent,
+                        false
+                    )
+                )
+            }
+
+            else -> InstallablePropertyViewHolder(
+                CardInstallableBinding.inflate(
+                    inflater,
+                    parent,
+                    false
+                )
+            )
+        }
+    }
+
+    override fun getItemCount(): Int = properties.size
+
+    override fun onBindViewHolder(holder: GamePropertyViewHolder, position: Int) =
+        holder.bind(properties[position])
+
+    override fun getItemViewType(position: Int): Int {
+        return when (properties[position]) {
+            is SubmenuProperty -> PropertyType.Submenu.ordinal
+            else -> PropertyType.Installable.ordinal
+        }
+    }
+
+    sealed class GamePropertyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        abstract fun bind(property: GameProperty)
+    }
+
+    inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) :
+        GamePropertyViewHolder(binding.root) {
+        override fun bind(property: GameProperty) {
+            val submenuProperty = property as SubmenuProperty
+
+            binding.root.setOnClickListener {
+                submenuProperty.action.invoke()
+            }
+
+            binding.title.setText(submenuProperty.titleId)
+            binding.description.setText(submenuProperty.descriptionId)
+            binding.icon.setImageDrawable(
+                ResourcesCompat.getDrawable(
+                    binding.icon.context.resources,
+                    submenuProperty.iconId,
+                    binding.icon.context.theme
+                )
+            )
+
+            binding.details.postDelayed({
+                binding.details.isSelected = true
+                binding.details.ellipsize = TextUtils.TruncateAt.MARQUEE
+            }, 3000)
+
+            if (submenuProperty.details != null) {
+                binding.details.visibility = View.VISIBLE
+                binding.details.text = submenuProperty.details.invoke()
+            } else if (submenuProperty.detailsFlow != null) {
+                binding.details.visibility = View.VISIBLE
+                viewLifecycle.lifecycleScope.launch {
+                    viewLifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                        submenuProperty.detailsFlow.collect { binding.details.text = it }
+                    }
+                }
+            } else {
+                binding.details.visibility = View.GONE
+            }
+        }
+    }
+
+    inner class InstallablePropertyViewHolder(val binding: CardInstallableBinding) :
+        GamePropertyViewHolder(binding.root) {
+        override fun bind(property: GameProperty) {
+            val installableProperty = property as InstallableProperty
+
+            binding.title.setText(installableProperty.titleId)
+            binding.description.setText(installableProperty.descriptionId)
+
+            if (installableProperty.install != null) {
+                binding.buttonInstall.visibility = View.VISIBLE
+                binding.buttonInstall.setOnClickListener { installableProperty.install.invoke() }
+            }
+            if (installableProperty.export != null) {
+                binding.buttonExport.visibility = View.VISIBLE
+                binding.buttonExport.setOnClickListener { installableProperty.export.invoke() }
+            }
+        }
+    }
+
+    enum class PropertyType {
+        Submenu,
+        Installable
+    }
+}

+ 214 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt

@@ -0,0 +1,214 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.documentfile.provider.DocumentFile
+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.navigation.fragment.navArgs
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.AddonAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentAddonsBinding
+import org.yuzu.yuzu_emu.model.AddonViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.AddonUtil
+import org.yuzu.yuzu_emu.utils.FileUtil.copyFilesTo
+import java.io.File
+
+class AddonsFragment : Fragment() {
+    private var _binding: FragmentAddonsBinding? = null
+    private val binding get() = _binding!!
+
+    private val homeViewModel: HomeViewModel by activityViewModels()
+    private val addonViewModel: AddonViewModel by activityViewModels()
+
+    private val args by navArgs<AddonsFragmentArgs>()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        addonViewModel.onOpenAddons(args.game)
+        enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+        returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+        reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        _binding = FragmentAddonsBinding.inflate(inflater)
+        return binding.root
+    }
+
+    // This is using the correct scope, lint is just acting up
+    @SuppressLint("UnsafeRepeatOnLifecycleDetector")
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        homeViewModel.setNavigationVisibility(visible = false, animated = false)
+        homeViewModel.setStatusBarShadeVisibility(false)
+
+        binding.toolbarAddons.setNavigationOnClickListener {
+            binding.root.findNavController().popBackStack()
+        }
+
+        binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title)
+
+        binding.listAddons.apply {
+            layoutManager = LinearLayoutManager(requireContext())
+            adapter = AddonAdapter()
+        }
+
+        viewLifecycleOwner.lifecycleScope.apply {
+            launch {
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    addonViewModel.addonList.collect {
+                        (binding.listAddons.adapter as AddonAdapter).submitList(it)
+                    }
+                }
+            }
+            launch {
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    addonViewModel.showModInstallPicker.collect {
+                        if (it) {
+                            installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
+                            addonViewModel.showModInstallPicker(false)
+                        }
+                    }
+                }
+            }
+            launch {
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    addonViewModel.showModNoticeDialog.collect {
+                        if (it) {
+                            MessageDialogFragment.newInstance(
+                                requireActivity(),
+                                titleId = R.string.addon_notice,
+                                descriptionId = R.string.addon_notice_description,
+                                positiveAction = { addonViewModel.showModInstallPicker(true) }
+                            ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                            addonViewModel.showModNoticeDialog(false)
+                        }
+                    }
+                }
+            }
+        }
+
+        binding.buttonInstall.setOnClickListener {
+            ContentTypeSelectionDialogFragment().show(
+                parentFragmentManager,
+                ContentTypeSelectionDialogFragment.TAG
+            )
+        }
+
+        setInsets()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        addonViewModel.refreshAddons()
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        addonViewModel.onCloseAddons()
+    }
+
+    val installAddon =
+        registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
+            if (result == null) {
+                return@registerForActivityResult
+            }
+
+            val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result)
+            if (externalAddonDirectory == null) {
+                MessageDialogFragment.newInstance(
+                    requireActivity(),
+                    titleId = R.string.invalid_directory,
+                    descriptionId = R.string.invalid_directory_description
+                ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                return@registerForActivityResult
+            }
+
+            val isValid = externalAddonDirectory.listFiles()
+                .any { AddonUtil.validAddonDirectories.contains(it.name) }
+            val errorMessage = MessageDialogFragment.newInstance(
+                requireActivity(),
+                titleId = R.string.invalid_directory,
+                descriptionId = R.string.invalid_directory_description
+            )
+            if (isValid) {
+                IndeterminateProgressDialogFragment.newInstance(
+                    requireActivity(),
+                    R.string.installing_game_content,
+                    false
+                ) {
+                    val parentDirectoryName = externalAddonDirectory.name
+                    val internalAddonDirectory =
+                        File(args.game.addonDir + parentDirectoryName)
+                    try {
+                        externalAddonDirectory.copyFilesTo(internalAddonDirectory)
+                    } catch (_: Exception) {
+                        return@newInstance errorMessage
+                    }
+                    addonViewModel.refreshAddons()
+                    return@newInstance getString(R.string.addon_installed_successfully)
+                }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+            } else {
+                errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG)
+            }
+        }
+
+    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.toolbarAddons.layoutParams as ViewGroup.MarginLayoutParams
+            mlpToolbar.leftMargin = leftInsets
+            mlpToolbar.rightMargin = rightInsets
+            binding.toolbarAddons.layoutParams = mlpToolbar
+
+            val mlpAddonsList = binding.listAddons.layoutParams as ViewGroup.MarginLayoutParams
+            mlpAddonsList.leftMargin = leftInsets
+            mlpAddonsList.rightMargin = rightInsets
+            binding.listAddons.layoutParams = mlpAddonsList
+            binding.listAddons.updatePadding(
+                bottom = barInsets.bottom +
+                    resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
+            )
+
+            val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
+            val mlpFab =
+                binding.buttonInstall.layoutParams as ViewGroup.MarginLayoutParams
+            mlpFab.leftMargin = leftInsets + fabSpacing
+            mlpFab.rightMargin = rightInsets + fabSpacing
+            mlpFab.bottomMargin = barInsets.bottom + fabSpacing
+            binding.buttonInstall.layoutParams = mlpFab
+
+            windowInsets
+        }
+}

+ 68 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt

@@ -0,0 +1,68 @@
+// 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 androidx.preference.PreferenceManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.model.AddonViewModel
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+
+class ContentTypeSelectionDialogFragment : DialogFragment() {
+    private val addonViewModel: AddonViewModel by activityViewModels()
+
+    private val preferences get() =
+        PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+    private var selectedItem = 0
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val launchOptions =
+            arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats))
+
+        if (savedInstanceState != null) {
+            selectedItem = savedInstanceState.getInt(SELECTED_ITEM)
+        }
+
+        val mainActivity = requireActivity() as MainActivity
+        return MaterialAlertDialogBuilder(requireContext())
+            .setTitle(R.string.select_content_type)
+            .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+                when (selectedItem) {
+                    0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*"))
+                    else -> {
+                        if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) {
+                            preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply()
+                            addonViewModel.showModNoticeDialog(true)
+                            return@setPositiveButton
+                        }
+                        addonViewModel.showModInstallPicker(true)
+                    }
+                }
+            }
+            .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int ->
+                selectedItem = i
+            }
+            .setNegativeButton(android.R.string.cancel, null)
+            .show()
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putInt(SELECTED_ITEM, selectedItem)
+    }
+
+    companion object {
+        const val TAG = "ContentTypeSelectionDialogFragment"
+
+        private const val SELECTED_ITEM = "SelectedItem"
+        private const val MOD_NOTICE_SEEN = "ModNoticeSeen"
+    }
+}

+ 148 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt

@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+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.navigation.findNavController
+import androidx.navigation.fragment.navArgs
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.FragmentGameInfoBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.GameMetadata
+
+class GameInfoFragment : Fragment() {
+    private var _binding: FragmentGameInfoBinding? = null
+    private val binding get() = _binding!!
+
+    private val homeViewModel: HomeViewModel by activityViewModels()
+
+    private val args by navArgs<GameInfoFragmentArgs>()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+        returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+        reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+
+        // Check for an up-to-date version string
+        args.game.version = GameMetadata.getVersion(args.game.path, true)
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        _binding = FragmentGameInfoBinding.inflate(inflater)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        homeViewModel.setNavigationVisibility(visible = false, animated = false)
+        homeViewModel.setStatusBarShadeVisibility(false)
+
+        binding.apply {
+            toolbarInfo.title = args.game.title
+            toolbarInfo.setNavigationOnClickListener {
+                view.findNavController().popBackStack()
+            }
+
+            val pathString = Uri.parse(args.game.path).path ?: ""
+            path.setHint(R.string.path)
+            pathField.setText(pathString)
+            pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) }
+
+            programId.setHint(R.string.program_id)
+            programIdField.setText(args.game.programIdHex)
+            programIdField.setOnClickListener {
+                copyToClipboard(getString(R.string.program_id), args.game.programIdHex)
+            }
+
+            if (args.game.developer.isNotEmpty()) {
+                developer.setHint(R.string.developer)
+                developerField.setText(args.game.developer)
+                developerField.setOnClickListener {
+                    copyToClipboard(getString(R.string.developer), args.game.developer)
+                }
+            } else {
+                developer.visibility = View.GONE
+            }
+
+            version.setHint(R.string.version)
+            versionField.setText(args.game.version)
+            versionField.setOnClickListener {
+                copyToClipboard(getString(R.string.version), args.game.version)
+            }
+
+            buttonCopy.setOnClickListener {
+                val details = """
+                    ${args.game.title}
+                    ${getString(R.string.path)} - $pathString
+                    ${getString(R.string.program_id)} - ${args.game.programIdHex}
+                    ${getString(R.string.developer)} - ${args.game.developer}
+                    ${getString(R.string.version)} - ${args.game.version}
+                """.trimIndent()
+                copyToClipboard(args.game.title, details)
+            }
+        }
+
+        setInsets()
+    }
+
+    private fun copyToClipboard(label: String, body: String) {
+        val clipBoard =
+            requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+        val clip = ClipData.newPlainText(label, body)
+        clipBoard.setPrimaryClip(clip)
+
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+            Toast.makeText(
+                requireContext(),
+                R.string.copied_to_clipboard,
+                Toast.LENGTH_SHORT
+            ).show()
+        }
+    }
+
+    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.toolbarInfo.layoutParams as ViewGroup.MarginLayoutParams
+            mlpToolbar.leftMargin = leftInsets
+            mlpToolbar.rightMargin = rightInsets
+            binding.toolbarInfo.layoutParams = mlpToolbar
+
+            val mlpScrollAbout = binding.scrollInfo.layoutParams as ViewGroup.MarginLayoutParams
+            mlpScrollAbout.leftMargin = leftInsets
+            mlpScrollAbout.rightMargin = rightInsets
+            binding.scrollInfo.layoutParams = mlpScrollAbout
+
+            binding.contentInfo.updatePadding(bottom = barInsets.bottom)
+
+            windowInsets
+        }
+}

+ 418 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt

@@ -0,0 +1,418 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+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.navigation.fragment.navArgs
+import androidx.recyclerview.widget.GridLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.HomeNavigationDirections
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.model.DriverViewModel
+import org.yuzu.yuzu_emu.model.GameProperty
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.InstallableProperty
+import org.yuzu.yuzu_emu.model.SubmenuProperty
+import org.yuzu.yuzu_emu.model.TaskState
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.GameIconUtils
+import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+import org.yuzu.yuzu_emu.utils.MemoryUtil
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.File
+
+class GamePropertiesFragment : Fragment() {
+    private var _binding: FragmentGamePropertiesBinding? = null
+    private val binding get() = _binding!!
+
+    private val homeViewModel: HomeViewModel by activityViewModels()
+    private val gamesViewModel: GamesViewModel by activityViewModels()
+    private val driverViewModel: DriverViewModel by activityViewModels()
+
+    private val args by navArgs<GamePropertiesFragmentArgs>()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true)
+        returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false)
+        reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        _binding = FragmentGamePropertiesBinding.inflate(layoutInflater)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        homeViewModel.setNavigationVisibility(visible = false, animated = true)
+        homeViewModel.setStatusBarShadeVisibility(true)
+
+        binding.buttonBack.setOnClickListener {
+            view.findNavController().popBackStack()
+        }
+
+        GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen)
+        binding.title.text = args.game.title
+        binding.title.postDelayed(
+            {
+                binding.title.ellipsize = TextUtils.TruncateAt.MARQUEE
+                binding.title.isSelected = true
+            },
+            3000
+        )
+
+        binding.buttonStart.setOnClickListener {
+            LaunchGameDialogFragment.newInstance(args.game)
+                .show(childFragmentManager, LaunchGameDialogFragment.TAG)
+        }
+
+        reloadList()
+
+        viewLifecycleOwner.lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.STARTED) {
+                homeViewModel.openImportSaves.collect {
+                    if (it) {
+                        importSaves.launch(arrayOf("application/zip"))
+                        homeViewModel.setOpenImportSaves(false)
+                    }
+                }
+            }
+        }
+
+        setInsets()
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        gamesViewModel.reloadGames(true)
+    }
+
+    private fun reloadList() {
+        _binding ?: return
+
+        driverViewModel.updateDriverNameForGame(args.game)
+        val properties = mutableListOf<GameProperty>().apply {
+            add(
+                SubmenuProperty(
+                    R.string.info,
+                    R.string.info_description,
+                    R.drawable.ic_info_outline
+                ) {
+                    val action = GamePropertiesFragmentDirections
+                        .actionPerGamePropertiesFragmentToGameInfoFragment(args.game)
+                    binding.root.findNavController().navigate(action)
+                }
+            )
+            add(
+                SubmenuProperty(
+                    R.string.preferences_settings,
+                    R.string.per_game_settings_description,
+                    R.drawable.ic_settings
+                ) {
+                    val action = HomeNavigationDirections.actionGlobalSettingsActivity(
+                        args.game,
+                        Settings.MenuTag.SECTION_ROOT
+                    )
+                    binding.root.findNavController().navigate(action)
+                }
+            )
+
+            if (!args.game.isHomebrew) {
+                add(
+                    SubmenuProperty(
+                        R.string.add_ons,
+                        R.string.add_ons_description,
+                        R.drawable.ic_edit
+                    ) {
+                        val action = GamePropertiesFragmentDirections
+                            .actionPerGamePropertiesFragmentToAddonsFragment(args.game)
+                        binding.root.findNavController().navigate(action)
+                    }
+                )
+                add(
+                    InstallableProperty(
+                        R.string.save_data,
+                        R.string.save_data_description,
+                        {
+                            MessageDialogFragment.newInstance(
+                                requireActivity(),
+                                titleId = R.string.import_save_warning,
+                                descriptionId = R.string.import_save_warning_description,
+                                positiveAction = { homeViewModel.setOpenImportSaves(true) }
+                            ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                        },
+                        if (File(args.game.saveDir).exists()) {
+                            { exportSaves.launch(args.game.saveZipName) }
+                        } else {
+                            null
+                        }
+                    )
+                )
+
+                val saveDirFile = File(args.game.saveDir)
+                if (saveDirFile.exists()) {
+                    add(
+                        SubmenuProperty(
+                            R.string.delete_save_data,
+                            R.string.delete_save_data_description,
+                            R.drawable.ic_delete,
+                            action = {
+                                MessageDialogFragment.newInstance(
+                                    requireActivity(),
+                                    titleId = R.string.delete_save_data,
+                                    descriptionId = R.string.delete_save_data_warning_description,
+                                    positiveAction = {
+                                        File(args.game.saveDir).deleteRecursively()
+                                        Toast.makeText(
+                                            YuzuApplication.appContext,
+                                            R.string.save_data_deleted_successfully,
+                                            Toast.LENGTH_SHORT
+                                        ).show()
+                                        reloadList()
+                                    }
+                                ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                            }
+                        )
+                    )
+                }
+
+                val shaderCacheDir = File(
+                    DirectoryInitialization.userDirectory +
+                        "/shader/" + args.game.settingsName.lowercase()
+                )
+                if (shaderCacheDir.exists()) {
+                    add(
+                        SubmenuProperty(
+                            R.string.clear_shader_cache,
+                            R.string.clear_shader_cache_description,
+                            R.drawable.ic_delete,
+                            {
+                                if (shaderCacheDir.exists()) {
+                                    val bytes = shaderCacheDir.walkTopDown().filter { it.isFile }
+                                        .map { it.length() }.sum()
+                                    MemoryUtil.bytesToSizeUnit(bytes.toFloat())
+                                } else {
+                                    MemoryUtil.bytesToSizeUnit(0f)
+                                }
+                            }
+                        ) {
+                            shaderCacheDir.deleteRecursively()
+                            Toast.makeText(
+                                YuzuApplication.appContext,
+                                R.string.cleared_shaders_successfully,
+                                Toast.LENGTH_SHORT
+                            ).show()
+                            reloadList()
+                        }
+                    )
+                }
+            }
+        }
+        binding.listProperties.apply {
+            layoutManager =
+                GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns))
+            adapter = GamePropertiesAdapter(viewLifecycleOwner, properties)
+        }
+    }
+
+    override fun onResume() {
+        super.onResume()
+        driverViewModel.updateDriverNameForGame(args.game)
+    }
+
+    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 smallLayout = resources.getBoolean(R.bool.small_layout)
+            if (smallLayout) {
+                val mlpListAll =
+                    binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
+                mlpListAll.leftMargin = leftInsets
+                mlpListAll.rightMargin = rightInsets
+                binding.listAll.layoutParams = mlpListAll
+            } else {
+                if (ViewCompat.getLayoutDirection(binding.root) ==
+                    ViewCompat.LAYOUT_DIRECTION_LTR
+                ) {
+                    val mlpListAll =
+                        binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
+                    mlpListAll.rightMargin = rightInsets
+                    binding.listAll.layoutParams = mlpListAll
+
+                    val mlpIconLayout =
+                        binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams
+                    mlpIconLayout.topMargin = barInsets.top
+                    mlpIconLayout.leftMargin = leftInsets
+                    binding.iconLayout!!.layoutParams = mlpIconLayout
+                } else {
+                    val mlpListAll =
+                        binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
+                    mlpListAll.leftMargin = leftInsets
+                    binding.listAll.layoutParams = mlpListAll
+
+                    val mlpIconLayout =
+                        binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams
+                    mlpIconLayout.topMargin = barInsets.top
+                    mlpIconLayout.rightMargin = rightInsets
+                    binding.iconLayout!!.layoutParams = mlpIconLayout
+                }
+            }
+
+            val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
+            val mlpFab =
+                binding.buttonStart.layoutParams as ViewGroup.MarginLayoutParams
+            mlpFab.leftMargin = leftInsets + fabSpacing
+            mlpFab.rightMargin = rightInsets + fabSpacing
+            mlpFab.bottomMargin = barInsets.bottom + fabSpacing
+            binding.buttonStart.layoutParams = mlpFab
+
+            binding.layoutAll.updatePadding(
+                top = barInsets.top,
+                bottom = barInsets.bottom +
+                    resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
+            )
+
+            windowInsets
+        }
+
+    private val importSaves =
+        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+            if (result == null) {
+                return@registerForActivityResult
+            }
+
+            val inputZip = requireContext().contentResolver.openInputStream(result)
+            val savesFolder = File(args.game.saveDir)
+            val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
+            cacheSaveDir.mkdir()
+
+            if (inputZip == null) {
+                Toast.makeText(
+                    YuzuApplication.appContext,
+                    getString(R.string.fatal_error),
+                    Toast.LENGTH_LONG
+                ).show()
+                return@registerForActivityResult
+            }
+
+            IndeterminateProgressDialogFragment.newInstance(
+                requireActivity(),
+                R.string.save_files_importing,
+                false
+            ) {
+                try {
+                    FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
+                    val files = cacheSaveDir.listFiles()
+                    var savesFolderFile: File? = null
+                    if (files != null) {
+                        val savesFolderName = args.game.programIdHex
+                        for (file in files) {
+                            if (file.isDirectory && file.name == savesFolderName) {
+                                savesFolderFile = file
+                                break
+                            }
+                        }
+                    }
+
+                    if (savesFolderFile != null) {
+                        savesFolder.deleteRecursively()
+                        savesFolder.mkdir()
+                        savesFolderFile.copyRecursively(savesFolder)
+                        savesFolderFile.deleteRecursively()
+                    }
+
+                    withContext(Dispatchers.Main) {
+                        if (savesFolderFile == null) {
+                            MessageDialogFragment.newInstance(
+                                requireActivity(),
+                                titleId = R.string.save_file_invalid_zip_structure,
+                                descriptionId = R.string.save_file_invalid_zip_structure_description
+                            ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                            return@withContext
+                        }
+                        Toast.makeText(
+                            YuzuApplication.appContext,
+                            getString(R.string.save_file_imported_success),
+                            Toast.LENGTH_LONG
+                        ).show()
+                        reloadList()
+                    }
+
+                    cacheSaveDir.deleteRecursively()
+                } catch (e: Exception) {
+                    Toast.makeText(
+                        YuzuApplication.appContext,
+                        getString(R.string.fatal_error),
+                        Toast.LENGTH_LONG
+                    ).show()
+                }
+            }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+        }
+
+    /**
+     * Exports the save file located in the given folder path by creating a zip file and opening a
+     * file picker to save.
+     */
+    private val exportSaves = registerForActivityResult(
+        ActivityResultContracts.CreateDocument("application/zip")
+    ) { result ->
+        if (result == null) {
+            return@registerForActivityResult
+        }
+
+        IndeterminateProgressDialogFragment.newInstance(
+            requireActivity(),
+            R.string.save_files_exporting,
+            false
+        ) {
+            val saveLocation = args.game.saveDir
+            val zipResult = FileUtil.zipFromInternalStorage(
+                File(saveLocation),
+                saveLocation.replaceAfterLast("/", ""),
+                BufferedOutputStream(requireContext().contentResolver.openOutputStream(result))
+            )
+            return@newInstance when (zipResult) {
+                TaskState.Completed -> getString(R.string.export_success)
+                TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
+            }
+        }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+    }
+}

+ 1 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt

@@ -122,7 +122,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
             activity: FragmentActivity,
             titleId: Int,
             cancellable: Boolean = false,
-            task: () -> Any
+            task: suspend () -> Any
         ): IndeterminateProgressDialogFragment {
             val dialog = IndeterminateProgressDialogFragment()
             val args = Bundle()

+ 61 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt

@@ -0,0 +1,61 @@
+// 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.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.HomeNavigationDirections
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
+
+class LaunchGameDialogFragment : DialogFragment() {
+    private var selectedItem = 0
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val game = requireArguments().parcelable<Game>(GAME)
+        val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom))
+
+        if (savedInstanceState != null) {
+            selectedItem = savedInstanceState.getInt(SELECTED_ITEM)
+        }
+
+        return MaterialAlertDialogBuilder(requireContext())
+            .setTitle(R.string.launch_options)
+            .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+                val action = HomeNavigationDirections
+                    .actionGlobalEmulationActivity(game, selectedItem != 0)
+                requireParentFragment().findNavController().navigate(action)
+            }
+            .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int ->
+                selectedItem = i
+            }
+            .setNegativeButton(android.R.string.cancel, null)
+            .show()
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putInt(SELECTED_ITEM, selectedItem)
+    }
+
+    companion object {
+        const val TAG = "LaunchGameDialogFragment"
+
+        const val GAME = "Game"
+        const val SELECTED_ITEM = "SelectedItem"
+
+        fun newInstance(game: Game): LaunchGameDialogFragment {
+            val args = Bundle()
+            args.putParcelable(GAME, game)
+            val fragment = LaunchGameDialogFragment()
+            fragment.arguments = args
+            return fragment
+        }
+    }
+}

+ 20 - 17
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt

@@ -27,30 +27,31 @@ class MessageDialogFragment : DialogFragment() {
         val descriptionString = requireArguments().getString(DESCRIPTION_STRING)!!
         val helpLinkId = requireArguments().getInt(HELP_LINK)
 
-        val dialog = MaterialAlertDialogBuilder(requireContext())
-            .setPositiveButton(R.string.close, null)
+        val builder = MaterialAlertDialogBuilder(requireContext())
 
-        if (titleId != 0) dialog.setTitle(titleId)
-        if (titleString.isNotEmpty()) dialog.setTitle(titleString)
+        if (messageDialogViewModel.positiveAction == null) {
+            builder.setPositiveButton(R.string.close, null)
+        } else {
+            builder.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+                messageDialogViewModel.positiveAction?.invoke()
+            }.setNegativeButton(android.R.string.cancel, null)
+        }
+
+        if (titleId != 0) builder.setTitle(titleId)
+        if (titleString.isNotEmpty()) builder.setTitle(titleString)
 
         if (descriptionId != 0) {
-            dialog.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY))
+            builder.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY))
         }
-        if (descriptionString.isNotEmpty()) dialog.setMessage(descriptionString)
+        if (descriptionString.isNotEmpty()) builder.setMessage(descriptionString)
 
         if (helpLinkId != 0) {
-            dialog.setNeutralButton(R.string.learn_more) { _, _ ->
+            builder.setNeutralButton(R.string.learn_more) { _, _ ->
                 openLink(getString(helpLinkId))
             }
         }
 
-        return dialog.show()
-    }
-
-    override fun onDismiss(dialog: DialogInterface) {
-        super.onDismiss(dialog)
-        messageDialogViewModel.dismissAction.invoke()
-        messageDialogViewModel.clear()
+        return builder.show()
     }
 
     private fun openLink(link: String) {
@@ -74,7 +75,7 @@ class MessageDialogFragment : DialogFragment() {
             descriptionId: Int = 0,
             descriptionString: String = "",
             helpLinkId: Int = 0,
-            dismissAction: () -> Unit = {}
+            positiveAction: (() -> Unit)? = null
         ): MessageDialogFragment {
             val dialog = MessageDialogFragment()
             val bundle = Bundle()
@@ -85,8 +86,10 @@ class MessageDialogFragment : DialogFragment() {
                 putString(DESCRIPTION_STRING, descriptionString)
                 putInt(HELP_LINK, helpLinkId)
             }
-            ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction =
-                dismissAction
+            ViewModelProvider(activity)[MessageDialogViewModel::class.java].apply {
+                clear()
+                this.positiveAction = positiveAction
+            }
             dialog.arguments = bundle
             return dialog
         }

+ 3 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt

@@ -60,7 +60,9 @@ class SearchFragment : Fragment() {
     // This is using the correct scope, lint is just acting up
     @SuppressLint("UnsafeRepeatOnLifecycleDetector")
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        homeViewModel.setNavigationVisibility(visible = true, animated = false)
+        super.onViewCreated(view, savedInstanceState)
+        homeViewModel.setNavigationVisibility(visible = true, animated = true)
+        homeViewModel.setStatusBarShadeVisibility(true)
         preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
 
         if (savedInstanceState != null) {

+ 10 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt

@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+data class Addon(
+    var enabled: Boolean,
+    val title: String,
+    val version: String
+)

+ 83 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt

@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.utils.NativeConfig
+import java.util.concurrent.atomic.AtomicBoolean
+
+class AddonViewModel : ViewModel() {
+    private val _addonList = MutableStateFlow(mutableListOf<Addon>())
+    val addonList get() = _addonList.asStateFlow()
+
+    private val _showModInstallPicker = MutableStateFlow(false)
+    val showModInstallPicker get() = _showModInstallPicker.asStateFlow()
+
+    private val _showModNoticeDialog = MutableStateFlow(false)
+    val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow()
+
+    var game: Game? = null
+
+    private val isRefreshing = AtomicBoolean(false)
+
+    fun onOpenAddons(game: Game) {
+        this.game = game
+        refreshAddons()
+    }
+
+    fun refreshAddons() {
+        if (isRefreshing.get() || game == null) {
+            return
+        }
+        isRefreshing.set(true)
+        viewModelScope.launch {
+            withContext(Dispatchers.IO) {
+                val addonList = mutableListOf<Addon>()
+                val disabledAddons = NativeConfig.getDisabledAddons(game!!.programId)
+                NativeLibrary.getAddonsForFile(game!!.path, game!!.programId)?.forEach {
+                    val name = it.first.replace("[D] ", "")
+                    addonList.add(Addon(!disabledAddons.contains(name), name, it.second))
+                }
+                addonList.sortBy { it.title }
+                _addonList.value = addonList
+                isRefreshing.set(false)
+            }
+        }
+    }
+
+    fun onCloseAddons() {
+        if (_addonList.value.isEmpty()) {
+            return
+        }
+
+        NativeConfig.setDisabledAddons(
+            game!!.programId,
+            _addonList.value.mapNotNull {
+                if (it.enabled) {
+                    null
+                } else {
+                    it.title
+                }
+            }.toTypedArray()
+        )
+        NativeConfig.saveGlobalConfig()
+        _addonList.value.clear()
+        game = null
+    }
+
+    fun showModInstallPicker(install: Boolean) {
+        _showModInstallPicker.value = install
+    }
+
+    fun showModNoticeDialog(show: Boolean) {
+        _showModNoticeDialog.value = show
+    }
+}

+ 41 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt

@@ -3,10 +3,18 @@
 
 package org.yuzu.yuzu_emu.model
 
+import android.net.Uri
 import android.os.Parcelable
 import java.util.HashSet
 import kotlinx.parcelize.Parcelize
 import kotlinx.serialization.Serializable
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.FileUtil
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
 
 @Parcelize
 @Serializable
@@ -15,12 +23,44 @@ class Game(
     val path: String,
     val programId: String = "",
     val developer: String = "",
-    val version: String = "",
+    var version: String = "",
     val isHomebrew: Boolean = false
 ) : Parcelable {
     val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime"
     val keyLastPlayedTime get() = "${path}_LastPlayed"
 
+    val settingsName: String
+        get() {
+            val programIdLong = programId.toLong()
+            return if (programIdLong == 0L) {
+                FileUtil.getFilename(Uri.parse(path))
+            } else {
+                "0" + programIdLong.toString(16).uppercase()
+            }
+        }
+
+    val programIdHex: String
+        get() {
+            val programIdLong = programId.toLong()
+            return if (programIdLong == 0L) {
+                "0"
+            } else {
+                "0" + programIdLong.toString(16).uppercase()
+            }
+        }
+
+    val saveZipName: String
+        get() = "$title ${YuzuApplication.appContext.getString(R.string.save_data).lowercase()} - ${
+        LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
+        }.zip"
+
+    val saveDir: String
+        get() = DirectoryInitialization.userDirectory + "/nand" +
+            NativeLibrary.getSavePath(programId)
+
+    val addonDir: String
+        get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/"
+
     override fun equals(other: Any?): Boolean {
         if (other !is Game) {
             return false

+ 34 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt

@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import kotlinx.coroutines.flow.StateFlow
+
+interface GameProperty {
+    @get:StringRes
+    val titleId: Int
+        get() = -1
+
+    @get:StringRes
+    val descriptionId: Int
+        get() = -1
+}
+
+data class SubmenuProperty(
+    override val titleId: Int,
+    override val descriptionId: Int,
+    @DrawableRes val iconId: Int,
+    val details: (() -> String)? = null,
+    val detailsFlow: StateFlow<String>? = null,
+    val action: () -> Unit
+) : GameProperty
+
+data class InstallableProperty(
+    override val titleId: Int,
+    override val descriptionId: Int,
+    val install: (() -> Unit)? = null,
+    val export: (() -> Unit)? = null
+) : GameProperty

+ 15 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt

@@ -3,6 +3,7 @@
 
 package org.yuzu.yuzu_emu.model
 
+import android.net.Uri
 import androidx.lifecycle.ViewModel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
@@ -21,6 +22,12 @@ class HomeViewModel : ViewModel() {
     private val _gamesDirSelected = MutableStateFlow(false)
     val gamesDirSelected get() = _gamesDirSelected.asStateFlow()
 
+    private val _openImportSaves = MutableStateFlow(false)
+    val openImportSaves get() = _openImportSaves.asStateFlow()
+
+    private val _contentToInstall = MutableStateFlow<List<Uri>?>(null)
+    val contentToInstall get() = _contentToInstall.asStateFlow()
+
     var navigatedToSetup = false
 
     fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
@@ -44,4 +51,12 @@ class HomeViewModel : ViewModel() {
     fun setGamesDirSelected(selected: Boolean) {
         _gamesDirSelected.value = selected
     }
+
+    fun setOpenImportSaves(import: Boolean) {
+        _openImportSaves.value = import
+    }
+
+    fun setContentToInstall(documents: List<Uri>?) {
+        _contentToInstall.value = documents
+    }
 }

+ 2 - 2
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt

@@ -6,9 +6,9 @@ package org.yuzu.yuzu_emu.model
 import androidx.lifecycle.ViewModel
 
 class MessageDialogViewModel : ViewModel() {
-    var dismissAction: () -> Unit = {}
+    var positiveAction: (() -> Unit)? = null
 
     fun clear() {
-        dismissAction = {}
+        positiveAction = null
     }
 }

+ 1 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt

@@ -23,7 +23,7 @@ class TaskViewModel : ViewModel() {
     val cancelled: StateFlow<Boolean> get() = _cancelled
     private val _cancelled = MutableStateFlow(false)
 
-    lateinit var task: () -> Any
+    lateinit var task: suspend () -> Any
 
     fun clear() {
         _result.value = Any()

+ 4 - 7
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt

@@ -19,7 +19,7 @@ import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
 import com.google.android.material.color.MaterialColors
-import com.google.android.material.transition.MaterialFadeThrough
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.adapters.GameAdapter
@@ -35,11 +35,6 @@ class GamesFragment : Fragment() {
     private val gamesViewModel: GamesViewModel by activityViewModels()
     private val homeViewModel: HomeViewModel by activityViewModels()
 
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        enterTransition = MaterialFadeThrough()
-    }
-
     override fun onCreateView(
         inflater: LayoutInflater,
         container: ViewGroup?,
@@ -52,7 +47,9 @@ class GamesFragment : Fragment() {
     // This is using the correct scope, lint is just acting up
     @SuppressLint("UnsafeRepeatOnLifecycleDetector")
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        homeViewModel.setNavigationVisibility(visible = true, animated = false)
+        super.onViewCreated(view, savedInstanceState)
+        homeViewModel.setNavigationVisibility(visible = true, animated = true)
+        homeViewModel.setStatusBarShadeVisibility(true)
 
         binding.gridGames.apply {
             layoutManager = AutofitGridLayoutManager(

+ 137 - 190
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt

@@ -43,7 +43,7 @@ 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
+import org.yuzu.yuzu_emu.model.AddonViewModel
 import org.yuzu.yuzu_emu.model.GamesViewModel
 import org.yuzu.yuzu_emu.model.HomeViewModel
 import org.yuzu.yuzu_emu.model.TaskState
@@ -60,15 +60,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
     private val homeViewModel: HomeViewModel by viewModels()
     private val gamesViewModel: GamesViewModel by viewModels()
     private val taskViewModel: TaskViewModel by viewModels()
+    private val addonViewModel: AddonViewModel by viewModels()
 
     override var themeId: Int = 0
 
-    private val savesFolder
-        get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
-
-    // Get first subfolder in saves folder (should be the user folder)
-    val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
-
     override fun onCreate(savedInstanceState: Bundle?) {
         val splashScreen = installSplashScreen()
         splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@@ -145,6 +140,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                     homeViewModel.statusBarShadeVisible.collect { showStatusBarShade(it) }
                 }
             }
+            launch {
+                repeatOnLifecycle(Lifecycle.State.CREATED) {
+                    homeViewModel.contentToInstall.collect {
+                        if (it != null) {
+                            installContent(it)
+                            homeViewModel.setContentToInstall(null)
+                        }
+                    }
+                }
+            }
         }
 
         // Dismiss previous notifications (should not happen unless a crash occurred)
@@ -468,110 +473,150 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
     val installGameUpdate = registerForActivityResult(
         ActivityResultContracts.OpenMultipleDocuments()
     ) { documents: List<Uri> ->
-        if (documents.isNotEmpty()) {
-            IndeterminateProgressDialogFragment.newInstance(
-                this@MainActivity,
-                R.string.installing_game_content
-            ) {
-                var installSuccess = 0
-                var installOverwrite = 0
-                var errorBaseGame = 0
-                var errorExtension = 0
-                var errorOther = 0
-                documents.forEach {
-                    when (
-                        NativeLibrary.installFileToNand(
-                            it.toString(),
-                            FileUtil.getExtension(it)
-                        )
-                    ) {
-                        NativeLibrary.InstallFileToNandResult.Success -> {
-                            installSuccess += 1
-                        }
+        if (documents.isEmpty()) {
+            return@registerForActivityResult
+        }
 
-                        NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> {
-                            installOverwrite += 1
-                        }
+        if (addonViewModel.game == null) {
+            installContent(documents)
+            return@registerForActivityResult
+        }
 
-                        NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> {
-                            errorBaseGame += 1
-                        }
+        IndeterminateProgressDialogFragment.newInstance(
+            this@MainActivity,
+            R.string.verifying_content,
+            false
+        ) {
+            var updatesMatchProgram = true
+            for (document in documents) {
+                val valid = NativeLibrary.doesUpdateMatchProgram(
+                    addonViewModel.game!!.programId,
+                    document.toString()
+                )
+                if (!valid) {
+                    updatesMatchProgram = false
+                    break
+                }
+            }
 
-                        NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> {
-                            errorExtension += 1
-                        }
+            if (updatesMatchProgram) {
+                homeViewModel.setContentToInstall(documents)
+            } else {
+                MessageDialogFragment.newInstance(
+                    this@MainActivity,
+                    titleId = R.string.content_install_notice,
+                    descriptionId = R.string.content_install_notice_description,
+                    positiveAction = { homeViewModel.setContentToInstall(documents) }
+                )
+            }
+        }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+    }
 
-                        else -> {
-                            errorOther += 1
-                        }
+    private fun installContent(documents: List<Uri>) {
+        IndeterminateProgressDialogFragment.newInstance(
+            this@MainActivity,
+            R.string.installing_game_content
+        ) {
+            var installSuccess = 0
+            var installOverwrite = 0
+            var errorBaseGame = 0
+            var errorExtension = 0
+            var errorOther = 0
+            documents.forEach {
+                when (
+                    NativeLibrary.installFileToNand(
+                        it.toString(),
+                        FileUtil.getExtension(it)
+                    )
+                ) {
+                    NativeLibrary.InstallFileToNandResult.Success -> {
+                        installSuccess += 1
+                    }
+
+                    NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> {
+                        installOverwrite += 1
+                    }
+
+                    NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> {
+                        errorBaseGame += 1
+                    }
+
+                    NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> {
+                        errorExtension += 1
+                    }
+
+                    else -> {
+                        errorOther += 1
                     }
                 }
+            }
 
-                val separator = System.getProperty("line.separator") ?: "\n"
-                val installResult = StringBuilder()
-                if (installSuccess > 0) {
-                    installResult.append(
-                        getString(
-                            R.string.install_game_content_success_install,
-                            installSuccess
-                        )
+            addonViewModel.refreshAddons()
+
+            val separator = System.getProperty("line.separator") ?: "\n"
+            val installResult = StringBuilder()
+            if (installSuccess > 0) {
+                installResult.append(
+                    getString(
+                        R.string.install_game_content_success_install,
+                        installSuccess
+                    )
+                )
+                installResult.append(separator)
+            }
+            if (installOverwrite > 0) {
+                installResult.append(
+                    getString(
+                        R.string.install_game_content_success_overwrite,
+                        installOverwrite
                     )
+                )
+                installResult.append(separator)
+            }
+            val errorTotal: Int = errorBaseGame + errorExtension + errorOther
+            if (errorTotal > 0) {
+                installResult.append(separator)
+                installResult.append(
+                    getString(
+                        R.string.install_game_content_failed_count,
+                        errorTotal
+                    )
+                )
+                installResult.append(separator)
+                if (errorBaseGame > 0) {
                     installResult.append(separator)
-                }
-                if (installOverwrite > 0) {
                     installResult.append(
-                        getString(
-                            R.string.install_game_content_success_overwrite,
-                            installOverwrite
-                        )
+                        getString(R.string.install_game_content_failure_base)
                     )
                     installResult.append(separator)
                 }
-                val errorTotal: Int = errorBaseGame + errorExtension + errorOther
-                if (errorTotal > 0) {
+                if (errorExtension > 0) {
                     installResult.append(separator)
                     installResult.append(
-                        getString(
-                            R.string.install_game_content_failed_count,
-                            errorTotal
-                        )
+                        getString(R.string.install_game_content_failure_file_extension)
                     )
                     installResult.append(separator)
-                    if (errorBaseGame > 0) {
-                        installResult.append(separator)
-                        installResult.append(
-                            getString(R.string.install_game_content_failure_base)
-                        )
-                        installResult.append(separator)
-                    }
-                    if (errorExtension > 0) {
-                        installResult.append(separator)
-                        installResult.append(
-                            getString(R.string.install_game_content_failure_file_extension)
-                        )
-                        installResult.append(separator)
-                    }
-                    if (errorOther > 0) {
-                        installResult.append(
-                            getString(R.string.install_game_content_failure_description)
-                        )
-                        installResult.append(separator)
-                    }
-                    return@newInstance MessageDialogFragment.newInstance(
-                        this,
-                        titleId = R.string.install_game_content_failure,
-                        descriptionString = installResult.toString().trim(),
-                        helpLinkId = R.string.install_game_content_help_link
-                    )
-                } else {
-                    return@newInstance MessageDialogFragment.newInstance(
-                        this,
-                        titleId = R.string.install_game_content_success,
-                        descriptionString = installResult.toString().trim()
+                }
+                if (errorOther > 0) {
+                    installResult.append(
+                        getString(R.string.install_game_content_failure_description)
                     )
+                    installResult.append(separator)
                 }
-            }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
-        }
+                return@newInstance MessageDialogFragment.newInstance(
+                    this,
+                    titleId = R.string.install_game_content_failure,
+                    descriptionString = installResult.toString().trim(),
+                    helpLinkId = R.string.install_game_content_help_link
+                )
+            } else {
+                return@newInstance MessageDialogFragment.newInstance(
+                    this,
+                    titleId = R.string.install_game_content_success,
+                    descriptionString = installResult.toString().trim()
+                )
+            }
+        }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
     }
 
     val exportUserData = registerForActivityResult(
@@ -657,102 +702,4 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 return@newInstance getString(R.string.user_data_import_success)
             }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
         }
-
-    /**
-     * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
-     */
-    val exportSaves = registerForActivityResult(
-        ActivityResultContracts.CreateDocument("application/zip")
-    ) { result ->
-        if (result == null) {
-            return@registerForActivityResult
-        }
-
-        IndeterminateProgressDialogFragment.newInstance(
-            this,
-            R.string.save_files_exporting,
-            false
-        ) {
-            val zipResult = FileUtil.zipFromInternalStorage(
-                File(savesFolderRoot),
-                savesFolderRoot,
-                BufferedOutputStream(contentResolver.openOutputStream(result))
-            )
-            return@newInstance when (zipResult) {
-                TaskState.Completed -> getString(R.string.export_success)
-                TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
-            }
-        }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
-    }
-
-    private val startForResultExportSave =
-        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
-            File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
-        }
-
-    val importSaves =
-        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
-            if (result == null) {
-                return@registerForActivityResult
-            }
-
-            NativeLibrary.initializeEmptyUserDirectory()
-
-            val inputZip = contentResolver.openInputStream(result)
-            // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
-            var validZip = false
-            val savesFolder = File(savesFolderRoot)
-            val cacheSaveDir = File("${applicationContext.cacheDir.path}/saves/")
-            cacheSaveDir.mkdir()
-
-            if (inputZip == null) {
-                Toast.makeText(
-                    applicationContext,
-                    getString(R.string.fatal_error),
-                    Toast.LENGTH_LONG
-                ).show()
-                return@registerForActivityResult
-            }
-
-            val filterTitleId =
-                FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
-
-            try {
-                CoroutineScope(Dispatchers.IO).launch {
-                    FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
-                    cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
-                        File(savesFolder, savePath).deleteRecursively()
-                        File(cacheSaveDir, savePath).copyRecursively(
-                            File(savesFolder, savePath),
-                            true
-                        )
-                        validZip = true
-                    }
-
-                    withContext(Dispatchers.Main) {
-                        if (!validZip) {
-                            MessageDialogFragment.newInstance(
-                                this@MainActivity,
-                                titleId = R.string.save_file_invalid_zip_structure,
-                                descriptionId = R.string.save_file_invalid_zip_structure_description
-                            ).show(supportFragmentManager, MessageDialogFragment.TAG)
-                            return@withContext
-                        }
-                        Toast.makeText(
-                            applicationContext,
-                            getString(R.string.save_file_imported_success),
-                            Toast.LENGTH_LONG
-                        ).show()
-                    }
-
-                    cacheSaveDir.deleteRecursively()
-                }
-            } catch (e: Exception) {
-                Toast.makeText(
-                    applicationContext,
-                    getString(R.string.fatal_error),
-                    Toast.LENGTH_LONG
-                ).show()
-            }
-        }
 }

+ 8 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt

@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+object AddonUtil {
+    val validAddonDirectories = listOf("cheats", "exefs", "romfs")
+}

+ 32 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt

@@ -22,6 +22,7 @@ import java.io.BufferedOutputStream
 import java.lang.NullPointerException
 import java.nio.charset.StandardCharsets
 import java.util.zip.ZipOutputStream
+import kotlin.IllegalStateException
 
 object FileUtil {
     const val PATH_TREE = "tree"
@@ -342,6 +343,37 @@ object FileUtil {
         return TaskState.Completed
     }
 
+    /**
+     * Helper function that copies the contents of a DocumentFile folder into a [File]
+     * @param file [File] representation of the folder to copy into
+     * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa
+     */
+    fun DocumentFile.copyFilesTo(file: File) {
+        file.mkdirs()
+        if (!this.isDirectory || !file.isDirectory) {
+            throw IllegalStateException(
+                "[FileUtil] Tried to copy a folder into a file or vice versa"
+            )
+        }
+
+        this.listFiles().forEach {
+            val newFile = File(file, it.name!!)
+            if (it.isDirectory) {
+                newFile.mkdirs()
+                DocumentFile.fromTreeUri(YuzuApplication.appContext, it.uri)?.copyFilesTo(newFile)
+            } else {
+                val inputStream =
+                    YuzuApplication.appContext.contentResolver.openInputStream(it.uri)
+                BufferedInputStream(inputStream).use { bos ->
+                    if (!newFile.exists()) {
+                        newFile.createNewFile()
+                    }
+                    newFile.outputStream().use { os -> bos.copyTo(os) }
+                }
+            }
+        }
+    }
+
     fun isRootTreeUri(uri: Uri): Boolean {
         val paths = uri.pathSegments
         return paths.size == 2 && PATH_TREE == paths[0]

+ 19 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt

@@ -105,4 +105,23 @@ object NativeConfig {
      */
     @Synchronized
     external fun addGameDir(dir: GameDir)
+
+    /**
+     * Gets an array of the addons that are disabled for a given game
+     *
+     * @param programId String representation of a game's program ID
+     * @return An array of disabled addons
+     */
+    @Synchronized
+    external fun getDisabledAddons(programId: String): Array<String>
+
+    /**
+     * Clears the disabled addons array corresponding to [programId] and replaces them
+     * with [disabledAddons]
+     *
+     * @param programId String representation of a game's program ID
+     * @param disabledAddons Replacement array of disabled addons
+     */
+    @Synchronized
+    external fun setDisabledAddons(programId: String, disabledAddons: Array<String>)
 }

+ 40 - 0
src/android/app/src/main/jni/id_cache.cpp

@@ -20,6 +20,12 @@ static jmethodID s_disk_cache_load_progress;
 static jmethodID s_on_emulation_started;
 static jmethodID s_on_emulation_stopped;
 
+static jclass s_string_class;
+static jclass s_pair_class;
+static jmethodID s_pair_constructor;
+static jfieldID s_pair_first_field;
+static jfieldID s_pair_second_field;
+
 static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
 
 namespace IDCache {
@@ -79,6 +85,26 @@ jmethodID GetOnEmulationStopped() {
     return s_on_emulation_stopped;
 }
 
+jclass GetStringClass() {
+    return s_string_class;
+}
+
+jclass GetPairClass() {
+    return s_pair_class;
+}
+
+jmethodID GetPairConstructor() {
+    return s_pair_constructor;
+}
+
+jfieldID GetPairFirstField() {
+    return s_pair_first_field;
+}
+
+jfieldID GetPairSecondField() {
+    return s_pair_second_field;
+}
+
 } // namespace IDCache
 
 #ifdef __cplusplus
@@ -115,6 +141,18 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
     s_on_emulation_stopped =
         env->GetStaticMethodID(s_native_library_class, "onEmulationStopped", "(I)V");
 
+    const jclass string_class = env->FindClass("java/lang/String");
+    s_string_class = reinterpret_cast<jclass>(env->NewGlobalRef(string_class));
+    env->DeleteLocalRef(string_class);
+
+    const jclass pair_class = env->FindClass("kotlin/Pair");
+    s_pair_class = reinterpret_cast<jclass>(env->NewGlobalRef(pair_class));
+    s_pair_constructor =
+        env->GetMethodID(pair_class, "<init>", "(Ljava/lang/Object;Ljava/lang/Object;)V");
+    s_pair_first_field = env->GetFieldID(pair_class, "first", "Ljava/lang/Object;");
+    s_pair_second_field = env->GetFieldID(pair_class, "second", "Ljava/lang/Object;");
+    env->DeleteLocalRef(pair_class);
+
     // Initialize Android Storage
     Common::FS::Android::RegisterCallbacks(env, s_native_library_class);
 
@@ -136,6 +174,8 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
     env->DeleteGlobalRef(s_disk_cache_progress_class);
     env->DeleteGlobalRef(s_load_callback_stage_class);
     env->DeleteGlobalRef(s_game_dir_class);
+    env->DeleteGlobalRef(s_string_class);
+    env->DeleteGlobalRef(s_pair_class);
 
     // UnInitialize applets
     SoftwareKeyboard::CleanupJNI(env);

+ 6 - 0
src/android/app/src/main/jni/id_cache.h

@@ -20,4 +20,10 @@ jmethodID GetDiskCacheLoadProgress();
 jmethodID GetOnEmulationStarted();
 jmethodID GetOnEmulationStopped();
 
+jclass GetStringClass();
+jclass GetPairClass();
+jmethodID GetPairConstructor();
+jfieldID GetPairFirstField();
+jfieldID GetPairSecondField();
+
 } // namespace IDCache

+ 98 - 10
src/android/app/src/main/jni/native.cpp

@@ -14,6 +14,7 @@
 #include <android/api-level.h>
 #include <android/native_window_jni.h>
 #include <common/fs/fs.h>
+#include <core/file_sys/patch_manager.h>
 #include <core/file_sys/savedata_factory.h>
 #include <core/loader/nro.h>
 #include <jni.h>
@@ -79,6 +80,10 @@ Core::System& EmulationSession::System() {
     return m_system;
 }
 
+FileSys::ManualContentProvider* EmulationSession::ContentProvider() {
+    return m_manual_provider.get();
+}
+
 const EmuWindow_Android& EmulationSession::Window() const {
     return *m_window;
 }
@@ -455,6 +460,15 @@ void EmulationSession::OnEmulationStopped(Core::SystemResultStatus result) {
                               static_cast<jint>(result));
 }
 
+u64 EmulationSession::GetProgramId(JNIEnv* env, jstring jprogramId) {
+    auto program_id_string = GetJString(env, jprogramId);
+    try {
+        return std::stoull(program_id_string);
+    } catch (...) {
+        return 0;
+    }
+}
+
 static Core::SystemResultStatus RunEmulation(const std::string& filepath) {
     MicroProfileOnThreadCreate("EmuThread");
     SCOPE_EXIT({ MicroProfileShutdown(); });
@@ -504,6 +518,27 @@ int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject
                                                              GetJString(env, j_file_extension));
 }
 
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_doesUpdateMatchProgram(JNIEnv* env, jobject jobj,
+                                                                      jstring jprogramId,
+                                                                      jstring jupdatePath) {
+    u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
+    std::string updatePath = GetJString(env, jupdatePath);
+    std::shared_ptr<FileSys::NSP> nsp = std::make_shared<FileSys::NSP>(
+        EmulationSession::GetInstance().System().GetFilesystem()->OpenFile(updatePath,
+                                                                           FileSys::Mode::Read));
+    for (const auto& item : nsp->GetNCAs()) {
+        for (const auto& nca_details : item.second) {
+            if (nca_details.second->GetName().ends_with(".cnmt.nca")) {
+                auto update_id = nca_details.second->GetTitleId() & ~0xFFFULL;
+                if (update_id == program_id) {
+                    return true;
+                }
+            }
+        }
+    }
+    return false;
+}
+
 void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz,
                                                                        jstring hook_lib_dir,
                                                                        jstring custom_driver_dir,
@@ -665,13 +700,6 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass
     EmulationSession::GetInstance().InitializeSystem(reload);
 }
 
-jint Java_org_yuzu_yuzu_1emu_NativeLibrary_defaultCPUCore(JNIEnv* env, jclass clazz) {
-    return {};
-}
-
-void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2Ljava_lang_String_2Z(
-    JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate) {}
-
 jdoubleArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPerfStats(JNIEnv* env, jclass clazz) {
     jdoubleArray j_stats = env->NewDoubleArray(4);
 
@@ -696,9 +724,13 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getCpuBackend(JNIEnv* env, jclass
     return ToJString(env, "JIT");
 }
 
-void Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_setSysDirectory(JNIEnv* env,
-                                                                           jclass clazz,
-                                                                           jstring j_path) {}
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_applySettings(JNIEnv* env, jobject jobj) {
+    EmulationSession::GetInstance().System().ApplySettings();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_logSettings(JNIEnv* env, jobject jobj) {
+    Settings::LogSettings();
+}
 
 void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2(JNIEnv* env, jclass clazz,
                                                                     jstring j_path) {
@@ -792,4 +824,60 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isFirmwareAvailable(JNIEnv* env,
     return true;
 }
 
+jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env, jobject jobj,
+                                                                    jstring jpath,
+                                                                    jstring jprogramId) {
+    const auto path = GetJString(env, jpath);
+    const auto vFile =
+        Core::GetGameFileFromPath(EmulationSession::GetInstance().System().GetFilesystem(), path);
+    if (vFile == nullptr) {
+        return nullptr;
+    }
+
+    auto& system = EmulationSession::GetInstance().System();
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    const FileSys::PatchManager pm{program_id, system.GetFileSystemController(),
+                                   system.GetContentProvider()};
+    const auto loader = Loader::GetLoader(system, vFile);
+
+    FileSys::VirtualFile update_raw;
+    loader->ReadUpdateRaw(update_raw);
+
+    auto addons = pm.GetPatchVersionNames(update_raw);
+    auto jemptyString = ToJString(env, "");
+    auto jemptyStringPair = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
+                                           jemptyString, jemptyString);
+    jobjectArray jaddonsArray =
+        env->NewObjectArray(addons.size(), IDCache::GetPairClass(), jemptyStringPair);
+    int i = 0;
+    for (const auto& addon : addons) {
+        jobject jaddon = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
+                                        ToJString(env, addon.first), ToJString(env, addon.second));
+        env->SetObjectArrayElement(jaddonsArray, i, jaddon);
+        ++i;
+    }
+    return jaddonsArray;
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj,
+                                                          jstring jprogramId) {
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+
+    auto& system = EmulationSession::GetInstance().System();
+
+    Service::Account::ProfileManager manager;
+    // TODO: Pass in a selected user once we get the relevant UI working
+    const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
+    ASSERT(user_id);
+
+    const auto nandDir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir);
+    auto vfsNandDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(nandDir),
+                                                            FileSys::Mode::Read);
+
+    const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
+        system, vfsNandDir, FileSys::SaveDataSpaceId::NandUser, FileSys::SaveDataType::SaveData,
+        program_id, user_id->AsU128(), 0);
+    return ToJString(env, user_save_data_path);
+}
+
 } // extern "C"

+ 2 - 0
src/android/app/src/main/jni/native.h

@@ -54,6 +54,8 @@ public:
 
     static void OnEmulationStarted();
 
+    static u64 GetProgramId(JNIEnv* env, jstring jprogramId);
+
 private:
     static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max);
     static void OnEmulationStopped(Core::SystemResultStatus result);

+ 26 - 0
src/android/app/src/main/jni/native_config.cpp

@@ -283,4 +283,30 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject
         AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)});
 }
 
+jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getDisabledAddons(JNIEnv* env, jobject obj,
+                                                                          jstring jprogramId) {
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    auto& disabledAddons = Settings::values.disabled_addons[program_id];
+    jobjectArray jdisabledAddonsArray =
+        env->NewObjectArray(disabledAddons.size(), IDCache::GetStringClass(), ToJString(env, ""));
+    for (size_t i = 0; i < disabledAddons.size(); ++i) {
+        env->SetObjectArrayElement(jdisabledAddonsArray, i, ToJString(env, disabledAddons[i]));
+    }
+    return jdisabledAddonsArray;
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setDisabledAddons(JNIEnv* env, jobject obj,
+                                                                  jstring jprogramId,
+                                                                  jobjectArray jdisabledAddons) {
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    Settings::values.disabled_addons[program_id].clear();
+    std::vector<std::string> disabled_addons;
+    const int size = env->GetArrayLength(jdisabledAddons);
+    for (int i = 0; i < size; ++i) {
+        auto jaddon = static_cast<jstring>(env->GetObjectArrayElement(jdisabledAddons, i));
+        disabled_addons.push_back(GetJString(env, jaddon));
+    }
+    Settings::values.disabled_addons[program_id] = disabled_addons;
+}
+
 } // extern "C"

+ 99 - 0
src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml

@@ -0,0 +1,99 @@
+<?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"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="?attr/colorSurface">
+
+    <androidx.core.widget.NestedScrollView
+        android:id="@+id/list_all"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:clipToPadding="false"
+        android:fadeScrollbars="false"
+        android:scrollbars="vertical"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@+id/icon_layout"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <LinearLayout
+            android:id="@+id/layout_all"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center_horizontal"
+            android:orientation="horizontal">
+
+            <androidx.recyclerview.widget.RecyclerView
+                android:id="@+id/list_properties"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                tools:listitem="@layout/card_simple_outlined" />
+
+        </LinearLayout>
+
+    </androidx.core.widget.NestedScrollView>
+
+    <LinearLayout
+        android:id="@+id/icon_layout"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <Button
+            android:id="@+id/button_back"
+            style="?attr/materialIconButtonStyle"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="start"
+            android:layout_margin="8dp"
+            app:icon="@drawable/ic_back"
+            app:iconSize="24dp"
+            app:iconTint="?attr/colorOnSurface" />
+
+        <com.google.android.material.card.MaterialCardView
+            style="?attr/materialCardViewElevatedStyle"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginHorizontal="16dp"
+            android:layout_marginTop="8dp"
+            app:cardCornerRadius="4dp"
+            app:cardElevation="4dp">
+
+            <ImageView
+                android:id="@+id/image_game_screen"
+                android:layout_width="175dp"
+                android:layout_height="175dp"
+                tools:src="@drawable/default_icon" />
+
+        </com.google.android.material.card.MaterialCardView>
+
+        <com.google.android.material.textview.MaterialTextView
+            android:id="@+id/title"
+            style="@style/TextAppearance.Material3.TitleMedium"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginHorizontal="16dp"
+            android:layout_marginTop="12dp"
+            android:ellipsize="none"
+            android:marqueeRepeatLimit="marquee_forever"
+            android:requiresFadingEdge="horizontal"
+            android:singleLine="true"
+            android:textAlignment="center"
+            tools:text="deko_basic" />
+
+    </LinearLayout>
+
+    <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+        android:id="@+id/button_start"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/start"
+        app:icon="@drawable/ic_play"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 2 - 1
src/android/app/src/main/res/layout/card_installable.xml

@@ -11,7 +11,8 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:layout_margin="16dp"
+        android:paddingVertical="16dp"
+        android:paddingHorizontal="24dp"
         android:orientation="horizontal"
         android:layout_gravity="center">
 

+ 19 - 1
src/android/app/src/main/res/layout/card_applet_option.xml → src/android/app/src/main/res/layout/card_simple_outlined.xml

@@ -16,7 +16,8 @@
         android:layout_height="wrap_content"
         android:orientation="horizontal"
         android:layout_gravity="center"
-        android:padding="24dp">
+        android:paddingVertical="16dp"
+        android:paddingHorizontal="24dp">
 
         <ImageView
             android:id="@+id/icon"
@@ -50,6 +51,23 @@
                 android:textAlignment="viewStart"
                 tools:text="@string/applets_description" />
 
+            <com.google.android.material.textview.MaterialTextView
+                style="@style/TextAppearance.Material3.LabelMedium"
+                android:id="@+id/details"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:textAlignment="viewStart"
+                android:textSize="14sp"
+                android:textStyle="bold"
+                android:singleLine="true"
+                android:marqueeRepeatLimit="marquee_forever"
+                android:ellipsize="none"
+                android:requiresFadingEdge="horizontal"
+                android:layout_marginTop="6dp"
+                android:visibility="gone"
+                tools:visibility="visible"
+                tools:text="/tree/primary:Games" />
+
         </LinearLayout>
 
     </LinearLayout>

+ 47 - 0
src/android/app/src/main/res/layout/fragment_addons.xml

@@ -0,0 +1,47 @@
+<?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_about"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="?attr/colorSurface">
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/appbar_addons"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:fitsSystemWindows="true"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <com.google.android.material.appbar.MaterialToolbar
+            android:id="@+id/toolbar_addons"
+            android:layout_width="match_parent"
+            android:layout_height="?attr/actionBarSize"
+            app:navigationIcon="@drawable/ic_back" />
+
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/list_addons"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:clipToPadding="false"
+        app:layout_behavior="@string/appbar_scrolling_view_behavior"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/appbar_addons" />
+
+    <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+        android:id="@+id/button_install"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="bottom|end"
+        android:text="@string/install"
+        app:icon="@drawable/ic_add"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 125 - 0
src/android/app/src/main/res/layout/fragment_game_info.xml

@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout 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"
+    android:id="@+id/coordinator_about"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="?attr/colorSurface">
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/appbar_info"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:fitsSystemWindows="true">
+
+        <com.google.android.material.appbar.MaterialToolbar
+            android:id="@+id/toolbar_info"
+            android:layout_width="match_parent"
+            android:layout_height="?attr/actionBarSize"
+            app:navigationIcon="@drawable/ic_back" />
+
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <androidx.core.widget.NestedScrollView
+        android:id="@+id/scroll_info"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+        <LinearLayout
+            android:id="@+id/content_info"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:paddingHorizontal="16dp">
+
+            <com.google.android.material.textfield.TextInputLayout
+                android:id="@+id/path"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:paddingTop="16dp">
+
+                <com.google.android.material.textfield.TextInputEditText
+                    android:id="@+id/path_field"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:editable="false"
+                    android:importantForAutofill="no"
+                    android:inputType="none"
+                    android:minHeight="48dp"
+                    android:textAlignment="viewStart"
+                    tools:text="1.0.0" />
+
+            </com.google.android.material.textfield.TextInputLayout>
+
+            <com.google.android.material.textfield.TextInputLayout
+                android:id="@+id/program_id"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:paddingTop="16dp">
+
+                <com.google.android.material.textfield.TextInputEditText
+                    android:id="@+id/program_id_field"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:editable="false"
+                    android:importantForAutofill="no"
+                    android:inputType="none"
+                    android:minHeight="48dp"
+                    android:textAlignment="viewStart"
+                    tools:text="1.0.0" />
+
+            </com.google.android.material.textfield.TextInputLayout>
+
+            <com.google.android.material.textfield.TextInputLayout
+                android:id="@+id/developer"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:paddingTop="16dp">
+
+                <com.google.android.material.textfield.TextInputEditText
+                    android:id="@+id/developer_field"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:editable="false"
+                    android:importantForAutofill="no"
+                    android:inputType="none"
+                    android:minHeight="48dp"
+                    android:textAlignment="viewStart"
+                    tools:text="1.0.0" />
+
+            </com.google.android.material.textfield.TextInputLayout>
+
+            <com.google.android.material.textfield.TextInputLayout
+                android:id="@+id/version"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:paddingTop="16dp">
+
+                <com.google.android.material.textfield.TextInputEditText
+                    android:id="@+id/version_field"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:editable="false"
+                    android:importantForAutofill="no"
+                    android:inputType="none"
+                    android:minHeight="48dp"
+                    android:textAlignment="viewStart"
+                    tools:text="1.0.0" />
+
+            </com.google.android.material.textfield.TextInputLayout>
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/button_copy"
+                style="@style/Widget.Material3.Button"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="16dp"
+                android:text="@string/copy_details" />
+
+        </LinearLayout>
+
+    </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 86 - 0
src/android/app/src/main/res/layout/fragment_game_properties.xml

@@ -0,0 +1,86 @@
+<?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"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="?attr/colorSurface">
+
+    <androidx.core.widget.NestedScrollView
+        android:id="@+id/list_all"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:scrollbars="vertical"
+        android:fadeScrollbars="false"
+        android:clipToPadding="false">
+
+        <LinearLayout
+            android:id="@+id/layout_all"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:gravity="center_horizontal">
+
+            <Button
+                android:id="@+id/button_back"
+                style="?attr/materialIconButtonStyle"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_margin="8dp"
+                android:layout_gravity="start"
+                app:icon="@drawable/ic_back"
+                app:iconSize="24dp"
+                app:iconTint="?attr/colorOnSurface" />
+
+            <com.google.android.material.card.MaterialCardView
+                style="?attr/materialCardViewElevatedStyle"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="8dp"
+                app:cardCornerRadius="4dp"
+                app:cardElevation="4dp">
+
+                <ImageView
+                    android:id="@+id/image_game_screen"
+                    android:layout_width="175dp"
+                    android:layout_height="175dp"
+                    tools:src="@drawable/default_icon"/>
+
+            </com.google.android.material.card.MaterialCardView>
+
+            <com.google.android.material.textview.MaterialTextView
+                android:id="@+id/title"
+                style="@style/TextAppearance.Material3.TitleMedium"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="12dp"
+                android:layout_marginBottom="12dp"
+                android:layout_marginHorizontal="16dp"
+                android:ellipsize="none"
+                android:marqueeRepeatLimit="marquee_forever"
+                android:requiresFadingEdge="horizontal"
+                android:singleLine="true"
+                android:textAlignment="center"
+                tools:text="deko_basic" />
+
+            <androidx.recyclerview.widget.RecyclerView
+                android:id="@+id/list_properties"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                tools:listitem="@layout/card_simple_outlined" />
+
+        </LinearLayout>
+
+    </androidx.core.widget.NestedScrollView>
+
+    <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+        android:id="@+id/button_start"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/start"
+        app:icon="@drawable/ic_play"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 57 - 0
src/android/app/src/main/res/layout/list_item_addon.xml

@@ -0,0 +1,57 @@
+<?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"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/addon_container"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="?attr/selectableItemBackground"
+    android:focusable="true"
+    android:paddingHorizontal="20dp"
+    android:paddingVertical="16dp">
+
+    <LinearLayout
+        android:id="@+id/text_container"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="16dp"
+        android:orientation="vertical"
+        app:layout_constraintBottom_toBottomOf="@+id/addon_switch"
+        app:layout_constraintEnd_toStartOf="@+id/addon_switch"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="@+id/addon_switch">
+
+        <com.google.android.material.textview.MaterialTextView
+            android:id="@+id/title"
+            style="@style/TextAppearance.Material3.HeadlineMedium"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textAlignment="viewStart"
+            android:textSize="17sp"
+            app:lineHeight="28dp"
+            tools:text="1440p Resolution" />
+
+        <com.google.android.material.textview.MaterialTextView
+            android:id="@+id/version"
+            style="@style/TextAppearance.Material3.BodySmall"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/spacing_small"
+            android:textAlignment="viewStart"
+            tools:text="1.0.0" />
+
+    </LinearLayout>
+
+    <com.google.android.material.materialswitch.MaterialSwitch
+        android:id="@+id/addon_switch"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:focusable="true"
+        android:gravity="center"
+        android:nextFocusLeft="@id/addon_container"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/text_container"
+        app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 33 - 0
src/android/app/src/main/res/navigation/home_navigation.xml

@@ -124,5 +124,38 @@
         android:id="@+id/gameFoldersFragment"
         android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment"
         android:label="GameFoldersFragment" />
+    <fragment
+        android:id="@+id/perGamePropertiesFragment"
+        android:name="org.yuzu.yuzu_emu.fragments.GamePropertiesFragment"
+        android:label="PerGamePropertiesFragment" >
+        <argument
+            android:name="game"
+            app:argType="org.yuzu.yuzu_emu.model.Game" />
+        <action
+            android:id="@+id/action_perGamePropertiesFragment_to_gameInfoFragment"
+            app:destination="@id/gameInfoFragment" />
+        <action
+            android:id="@+id/action_perGamePropertiesFragment_to_addonsFragment"
+            app:destination="@id/addonsFragment" />
+    </fragment>
+    <action
+        android:id="@+id/action_global_perGamePropertiesFragment"
+        app:destination="@id/perGamePropertiesFragment" />
+    <fragment
+        android:id="@+id/gameInfoFragment"
+        android:name="org.yuzu.yuzu_emu.fragments.GameInfoFragment"
+        android:label="GameInfoFragment" >
+        <argument
+            android:name="game"
+            app:argType="org.yuzu.yuzu_emu.model.Game" />
+    </fragment>
+    <fragment
+        android:id="@+id/addonsFragment"
+        android:name="org.yuzu.yuzu_emu.fragments.AddonsFragment"
+        android:label="AddonsFragment" >
+        <argument
+            android:name="game"
+            app:argType="org.yuzu.yuzu_emu.model.Game" />
+    </fragment>
 
 </navigation>

+ 1 - 1
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">76dp</dimen>
+    <dimen name="spacing_bottom_list_fab">96dp</dimen>
     <dimen name="spacing_fab">24dp</dimen>
 
     <dimen name="dialog_margin">20dp</dimen>

+ 45 - 0
src/android/app/src/main/res/values/strings.xml

@@ -91,7 +91,10 @@
     <string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string>
     <string name="manage_save_data">Manage save data</string>
     <string name="manage_save_data_description">Save data found. Please select an option below.</string>
+    <string name="import_save_warning">Import save data</string>
+    <string name="import_save_warning_description">This will overwrite all existing save data with the provided file. Are you sure that you want to continue?</string>
     <string name="import_export_saves_description">Import or export save files</string>
+    <string name="save_files_importing">Importing save files…</string>
     <string name="save_files_exporting">Exporting save files…</string>
     <string name="save_file_imported_success">Imported successfully</string>
     <string name="save_file_invalid_zip_structure">Invalid save directory structure</string>
@@ -266,6 +269,11 @@
     <string name="delete">Delete</string>
     <string name="edit">Edit</string>
     <string name="export_success">Exported successfully</string>
+    <string name="start">Start</string>
+    <string name="clear">Clear</string>
+    <string name="global">Global</string>
+    <string name="custom">Custom</string>
+    <string name="notice">Notice</string>
 
     <!-- GPU driver installation -->
     <string name="select_gpu_driver">Select GPU driver</string>
@@ -291,6 +299,43 @@
     <string name="preferences_debug">Debug</string>
     <string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string>
 
+    <!-- Game properties -->
+    <string name="info">Info</string>
+    <string name="info_description">Program ID, developer, version</string>
+    <string name="per_game_settings">Per-game settings</string>
+    <string name="per_game_settings_description">Edit settings specific to this game</string>
+    <string name="launch_options">Launch config</string>
+    <string name="path">Path</string>
+    <string name="program_id">Program ID</string>
+    <string name="developer">Developer</string>
+    <string name="version">Version</string>
+    <string name="copy_details">Copy details</string>
+    <string name="add_ons">Add-ons</string>
+    <string name="add_ons_description">Toggle mods, updates and DLC</string>
+    <string name="clear_shader_cache">Clear shader cache</string>
+    <string name="clear_shader_cache_description">Removes all shaders built while playing this game</string>
+    <string name="cleared_shaders_successfully">Cleared shaders successfully</string>
+    <string name="addons_game">Addons: %1$s</string>
+    <string name="save_data">Save data</string>
+    <string name="save_data_description">Manage save data specific to this game</string>
+    <string name="delete_save_data">Delete save data</string>
+    <string name="delete_save_data_description">Removes all save data specific to this game</string>
+    <string name="delete_save_data_warning_description">This irrecoverably removes all of this game\'s save data. Are you sure you want to continue?</string>
+    <string name="save_data_deleted_successfully">Save data deleted successfully</string>
+    <string name="select_content_type">Content type</string>
+    <string name="updates_and_dlc">Updates and DLC</string>
+    <string name="mods_and_cheats">Mods and cheats</string>
+    <string name="addon_notice">Important addon notice</string>
+    <!-- "cheats/" "romfs/" and "exefs/ should not be translated -->
+    <string name="addon_notice_description">In order to install mods and cheats, you must select a folder that contains a cheats/, romfs/, or exefs/ directory. We can\'t verify if these will be compatible with your game so be careful!</string>
+    <string name="invalid_directory">Invalid directory</string>
+    <!-- "cheats/" "romfs/" and "exefs/ should not be translated -->
+    <string name="invalid_directory_description">Please make sure that the directory you selected contains a cheats/, romfs/, or exefs/ folder and try again.</string>
+    <string name="addon_installed_successfully">Addon installed successfully</string>
+    <string name="verifying_content">Verifying content…</string>
+    <string name="content_install_notice">Content install notice</string>
+    <string name="content_install_notice_description">The content that you selected does not match this game.\nInstall anyway?</string>
+
     <!-- ROM loading errors -->
     <string name="loader_error_encrypted">Your ROM is encrypted</string>
     <string name="loader_error_encrypted_roms_description"><![CDATA[Please follow the guides to redump your <a href="https://yuzu-emu.org/help/quickstart/#dumping-physical-titles-game-cards">game cartidges</a> or <a href="https://yuzu-emu.org/help/quickstart/#dumping-digital-titles-eshop">installed titles</a>.]]></string>