summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt5
-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/fragments/AboutFragment.kt7
-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/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.kt248
-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.cpp22
-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_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.xml10
33 files changed, 616 insertions, 421 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 7745c9fc7..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
@@ -517,6 +517,11 @@ object NativeLibrary {
517 external fun submitInlineKeyboardInput(key_code: Int) 517 external fun submitInlineKeyboardInput(key_code: Int)
518 518
519 /** 519 /**
520 * Creates a generic user directory if it doesn't exist already
521 */
522 external fun initializeEmptyUserDirectory()
523
524 /**
520 * Button type for use in onTouchEvent 525 * Button type for use in onTouchEvent
521 */ 526 */
522 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/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/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/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 ee490abc0..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) {
@@ -630,35 +643,17 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
630 R.string.exporting_user_data, 643 R.string.exporting_user_data,
631 true 644 true
632 ) { 645 ) {
633 val zos = ZipOutputStream( 646 val zipResult = FileUtil.zipFromInternalStorage(
634 BufferedOutputStream(contentResolver.openOutputStream(result)) 647 File(DirectoryInitialization.userDirectory!!),
648 DirectoryInitialization.userDirectory!!,
649 BufferedOutputStream(contentResolver.openOutputStream(result)),
650 taskViewModel.cancelled
635 ) 651 )
636 zos.use { stream -> 652 return@newInstance when (zipResult) {
637 File(DirectoryInitialization.userDirectory!!).walkTopDown().forEach { file -> 653 TaskState.Completed -> getString(R.string.user_data_export_success)
638 if (taskViewModel.cancelled.value) { 654 TaskState.Failed -> R.string.export_failed
639 return@newInstance R.string.user_data_export_cancelled 655 TaskState.Cancelled -> R.string.user_data_export_cancelled
640 }
641
642 if (!file.isDirectory) {
643 val newPath = file.path.substring(
644 DirectoryInitialization.userDirectory!!.length,
645 file.path.length
646 )
647 stream.putNextEntry(ZipEntry(newPath))
648
649 val buffer = ByteArray(8096)
650 var read: Int
651 FileInputStream(file).use { fis ->
652 while (fis.read(buffer).also { read = it } != -1) {
653 stream.write(buffer, 0, read)
654 }
655 }
656
657 stream.closeEntry()
658 }
659 }
660 } 656 }
661 return@newInstance getString(R.string.user_data_export_success)
662 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) 657 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
663 } 658 }
664 659
@@ -686,43 +681,28 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
686 } 681 }
687 } 682 }
688 if (!isYuzuBackup) { 683 if (!isYuzuBackup) {
689 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 )
690 } 689 }
691 690
691 // Clear existing user data
692 File(DirectoryInitialization.userDirectory!!).deleteRecursively() 692 File(DirectoryInitialization.userDirectory!!).deleteRecursively()
693 693
694 val zis = 694 // Copy archive to internal storage
695 ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) 695 try {
696 val userDirectory = File(DirectoryInitialization.userDirectory!!) 696 FileUtil.unzipToInternalStorage(
697 val canonicalPath = userDirectory.canonicalPath + '/' 697 BufferedInputStream(contentResolver.openInputStream(result)),
698 zis.use { stream -> 698 File(DirectoryInitialization.userDirectory!!)
699 var ze: ZipEntry? = stream.nextEntry 699 )
700 while (ze != null) { 700 } catch (e: Exception) {
701 val newFile = File(userDirectory, ze!!.name) 701 return@newInstance MessageDialogFragment.newInstance(
702 val destinationDirectory = 702 this,
703 if (ze!!.isDirectory) newFile else newFile.parentFile 703 titleId = R.string.import_failed,
704 704 descriptionId = R.string.user_data_import_failed_description
705 if (!newFile.canonicalPath.startsWith(canonicalPath)) { 705 )
706 throw SecurityException(
707 "Zip file attempted path traversal! ${ze!!.name}"
708 )
709 }
710
711 if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) {
712 throw IOException("Failed to create directory $destinationDirectory")
713 }
714
715 if (!ze!!.isDirectory) {
716 val buffer = ByteArray(8096)
717 var read: Int
718 BufferedOutputStream(FileOutputStream(newFile)).use { bos ->
719 while (zis.read(buffer).also { read = it } != -1) {
720 bos.write(buffer, 0, read)
721 }
722 }
723 }
724 ze = stream.nextEntry
725 }
726 } 706 }
727 707
728 // Reinitialize relevant data 708 // Reinitialize relevant data
@@ -732,4 +712,146 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
732 return@newInstance getString(R.string.user_data_import_success) 712 return@newInstance getString(R.string.user_data_import_success)
733 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) 713 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
734 } 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 }
735} 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 71ef2833d..9fa082dd5 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
@@ -881,4 +883,24 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardInput(JNIEnv* env
881 EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code); 883 EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code);
882} 884}
883 885
886void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmptyUserDirectory(JNIEnv* env,
887 jobject instance) {
888 const auto nand_dir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir);
889 auto vfs_nand_dir = EmulationSession::GetInstance().System().GetFilesystem()->OpenDirectory(
890 Common::FS::PathToUTF8String(nand_dir), FileSys::Mode::Read);
891
892 Service::Account::ProfileManager manager;
893 const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
894 ASSERT(user_id);
895
896 const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
897 EmulationSession::GetInstance().System(), vfs_nand_dir, FileSys::SaveDataSpaceId::NandUser,
898 FileSys::SaveDataType::SaveData, 1, user_id->AsU128(), 0);
899
900 const auto full_path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path);
901 if (!Common::FS::CreateParentDirs(full_path)) {
902 LOG_WARNING(Frontend, "Failed to create full path of the default user's save directory");
903 }
904}
905
884} // extern "C" 906} // extern "C"
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_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 574290479..21a40238c 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,7 +100,7 @@
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>
@@ -119,6 +118,10 @@
119 <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>
120 <string name="custom_driver_not_supported">Custom drivers not supported</string> 119 <string name="custom_driver_not_supported">Custom drivers not supported</string>
121 <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>
122 125
123 <!-- About screen strings --> 126 <!-- About screen strings -->
124 <string name="gaia_is_not_real">Gaia isn\'t real</string> 127 <string name="gaia_is_not_real">Gaia isn\'t real</string>
@@ -138,6 +141,7 @@
138 <string name="user_data_export_success">User data exported successfully</string> 141 <string name="user_data_export_success">User data exported successfully</string>
139 <string name="user_data_import_success">User data imported successfully</string> 142 <string name="user_data_import_success">User data imported successfully</string>
140 <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>
141 <string name="support_link">https://discord.gg/u77vRWY</string> 145 <string name="support_link">https://discord.gg/u77vRWY</string>
142 <string name="website_link">https://yuzu-emu.org/</string> 146 <string name="website_link">https://yuzu-emu.org/</string>
143 <string name="github_link">https://github.com/yuzu-emu</string> 147 <string name="github_link">https://github.com/yuzu-emu</string>
@@ -227,6 +231,8 @@
227 <string name="string_null">Null</string> 231 <string name="string_null">Null</string>
228 <string name="string_import">Import</string> 232 <string name="string_import">Import</string>
229 <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>
230 <string name="cancelling">Cancelling</string> 236 <string name="cancelling">Cancelling</string>
231 237
232 <!-- GPU driver installation --> 238 <!-- GPU driver installation -->