summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt12
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt49
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt7
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt42
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt48
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt214
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt18
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt138
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt14
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt6
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt257
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt67
-rw-r--r--src/android/app/src/main/jni/native.cpp36
-rw-r--r--src/android/app/src/main/res/layout/activity_settings.xml2
-rw-r--r--src/android/app/src/main/res/layout/card_installable.xml71
-rw-r--r--src/android/app/src/main/res/layout/fragment_about.xml61
-rw-r--r--src/android/app/src/main/res/layout/fragment_emulation.xml5
-rw-r--r--src/android/app/src/main/res/layout/fragment_installables.xml31
-rw-r--r--src/android/app/src/main/res/navigation/home_navigation.xml7
-rw-r--r--src/android/app/src/main/res/values-de/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-es/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-fr/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-it/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-ja/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-ko/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-nb/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-pl/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-pt-rBR/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-pt-rPT/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-ru/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-uk/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-w600dp/integers.xml6
-rw-r--r--src/android/app/src/main/res/values-zh-rCN/strings.xml1
-rw-r--r--src/android/app/src/main/res/values-zh-rTW/strings.xml1
-rw-r--r--src/android/app/src/main/res/values/integers.xml2
-rw-r--r--src/android/app/src/main/res/values/strings.xml12
-rw-r--r--src/common/settings_setting.h2
-rw-r--r--src/video_core/host_shaders/convert_msaa_to_non_msaa.comp7
-rw-r--r--src/video_core/host_shaders/convert_non_msaa_to_msaa.comp7
-rw-r--r--src/video_core/renderer_vulkan/vk_compute_pass.cpp139
-rw-r--r--src/video_core/renderer_vulkan/vk_compute_pass.h19
-rw-r--r--src/video_core/renderer_vulkan/vk_texture_cache.cpp81
-rw-r--r--src/video_core/renderer_vulkan/vk_texture_cache.h7
-rw-r--r--src/video_core/vulkan_common/vulkan_device.h5
-rw-r--r--src/yuzu/main.cpp11
-rw-r--r--src/yuzu/main.h2
48 files changed, 924 insertions, 501 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index 21f67f32a..6e39e542b 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -247,7 +247,12 @@ object NativeLibrary {
247 247
248 external fun setAppDirectory(directory: String) 248 external fun setAppDirectory(directory: String)
249 249
250 external fun installFileToNand(filename: String): Int 250 /**
251 * Installs a nsp or xci file to nand
252 * @param filename String representation of file uri
253 * @param extension Lowercase string representation of file extension without "."
254 */
255 external fun installFileToNand(filename: String, extension: String): Int
251 256
252 external fun initializeGpuDriver( 257 external fun initializeGpuDriver(
253 hookLibDir: String?, 258 hookLibDir: String?,
@@ -512,6 +517,11 @@ object NativeLibrary {
512 external fun submitInlineKeyboardInput(key_code: Int) 517 external fun submitInlineKeyboardInput(key_code: Int)
513 518
514 /** 519 /**
520 * Creates a generic user directory if it doesn't exist already
521 */
522 external fun initializeEmptyUserDirectory()
523
524 /**
515 * Button type for use in onTouchEvent 525 * Button type for use in onTouchEvent
516 */ 526 */
517 object ButtonType { 527 object ButtonType {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt
new file mode 100644
index 000000000..e960fbaab
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt
@@ -0,0 +1,49 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.adapters
5
6import android.view.LayoutInflater
7import android.view.View
8import android.view.ViewGroup
9import androidx.recyclerview.widget.RecyclerView
10import org.yuzu.yuzu_emu.databinding.CardInstallableBinding
11import org.yuzu.yuzu_emu.model.Installable
12
13class InstallableAdapter(private val installables: List<Installable>) :
14 RecyclerView.Adapter<InstallableAdapter.InstallableViewHolder>() {
15 override fun onCreateViewHolder(
16 parent: ViewGroup,
17 viewType: Int
18 ): InstallableAdapter.InstallableViewHolder {
19 val binding =
20 CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false)
21 return InstallableViewHolder(binding)
22 }
23
24 override fun getItemCount(): Int = installables.size
25
26 override fun onBindViewHolder(holder: InstallableAdapter.InstallableViewHolder, position: Int) =
27 holder.bind(installables[position])
28
29 inner class InstallableViewHolder(val binding: CardInstallableBinding) :
30 RecyclerView.ViewHolder(binding.root) {
31 lateinit var installable: Installable
32
33 fun bind(installable: Installable) {
34 this.installable = installable
35
36 binding.title.setText(installable.titleId)
37 binding.description.setText(installable.descriptionId)
38
39 if (installable.install != null) {
40 binding.buttonInstall.visibility = View.VISIBLE
41 binding.buttonInstall.setOnClickListener { installable.install.invoke() }
42 }
43 if (installable.export != null) {
44 binding.buttonExport.visibility = View.VISIBLE
45 binding.buttonExport.setOnClickListener { installable.export.invoke() }
46 }
47 }
48 }
49}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
index 4d2f2f604..c73edd50e 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
@@ -21,6 +21,7 @@ import androidx.navigation.navArgs
21import com.google.android.material.color.MaterialColors 21import com.google.android.material.color.MaterialColors
22import kotlinx.coroutines.flow.collectLatest 22import kotlinx.coroutines.flow.collectLatest
23import kotlinx.coroutines.launch 23import kotlinx.coroutines.launch
24import org.yuzu.yuzu_emu.NativeLibrary
24import java.io.IOException 25import java.io.IOException
25import org.yuzu.yuzu_emu.R 26import org.yuzu.yuzu_emu.R
26import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding 27import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
@@ -168,7 +169,7 @@ class SettingsActivity : AppCompatActivity() {
168 if (!settingsFile.delete()) { 169 if (!settingsFile.delete()) {
169 throw IOException("Failed to delete $settingsFile") 170 throw IOException("Failed to delete $settingsFile")
170 } 171 }
171 Settings.settingsList.forEach { it.reset() } 172 NativeLibrary.reloadSettings()
172 173
173 Toast.makeText( 174 Toast.makeText(
174 applicationContext, 175 applicationContext,
@@ -181,12 +182,14 @@ class SettingsActivity : AppCompatActivity() {
181 private fun setInsets() { 182 private fun setInsets() {
182 ViewCompat.setOnApplyWindowInsetsListener( 183 ViewCompat.setOnApplyWindowInsetsListener(
183 binding.navigationBarShade 184 binding.navigationBarShade
184 ) { view: View, windowInsets: WindowInsetsCompat -> 185 ) { _: View, windowInsets: WindowInsetsCompat ->
185 val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) 186 val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
186 187
187 val mlpShade = view.layoutParams as MarginLayoutParams 188 // The only situation where we care to have a nav bar shade is when it's at the bottom
188 mlpShade.height = barInsets.bottom 189 // of the screen where scrolling list elements can go behind it.
189 view.layoutParams = mlpShade 190 val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
191 mlpNavShade.height = barInsets.bottom
192 binding.navigationBarShade.layoutParams = mlpNavShade
190 193
191 windowInsets 194 windowInsets
192 } 195 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
index 7b8f99872..2ff827c6b 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
@@ -26,7 +26,6 @@ import org.yuzu.yuzu_emu.BuildConfig
26import org.yuzu.yuzu_emu.R 26import org.yuzu.yuzu_emu.R
27import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding 27import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
28import org.yuzu.yuzu_emu.model.HomeViewModel 28import org.yuzu.yuzu_emu.model.HomeViewModel
29import org.yuzu.yuzu_emu.ui.main.MainActivity
30 29
31class AboutFragment : Fragment() { 30class AboutFragment : Fragment() {
32 private var _binding: FragmentAboutBinding? = null 31 private var _binding: FragmentAboutBinding? = null
@@ -93,12 +92,6 @@ class AboutFragment : Fragment() {
93 } 92 }
94 } 93 }
95 94
96 val mainActivity = requireActivity() as MainActivity
97 binding.buttonExport.setOnClickListener { mainActivity.exportUserData.launch("export.zip") }
98 binding.buttonImport.setOnClickListener {
99 mainActivity.importUserData.launch(arrayOf("application/zip"))
100 }
101
102 binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) } 95 binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
103 binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) } 96 binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
104 binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) } 97 binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
index 750638bc9..e6ad2aa77 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
@@ -17,6 +17,7 @@ import android.os.Handler
17import android.os.Looper 17import android.os.Looper
18import android.view.* 18import android.view.*
19import android.widget.TextView 19import android.widget.TextView
20import android.widget.Toast
20import androidx.activity.OnBackPressedCallback 21import androidx.activity.OnBackPressedCallback
21import androidx.appcompat.widget.PopupMenu 22import androidx.appcompat.widget.PopupMenu
22import androidx.core.content.res.ResourcesCompat 23import androidx.core.content.res.ResourcesCompat
@@ -53,6 +54,7 @@ import org.yuzu.yuzu_emu.model.Game
53import org.yuzu.yuzu_emu.model.EmulationViewModel 54import org.yuzu.yuzu_emu.model.EmulationViewModel
54import org.yuzu.yuzu_emu.overlay.InputOverlay 55import org.yuzu.yuzu_emu.overlay.InputOverlay
55import org.yuzu.yuzu_emu.utils.* 56import org.yuzu.yuzu_emu.utils.*
57import java.lang.NullPointerException
56 58
57class EmulationFragment : Fragment(), SurfaceHolder.Callback { 59class EmulationFragment : Fragment(), SurfaceHolder.Callback {
58 private lateinit var preferences: SharedPreferences 60 private lateinit var preferences: SharedPreferences
@@ -104,10 +106,21 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
104 null 106 null
105 } 107 }
106 } 108 }
107 game = if (args.game != null) { 109
108 args.game!! 110 try {
109 } else { 111 game = if (args.game != null) {
110 intentGame ?: error("[EmulationFragment] No bootable game present!") 112 args.game!!
113 } else {
114 intentGame!!
115 }
116 } catch (e: NullPointerException) {
117 Toast.makeText(
118 requireContext(),
119 R.string.no_game_present,
120 Toast.LENGTH_SHORT
121 ).show()
122 requireActivity().finish()
123 return
111 } 124 }
112 125
113 // So this fragment doesn't restart on configuration changes; i.e. rotation. 126 // So this fragment doesn't restart on configuration changes; i.e. rotation.
@@ -131,6 +144,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
131 // This is using the correct scope, lint is just acting up 144 // This is using the correct scope, lint is just acting up
132 @SuppressLint("UnsafeRepeatOnLifecycleDetector") 145 @SuppressLint("UnsafeRepeatOnLifecycleDetector")
133 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 146 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
147 super.onViewCreated(view, savedInstanceState)
148 if (requireActivity().isFinishing) {
149 return
150 }
151
134 binding.surfaceEmulation.holder.addCallback(this) 152 binding.surfaceEmulation.holder.addCallback(this)
135 binding.showFpsText.setTextColor(Color.YELLOW) 153 binding.showFpsText.setTextColor(Color.YELLOW)
136 binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } 154 binding.doneControlConfig.setOnClickListener { stopConfiguringControls() }
@@ -286,25 +304,23 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
286 304
287 override fun onConfigurationChanged(newConfig: Configuration) { 305 override fun onConfigurationChanged(newConfig: Configuration) {
288 super.onConfigurationChanged(newConfig) 306 super.onConfigurationChanged(newConfig)
307 if (_binding == null) {
308 return
309 }
310
289 updateScreenLayout() 311 updateScreenLayout()
290 if (emulationActivity?.isInPictureInPictureMode == true) { 312 if (emulationActivity?.isInPictureInPictureMode == true) {
291 if (binding.drawerLayout.isOpen) { 313 if (binding.drawerLayout.isOpen) {
292 binding.drawerLayout.close() 314 binding.drawerLayout.close()
293 } 315 }
294 if (EmulationMenuSettings.showOverlay) { 316 if (EmulationMenuSettings.showOverlay) {
295 binding.surfaceInputOverlay.post { 317 binding.surfaceInputOverlay.visibility = View.INVISIBLE
296 binding.surfaceInputOverlay.visibility = View.INVISIBLE
297 }
298 } 318 }
299 } else { 319 } else {
300 if (EmulationMenuSettings.showOverlay && emulationViewModel.emulationStarted.value) { 320 if (EmulationMenuSettings.showOverlay && emulationViewModel.emulationStarted.value) {
301 binding.surfaceInputOverlay.post { 321 binding.surfaceInputOverlay.visibility = View.VISIBLE
302 binding.surfaceInputOverlay.visibility = View.VISIBLE
303 }
304 } else { 322 } else {
305 binding.surfaceInputOverlay.post { 323 binding.surfaceInputOverlay.visibility = View.INVISIBLE
306 binding.surfaceInputOverlay.visibility = View.INVISIBLE
307 }
308 } 324 }
309 if (!isInFoldableLayout) { 325 if (!isInFoldableLayout) {
310 if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { 326 if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
index c119e69c9..8923c0ea2 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
@@ -118,18 +118,13 @@ class HomeSettingsFragment : Fragment() {
118 ) 118 )
119 add( 119 add(
120 HomeSetting( 120 HomeSetting(
121 R.string.install_amiibo_keys, 121 R.string.manage_yuzu_data,
122 R.string.install_amiibo_keys_description, 122 R.string.manage_yuzu_data_description,
123 R.drawable.ic_nfc, 123 R.drawable.ic_install,
124 { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) } 124 {
125 ) 125 binding.root.findNavController()
126 ) 126 .navigate(R.id.action_homeSettingsFragment_to_installableFragment)
127 add( 127 }
128 HomeSetting(
129 R.string.install_game_content,
130 R.string.install_game_content_description,
131 R.drawable.ic_system_update_alt,
132 { mainActivity.installGameUpdate.launch(arrayOf("*/*")) }
133 ) 128 )
134 ) 129 )
135 add( 130 add(
@@ -150,35 +145,6 @@ class HomeSettingsFragment : Fragment() {
150 ) 145 )
151 add( 146 add(
152 HomeSetting( 147 HomeSetting(
153 R.string.manage_save_data,
154 R.string.import_export_saves_description,
155 R.drawable.ic_save,
156 {
157 ImportExportSavesFragment().show(
158 parentFragmentManager,
159 ImportExportSavesFragment.TAG
160 )
161 }
162 )
163 )
164 add(
165 HomeSetting(
166 R.string.install_prod_keys,
167 R.string.install_prod_keys_description,
168 R.drawable.ic_unlock,
169 { mainActivity.getProdKey.launch(arrayOf("*/*")) }
170 )
171 )
172 add(
173 HomeSetting(
174 R.string.install_firmware,
175 R.string.install_firmware_description,
176 R.drawable.ic_firmware,
177 { mainActivity.getFirmware.launch(arrayOf("application/zip")) }
178 )
179 )
180 add(
181 HomeSetting(
182 R.string.share_log, 148 R.string.share_log,
183 R.string.share_log_description, 149 R.string.share_log_description,
184 R.drawable.ic_log, 150 R.drawable.ic_log,
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
deleted file mode 100644
index ee2d44718..000000000
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
+++ /dev/null
@@ -1,214 +0,0 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.app.Dialog
7import android.content.Intent
8import android.net.Uri
9import android.os.Bundle
10import android.provider.DocumentsContract
11import android.widget.Toast
12import androidx.activity.result.ActivityResultLauncher
13import androidx.activity.result.contract.ActivityResultContracts
14import androidx.appcompat.app.AppCompatActivity
15import androidx.documentfile.provider.DocumentFile
16import androidx.fragment.app.DialogFragment
17import com.google.android.material.dialog.MaterialAlertDialogBuilder
18import java.io.BufferedOutputStream
19import java.io.File
20import java.io.FileOutputStream
21import java.io.FilenameFilter
22import java.time.LocalDateTime
23import java.time.format.DateTimeFormatter
24import java.util.zip.ZipEntry
25import java.util.zip.ZipOutputStream
26import kotlinx.coroutines.CoroutineScope
27import kotlinx.coroutines.Dispatchers
28import kotlinx.coroutines.launch
29import kotlinx.coroutines.withContext
30import org.yuzu.yuzu_emu.R
31import org.yuzu.yuzu_emu.YuzuApplication
32import org.yuzu.yuzu_emu.features.DocumentProvider
33import org.yuzu.yuzu_emu.getPublicFilesDir
34import org.yuzu.yuzu_emu.utils.FileUtil
35
36class ImportExportSavesFragment : DialogFragment() {
37 private val context = YuzuApplication.appContext
38 private val savesFolder =
39 "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
40
41 // Get first subfolder in saves folder (should be the user folder)
42 private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
43 private var lastZipCreated: File? = null
44
45 private lateinit var startForResultExportSave: ActivityResultLauncher<Intent>
46 private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
47
48 override fun onCreate(savedInstanceState: Bundle?) {
49 super.onCreate(savedInstanceState)
50 val activity = requireActivity() as AppCompatActivity
51
52 val activityResultRegistry = requireActivity().activityResultRegistry
53 startForResultExportSave = activityResultRegistry.register(
54 "startForResultExportSaveKey",
55 ActivityResultContracts.StartActivityForResult()
56 ) {
57 File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
58 }
59 documentPicker = activityResultRegistry.register(
60 "documentPickerKey",
61 ActivityResultContracts.OpenDocument()
62 ) {
63 it?.let { uri -> importSave(uri, activity) }
64 }
65 }
66
67 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
68 return if (savesFolderRoot == "") {
69 MaterialAlertDialogBuilder(requireContext())
70 .setTitle(R.string.manage_save_data)
71 .setMessage(R.string.import_export_saves_no_profile)
72 .setPositiveButton(android.R.string.ok, null)
73 .show()
74 } else {
75 MaterialAlertDialogBuilder(requireContext())
76 .setTitle(R.string.manage_save_data)
77 .setMessage(R.string.manage_save_data_description)
78 .setNegativeButton(R.string.export_saves) { _, _ ->
79 exportSave()
80 }
81 .setPositiveButton(R.string.import_saves) { _, _ ->
82 documentPicker.launch(arrayOf("application/zip"))
83 }
84 .setNeutralButton(android.R.string.cancel, null)
85 .show()
86 }
87 }
88
89 /**
90 * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
91 * @return true if the zip file is successfully created, false otherwise.
92 */
93 private fun zipSave(): Boolean {
94 try {
95 val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
96 tempFolder.mkdirs()
97 val saveFolder = File(savesFolderRoot)
98 val outputZipFile = File(
99 tempFolder,
100 "yuzu saves - ${
101 LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
102 }.zip"
103 )
104 outputZipFile.createNewFile()
105 ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
106 saveFolder.walkTopDown().forEach { file ->
107 val zipFileName =
108 file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/")
109 if (zipFileName == "") {
110 return@forEach
111 }
112 val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
113 zos.putNextEntry(entry)
114 if (file.isFile) {
115 file.inputStream().use { fis -> fis.copyTo(zos) }
116 }
117 }
118 }
119 lastZipCreated = outputZipFile
120 } catch (e: Exception) {
121 return false
122 }
123 return true
124 }
125
126 /**
127 * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
128 */
129 private fun exportSave() {
130 CoroutineScope(Dispatchers.IO).launch {
131 val wasZipCreated = zipSave()
132 val lastZipFile = lastZipCreated
133 if (!wasZipCreated || lastZipFile == null) {
134 withContext(Dispatchers.Main) {
135 Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
136 }
137 return@launch
138 }
139
140 withContext(Dispatchers.Main) {
141 val file = DocumentFile.fromSingleUri(
142 context,
143 DocumentsContract.buildDocumentUri(
144 DocumentProvider.AUTHORITY,
145 "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
146 )
147 )!!
148 val intent = Intent(Intent.ACTION_SEND)
149 .setDataAndType(file.uri, "application/zip")
150 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
151 .putExtra(Intent.EXTRA_STREAM, file.uri)
152 startForResultExportSave.launch(Intent.createChooser(intent, "Share save file"))
153 }
154 }
155 }
156
157 /**
158 * Imports the save files contained in the zip file, and replaces any existing ones with the new save file.
159 * @param zipUri The Uri of the zip file containing the save file(s) to import.
160 */
161 private fun importSave(zipUri: Uri, activity: AppCompatActivity) {
162 val inputZip = context.contentResolver.openInputStream(zipUri)
163 // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
164 var validZip = false
165 val savesFolder = File(savesFolderRoot)
166 val cacheSaveDir = File("${context.cacheDir.path}/saves/")
167 cacheSaveDir.mkdir()
168
169 if (inputZip == null) {
170 Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
171 .show()
172 return
173 }
174
175 val filterTitleId =
176 FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
177
178 try {
179 CoroutineScope(Dispatchers.IO).launch {
180 FileUtil.unzip(inputZip, cacheSaveDir)
181 cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
182 File(savesFolder, savePath).deleteRecursively()
183 File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true)
184 validZip = true
185 }
186
187 withContext(Dispatchers.Main) {
188 if (!validZip) {
189 MessageDialogFragment.newInstance(
190 requireActivity(),
191 titleId = R.string.save_file_invalid_zip_structure,
192 descriptionId = R.string.save_file_invalid_zip_structure_description
193 ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
194 return@withContext
195 }
196 Toast.makeText(
197 context,
198 context.getString(R.string.save_file_imported_success),
199 Toast.LENGTH_LONG
200 ).show()
201 }
202
203 cacheSaveDir.deleteRecursively()
204 }
205 } catch (e: Exception) {
206 Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
207 .show()
208 }
209 }
210
211 companion object {
212 const val TAG = "ImportExportSavesFragment"
213 }
214}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
index 0d16a7d37..f128deda8 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
@@ -4,12 +4,12 @@
4package org.yuzu.yuzu_emu.fragments 4package org.yuzu.yuzu_emu.fragments
5 5
6import android.app.Dialog 6import android.app.Dialog
7import android.content.DialogInterface
8import android.os.Bundle 7import android.os.Bundle
9import android.view.LayoutInflater 8import android.view.LayoutInflater
10import android.view.View 9import android.view.View
11import android.view.ViewGroup 10import android.view.ViewGroup
12import android.widget.Toast 11import android.widget.Toast
12import androidx.appcompat.app.AlertDialog
13import androidx.appcompat.app.AppCompatActivity 13import androidx.appcompat.app.AppCompatActivity
14import androidx.fragment.app.DialogFragment 14import androidx.fragment.app.DialogFragment
15import androidx.fragment.app.activityViewModels 15import androidx.fragment.app.activityViewModels
@@ -39,9 +39,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
39 .setView(binding.root) 39 .setView(binding.root)
40 40
41 if (cancellable) { 41 if (cancellable) {
42 dialog.setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> 42 dialog.setNegativeButton(android.R.string.cancel, null)
43 taskViewModel.setCancelled(true)
44 }
45 } 43 }
46 44
47 val alertDialog = dialog.create() 45 val alertDialog = dialog.create()
@@ -98,6 +96,18 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
98 } 96 }
99 } 97 }
100 98
99 // By default, the ProgressDialog will immediately dismiss itself upon a button being pressed.
100 // Setting the OnClickListener again after the dialog is shown overrides this behavior.
101 override fun onResume() {
102 super.onResume()
103 val alertDialog = dialog as AlertDialog
104 val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE)
105 negativeButton.setOnClickListener {
106 alertDialog.setTitle(getString(R.string.cancelling))
107 taskViewModel.setCancelled(true)
108 }
109 }
110
101 companion object { 111 companion object {
102 const val TAG = "IndeterminateProgressDialogFragment" 112 const val TAG = "IndeterminateProgressDialogFragment"
103 113
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
new file mode 100644
index 000000000..ec116ab62
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
@@ -0,0 +1,138 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.os.Bundle
7import android.view.LayoutInflater
8import android.view.View
9import android.view.ViewGroup
10import androidx.core.view.ViewCompat
11import androidx.core.view.WindowInsetsCompat
12import androidx.core.view.updatePadding
13import androidx.fragment.app.Fragment
14import androidx.fragment.app.activityViewModels
15import androidx.navigation.findNavController
16import androidx.recyclerview.widget.GridLayoutManager
17import com.google.android.material.transition.MaterialSharedAxis
18import org.yuzu.yuzu_emu.R
19import org.yuzu.yuzu_emu.adapters.InstallableAdapter
20import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding
21import org.yuzu.yuzu_emu.model.HomeViewModel
22import org.yuzu.yuzu_emu.model.Installable
23import org.yuzu.yuzu_emu.ui.main.MainActivity
24
25class InstallableFragment : Fragment() {
26 private var _binding: FragmentInstallablesBinding? = null
27 private val binding get() = _binding!!
28
29 private val homeViewModel: HomeViewModel by activityViewModels()
30
31 override fun onCreate(savedInstanceState: Bundle?) {
32 super.onCreate(savedInstanceState)
33 enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
34 returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
35 reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
36 }
37
38 override fun onCreateView(
39 inflater: LayoutInflater,
40 container: ViewGroup?,
41 savedInstanceState: Bundle?
42 ): View {
43 _binding = FragmentInstallablesBinding.inflate(layoutInflater)
44 return binding.root
45 }
46
47 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
48 super.onViewCreated(view, savedInstanceState)
49
50 val mainActivity = requireActivity() as MainActivity
51
52 homeViewModel.setNavigationVisibility(visible = false, animated = true)
53 homeViewModel.setStatusBarShadeVisibility(visible = false)
54
55 binding.toolbarInstallables.setNavigationOnClickListener {
56 binding.root.findNavController().popBackStack()
57 }
58
59 val installables = listOf(
60 Installable(
61 R.string.user_data,
62 R.string.user_data_description,
63 install = { mainActivity.importUserData.launch(arrayOf("application/zip")) },
64 export = { mainActivity.exportUserData.launch("export.zip") }
65 ),
66 Installable(
67 R.string.install_game_content,
68 R.string.install_game_content_description,
69 install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) }
70 ),
71 Installable(
72 R.string.install_firmware,
73 R.string.install_firmware_description,
74 install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) }
75 ),
76 if (mainActivity.savesFolderRoot != "") {
77 Installable(
78 R.string.manage_save_data,
79 R.string.import_export_saves_description,
80 install = { mainActivity.importSaves.launch(arrayOf("application/zip")) },
81 export = { mainActivity.exportSave() }
82 )
83 } else {
84 Installable(
85 R.string.manage_save_data,
86 R.string.import_export_saves_description,
87 install = { mainActivity.importSaves.launch(arrayOf("application/zip")) }
88 )
89 },
90 Installable(
91 R.string.install_prod_keys,
92 R.string.install_prod_keys_description,
93 install = { mainActivity.getProdKey.launch(arrayOf("*/*")) }
94 ),
95 Installable(
96 R.string.install_amiibo_keys,
97 R.string.install_amiibo_keys_description,
98 install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }
99 )
100 )
101
102 binding.listInstallables.apply {
103 layoutManager = GridLayoutManager(
104 requireContext(),
105 resources.getInteger(R.integer.grid_columns)
106 )
107 adapter = InstallableAdapter(installables)
108 }
109
110 setInsets()
111 }
112
113 private fun setInsets() =
114 ViewCompat.setOnApplyWindowInsetsListener(
115 binding.root
116 ) { _: View, windowInsets: WindowInsetsCompat ->
117 val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
118 val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
119
120 val leftInsets = barInsets.left + cutoutInsets.left
121 val rightInsets = barInsets.right + cutoutInsets.right
122
123 val mlpAppBar = binding.toolbarInstallables.layoutParams as ViewGroup.MarginLayoutParams
124 mlpAppBar.leftMargin = leftInsets
125 mlpAppBar.rightMargin = rightInsets
126 binding.toolbarInstallables.layoutParams = mlpAppBar
127
128 val mlpScrollAbout =
129 binding.listInstallables.layoutParams as ViewGroup.MarginLayoutParams
130 mlpScrollAbout.leftMargin = leftInsets
131 mlpScrollAbout.rightMargin = rightInsets
132 binding.listInstallables.layoutParams = mlpScrollAbout
133
134 binding.listInstallables.updatePadding(bottom = barInsets.bottom)
135
136 windowInsets
137 }
138}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
index fbb2f6e18..c66bb635a 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
@@ -295,8 +295,10 @@ class SetupFragment : Fragment() {
295 295
296 override fun onSaveInstanceState(outState: Bundle) { 296 override fun onSaveInstanceState(outState: Bundle) {
297 super.onSaveInstanceState(outState) 297 super.onSaveInstanceState(outState)
298 outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) 298 if (_binding != null) {
299 outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) 299 outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible)
300 outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible)
301 }
300 outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) 302 outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned)
301 } 303 }
302 304
@@ -353,11 +355,15 @@ class SetupFragment : Fragment() {
353 } 355 }
354 356
355 fun pageForward() { 357 fun pageForward() {
356 binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1 358 if (_binding != null) {
359 binding.viewPager2.currentItem += 1
360 }
357 } 361 }
358 362
359 fun pageBackward() { 363 fun pageBackward() {
360 binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1 364 if (_binding != null) {
365 binding.viewPager2.currentItem -= 1
366 }
361 } 367 }
362 368
363 fun setPageWarned(page: Int) { 369 fun setPageWarned(page: Int) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt
new file mode 100644
index 000000000..36a7c97b8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt
@@ -0,0 +1,13 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.model
5
6import androidx.annotation.StringRes
7
8data class Installable(
9 @StringRes val titleId: Int,
10 @StringRes val descriptionId: Int,
11 val install: (() -> Unit)? = null,
12 val export: (() -> Unit)? = null
13)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
index d6418a666..16a794dee 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
@@ -50,3 +50,9 @@ class TaskViewModel : ViewModel() {
50 } 50 }
51 } 51 }
52} 52}
53
54enum class TaskState {
55 Completed,
56 Failed,
57 Cancelled
58}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index 6fa847631..0fa5df5e5 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.ui.main
6import android.content.Intent 6import android.content.Intent
7import android.net.Uri 7import android.net.Uri
8import android.os.Bundle 8import android.os.Bundle
9import android.provider.DocumentsContract
9import android.view.View 10import android.view.View
10import android.view.ViewGroup.MarginLayoutParams 11import android.view.ViewGroup.MarginLayoutParams
11import android.view.WindowManager 12import android.view.WindowManager
@@ -19,6 +20,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
19import androidx.core.view.ViewCompat 20import androidx.core.view.ViewCompat
20import androidx.core.view.WindowCompat 21import androidx.core.view.WindowCompat
21import androidx.core.view.WindowInsetsCompat 22import androidx.core.view.WindowInsetsCompat
23import androidx.documentfile.provider.DocumentFile
22import androidx.lifecycle.Lifecycle 24import androidx.lifecycle.Lifecycle
23import androidx.lifecycle.lifecycleScope 25import androidx.lifecycle.lifecycleScope
24import androidx.lifecycle.repeatOnLifecycle 26import androidx.lifecycle.repeatOnLifecycle
@@ -29,6 +31,7 @@ import androidx.preference.PreferenceManager
29import com.google.android.material.color.MaterialColors 31import com.google.android.material.color.MaterialColors
30import com.google.android.material.dialog.MaterialAlertDialogBuilder 32import com.google.android.material.dialog.MaterialAlertDialogBuilder
31import com.google.android.material.navigation.NavigationBarView 33import com.google.android.material.navigation.NavigationBarView
34import kotlinx.coroutines.CoroutineScope
32import java.io.File 35import java.io.File
33import java.io.FilenameFilter 36import java.io.FilenameFilter
34import java.io.IOException 37import java.io.IOException
@@ -41,20 +44,23 @@ import org.yuzu.yuzu_emu.R
41import org.yuzu.yuzu_emu.activities.EmulationActivity 44import org.yuzu.yuzu_emu.activities.EmulationActivity
42import org.yuzu.yuzu_emu.databinding.ActivityMainBinding 45import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
43import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding 46import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
47import org.yuzu.yuzu_emu.features.DocumentProvider
44import org.yuzu.yuzu_emu.features.settings.model.Settings 48import org.yuzu.yuzu_emu.features.settings.model.Settings
45import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment 49import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
46import org.yuzu.yuzu_emu.fragments.MessageDialogFragment 50import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
51import org.yuzu.yuzu_emu.getPublicFilesDir
47import org.yuzu.yuzu_emu.model.GamesViewModel 52import org.yuzu.yuzu_emu.model.GamesViewModel
48import org.yuzu.yuzu_emu.model.HomeViewModel 53import org.yuzu.yuzu_emu.model.HomeViewModel
54import org.yuzu.yuzu_emu.model.TaskState
49import org.yuzu.yuzu_emu.model.TaskViewModel 55import org.yuzu.yuzu_emu.model.TaskViewModel
50import org.yuzu.yuzu_emu.utils.* 56import org.yuzu.yuzu_emu.utils.*
51import java.io.BufferedInputStream 57import java.io.BufferedInputStream
52import java.io.BufferedOutputStream 58import java.io.BufferedOutputStream
53import java.io.FileInputStream
54import java.io.FileOutputStream 59import java.io.FileOutputStream
60import java.time.LocalDateTime
61import java.time.format.DateTimeFormatter
55import java.util.zip.ZipEntry 62import java.util.zip.ZipEntry
56import java.util.zip.ZipInputStream 63import java.util.zip.ZipInputStream
57import java.util.zip.ZipOutputStream
58 64
59class MainActivity : AppCompatActivity(), ThemeProvider { 65class MainActivity : AppCompatActivity(), ThemeProvider {
60 private lateinit var binding: ActivityMainBinding 66 private lateinit var binding: ActivityMainBinding
@@ -65,6 +71,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
65 71
66 override var themeId: Int = 0 72 override var themeId: Int = 0
67 73
74 private val savesFolder
75 get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
76
77 // Get first subfolder in saves folder (should be the user folder)
78 val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
79 private var lastZipCreated: File? = null
80
68 override fun onCreate(savedInstanceState: Bundle?) { 81 override fun onCreate(savedInstanceState: Bundle?) {
69 val splashScreen = installSplashScreen() 82 val splashScreen = installSplashScreen()
70 splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } 83 splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@@ -382,7 +395,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
382 val task: () -> Any = { 395 val task: () -> Any = {
383 var messageToShow: Any 396 var messageToShow: Any
384 try { 397 try {
385 FileUtil.unzip(inputZip, cacheFirmwareDir) 398 FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheFirmwareDir)
386 val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 399 val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1
387 val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 400 val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
388 messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { 401 messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
@@ -515,7 +528,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
515 if (documents.isNotEmpty()) { 528 if (documents.isNotEmpty()) {
516 IndeterminateProgressDialogFragment.newInstance( 529 IndeterminateProgressDialogFragment.newInstance(
517 this@MainActivity, 530 this@MainActivity,
518 R.string.install_game_content 531 R.string.installing_game_content
519 ) { 532 ) {
520 var installSuccess = 0 533 var installSuccess = 0
521 var installOverwrite = 0 534 var installOverwrite = 0
@@ -523,7 +536,12 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
523 var errorExtension = 0 536 var errorExtension = 0
524 var errorOther = 0 537 var errorOther = 0
525 documents.forEach { 538 documents.forEach {
526 when (NativeLibrary.installFileToNand(it.toString())) { 539 when (
540 NativeLibrary.installFileToNand(
541 it.toString(),
542 FileUtil.getExtension(it)
543 )
544 ) {
527 NativeLibrary.InstallFileToNandResult.Success -> { 545 NativeLibrary.InstallFileToNandResult.Success -> {
528 installSuccess += 1 546 installSuccess += 1
529 } 547 }
@@ -625,35 +643,17 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
625 R.string.exporting_user_data, 643 R.string.exporting_user_data,
626 true 644 true
627 ) { 645 ) {
628 val zos = ZipOutputStream( 646 val zipResult = FileUtil.zipFromInternalStorage(
629 BufferedOutputStream(contentResolver.openOutputStream(result)) 647 File(DirectoryInitialization.userDirectory!!),
648 DirectoryInitialization.userDirectory!!,
649 BufferedOutputStream(contentResolver.openOutputStream(result)),
650 taskViewModel.cancelled
630 ) 651 )
631 zos.use { stream -> 652 return@newInstance when (zipResult) {
632 File(DirectoryInitialization.userDirectory!!).walkTopDown().forEach { file -> 653 TaskState.Completed -> getString(R.string.user_data_export_success)
633 if (taskViewModel.cancelled.value) { 654 TaskState.Failed -> R.string.export_failed
634 return@newInstance R.string.user_data_export_cancelled 655 TaskState.Cancelled -> R.string.user_data_export_cancelled
635 }
636
637 if (!file.isDirectory) {
638 val newPath = file.path.substring(
639 DirectoryInitialization.userDirectory!!.length,
640 file.path.length
641 )
642 stream.putNextEntry(ZipEntry(newPath))
643
644 val buffer = ByteArray(8096)
645 var read: Int
646 FileInputStream(file).use { fis ->
647 while (fis.read(buffer).also { read = it } != -1) {
648 stream.write(buffer, 0, read)
649 }
650 }
651
652 stream.closeEntry()
653 }
654 }
655 } 656 }
656 return@newInstance getString(R.string.user_data_export_success)
657 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) 657 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
658 } 658 }
659 659
@@ -681,43 +681,28 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
681 } 681 }
682 } 682 }
683 if (!isYuzuBackup) { 683 if (!isYuzuBackup) {
684 return@newInstance getString(R.string.invalid_yuzu_backup) 684 return@newInstance MessageDialogFragment.newInstance(
685 this,
686 titleId = R.string.invalid_yuzu_backup,
687 descriptionId = R.string.user_data_import_failed_description
688 )
685 } 689 }
686 690
691 // Clear existing user data
687 File(DirectoryInitialization.userDirectory!!).deleteRecursively() 692 File(DirectoryInitialization.userDirectory!!).deleteRecursively()
688 693
689 val zis = 694 // Copy archive to internal storage
690 ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) 695 try {
691 val userDirectory = File(DirectoryInitialization.userDirectory!!) 696 FileUtil.unzipToInternalStorage(
692 val canonicalPath = userDirectory.canonicalPath + '/' 697 BufferedInputStream(contentResolver.openInputStream(result)),
693 zis.use { stream -> 698 File(DirectoryInitialization.userDirectory!!)
694 var ze: ZipEntry? = stream.nextEntry 699 )
695 while (ze != null) { 700 } catch (e: Exception) {
696 val newFile = File(userDirectory, ze!!.name) 701 return@newInstance MessageDialogFragment.newInstance(
697 val destinationDirectory = 702 this,
698 if (ze!!.isDirectory) newFile else newFile.parentFile 703 titleId = R.string.import_failed,
699 704 descriptionId = R.string.user_data_import_failed_description
700 if (!newFile.canonicalPath.startsWith(canonicalPath)) { 705 )
701 throw SecurityException(
702 "Zip file attempted path traversal! ${ze!!.name}"
703 )
704 }
705
706 if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) {
707 throw IOException("Failed to create directory $destinationDirectory")
708 }
709
710 if (!ze!!.isDirectory) {
711 val buffer = ByteArray(8096)
712 var read: Int
713 BufferedOutputStream(FileOutputStream(newFile)).use { bos ->
714 while (zis.read(buffer).also { read = it } != -1) {
715 bos.write(buffer, 0, read)
716 }
717 }
718 }
719 ze = stream.nextEntry
720 }
721 } 706 }
722 707
723 // Reinitialize relevant data 708 // Reinitialize relevant data
@@ -727,4 +712,146 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
727 return@newInstance getString(R.string.user_data_import_success) 712 return@newInstance getString(R.string.user_data_import_success)
728 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) 713 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
729 } 714 }
715
716 /**
717 * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
718 * @return true if the zip file is successfully created, false otherwise.
719 */
720 private fun zipSave(): Boolean {
721 try {
722 val tempFolder = File(getPublicFilesDir().canonicalPath, "temp")
723 tempFolder.mkdirs()
724 val saveFolder = File(savesFolderRoot)
725 val outputZipFile = File(
726 tempFolder,
727 "yuzu saves - ${
728 LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
729 }.zip"
730 )
731 outputZipFile.createNewFile()
732 val result = FileUtil.zipFromInternalStorage(
733 saveFolder,
734 savesFolderRoot,
735 BufferedOutputStream(FileOutputStream(outputZipFile))
736 )
737 if (result == TaskState.Failed) {
738 return false
739 }
740 lastZipCreated = outputZipFile
741 } catch (e: Exception) {
742 return false
743 }
744 return true
745 }
746
747 /**
748 * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
749 */
750 fun exportSave() {
751 CoroutineScope(Dispatchers.IO).launch {
752 val wasZipCreated = zipSave()
753 val lastZipFile = lastZipCreated
754 if (!wasZipCreated || lastZipFile == null) {
755 withContext(Dispatchers.Main) {
756 Toast.makeText(
757 this@MainActivity,
758 getString(R.string.export_save_failed),
759 Toast.LENGTH_LONG
760 ).show()
761 }
762 return@launch
763 }
764
765 withContext(Dispatchers.Main) {
766 val file = DocumentFile.fromSingleUri(
767 this@MainActivity,
768 DocumentsContract.buildDocumentUri(
769 DocumentProvider.AUTHORITY,
770 "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
771 )
772 )!!
773 val intent = Intent(Intent.ACTION_SEND)
774 .setDataAndType(file.uri, "application/zip")
775 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
776 .putExtra(Intent.EXTRA_STREAM, file.uri)
777 startForResultExportSave.launch(
778 Intent.createChooser(
779 intent,
780 getString(R.string.share_save_file)
781 )
782 )
783 }
784 }
785 }
786
787 private val startForResultExportSave =
788 registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
789 File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
790 }
791
792 val importSaves =
793 registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
794 if (result == null) {
795 return@registerForActivityResult
796 }
797
798 NativeLibrary.initializeEmptyUserDirectory()
799
800 val inputZip = contentResolver.openInputStream(result)
801 // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
802 var validZip = false
803 val savesFolder = File(savesFolderRoot)
804 val cacheSaveDir = File("${applicationContext.cacheDir.path}/saves/")
805 cacheSaveDir.mkdir()
806
807 if (inputZip == null) {
808 Toast.makeText(
809 applicationContext,
810 getString(R.string.fatal_error),
811 Toast.LENGTH_LONG
812 ).show()
813 return@registerForActivityResult
814 }
815
816 val filterTitleId =
817 FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
818
819 try {
820 CoroutineScope(Dispatchers.IO).launch {
821 FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
822 cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
823 File(savesFolder, savePath).deleteRecursively()
824 File(cacheSaveDir, savePath).copyRecursively(
825 File(savesFolder, savePath),
826 true
827 )
828 validZip = true
829 }
830
831 withContext(Dispatchers.Main) {
832 if (!validZip) {
833 MessageDialogFragment.newInstance(
834 this@MainActivity,
835 titleId = R.string.save_file_invalid_zip_structure,
836 descriptionId = R.string.save_file_invalid_zip_structure_description
837 ).show(supportFragmentManager, MessageDialogFragment.TAG)
838 return@withContext
839 }
840 Toast.makeText(
841 applicationContext,
842 getString(R.string.save_file_imported_success),
843 Toast.LENGTH_LONG
844 ).show()
845 }
846
847 cacheSaveDir.deleteRecursively()
848 }
849 } catch (e: Exception) {
850 Toast.makeText(
851 applicationContext,
852 getString(R.string.fatal_error),
853 Toast.LENGTH_LONG
854 ).show()
855 }
856 }
730} 857}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
index 142af5f26..c3f53f1c5 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
@@ -8,6 +8,7 @@ import android.database.Cursor
8import android.net.Uri 8import android.net.Uri
9import android.provider.DocumentsContract 9import android.provider.DocumentsContract
10import androidx.documentfile.provider.DocumentFile 10import androidx.documentfile.provider.DocumentFile
11import kotlinx.coroutines.flow.StateFlow
11import java.io.BufferedInputStream 12import java.io.BufferedInputStream
12import java.io.File 13import java.io.File
13import java.io.FileOutputStream 14import java.io.FileOutputStream
@@ -18,6 +19,9 @@ import java.util.zip.ZipEntry
18import java.util.zip.ZipInputStream 19import java.util.zip.ZipInputStream
19import org.yuzu.yuzu_emu.YuzuApplication 20import org.yuzu.yuzu_emu.YuzuApplication
20import org.yuzu.yuzu_emu.model.MinimalDocumentFile 21import org.yuzu.yuzu_emu.model.MinimalDocumentFile
22import org.yuzu.yuzu_emu.model.TaskState
23import java.io.BufferedOutputStream
24import java.util.zip.ZipOutputStream
21 25
22object FileUtil { 26object FileUtil {
23 const val PATH_TREE = "tree" 27 const val PATH_TREE = "tree"
@@ -282,30 +286,65 @@ object FileUtil {
282 286
283 /** 287 /**
284 * Extracts the given zip file into the given directory. 288 * Extracts the given zip file into the given directory.
285 * @exception IOException if the file was being created outside of the target directory
286 */ 289 */
287 @Throws(SecurityException::class) 290 @Throws(SecurityException::class)
288 fun unzip(zipStream: InputStream, destDir: File): Boolean { 291 fun unzipToInternalStorage(zipStream: BufferedInputStream, destDir: File) {
289 ZipInputStream(BufferedInputStream(zipStream)).use { zis -> 292 ZipInputStream(zipStream).use { zis ->
290 var entry: ZipEntry? = zis.nextEntry 293 var entry: ZipEntry? = zis.nextEntry
291 while (entry != null) { 294 while (entry != null) {
292 val entryName = entry.name 295 val newFile = File(destDir, entry.name)
293 val entryFile = File(destDir, entryName) 296 val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile
294 if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { 297
295 throw SecurityException("Entry is outside of the target dir: " + entryFile.name) 298 if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
299 throw SecurityException("Zip file attempted path traversal! ${entry.name}")
300 }
301
302 if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) {
303 throw IOException("Failed to create directory $destinationDirectory")
296 } 304 }
297 if (entry.isDirectory) { 305
298 entryFile.mkdirs() 306 if (!entry.isDirectory) {
299 } else { 307 newFile.outputStream().use { fos -> zis.copyTo(fos) }
300 entryFile.parentFile?.mkdirs()
301 entryFile.createNewFile()
302 entryFile.outputStream().use { fos -> zis.copyTo(fos) }
303 } 308 }
304 entry = zis.nextEntry 309 entry = zis.nextEntry
305 } 310 }
306 } 311 }
312 }
307 313
308 return true 314 /**
315 * Creates a zip file from a directory within internal storage
316 * @param inputFile File representation of the item that will be zipped
317 * @param rootDir Directory containing the inputFile
318 * @param outputStream Stream where the zip file will be output
319 */
320 fun zipFromInternalStorage(
321 inputFile: File,
322 rootDir: String,
323 outputStream: BufferedOutputStream,
324 cancelled: StateFlow<Boolean>? = null
325 ): TaskState {
326 try {
327 ZipOutputStream(outputStream).use { zos ->
328 inputFile.walkTopDown().forEach { file ->
329 if (cancelled?.value == true) {
330 return TaskState.Cancelled
331 }
332
333 if (!file.isDirectory) {
334 val entryName =
335 file.absolutePath.removePrefix(rootDir).removePrefix("/")
336 val entry = ZipEntry(entryName)
337 zos.putNextEntry(entry)
338 if (file.isFile) {
339 file.inputStream().use { fis -> fis.copyTo(zos) }
340 }
341 }
342 }
343 }
344 } catch (e: Exception) {
345 return TaskState.Failed
346 }
347 return TaskState.Completed
309 } 348 }
310 349
311 fun isRootTreeUri(uri: Uri): Boolean { 350 fun isRootTreeUri(uri: Uri): Boolean {
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index f31fe054b..9cf71680c 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -13,6 +13,8 @@
13 13
14#include <android/api-level.h> 14#include <android/api-level.h>
15#include <android/native_window_jni.h> 15#include <android/native_window_jni.h>
16#include <common/fs/fs.h>
17#include <core/file_sys/savedata_factory.h>
16#include <core/loader/nro.h> 18#include <core/loader/nro.h>
17#include <jni.h> 19#include <jni.h>
18 20
@@ -102,7 +104,7 @@ public:
102 m_native_window = native_window; 104 m_native_window = native_window;
103 } 105 }
104 106
105 int InstallFileToNand(std::string filename) { 107 int InstallFileToNand(std::string filename, std::string file_extension) {
106 jconst copy_func = [](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest, 108 jconst copy_func = [](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest,
107 std::size_t block_size) { 109 std::size_t block_size) {
108 if (src == nullptr || dest == nullptr) { 110 if (src == nullptr || dest == nullptr) {
@@ -134,15 +136,11 @@ public:
134 m_system.GetFileSystemController().CreateFactories(*m_vfs); 136 m_system.GetFileSystemController().CreateFactories(*m_vfs);
135 137
136 [[maybe_unused]] std::shared_ptr<FileSys::NSP> nsp; 138 [[maybe_unused]] std::shared_ptr<FileSys::NSP> nsp;
137 if (filename.ends_with("nsp")) { 139 if (file_extension == "nsp") {
138 nsp = std::make_shared<FileSys::NSP>(m_vfs->OpenFile(filename, FileSys::Mode::Read)); 140 nsp = std::make_shared<FileSys::NSP>(m_vfs->OpenFile(filename, FileSys::Mode::Read));
139 if (nsp->IsExtractedType()) { 141 if (nsp->IsExtractedType()) {
140 return InstallError; 142 return InstallError;
141 } 143 }
142 } else if (filename.ends_with("xci")) {
143 jconst xci =
144 std::make_shared<FileSys::XCI>(m_vfs->OpenFile(filename, FileSys::Mode::Read));
145 nsp = xci->GetSecurePartitionNSP();
146 } else { 144 } else {
147 return ErrorFilenameExtension; 145 return ErrorFilenameExtension;
148 } 146 }
@@ -607,8 +605,10 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject
607} 605}
608 606
609int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject instance, 607int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject instance,
610 [[maybe_unused]] jstring j_file) { 608 jstring j_file,
611 return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file)); 609 jstring j_file_extension) {
610 return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file),
611 GetJString(env, j_file_extension));
612} 612}
613 613
614void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz, 614void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz,
@@ -879,4 +879,24 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardInput(JNIEnv* env
879 EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code); 879 EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code);
880} 880}
881 881
882void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmptyUserDirectory(JNIEnv* env,
883 jobject instance) {
884 const auto nand_dir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir);
885 auto vfs_nand_dir = EmulationSession::GetInstance().System().GetFilesystem()->OpenDirectory(
886 Common::FS::PathToUTF8String(nand_dir), FileSys::Mode::Read);
887
888 Service::Account::ProfileManager manager;
889 const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
890 ASSERT(user_id);
891
892 const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
893 EmulationSession::GetInstance().System(), vfs_nand_dir, FileSys::SaveDataSpaceId::NandUser,
894 FileSys::SaveDataType::SaveData, 1, user_id->AsU128(), 0);
895
896 const auto full_path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path);
897 if (!Common::FS::CreateParentDirs(full_path)) {
898 LOG_WARNING(Frontend, "Failed to create full path of the default user's save directory");
899 }
900}
901
882} // extern "C" 902} // extern "C"
diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml
index 8a026a30a..a187665f2 100644
--- a/src/android/app/src/main/res/layout/activity_settings.xml
+++ b/src/android/app/src/main/res/layout/activity_settings.xml
@@ -22,7 +22,7 @@
22 22
23 <View 23 <View
24 android:id="@+id/navigation_bar_shade" 24 android:id="@+id/navigation_bar_shade"
25 android:layout_width="match_parent" 25 android:layout_width="0dp"
26 android:layout_height="1px" 26 android:layout_height="1px"
27 android:background="@android:color/transparent" 27 android:background="@android:color/transparent"
28 android:clickable="false" 28 android:clickable="false"
diff --git a/src/android/app/src/main/res/layout/card_installable.xml b/src/android/app/src/main/res/layout/card_installable.xml
new file mode 100644
index 000000000..f5b0e3741
--- /dev/null
+++ b/src/android/app/src/main/res/layout/card_installable.xml
@@ -0,0 +1,71 @@
1<?xml version="1.0" encoding="utf-8"?>
2<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:app="http://schemas.android.com/apk/res-auto"
4 xmlns:tools="http://schemas.android.com/tools"
5 style="?attr/materialCardViewOutlinedStyle"
6 android:layout_width="match_parent"
7 android:layout_height="wrap_content"
8 android:layout_marginHorizontal="16dp"
9 android:layout_marginVertical="12dp">
10
11 <LinearLayout
12 android:layout_width="match_parent"
13 android:layout_height="wrap_content"
14 android:layout_margin="16dp"
15 android:orientation="horizontal"
16 android:layout_gravity="center">
17
18 <LinearLayout
19 android:layout_width="0dp"
20 android:layout_height="wrap_content"
21 android:layout_marginEnd="16dp"
22 android:layout_weight="1"
23 android:orientation="vertical">
24
25 <com.google.android.material.textview.MaterialTextView
26 android:id="@+id/title"
27 style="@style/TextAppearance.Material3.TitleMedium"
28 android:layout_width="match_parent"
29 android:layout_height="wrap_content"
30 android:text="@string/user_data"
31 android:textAlignment="viewStart" />
32
33 <com.google.android.material.textview.MaterialTextView
34 android:id="@+id/description"
35 style="@style/TextAppearance.Material3.BodyMedium"
36 android:layout_width="match_parent"
37 android:layout_height="wrap_content"
38 android:layout_marginTop="6dp"
39 android:text="@string/user_data_description"
40 android:textAlignment="viewStart" />
41
42 </LinearLayout>
43
44 <Button
45 android:id="@+id/button_export"
46 style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
47 android:layout_width="wrap_content"
48 android:layout_height="wrap_content"
49 android:layout_gravity="center_vertical"
50 android:contentDescription="@string/export"
51 android:tooltipText="@string/export"
52 android:visibility="gone"
53 app:icon="@drawable/ic_export"
54 tools:visibility="visible" />
55
56 <Button
57 android:id="@+id/button_install"
58 style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
59 android:layout_width="wrap_content"
60 android:layout_height="wrap_content"
61 android:layout_gravity="center_vertical"
62 android:layout_marginStart="12dp"
63 android:contentDescription="@string/string_import"
64 android:tooltipText="@string/string_import"
65 android:visibility="gone"
66 app:icon="@drawable/ic_import"
67 tools:visibility="visible" />
68
69 </LinearLayout>
70
71</com.google.android.material.card.MaterialCardView>
diff --git a/src/android/app/src/main/res/layout/fragment_about.xml b/src/android/app/src/main/res/layout/fragment_about.xml
index 36b350338..3e1d98451 100644
--- a/src/android/app/src/main/res/layout/fragment_about.xml
+++ b/src/android/app/src/main/res/layout/fragment_about.xml
@@ -184,67 +184,6 @@
184 <LinearLayout 184 <LinearLayout
185 android:layout_width="match_parent" 185 android:layout_width="match_parent"
186 android:layout_height="wrap_content" 186 android:layout_height="wrap_content"
187 android:orientation="horizontal">
188
189 <LinearLayout
190 android:layout_width="match_parent"
191 android:layout_height="wrap_content"
192 android:paddingVertical="16dp"
193 android:paddingHorizontal="16dp"
194 android:orientation="vertical"
195 android:layout_weight="1">
196
197 <com.google.android.material.textview.MaterialTextView
198 style="@style/TextAppearance.Material3.TitleMedium"
199 android:layout_width="match_parent"
200 android:layout_height="wrap_content"
201 android:layout_marginHorizontal="24dp"
202 android:textAlignment="viewStart"
203 android:text="@string/user_data" />
204
205 <com.google.android.material.textview.MaterialTextView
206 style="@style/TextAppearance.Material3.BodyMedium"
207 android:layout_width="match_parent"
208 android:layout_height="wrap_content"
209 android:layout_marginHorizontal="24dp"
210 android:layout_marginTop="6dp"
211 android:textAlignment="viewStart"
212 android:text="@string/user_data_description" />
213
214 </LinearLayout>
215
216 <Button
217 android:id="@+id/button_import"
218 style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
219 android:layout_width="wrap_content"
220 android:layout_height="wrap_content"
221 android:layout_gravity="center_vertical"
222 android:contentDescription="@string/string_import"
223 android:tooltipText="@string/string_import"
224 app:icon="@drawable/ic_import" />
225
226 <Button
227 android:id="@+id/button_export"
228 style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
229 android:layout_width="wrap_content"
230 android:layout_height="wrap_content"
231 android:layout_marginStart="12dp"
232 android:layout_marginEnd="24dp"
233 android:layout_gravity="center_vertical"
234 android:contentDescription="@string/export"
235 android:tooltipText="@string/export"
236 app:icon="@drawable/ic_export" />
237
238 </LinearLayout>
239
240 <com.google.android.material.divider.MaterialDivider
241 android:layout_width="match_parent"
242 android:layout_height="wrap_content"
243 android:layout_marginHorizontal="20dp" />
244
245 <LinearLayout
246 android:layout_width="match_parent"
247 android:layout_height="wrap_content"
248 android:orientation="horizontal" 187 android:orientation="horizontal"
249 android:gravity="center_horizontal" 188 android:gravity="center_horizontal"
250 android:layout_marginTop="12dp" 189 android:layout_marginTop="12dp"
diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml
index da97d85c1..750ce094a 100644
--- a/src/android/app/src/main/res/layout/fragment_emulation.xml
+++ b/src/android/app/src/main/res/layout/fragment_emulation.xml
@@ -32,7 +32,8 @@
32 android:layout_width="wrap_content" 32 android:layout_width="wrap_content"
33 android:layout_height="wrap_content" 33 android:layout_height="wrap_content"
34 android:layout_gravity="center" 34 android:layout_gravity="center"
35 android:focusable="false"> 35 android:focusable="false"
36 android:clickable="false">
36 37
37 <androidx.constraintlayout.widget.ConstraintLayout 38 <androidx.constraintlayout.widget.ConstraintLayout
38 android:id="@+id/loading_layout" 39 android:id="@+id/loading_layout"
@@ -155,7 +156,7 @@
155 android:id="@+id/in_game_menu" 156 android:id="@+id/in_game_menu"
156 android:layout_width="wrap_content" 157 android:layout_width="wrap_content"
157 android:layout_height="match_parent" 158 android:layout_height="match_parent"
158 android:layout_gravity="start|bottom" 159 android:layout_gravity="start"
159 app:headerLayout="@layout/header_in_game" 160 app:headerLayout="@layout/header_in_game"
160 app:menu="@menu/menu_in_game" 161 app:menu="@menu/menu_in_game"
161 tools:visibility="gone" /> 162 tools:visibility="gone" />
diff --git a/src/android/app/src/main/res/layout/fragment_installables.xml b/src/android/app/src/main/res/layout/fragment_installables.xml
new file mode 100644
index 000000000..3a4df81a6
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_installables.xml
@@ -0,0 +1,31 @@
1<?xml version="1.0" encoding="utf-8"?>
2<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:app="http://schemas.android.com/apk/res-auto"
4 android:id="@+id/coordinator_licenses"
5 android:layout_width="match_parent"
6 android:layout_height="match_parent"
7 android:background="?attr/colorSurface">
8
9 <com.google.android.material.appbar.AppBarLayout
10 android:id="@+id/appbar_installables"
11 android:layout_width="match_parent"
12 android:layout_height="wrap_content"
13 android:fitsSystemWindows="true">
14
15 <com.google.android.material.appbar.MaterialToolbar
16 android:id="@+id/toolbar_installables"
17 android:layout_width="match_parent"
18 android:layout_height="?attr/actionBarSize"
19 app:title="@string/manage_yuzu_data"
20 app:navigationIcon="@drawable/ic_back" />
21
22 </com.google.android.material.appbar.AppBarLayout>
23
24 <androidx.recyclerview.widget.RecyclerView
25 android:id="@+id/list_installables"
26 android:layout_width="match_parent"
27 android:layout_height="match_parent"
28 android:clipToPadding="false"
29 app:layout_behavior="@string/appbar_scrolling_view_behavior" />
30
31</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml
index 2e0ce7a3d..2356b802b 100644
--- a/src/android/app/src/main/res/navigation/home_navigation.xml
+++ b/src/android/app/src/main/res/navigation/home_navigation.xml
@@ -19,6 +19,9 @@
19 <action 19 <action
20 android:id="@+id/action_homeSettingsFragment_to_earlyAccessFragment" 20 android:id="@+id/action_homeSettingsFragment_to_earlyAccessFragment"
21 app:destination="@id/earlyAccessFragment" /> 21 app:destination="@id/earlyAccessFragment" />
22 <action
23 android:id="@+id/action_homeSettingsFragment_to_installableFragment"
24 app:destination="@id/installableFragment" />
22 </fragment> 25 </fragment>
23 26
24 <fragment 27 <fragment
@@ -88,5 +91,9 @@
88 <action 91 <action
89 android:id="@+id/action_global_settingsActivity" 92 android:id="@+id/action_global_settingsActivity"
90 app:destination="@id/settingsActivity" /> 93 app:destination="@id/settingsActivity" />
94 <fragment
95 android:id="@+id/installableFragment"
96 android:name="org.yuzu.yuzu_emu.fragments.InstallableFragment"
97 android:label="InstallableFragment" />
91 98
92</navigation> 99</navigation>
diff --git a/src/android/app/src/main/res/values-de/strings.xml b/src/android/app/src/main/res/values-de/strings.xml
index daaa7ffde..dd0f36392 100644
--- a/src/android/app/src/main/res/values-de/strings.xml
+++ b/src/android/app/src/main/res/values-de/strings.xml
@@ -79,7 +79,6 @@
79 <string name="manage_save_data">Speicherdaten verwalten</string> 79 <string name="manage_save_data">Speicherdaten verwalten</string>
80 <string name="manage_save_data_description">Speicherdaten gefunden. Bitte wähle unten eine Option aus.</string> 80 <string name="manage_save_data_description">Speicherdaten gefunden. Bitte wähle unten eine Option aus.</string>
81 <string name="import_export_saves_description">Speicherdaten importieren oder exportieren</string> 81 <string name="import_export_saves_description">Speicherdaten importieren oder exportieren</string>
82 <string name="import_export_saves_no_profile">Keine Speicherdaten gefunden. Bitte starte ein Spiel und versuche es erneut.</string>
83 <string name="save_file_imported_success">Erfolgreich importiert</string> 82 <string name="save_file_imported_success">Erfolgreich importiert</string>
84 <string name="save_file_invalid_zip_structure">Ungültige Speicherverzeichnisstruktur</string> 83 <string name="save_file_invalid_zip_structure">Ungültige Speicherverzeichnisstruktur</string>
85 <string name="save_file_invalid_zip_structure_description">Der erste Unterordnername muss die Titel-ID des Spiels sein.</string> 84 <string name="save_file_invalid_zip_structure_description">Der erste Unterordnername muss die Titel-ID des Spiels sein.</string>
diff --git a/src/android/app/src/main/res/values-es/strings.xml b/src/android/app/src/main/res/values-es/strings.xml
index e9129cb00..d398f862f 100644
--- a/src/android/app/src/main/res/values-es/strings.xml
+++ b/src/android/app/src/main/res/values-es/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Administrar datos de guardado</string> 81 <string name="manage_save_data">Administrar datos de guardado</string>
82 <string name="manage_save_data_description">Guardar los datos encontrados. Por favor, seleccione una opción de abajo.</string> 82 <string name="manage_save_data_description">Guardar los datos encontrados. Por favor, seleccione una opción de abajo.</string>
83 <string name="import_export_saves_description">Importar o exportar archivos de guardado</string> 83 <string name="import_export_saves_description">Importar o exportar archivos de guardado</string>
84 <string name="import_export_saves_no_profile">No se han encontrado datos de guardado. Por favor, ejecute un juego y vuelva a intentarlo.</string>
85 <string name="save_file_imported_success">Importado correctamente</string> 84 <string name="save_file_imported_success">Importado correctamente</string>
86 <string name="save_file_invalid_zip_structure">Estructura del directorio de guardado no válido</string> 85 <string name="save_file_invalid_zip_structure">Estructura del directorio de guardado no válido</string>
87 <string name="save_file_invalid_zip_structure_description">El nombre de la primera subcarpeta debe ser el Title ID del juego.</string> 86 <string name="save_file_invalid_zip_structure_description">El nombre de la primera subcarpeta debe ser el Title ID del juego.</string>
diff --git a/src/android/app/src/main/res/values-fr/strings.xml b/src/android/app/src/main/res/values-fr/strings.xml
index 2d99d618e..a7abd9077 100644
--- a/src/android/app/src/main/res/values-fr/strings.xml
+++ b/src/android/app/src/main/res/values-fr/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Gérer les données de sauvegarde</string> 81 <string name="manage_save_data">Gérer les données de sauvegarde</string>
82 <string name="manage_save_data_description">Données de sauvegarde trouvées. Veuillez sélectionner une option ci-dessous.</string> 82 <string name="manage_save_data_description">Données de sauvegarde trouvées. Veuillez sélectionner une option ci-dessous.</string>
83 <string name="import_export_saves_description">Importer ou exporter des fichiers de sauvegarde</string> 83 <string name="import_export_saves_description">Importer ou exporter des fichiers de sauvegarde</string>
84 <string name="import_export_saves_no_profile">Aucune données de sauvegarde trouvées. Veuillez lancer un jeu et réessayer.</string>
85 <string name="save_file_imported_success">Importé avec succès</string> 84 <string name="save_file_imported_success">Importé avec succès</string>
86 <string name="save_file_invalid_zip_structure">Structure de répertoire de sauvegarde non valide</string> 85 <string name="save_file_invalid_zip_structure">Structure de répertoire de sauvegarde non valide</string>
87 <string name="save_file_invalid_zip_structure_description">Le nom du premier sous-dossier doit être l\'identifiant du titre du jeu.</string> 86 <string name="save_file_invalid_zip_structure_description">Le nom du premier sous-dossier doit être l\'identifiant du titre du jeu.</string>
diff --git a/src/android/app/src/main/res/values-it/strings.xml b/src/android/app/src/main/res/values-it/strings.xml
index d9c3de385..b18161801 100644
--- a/src/android/app/src/main/res/values-it/strings.xml
+++ b/src/android/app/src/main/res/values-it/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Gestisci i salvataggi</string> 81 <string name="manage_save_data">Gestisci i salvataggi</string>
82 <string name="manage_save_data_description">Salvataggio non trovato. Seleziona un\'opzione di seguito.</string> 82 <string name="manage_save_data_description">Salvataggio non trovato. Seleziona un\'opzione di seguito.</string>
83 <string name="import_export_saves_description">Importa o esporta i salvataggi</string> 83 <string name="import_export_saves_description">Importa o esporta i salvataggi</string>
84 <string name="import_export_saves_no_profile">Nessun salvataggio trovato. Avvia un gioco e riprova.</string>
85 <string name="save_file_imported_success">Importato con successo</string> 84 <string name="save_file_imported_success">Importato con successo</string>
86 <string name="save_file_invalid_zip_structure">La struttura della cartella dei salvataggi è invalida</string> 85 <string name="save_file_invalid_zip_structure">La struttura della cartella dei salvataggi è invalida</string>
87 <string name="save_file_invalid_zip_structure_description">La prima sotto cartella <b>deve</b> chiamarsi come l\'ID del titolo del gioco.</string> 86 <string name="save_file_invalid_zip_structure_description">La prima sotto cartella <b>deve</b> chiamarsi come l\'ID del titolo del gioco.</string>
diff --git a/src/android/app/src/main/res/values-ja/strings.xml b/src/android/app/src/main/res/values-ja/strings.xml
index 7a226cd5c..88fa5a0bb 100644
--- a/src/android/app/src/main/res/values-ja/strings.xml
+++ b/src/android/app/src/main/res/values-ja/strings.xml
@@ -80,7 +80,6 @@
80 <string name="manage_save_data">セーブデータを管理</string> 80 <string name="manage_save_data">セーブデータを管理</string>
81 <string name="manage_save_data_description">セーブデータが見つかりました。以下のオプションから選択してください。</string> 81 <string name="manage_save_data_description">セーブデータが見つかりました。以下のオプションから選択してください。</string>
82 <string name="import_export_saves_description">セーブファイルをインポート/エクスポート</string> 82 <string name="import_export_saves_description">セーブファイルをインポート/エクスポート</string>
83 <string name="import_export_saves_no_profile">セーブデータがありません。ゲームを起動してから再度お試しください。</string>
84 <string name="save_file_imported_success">インポートが完了しました</string> 83 <string name="save_file_imported_success">インポートが完了しました</string>
85 <string name="save_file_invalid_zip_structure">セーブデータのディレクトリ構造が無効です</string> 84 <string name="save_file_invalid_zip_structure">セーブデータのディレクトリ構造が無効です</string>
86 <string name="save_file_invalid_zip_structure_description">最初のサブフォルダ名は、ゲームのタイトルIDである必要があります。</string> 85 <string name="save_file_invalid_zip_structure_description">最初のサブフォルダ名は、ゲームのタイトルIDである必要があります。</string>
diff --git a/src/android/app/src/main/res/values-ko/strings.xml b/src/android/app/src/main/res/values-ko/strings.xml
index 427b6e5a0..4b658255c 100644
--- a/src/android/app/src/main/res/values-ko/strings.xml
+++ b/src/android/app/src/main/res/values-ko/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">저장 데이터 관리</string> 81 <string name="manage_save_data">저장 데이터 관리</string>
82 <string name="manage_save_data_description">데이터를 저장했습니다. 아래에서 옵션을 선택하세요.</string> 82 <string name="manage_save_data_description">데이터를 저장했습니다. 아래에서 옵션을 선택하세요.</string>
83 <string name="import_export_saves_description">저장 파일 가져오기 또는 내보내기</string> 83 <string name="import_export_saves_description">저장 파일 가져오기 또는 내보내기</string>
84 <string name="import_export_saves_no_profile">저장 데이터를 찾을 수 없습니다. 게임을 실행한 후 다시 시도하세요.</string>
85 <string name="save_file_imported_success">가져오기 성공</string> 84 <string name="save_file_imported_success">가져오기 성공</string>
86 <string name="save_file_invalid_zip_structure">저장 디렉터리 구조가 잘못됨</string> 85 <string name="save_file_invalid_zip_structure">저장 디렉터리 구조가 잘못됨</string>
87 <string name="save_file_invalid_zip_structure_description">첫 번째 하위 폴더 이름은 게임의 타이틀 ID여야 합니다.</string> 86 <string name="save_file_invalid_zip_structure_description">첫 번째 하위 폴더 이름은 게임의 타이틀 ID여야 합니다.</string>
diff --git a/src/android/app/src/main/res/values-nb/strings.xml b/src/android/app/src/main/res/values-nb/strings.xml
index ce8d7a9e4..dd602a389 100644
--- a/src/android/app/src/main/res/values-nb/strings.xml
+++ b/src/android/app/src/main/res/values-nb/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Administrere lagringsdata</string> 81 <string name="manage_save_data">Administrere lagringsdata</string>
82 <string name="manage_save_data_description">Lagringsdata funnet. Velg et alternativ nedenfor.</string> 82 <string name="manage_save_data_description">Lagringsdata funnet. Velg et alternativ nedenfor.</string>
83 <string name="import_export_saves_description">Importer eller eksporter lagringsfiler</string> 83 <string name="import_export_saves_description">Importer eller eksporter lagringsfiler</string>
84 <string name="import_export_saves_no_profile">Ingen lagringsdata funnet. Start et nytt spill og prøv på nytt.</string>
85 <string name="save_file_imported_success">Vellykket import</string> 84 <string name="save_file_imported_success">Vellykket import</string>
86 <string name="save_file_invalid_zip_structure">Ugyldig struktur for lagringskatalog</string> 85 <string name="save_file_invalid_zip_structure">Ugyldig struktur for lagringskatalog</string>
87 <string name="save_file_invalid_zip_structure_description">Det første undermappenavnet må være spillets tittel-ID.</string> 86 <string name="save_file_invalid_zip_structure_description">Det første undermappenavnet må være spillets tittel-ID.</string>
diff --git a/src/android/app/src/main/res/values-pl/strings.xml b/src/android/app/src/main/res/values-pl/strings.xml
index c2c24b48f..2fdd1f952 100644
--- a/src/android/app/src/main/res/values-pl/strings.xml
+++ b/src/android/app/src/main/res/values-pl/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Zarządzaj plikami zapisów gier</string> 81 <string name="manage_save_data">Zarządzaj plikami zapisów gier</string>
82 <string name="manage_save_data_description">Znaleziono pliki zapisów gier. Wybierz opcję poniżej.</string> 82 <string name="manage_save_data_description">Znaleziono pliki zapisów gier. Wybierz opcję poniżej.</string>
83 <string name="import_export_saves_description">Importuj lub wyeksportuj pliki zapisów</string> 83 <string name="import_export_saves_description">Importuj lub wyeksportuj pliki zapisów</string>
84 <string name="import_export_saves_no_profile">Nie znaleziono plików zapisów. Uruchom grę i spróbuj ponownie.</string>
85 <string name="save_file_imported_success">Zaimportowano pomyślnie</string> 84 <string name="save_file_imported_success">Zaimportowano pomyślnie</string>
86 <string name="save_file_invalid_zip_structure">Niepoprawna struktura folderów</string> 85 <string name="save_file_invalid_zip_structure">Niepoprawna struktura folderów</string>
87 <string name="save_file_invalid_zip_structure_description">Pierwszy podkatalog musi zawierać w nazwie numer ID tytułu gry.</string> 86 <string name="save_file_invalid_zip_structure_description">Pierwszy podkatalog musi zawierać w nazwie numer ID tytułu gry.</string>
diff --git a/src/android/app/src/main/res/values-pt-rBR/strings.xml b/src/android/app/src/main/res/values-pt-rBR/strings.xml
index 04f276108..2f26367fe 100644
--- a/src/android/app/src/main/res/values-pt-rBR/strings.xml
+++ b/src/android/app/src/main/res/values-pt-rBR/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Gerir dados guardados</string> 81 <string name="manage_save_data">Gerir dados guardados</string>
82 <string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string> 82 <string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string>
83 <string name="import_export_saves_description">Importa ou exporta dados guardados</string> 83 <string name="import_export_saves_description">Importa ou exporta dados guardados</string>
84 <string name="import_export_saves_no_profile">Dados não encontrados. Por favor lança o jogo e tenta novamente.</string>
85 <string name="save_file_imported_success">Importado com sucesso</string> 84 <string name="save_file_imported_success">Importado com sucesso</string>
86 <string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string> 85 <string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string>
87 <string name="save_file_invalid_zip_structure_description">O nome da primeira sub pasta tem de ser a ID do jogo.</string> 86 <string name="save_file_invalid_zip_structure_description">O nome da primeira sub pasta tem de ser a ID do jogo.</string>
diff --git a/src/android/app/src/main/res/values-pt-rPT/strings.xml b/src/android/app/src/main/res/values-pt-rPT/strings.xml
index 66a3a1a2e..4e1eb4cd7 100644
--- a/src/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/src/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Gerir dados guardados</string> 81 <string name="manage_save_data">Gerir dados guardados</string>
82 <string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string> 82 <string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string>
83 <string name="import_export_saves_description">Importa ou exporta dados guardados</string> 83 <string name="import_export_saves_description">Importa ou exporta dados guardados</string>
84 <string name="import_export_saves_no_profile">Dados não encontrados. Por favor lança o jogo e tenta novamente.</string>
85 <string name="save_file_imported_success">Importado com sucesso</string> 84 <string name="save_file_imported_success">Importado com sucesso</string>
86 <string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string> 85 <string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string>
87 <string name="save_file_invalid_zip_structure_description">O nome da primeira sub pasta tem de ser a ID do jogo.</string> 86 <string name="save_file_invalid_zip_structure_description">O nome da primeira sub pasta tem de ser a ID do jogo.</string>
diff --git a/src/android/app/src/main/res/values-ru/strings.xml b/src/android/app/src/main/res/values-ru/strings.xml
index f770e954f..f5695dc93 100644
--- a/src/android/app/src/main/res/values-ru/strings.xml
+++ b/src/android/app/src/main/res/values-ru/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Управление данными сохранений</string> 81 <string name="manage_save_data">Управление данными сохранений</string>
82 <string name="manage_save_data_description">Найдено данные сохранений. Пожалуйста, выберите вариант ниже.</string> 82 <string name="manage_save_data_description">Найдено данные сохранений. Пожалуйста, выберите вариант ниже.</string>
83 <string name="import_export_saves_description">Импорт или экспорт файлов сохранения</string> 83 <string name="import_export_saves_description">Импорт или экспорт файлов сохранения</string>
84 <string name="import_export_saves_no_profile">Данные сохранений не найдены. Пожалуйста, запустите игру и повторите попытку.</string>
85 <string name="save_file_imported_success">Успешно импортировано</string> 84 <string name="save_file_imported_success">Успешно импортировано</string>
86 <string name="save_file_invalid_zip_structure">Недопустимая структура папки сохранения</string> 85 <string name="save_file_invalid_zip_structure">Недопустимая структура папки сохранения</string>
87 <string name="save_file_invalid_zip_structure_description">Название первой вложенной папки должно быть идентификатором игры.</string> 86 <string name="save_file_invalid_zip_structure_description">Название первой вложенной папки должно быть идентификатором игры.</string>
diff --git a/src/android/app/src/main/res/values-uk/strings.xml b/src/android/app/src/main/res/values-uk/strings.xml
index ea3ab1b15..061bc6f04 100644
--- a/src/android/app/src/main/res/values-uk/strings.xml
+++ b/src/android/app/src/main/res/values-uk/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">Керування даними збережень</string> 81 <string name="manage_save_data">Керування даними збережень</string>
82 <string name="manage_save_data_description">Знайдено дані збережень. Будь ласка, виберіть варіант нижче.</string> 82 <string name="manage_save_data_description">Знайдено дані збережень. Будь ласка, виберіть варіант нижче.</string>
83 <string name="import_export_saves_description">Імпорт або експорт файлів збереження</string> 83 <string name="import_export_saves_description">Імпорт або експорт файлів збереження</string>
84 <string name="import_export_saves_no_profile">Дані збережень не знайдено. Будь ласка, запустіть гру та повторіть спробу.</string>
85 <string name="save_file_imported_success">Успішно імпортовано</string> 84 <string name="save_file_imported_success">Успішно імпортовано</string>
86 <string name="save_file_invalid_zip_structure">Неприпустима структура папки збереження</string> 85 <string name="save_file_invalid_zip_structure">Неприпустима структура папки збереження</string>
87 <string name="save_file_invalid_zip_structure_description">Назва першої вкладеної папки має бути ідентифікатором гри.</string> 86 <string name="save_file_invalid_zip_structure_description">Назва першої вкладеної папки має бути ідентифікатором гри.</string>
diff --git a/src/android/app/src/main/res/values-w600dp/integers.xml b/src/android/app/src/main/res/values-w600dp/integers.xml
new file mode 100644
index 000000000..9975db801
--- /dev/null
+++ b/src/android/app/src/main/res/values-w600dp/integers.xml
@@ -0,0 +1,6 @@
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
3
4 <integer name="grid_columns">2</integer>
5
6</resources>
diff --git a/src/android/app/src/main/res/values-zh-rCN/strings.xml b/src/android/app/src/main/res/values-zh-rCN/strings.xml
index b45a5a528..fe6dd5eaa 100644
--- a/src/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/src/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">管理存档数据</string> 81 <string name="manage_save_data">管理存档数据</string>
82 <string name="manage_save_data_description">已找到存档数据,请选择下方的选项。</string> 82 <string name="manage_save_data_description">已找到存档数据,请选择下方的选项。</string>
83 <string name="import_export_saves_description">导入或导出存档</string> 83 <string name="import_export_saves_description">导入或导出存档</string>
84 <string name="import_export_saves_no_profile">找不到存档数据,请启动游戏并重试。</string>
85 <string name="save_file_imported_success">已成功导入存档</string> 84 <string name="save_file_imported_success">已成功导入存档</string>
86 <string name="save_file_invalid_zip_structure">无效的存档目录</string> 85 <string name="save_file_invalid_zip_structure">无效的存档目录</string>
87 <string name="save_file_invalid_zip_structure_description">第一个子文件夹名称必须为当前游戏的 ID。</string> 86 <string name="save_file_invalid_zip_structure_description">第一个子文件夹名称必须为当前游戏的 ID。</string>
diff --git a/src/android/app/src/main/res/values-zh-rTW/strings.xml b/src/android/app/src/main/res/values-zh-rTW/strings.xml
index 3aab889e4..9b3e54224 100644
--- a/src/android/app/src/main/res/values-zh-rTW/strings.xml
+++ b/src/android/app/src/main/res/values-zh-rTW/strings.xml
@@ -81,7 +81,6 @@
81 <string name="manage_save_data">管理儲存資料</string> 81 <string name="manage_save_data">管理儲存資料</string>
82 <string name="manage_save_data_description">已找到儲存資料,請選取下方的選項。</string> 82 <string name="manage_save_data_description">已找到儲存資料,請選取下方的選項。</string>
83 <string name="import_export_saves_description">匯入或匯出儲存檔案</string> 83 <string name="import_export_saves_description">匯入或匯出儲存檔案</string>
84 <string name="import_export_saves_no_profile">找不到儲存資料,請啟動遊戲並重試。</string>
85 <string name="save_file_imported_success">已成功匯入</string> 84 <string name="save_file_imported_success">已成功匯入</string>
86 <string name="save_file_invalid_zip_structure">無效的儲存目錄結構</string> 85 <string name="save_file_invalid_zip_structure">無效的儲存目錄結構</string>
87 <string name="save_file_invalid_zip_structure_description">首個子資料夾名稱必須為遊戲標題 ID。</string> 86 <string name="save_file_invalid_zip_structure_description">首個子資料夾名稱必須為遊戲標題 ID。</string>
diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml
index 5e39bc7d9..dc527965c 100644
--- a/src/android/app/src/main/res/values/integers.xml
+++ b/src/android/app/src/main/res/values/integers.xml
@@ -1,6 +1,6 @@
1<?xml version="1.0" encoding="utf-8"?> 1<?xml version="1.0" encoding="utf-8"?>
2<resources> 2<resources>
3 <integer name="game_title_lines">2</integer> 3 <integer name="grid_columns">1</integer>
4 4
5 <!-- Default SWITCH landscape layout --> 5 <!-- Default SWITCH landscape layout -->
6 <integer name="SWITCH_BUTTON_A_X">760</integer> 6 <integer name="SWITCH_BUTTON_A_X">760</integer>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 0730143bd..e51edf872 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -90,7 +90,6 @@
90 <string name="manage_save_data">Manage save data</string> 90 <string name="manage_save_data">Manage save data</string>
91 <string name="manage_save_data_description">Save data found. Please select an option below.</string> 91 <string name="manage_save_data_description">Save data found. Please select an option below.</string>
92 <string name="import_export_saves_description">Import or export save files</string> 92 <string name="import_export_saves_description">Import or export save files</string>
93 <string name="import_export_saves_no_profile">No save data found. Please launch a game and retry.</string>
94 <string name="save_file_imported_success">Imported successfully</string> 93 <string name="save_file_imported_success">Imported successfully</string>
95 <string name="save_file_invalid_zip_structure">Invalid save directory structure</string> 94 <string name="save_file_invalid_zip_structure">Invalid save directory structure</string>
96 <string name="save_file_invalid_zip_structure_description">The first subfolder name must be the title ID of the game.</string> 95 <string name="save_file_invalid_zip_structure_description">The first subfolder name must be the title ID of the game.</string>
@@ -101,12 +100,13 @@
101 <string name="firmware_installing">Installing firmware</string> 100 <string name="firmware_installing">Installing firmware</string>
102 <string name="firmware_installed_success">Firmware installed successfully</string> 101 <string name="firmware_installed_success">Firmware installed successfully</string>
103 <string name="firmware_installed_failure">Firmware installation failed</string> 102 <string name="firmware_installed_failure">Firmware installation failed</string>
104 <string name="firmware_installed_failure_description">Verify that the ZIP contains valid firmware and try again.</string> 103 <string name="firmware_installed_failure_description">Make sure the firmware nca files are at the root of the zip and try again.</string>
105 <string name="share_log">Share debug logs</string> 104 <string name="share_log">Share debug logs</string>
106 <string name="share_log_description">Share yuzu\'s log file to debug issues</string> 105 <string name="share_log_description">Share yuzu\'s log file to debug issues</string>
107 <string name="share_log_missing">No log file found</string> 106 <string name="share_log_missing">No log file found</string>
108 <string name="install_game_content">Install game content</string> 107 <string name="install_game_content">Install game content</string>
109 <string name="install_game_content_description">Install game updates or DLC</string> 108 <string name="install_game_content_description">Install game updates or DLC</string>
109 <string name="installing_game_content">Installing content…</string>
110 <string name="install_game_content_failure">Error installing file(s) to NAND</string> 110 <string name="install_game_content_failure">Error installing file(s) to NAND</string>
111 <string name="install_game_content_failure_description">Please ensure content(s) are valid and that the prod.keys file is installed.</string> 111 <string name="install_game_content_failure_description">Please ensure content(s) are valid and that the prod.keys file is installed.</string>
112 <string name="install_game_content_failure_base">Installation of base games isn\'t permitted in order to avoid possible conflicts.</string> 112 <string name="install_game_content_failure_base">Installation of base games isn\'t permitted in order to avoid possible conflicts.</string>
@@ -118,6 +118,10 @@
118 <string name="install_game_content_help_link">https://yuzu-emu.org/help/quickstart/#dumping-installed-updates</string> 118 <string name="install_game_content_help_link">https://yuzu-emu.org/help/quickstart/#dumping-installed-updates</string>
119 <string name="custom_driver_not_supported">Custom drivers not supported</string> 119 <string name="custom_driver_not_supported">Custom drivers not supported</string>
120 <string name="custom_driver_not_supported_description">Custom driver loading isn\'t currently supported for this device.\nCheck this option again in the future to see if support was added!</string> 120 <string name="custom_driver_not_supported_description">Custom driver loading isn\'t currently supported for this device.\nCheck this option again in the future to see if support was added!</string>
121 <string name="manage_yuzu_data">Manage yuzu data</string>
122 <string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string>
123 <string name="share_save_file">Share save file</string>
124 <string name="export_save_failed">Failed to export save</string>
121 125
122 <!-- About screen strings --> 126 <!-- About screen strings -->
123 <string name="gaia_is_not_real">Gaia isn\'t real</string> 127 <string name="gaia_is_not_real">Gaia isn\'t real</string>
@@ -137,6 +141,7 @@
137 <string name="user_data_export_success">User data exported successfully</string> 141 <string name="user_data_export_success">User data exported successfully</string>
138 <string name="user_data_import_success">User data imported successfully</string> 142 <string name="user_data_import_success">User data imported successfully</string>
139 <string name="user_data_export_cancelled">Export cancelled</string> 143 <string name="user_data_export_cancelled">Export cancelled</string>
144 <string name="user_data_import_failed_description">Make sure the user data folders are at the root of the zip folder and contain a config file at config/config.ini and try again.</string>
140 <string name="support_link">https://discord.gg/u77vRWY</string> 145 <string name="support_link">https://discord.gg/u77vRWY</string>
141 <string name="website_link">https://yuzu-emu.org/</string> 146 <string name="website_link">https://yuzu-emu.org/</string>
142 <string name="github_link">https://github.com/yuzu-emu</string> 147 <string name="github_link">https://github.com/yuzu-emu</string>
@@ -226,6 +231,8 @@
226 <string name="string_null">Null</string> 231 <string name="string_null">Null</string>
227 <string name="string_import">Import</string> 232 <string name="string_import">Import</string>
228 <string name="export">Export</string> 233 <string name="export">Export</string>
234 <string name="export_failed">Export failed</string>
235 <string name="import_failed">Import failed</string>
229 <string name="cancelling">Cancelling</string> 236 <string name="cancelling">Cancelling</string>
230 237
231 <!-- GPU driver installation --> 238 <!-- GPU driver installation -->
@@ -293,6 +300,7 @@
293 <string name="performance_warning">Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled.</string> 300 <string name="performance_warning">Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled.</string>
294 <string name="device_memory_inadequate">Device RAM: %1$s\nRecommended: %2$s</string> 301 <string name="device_memory_inadequate">Device RAM: %1$s\nRecommended: %2$s</string>
295 <string name="memory_formatted">%1$s %2$s</string> 302 <string name="memory_formatted">%1$s %2$s</string>
303 <string name="no_game_present">No bootable game present!</string>
296 304
297 <!-- Region Names --> 305 <!-- Region Names -->
298 <string name="region_japan">Japan</string> 306 <string name="region_japan">Japan</string>
diff --git a/src/common/settings_setting.h b/src/common/settings_setting.h
index 7be6f26f7..3175ab07d 100644
--- a/src/common/settings_setting.h
+++ b/src/common/settings_setting.h
@@ -187,6 +187,8 @@ public:
187 this->SetValue(input == "true"); 187 this->SetValue(input == "true");
188 } else if constexpr (std::is_same_v<Type, float>) { 188 } else if constexpr (std::is_same_v<Type, float>) {
189 this->SetValue(std::stof(input)); 189 this->SetValue(std::stof(input));
190 } else if constexpr (std::is_same_v<Type, AudioEngine>) {
191 this->SetValue(ToEnum<AudioEngine>(input));
190 } else { 192 } else {
191 this->SetValue(static_cast<Type>(std::stoll(input))); 193 this->SetValue(static_cast<Type>(std::stoll(input)));
192 } 194 }
diff --git a/src/video_core/host_shaders/convert_msaa_to_non_msaa.comp b/src/video_core/host_shaders/convert_msaa_to_non_msaa.comp
index fc3854d18..66f2ad483 100644
--- a/src/video_core/host_shaders/convert_msaa_to_non_msaa.comp
+++ b/src/video_core/host_shaders/convert_msaa_to_non_msaa.comp
@@ -15,11 +15,14 @@ void main() {
15 15
16 // TODO: Specialization constants for num_samples? 16 // TODO: Specialization constants for num_samples?
17 const int num_samples = imageSamples(msaa_in); 17 const int num_samples = imageSamples(msaa_in);
18 const ivec3 msaa_size = imageSize(msaa_in);
19 const ivec3 out_size = imageSize(output_img);
20 const ivec3 scale = out_size / msaa_size;
18 for (int curr_sample = 0; curr_sample < num_samples; ++curr_sample) { 21 for (int curr_sample = 0; curr_sample < num_samples; ++curr_sample) {
19 const vec4 pixel = imageLoad(msaa_in, coords, curr_sample); 22 const vec4 pixel = imageLoad(msaa_in, coords, curr_sample);
20 23
21 const int single_sample_x = 2 * coords.x + (curr_sample & 1); 24 const int single_sample_x = scale.x * coords.x + (curr_sample & 1);
22 const int single_sample_y = 2 * coords.y + ((curr_sample / 2) & 1); 25 const int single_sample_y = scale.y * coords.y + ((curr_sample / 2) & 1);
23 const ivec3 dest_coords = ivec3(single_sample_x, single_sample_y, coords.z); 26 const ivec3 dest_coords = ivec3(single_sample_x, single_sample_y, coords.z);
24 27
25 if (any(greaterThanEqual(dest_coords, imageSize(output_img)))) { 28 if (any(greaterThanEqual(dest_coords, imageSize(output_img)))) {
diff --git a/src/video_core/host_shaders/convert_non_msaa_to_msaa.comp b/src/video_core/host_shaders/convert_non_msaa_to_msaa.comp
index dedd962f1..c7ce38efa 100644
--- a/src/video_core/host_shaders/convert_non_msaa_to_msaa.comp
+++ b/src/video_core/host_shaders/convert_non_msaa_to_msaa.comp
@@ -15,9 +15,12 @@ void main() {
15 15
16 // TODO: Specialization constants for num_samples? 16 // TODO: Specialization constants for num_samples?
17 const int num_samples = imageSamples(output_msaa); 17 const int num_samples = imageSamples(output_msaa);
18 const ivec3 msaa_size = imageSize(output_msaa);
19 const ivec3 out_size = imageSize(img_in);
20 const ivec3 scale = out_size / msaa_size;
18 for (int curr_sample = 0; curr_sample < num_samples; ++curr_sample) { 21 for (int curr_sample = 0; curr_sample < num_samples; ++curr_sample) {
19 const int single_sample_x = 2 * coords.x + (curr_sample & 1); 22 const int single_sample_x = scale.x * coords.x + (curr_sample & 1);
20 const int single_sample_y = 2 * coords.y + ((curr_sample / 2) & 1); 23 const int single_sample_y = scale.y * coords.y + ((curr_sample / 2) & 1);
21 const ivec3 single_coords = ivec3(single_sample_x, single_sample_y, coords.z); 24 const ivec3 single_coords = ivec3(single_sample_x, single_sample_y, coords.z);
22 25
23 if (any(greaterThanEqual(single_coords, imageSize(img_in)))) { 26 if (any(greaterThanEqual(single_coords, imageSize(img_in)))) {
diff --git a/src/video_core/renderer_vulkan/vk_compute_pass.cpp b/src/video_core/renderer_vulkan/vk_compute_pass.cpp
index 289d5b25c..617f92910 100644
--- a/src/video_core/renderer_vulkan/vk_compute_pass.cpp
+++ b/src/video_core/renderer_vulkan/vk_compute_pass.cpp
@@ -3,6 +3,7 @@
3 3
4#include <array> 4#include <array>
5#include <memory> 5#include <memory>
6#include <numeric>
6#include <optional> 7#include <optional>
7#include <utility> 8#include <utility>
8 9
@@ -11,7 +12,10 @@
11#include "common/assert.h" 12#include "common/assert.h"
12#include "common/common_types.h" 13#include "common/common_types.h"
13#include "common/div_ceil.h" 14#include "common/div_ceil.h"
15#include "common/vector_math.h"
14#include "video_core/host_shaders/astc_decoder_comp_spv.h" 16#include "video_core/host_shaders/astc_decoder_comp_spv.h"
17#include "video_core/host_shaders/convert_msaa_to_non_msaa_comp_spv.h"
18#include "video_core/host_shaders/convert_non_msaa_to_msaa_comp_spv.h"
15#include "video_core/host_shaders/queries_prefix_scan_sum_comp_spv.h" 19#include "video_core/host_shaders/queries_prefix_scan_sum_comp_spv.h"
16#include "video_core/host_shaders/queries_prefix_scan_sum_nosubgroups_comp_spv.h" 20#include "video_core/host_shaders/queries_prefix_scan_sum_nosubgroups_comp_spv.h"
17#include "video_core/host_shaders/resolve_conditional_render_comp_spv.h" 21#include "video_core/host_shaders/resolve_conditional_render_comp_spv.h"
@@ -131,6 +135,33 @@ constexpr DescriptorBankInfo ASTC_BANK_INFO{
131 .score = 2, 135 .score = 2,
132}; 136};
133 137
138constexpr std::array<VkDescriptorSetLayoutBinding, ASTC_NUM_BINDINGS> MSAA_DESCRIPTOR_SET_BINDINGS{{
139 {
140 .binding = 0,
141 .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
142 .descriptorCount = 1,
143 .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT,
144 .pImmutableSamplers = nullptr,
145 },
146 {
147 .binding = 1,
148 .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
149 .descriptorCount = 1,
150 .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT,
151 .pImmutableSamplers = nullptr,
152 },
153}};
154
155constexpr DescriptorBankInfo MSAA_BANK_INFO{
156 .uniform_buffers = 0,
157 .storage_buffers = 0,
158 .texture_buffers = 0,
159 .image_buffers = 0,
160 .textures = 0,
161 .images = 2,
162 .score = 2,
163};
164
134constexpr VkDescriptorUpdateTemplateEntry INPUT_OUTPUT_DESCRIPTOR_UPDATE_TEMPLATE{ 165constexpr VkDescriptorUpdateTemplateEntry INPUT_OUTPUT_DESCRIPTOR_UPDATE_TEMPLATE{
135 .dstBinding = 0, 166 .dstBinding = 0,
136 .dstArrayElement = 0, 167 .dstArrayElement = 0,
@@ -149,6 +180,15 @@ constexpr VkDescriptorUpdateTemplateEntry QUERIES_SCAN_DESCRIPTOR_UPDATE_TEMPLAT
149 .stride = sizeof(DescriptorUpdateEntry), 180 .stride = sizeof(DescriptorUpdateEntry),
150}; 181};
151 182
183constexpr VkDescriptorUpdateTemplateEntry MSAA_DESCRIPTOR_UPDATE_TEMPLATE{
184 .dstBinding = 0,
185 .dstArrayElement = 0,
186 .descriptorCount = 2,
187 .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
188 .offset = 0,
189 .stride = sizeof(DescriptorUpdateEntry),
190};
191
152constexpr std::array<VkDescriptorUpdateTemplateEntry, ASTC_NUM_BINDINGS> 192constexpr std::array<VkDescriptorUpdateTemplateEntry, ASTC_NUM_BINDINGS>
153 ASTC_PASS_DESCRIPTOR_UPDATE_TEMPLATE_ENTRY{{ 193 ASTC_PASS_DESCRIPTOR_UPDATE_TEMPLATE_ENTRY{{
154 { 194 {
@@ -224,6 +264,9 @@ ComputePass::ComputePass(const Device& device_, DescriptorPool& descriptor_pool,
224 }); 264 });
225 descriptor_allocator = descriptor_pool.Allocator(*descriptor_set_layout, bank_info); 265 descriptor_allocator = descriptor_pool.Allocator(*descriptor_set_layout, bank_info);
226 } 266 }
267 if (code.empty()) {
268 return;
269 }
227 module = device.GetLogical().CreateShaderModule({ 270 module = device.GetLogical().CreateShaderModule({
228 .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, 271 .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
229 .pNext = nullptr, 272 .pNext = nullptr,
@@ -590,4 +633,100 @@ void ASTCDecoderPass::Assemble(Image& image, const StagingBufferRef& map,
590 scheduler.Finish(); 633 scheduler.Finish();
591} 634}
592 635
636MSAACopyPass::MSAACopyPass(const Device& device_, Scheduler& scheduler_,
637 DescriptorPool& descriptor_pool_,
638 StagingBufferPool& staging_buffer_pool_,
639 ComputePassDescriptorQueue& compute_pass_descriptor_queue_)
640 : ComputePass(device_, descriptor_pool_, MSAA_DESCRIPTOR_SET_BINDINGS,
641 MSAA_DESCRIPTOR_UPDATE_TEMPLATE, MSAA_BANK_INFO, {},
642 CONVERT_NON_MSAA_TO_MSAA_COMP_SPV),
643 scheduler{scheduler_}, staging_buffer_pool{staging_buffer_pool_},
644 compute_pass_descriptor_queue{compute_pass_descriptor_queue_} {
645 const auto make_msaa_pipeline = [this](size_t i, std::span<const u32> code) {
646 modules[i] = device.GetLogical().CreateShaderModule({
647 .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
648 .pNext = nullptr,
649 .flags = 0,
650 .codeSize = static_cast<u32>(code.size_bytes()),
651 .pCode = code.data(),
652 });
653 pipelines[i] = device.GetLogical().CreateComputePipeline({
654 .sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
655 .pNext = nullptr,
656 .flags = 0,
657 .stage{
658 .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
659 .pNext = nullptr,
660 .flags = 0,
661 .stage = VK_SHADER_STAGE_COMPUTE_BIT,
662 .module = *modules[i],
663 .pName = "main",
664 .pSpecializationInfo = nullptr,
665 },
666 .layout = *layout,
667 .basePipelineHandle = nullptr,
668 .basePipelineIndex = 0,
669 });
670 };
671 make_msaa_pipeline(0, CONVERT_NON_MSAA_TO_MSAA_COMP_SPV);
672 make_msaa_pipeline(1, CONVERT_MSAA_TO_NON_MSAA_COMP_SPV);
673}
674
675MSAACopyPass::~MSAACopyPass() = default;
676
677void MSAACopyPass::CopyImage(Image& dst_image, Image& src_image,
678 std::span<const VideoCommon::ImageCopy> copies,
679 bool msaa_to_non_msaa) {
680 const VkPipeline msaa_pipeline = *pipelines[msaa_to_non_msaa ? 1 : 0];
681 scheduler.RequestOutsideRenderPassOperationContext();
682 for (const VideoCommon::ImageCopy& copy : copies) {
683 ASSERT(copy.src_subresource.base_layer == 0);
684 ASSERT(copy.src_subresource.num_layers == 1);
685 ASSERT(copy.dst_subresource.base_layer == 0);
686 ASSERT(copy.dst_subresource.num_layers == 1);
687
688 compute_pass_descriptor_queue.Acquire();
689 compute_pass_descriptor_queue.AddImage(
690 src_image.StorageImageView(copy.src_subresource.base_level));
691 compute_pass_descriptor_queue.AddImage(
692 dst_image.StorageImageView(copy.dst_subresource.base_level));
693 const void* const descriptor_data{compute_pass_descriptor_queue.UpdateData()};
694
695 const Common::Vec3<u32> num_dispatches = {
696 Common::DivCeil(copy.extent.width, 8U),
697 Common::DivCeil(copy.extent.height, 8U),
698 copy.extent.depth,
699 };
700
701 scheduler.Record([this, dst = dst_image.Handle(), msaa_pipeline, num_dispatches,
702 descriptor_data](vk::CommandBuffer cmdbuf) {
703 const VkDescriptorSet set = descriptor_allocator.Commit();
704 device.GetLogical().UpdateDescriptorSet(set, *descriptor_template, descriptor_data);
705 cmdbuf.BindPipeline(VK_PIPELINE_BIND_POINT_COMPUTE, msaa_pipeline);
706 cmdbuf.BindDescriptorSets(VK_PIPELINE_BIND_POINT_COMPUTE, *layout, 0, set, {});
707 cmdbuf.Dispatch(num_dispatches.x, num_dispatches.y, num_dispatches.z);
708 const VkImageMemoryBarrier write_barrier{
709 .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
710 .pNext = nullptr,
711 .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
712 .dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
713 .oldLayout = VK_IMAGE_LAYOUT_GENERAL,
714 .newLayout = VK_IMAGE_LAYOUT_GENERAL,
715 .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
716 .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
717 .image = dst,
718 .subresourceRange{
719 .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
720 .baseMipLevel = 0,
721 .levelCount = VK_REMAINING_MIP_LEVELS,
722 .baseArrayLayer = 0,
723 .layerCount = VK_REMAINING_ARRAY_LAYERS,
724 },
725 };
726 cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
727 VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, write_barrier);
728 });
729 }
730}
731
593} // namespace Vulkan 732} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/vk_compute_pass.h b/src/video_core/renderer_vulkan/vk_compute_pass.h
index 3ff935639..7b8f938c1 100644
--- a/src/video_core/renderer_vulkan/vk_compute_pass.h
+++ b/src/video_core/renderer_vulkan/vk_compute_pass.h
@@ -11,6 +11,7 @@
11#include "video_core/engines/maxwell_3d.h" 11#include "video_core/engines/maxwell_3d.h"
12#include "video_core/renderer_vulkan/vk_descriptor_pool.h" 12#include "video_core/renderer_vulkan/vk_descriptor_pool.h"
13#include "video_core/renderer_vulkan/vk_update_descriptor.h" 13#include "video_core/renderer_vulkan/vk_update_descriptor.h"
14#include "video_core/texture_cache/types.h"
14#include "video_core/vulkan_common/vulkan_memory_allocator.h" 15#include "video_core/vulkan_common/vulkan_memory_allocator.h"
15#include "video_core/vulkan_common/vulkan_wrapper.h" 16#include "video_core/vulkan_common/vulkan_wrapper.h"
16 17
@@ -130,4 +131,22 @@ private:
130 MemoryAllocator& memory_allocator; 131 MemoryAllocator& memory_allocator;
131}; 132};
132 133
134class MSAACopyPass final : public ComputePass {
135public:
136 explicit MSAACopyPass(const Device& device_, Scheduler& scheduler_,
137 DescriptorPool& descriptor_pool_, StagingBufferPool& staging_buffer_pool_,
138 ComputePassDescriptorQueue& compute_pass_descriptor_queue_);
139 ~MSAACopyPass();
140
141 void CopyImage(Image& dst_image, Image& src_image,
142 std::span<const VideoCommon::ImageCopy> copies, bool msaa_to_non_msaa);
143
144private:
145 Scheduler& scheduler;
146 StagingBufferPool& staging_buffer_pool;
147 ComputePassDescriptorQueue& compute_pass_descriptor_queue;
148 std::array<vk::ShaderModule, 2> modules;
149 std::array<vk::Pipeline, 2> pipelines;
150};
151
133} // namespace Vulkan 152} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp
index 1f9e7acaa..71fdec809 100644
--- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp
+++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp
@@ -176,6 +176,36 @@ constexpr VkBorderColor ConvertBorderColor(const std::array<float, 4>& color) {
176 return allocator.CreateImage(image_ci); 176 return allocator.CreateImage(image_ci);
177} 177}
178 178
179[[nodiscard]] vk::ImageView MakeStorageView(const vk::Device& device, u32 level, VkImage image,
180 VkFormat format) {
181 static constexpr VkImageViewUsageCreateInfo storage_image_view_usage_create_info{
182 .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_USAGE_CREATE_INFO,
183 .pNext = nullptr,
184 .usage = VK_IMAGE_USAGE_STORAGE_BIT,
185 };
186 return device.CreateImageView(VkImageViewCreateInfo{
187 .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
188 .pNext = &storage_image_view_usage_create_info,
189 .flags = 0,
190 .image = image,
191 .viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY,
192 .format = format,
193 .components{
194 .r = VK_COMPONENT_SWIZZLE_IDENTITY,
195 .g = VK_COMPONENT_SWIZZLE_IDENTITY,
196 .b = VK_COMPONENT_SWIZZLE_IDENTITY,
197 .a = VK_COMPONENT_SWIZZLE_IDENTITY,
198 },
199 .subresourceRange{
200 .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
201 .baseMipLevel = level,
202 .levelCount = 1,
203 .baseArrayLayer = 0,
204 .layerCount = VK_REMAINING_ARRAY_LAYERS,
205 },
206 });
207}
208
179[[nodiscard]] VkImageAspectFlags ImageAspectMask(PixelFormat format) { 209[[nodiscard]] VkImageAspectFlags ImageAspectMask(PixelFormat format) {
180 switch (VideoCore::Surface::GetFormatType(format)) { 210 switch (VideoCore::Surface::GetFormatType(format)) {
181 case VideoCore::Surface::SurfaceType::ColorTexture: 211 case VideoCore::Surface::SurfaceType::ColorTexture:
@@ -817,6 +847,10 @@ TextureCacheRuntime::TextureCacheRuntime(const Device& device_, Scheduler& sched
817 astc_decoder_pass.emplace(device, scheduler, descriptor_pool, staging_buffer_pool, 847 astc_decoder_pass.emplace(device, scheduler, descriptor_pool, staging_buffer_pool,
818 compute_pass_descriptor_queue, memory_allocator); 848 compute_pass_descriptor_queue, memory_allocator);
819 } 849 }
850 if (device.IsStorageImageMultisampleSupported()) {
851 msaa_copy_pass = std::make_unique<MSAACopyPass>(
852 device, scheduler, descriptor_pool, staging_buffer_pool, compute_pass_descriptor_queue);
853 }
820 if (!device.IsKhrImageFormatListSupported()) { 854 if (!device.IsKhrImageFormatListSupported()) {
821 return; 855 return;
822 } 856 }
@@ -1285,7 +1319,11 @@ void TextureCacheRuntime::CopyImage(Image& dst, Image& src,
1285 1319
1286void TextureCacheRuntime::CopyImageMSAA(Image& dst, Image& src, 1320void TextureCacheRuntime::CopyImageMSAA(Image& dst, Image& src,
1287 std::span<const VideoCommon::ImageCopy> copies) { 1321 std::span<const VideoCommon::ImageCopy> copies) {
1288 UNIMPLEMENTED_MSG("Copying images with different samples is not implemented in Vulkan."); 1322 const bool msaa_to_non_msaa = src.info.num_samples > 1 && dst.info.num_samples == 1;
1323 if (msaa_copy_pass) {
1324 return msaa_copy_pass->CopyImage(dst, src, copies, msaa_to_non_msaa);
1325 }
1326 UNIMPLEMENTED_MSG("Copying images with different samples is not supported.");
1289} 1327}
1290 1328
1291u64 TextureCacheRuntime::GetDeviceLocalMemory() const { 1329u64 TextureCacheRuntime::GetDeviceLocalMemory() const {
@@ -1333,39 +1371,15 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu
1333 if (runtime->device.HasDebuggingToolAttached()) { 1371 if (runtime->device.HasDebuggingToolAttached()) {
1334 original_image.SetObjectNameEXT(VideoCommon::Name(*this).c_str()); 1372 original_image.SetObjectNameEXT(VideoCommon::Name(*this).c_str());
1335 } 1373 }
1336 static constexpr VkImageViewUsageCreateInfo storage_image_view_usage_create_info{
1337 .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_USAGE_CREATE_INFO,
1338 .pNext = nullptr,
1339 .usage = VK_IMAGE_USAGE_STORAGE_BIT,
1340 };
1341 current_image = *original_image; 1374 current_image = *original_image;
1375 storage_image_views.resize(info.resources.levels);
1342 if (IsPixelFormatASTC(info.format) && !runtime->device.IsOptimalAstcSupported() && 1376 if (IsPixelFormatASTC(info.format) && !runtime->device.IsOptimalAstcSupported() &&
1343 Settings::values.astc_recompression.GetValue() == 1377 Settings::values.astc_recompression.GetValue() ==
1344 Settings::AstcRecompression::Uncompressed) { 1378 Settings::AstcRecompression::Uncompressed) {
1345 const auto& device = runtime->device.GetLogical(); 1379 const auto& device = runtime->device.GetLogical();
1346 storage_image_views.reserve(info.resources.levels);
1347 for (s32 level = 0; level < info.resources.levels; ++level) { 1380 for (s32 level = 0; level < info.resources.levels; ++level) {
1348 storage_image_views.push_back(device.CreateImageView(VkImageViewCreateInfo{ 1381 storage_image_views[level] =
1349 .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, 1382 MakeStorageView(device, level, *original_image, VK_FORMAT_A8B8G8R8_UNORM_PACK32);
1350 .pNext = &storage_image_view_usage_create_info,
1351 .flags = 0,
1352 .image = *original_image,
1353 .viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY,
1354 .format = VK_FORMAT_A8B8G8R8_UNORM_PACK32,
1355 .components{
1356 .r = VK_COMPONENT_SWIZZLE_IDENTITY,
1357 .g = VK_COMPONENT_SWIZZLE_IDENTITY,
1358 .b = VK_COMPONENT_SWIZZLE_IDENTITY,
1359 .a = VK_COMPONENT_SWIZZLE_IDENTITY,
1360 },
1361 .subresourceRange{
1362 .aspectMask = aspect_mask,
1363 .baseMipLevel = static_cast<u32>(level),
1364 .levelCount = 1,
1365 .baseArrayLayer = 0,
1366 .layerCount = VK_REMAINING_ARRAY_LAYERS,
1367 },
1368 }));
1369 } 1383 }
1370 } 1384 }
1371} 1385}
@@ -1496,6 +1510,17 @@ void Image::DownloadMemory(const StagingBufferRef& map, std::span<const BufferIm
1496 DownloadMemory(buffers, offsets, copies); 1510 DownloadMemory(buffers, offsets, copies);
1497} 1511}
1498 1512
1513VkImageView Image::StorageImageView(s32 level) noexcept {
1514 auto& view = storage_image_views[level];
1515 if (!view) {
1516 const auto format_info =
1517 MaxwellToVK::SurfaceFormat(runtime->device, FormatType::Optimal, true, info.format);
1518 view =
1519 MakeStorageView(runtime->device.GetLogical(), level, current_image, format_info.format);
1520 }
1521 return *view;
1522}
1523
1499bool Image::IsRescaled() const noexcept { 1524bool Image::IsRescaled() const noexcept {
1500 return True(flags & ImageFlagBits::Rescaled); 1525 return True(flags & ImageFlagBits::Rescaled);
1501} 1526}
diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.h b/src/video_core/renderer_vulkan/vk_texture_cache.h
index 565ce19a9..d6c5a15cc 100644
--- a/src/video_core/renderer_vulkan/vk_texture_cache.h
+++ b/src/video_core/renderer_vulkan/vk_texture_cache.h
@@ -117,6 +117,7 @@ public:
117 BlitImageHelper& blit_image_helper; 117 BlitImageHelper& blit_image_helper;
118 RenderPassCache& render_pass_cache; 118 RenderPassCache& render_pass_cache;
119 std::optional<ASTCDecoderPass> astc_decoder_pass; 119 std::optional<ASTCDecoderPass> astc_decoder_pass;
120 std::unique_ptr<MSAACopyPass> msaa_copy_pass;
120 const Settings::ResolutionScalingInfo& resolution; 121 const Settings::ResolutionScalingInfo& resolution;
121 std::array<std::vector<VkFormat>, VideoCore::Surface::MaxPixelFormat> view_formats; 122 std::array<std::vector<VkFormat>, VideoCore::Surface::MaxPixelFormat> view_formats;
122 123
@@ -161,15 +162,13 @@ public:
161 return aspect_mask; 162 return aspect_mask;
162 } 163 }
163 164
164 [[nodiscard]] VkImageView StorageImageView(s32 level) const noexcept {
165 return *storage_image_views[level];
166 }
167
168 /// Returns true when the image is already initialized and mark it as initialized 165 /// Returns true when the image is already initialized and mark it as initialized
169 [[nodiscard]] bool ExchangeInitialization() noexcept { 166 [[nodiscard]] bool ExchangeInitialization() noexcept {
170 return std::exchange(initialized, true); 167 return std::exchange(initialized, true);
171 } 168 }
172 169
170 VkImageView StorageImageView(s32 level) noexcept;
171
173 bool IsRescaled() const noexcept; 172 bool IsRescaled() const noexcept;
174 173
175 bool ScaleUp(bool ignore = false); 174 bool ScaleUp(bool ignore = false);
diff --git a/src/video_core/vulkan_common/vulkan_device.h b/src/video_core/vulkan_common/vulkan_device.h
index 94f41266d..dd1e7ea8c 100644
--- a/src/video_core/vulkan_common/vulkan_device.h
+++ b/src/video_core/vulkan_common/vulkan_device.h
@@ -324,6 +324,11 @@ public:
324 return features.shader_float16_int8.shaderInt8; 324 return features.shader_float16_int8.shaderInt8;
325 } 325 }
326 326
327 /// Returns true if the device supports binding multisample images as storage images.
328 bool IsStorageImageMultisampleSupported() const {
329 return features.features.shaderStorageImageMultisample;
330 }
331
327 /// Returns true if the device warp size can potentially be bigger than guest's warp size. 332 /// Returns true if the device warp size can potentially be bigger than guest's warp size.
328 bool IsWarpSizePotentiallyBiggerThanGuest() const { 333 bool IsWarpSizePotentiallyBiggerThanGuest() const {
329 return is_warp_potentially_bigger; 334 return is_warp_potentially_bigger;
diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp
index b1b6b9354..adb7b332f 100644
--- a/src/yuzu/main.cpp
+++ b/src/yuzu/main.cpp
@@ -3113,10 +3113,9 @@ void GMainWindow::OnMenuInstallToNAND() {
3113 QFuture<InstallResult> future; 3113 QFuture<InstallResult> future;
3114 InstallResult result; 3114 InstallResult result;
3115 3115
3116 if (file.endsWith(QStringLiteral("xci"), Qt::CaseInsensitive) || 3116 if (file.endsWith(QStringLiteral("nsp"), Qt::CaseInsensitive)) {
3117 file.endsWith(QStringLiteral("nsp"), Qt::CaseInsensitive)) {
3118 3117
3119 future = QtConcurrent::run([this, &file] { return InstallNSPXCI(file); }); 3118 future = QtConcurrent::run([this, &file] { return InstallNSP(file); });
3120 3119
3121 while (!future.isFinished()) { 3120 while (!future.isFinished()) {
3122 QCoreApplication::processEvents(); 3121 QCoreApplication::processEvents();
@@ -3175,7 +3174,7 @@ void GMainWindow::OnMenuInstallToNAND() {
3175 ui->action_Install_File_NAND->setEnabled(true); 3174 ui->action_Install_File_NAND->setEnabled(true);
3176} 3175}
3177 3176
3178InstallResult GMainWindow::InstallNSPXCI(const QString& filename) { 3177InstallResult GMainWindow::InstallNSP(const QString& filename) {
3179 const auto qt_raw_copy = [this](const FileSys::VirtualFile& src, 3178 const auto qt_raw_copy = [this](const FileSys::VirtualFile& src,
3180 const FileSys::VirtualFile& dest, std::size_t block_size) { 3179 const FileSys::VirtualFile& dest, std::size_t block_size) {
3181 if (src == nullptr || dest == nullptr) { 3180 if (src == nullptr || dest == nullptr) {
@@ -3209,9 +3208,7 @@ InstallResult GMainWindow::InstallNSPXCI(const QString& filename) {
3209 return InstallResult::Failure; 3208 return InstallResult::Failure;
3210 } 3209 }
3211 } else { 3210 } else {
3212 const auto xci = std::make_shared<FileSys::XCI>( 3211 return InstallResult::Failure;
3213 vfs->OpenFile(filename.toStdString(), FileSys::Mode::Read));
3214 nsp = xci->GetSecurePartitionNSP();
3215 } 3212 }
3216 3213
3217 if (nsp->GetStatus() != Loader::ResultStatus::Success) { 3214 if (nsp->GetStatus() != Loader::ResultStatus::Success) {
diff --git a/src/yuzu/main.h b/src/yuzu/main.h
index 53bedfab3..ba318eb11 100644
--- a/src/yuzu/main.h
+++ b/src/yuzu/main.h
@@ -387,7 +387,7 @@ private:
387 void RemoveCacheStorage(u64 program_id); 387 void RemoveCacheStorage(u64 program_id);
388 bool SelectRomFSDumpTarget(const FileSys::ContentProvider&, u64 program_id, 388 bool SelectRomFSDumpTarget(const FileSys::ContentProvider&, u64 program_id,
389 u64* selected_title_id, u8* selected_content_record_type); 389 u64* selected_title_id, u8* selected_content_record_type);
390 InstallResult InstallNSPXCI(const QString& filename); 390 InstallResult InstallNSP(const QString& filename);
391 InstallResult InstallNCA(const QString& filename); 391 InstallResult InstallNCA(const QString& filename);
392 void MigrateConfigFiles(); 392 void MigrateConfigFiles();
393 void UpdateWindowTitle(std::string_view title_name = {}, std::string_view title_version = {}, 393 void UpdateWindowTitle(std::string_view title_name = {}, std::string_view title_version = {},