summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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/features/settings/ui/SettingsActivity.kt3
-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
34 files changed, 618 insertions, 422 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 {
external fun submitInlineKeyboardInput(key_code: Int)
/**
+ * Creates a generic user directory if it doesn't exist already
+ */
+ external fun initializeEmptyUserDirectory()
+
+ /**
* Button type for use in onTouchEvent
*/
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 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.databinding.CardInstallableBinding
+import org.yuzu.yuzu_emu.model.Installable
+
+class InstallableAdapter(private val installables: List<Installable>) :
+ RecyclerView.Adapter<InstallableAdapter.InstallableViewHolder>() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): InstallableAdapter.InstallableViewHolder {
+ val binding =
+ CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return InstallableViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = installables.size
+
+ override fun onBindViewHolder(holder: InstallableAdapter.InstallableViewHolder, position: Int) =
+ holder.bind(installables[position])
+
+ inner class InstallableViewHolder(val binding: CardInstallableBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var installable: Installable
+
+ fun bind(installable: Installable) {
+ this.installable = installable
+
+ binding.title.setText(installable.titleId)
+ binding.description.setText(installable.descriptionId)
+
+ if (installable.install != null) {
+ binding.buttonInstall.visibility = View.VISIBLE
+ binding.buttonInstall.setOnClickListener { installable.install.invoke() }
+ }
+ if (installable.export != null) {
+ binding.buttonExport.visibility = View.VISIBLE
+ binding.buttonExport.setOnClickListener { installable.export.invoke() }
+ }
+ }
+ }
+}
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 ea26a21d0..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
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.NativeLibrary
import java.io.IOException
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
@@ -168,7 +169,7 @@ class SettingsActivity : AppCompatActivity() {
if (!settingsFile.delete()) {
throw IOException("Failed to delete $settingsFile")
}
- Settings.settingsList.forEach { it.reset() }
+ NativeLibrary.reloadSettings()
Toast.makeText(
applicationContext,
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
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
import org.yuzu.yuzu_emu.model.HomeViewModel
-import org.yuzu.yuzu_emu.ui.main.MainActivity
class AboutFragment : Fragment() {
private var _binding: FragmentAboutBinding? = null
@@ -93,12 +92,6 @@ class AboutFragment : Fragment() {
}
}
- val mainActivity = requireActivity() as MainActivity
- binding.buttonExport.setOnClickListener { mainActivity.exportUserData.launch("export.zip") }
- binding.buttonImport.setOnClickListener {
- mainActivity.importUserData.launch(arrayOf("application/zip"))
- }
-
binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
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() {
)
add(
HomeSetting(
- R.string.install_amiibo_keys,
- R.string.install_amiibo_keys_description,
- R.drawable.ic_nfc,
- { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }
- )
- )
- add(
- HomeSetting(
- R.string.install_game_content,
- R.string.install_game_content_description,
- R.drawable.ic_system_update_alt,
- { mainActivity.installGameUpdate.launch(arrayOf("*/*")) }
+ R.string.manage_yuzu_data,
+ R.string.manage_yuzu_data_description,
+ R.drawable.ic_install,
+ {
+ binding.root.findNavController()
+ .navigate(R.id.action_homeSettingsFragment_to_installableFragment)
+ }
)
)
add(
@@ -150,35 +145,6 @@ class HomeSettingsFragment : Fragment() {
)
add(
HomeSetting(
- R.string.manage_save_data,
- R.string.import_export_saves_description,
- R.drawable.ic_save,
- {
- ImportExportSavesFragment().show(
- parentFragmentManager,
- ImportExportSavesFragment.TAG
- )
- }
- )
- )
- add(
- HomeSetting(
- R.string.install_prod_keys,
- R.string.install_prod_keys_description,
- R.drawable.ic_unlock,
- { mainActivity.getProdKey.launch(arrayOf("*/*")) }
- )
- )
- add(
- HomeSetting(
- R.string.install_firmware,
- R.string.install_firmware_description,
- R.drawable.ic_firmware,
- { mainActivity.getFirmware.launch(arrayOf("application/zip")) }
- )
- )
- add(
- HomeSetting(
R.string.share_log,
R.string.share_log_description,
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 @@
-// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.yuzu.yuzu_emu.fragments
-
-import android.app.Dialog
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.provider.DocumentsContract
-import android.widget.Toast
-import androidx.activity.result.ActivityResultLauncher
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import androidx.documentfile.provider.DocumentFile
-import androidx.fragment.app.DialogFragment
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import java.io.BufferedOutputStream
-import java.io.File
-import java.io.FileOutputStream
-import java.io.FilenameFilter
-import java.time.LocalDateTime
-import java.time.format.DateTimeFormatter
-import java.util.zip.ZipEntry
-import java.util.zip.ZipOutputStream
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.yuzu.yuzu_emu.R
-import org.yuzu.yuzu_emu.YuzuApplication
-import org.yuzu.yuzu_emu.features.DocumentProvider
-import org.yuzu.yuzu_emu.getPublicFilesDir
-import org.yuzu.yuzu_emu.utils.FileUtil
-
-class ImportExportSavesFragment : DialogFragment() {
- private val context = YuzuApplication.appContext
- private val savesFolder =
- "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
-
- // Get first subfolder in saves folder (should be the user folder)
- private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
- private var lastZipCreated: File? = null
-
- private lateinit var startForResultExportSave: ActivityResultLauncher<Intent>
- private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val activity = requireActivity() as AppCompatActivity
-
- val activityResultRegistry = requireActivity().activityResultRegistry
- startForResultExportSave = activityResultRegistry.register(
- "startForResultExportSaveKey",
- ActivityResultContracts.StartActivityForResult()
- ) {
- File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
- }
- documentPicker = activityResultRegistry.register(
- "documentPickerKey",
- ActivityResultContracts.OpenDocument()
- ) {
- it?.let { uri -> importSave(uri, activity) }
- }
- }
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return if (savesFolderRoot == "") {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.manage_save_data)
- .setMessage(R.string.import_export_saves_no_profile)
- .setPositiveButton(android.R.string.ok, null)
- .show()
- } else {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.manage_save_data)
- .setMessage(R.string.manage_save_data_description)
- .setNegativeButton(R.string.export_saves) { _, _ ->
- exportSave()
- }
- .setPositiveButton(R.string.import_saves) { _, _ ->
- documentPicker.launch(arrayOf("application/zip"))
- }
- .setNeutralButton(android.R.string.cancel, null)
- .show()
- }
- }
-
- /**
- * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
- * @return true if the zip file is successfully created, false otherwise.
- */
- private fun zipSave(): Boolean {
- try {
- val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
- tempFolder.mkdirs()
- val saveFolder = File(savesFolderRoot)
- val outputZipFile = File(
- tempFolder,
- "yuzu saves - ${
- LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
- }.zip"
- )
- outputZipFile.createNewFile()
- ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
- saveFolder.walkTopDown().forEach { file ->
- val zipFileName =
- file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/")
- if (zipFileName == "") {
- return@forEach
- }
- val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
- zos.putNextEntry(entry)
- if (file.isFile) {
- file.inputStream().use { fis -> fis.copyTo(zos) }
- }
- }
- }
- lastZipCreated = outputZipFile
- } catch (e: Exception) {
- return false
- }
- return true
- }
-
- /**
- * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
- */
- private fun exportSave() {
- CoroutineScope(Dispatchers.IO).launch {
- val wasZipCreated = zipSave()
- val lastZipFile = lastZipCreated
- if (!wasZipCreated || lastZipFile == null) {
- withContext(Dispatchers.Main) {
- Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
- }
- return@launch
- }
-
- withContext(Dispatchers.Main) {
- val file = DocumentFile.fromSingleUri(
- context,
- DocumentsContract.buildDocumentUri(
- DocumentProvider.AUTHORITY,
- "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
- )
- )!!
- val intent = Intent(Intent.ACTION_SEND)
- .setDataAndType(file.uri, "application/zip")
- .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- .putExtra(Intent.EXTRA_STREAM, file.uri)
- startForResultExportSave.launch(Intent.createChooser(intent, "Share save file"))
- }
- }
- }
-
- /**
- * Imports the save files contained in the zip file, and replaces any existing ones with the new save file.
- * @param zipUri The Uri of the zip file containing the save file(s) to import.
- */
- private fun importSave(zipUri: Uri, activity: AppCompatActivity) {
- val inputZip = context.contentResolver.openInputStream(zipUri)
- // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
- var validZip = false
- val savesFolder = File(savesFolderRoot)
- val cacheSaveDir = File("${context.cacheDir.path}/saves/")
- cacheSaveDir.mkdir()
-
- if (inputZip == null) {
- Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
- .show()
- return
- }
-
- val filterTitleId =
- FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
-
- try {
- CoroutineScope(Dispatchers.IO).launch {
- FileUtil.unzip(inputZip, cacheSaveDir)
- cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
- File(savesFolder, savePath).deleteRecursively()
- File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true)
- validZip = true
- }
-
- withContext(Dispatchers.Main) {
- if (!validZip) {
- MessageDialogFragment.newInstance(
- requireActivity(),
- titleId = R.string.save_file_invalid_zip_structure,
- descriptionId = R.string.save_file_invalid_zip_structure_description
- ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
- return@withContext
- }
- Toast.makeText(
- context,
- context.getString(R.string.save_file_imported_success),
- Toast.LENGTH_LONG
- ).show()
- }
-
- cacheSaveDir.deleteRecursively()
- }
- } catch (e: Exception) {
- Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
- .show()
- }
- }
-
- companion object {
- const val TAG = "ImportExportSavesFragment"
- }
-}
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 @@
package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
-import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
@@ -39,9 +39,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
.setView(binding.root)
if (cancellable) {
- dialog.setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int ->
- taskViewModel.setCancelled(true)
- }
+ dialog.setNegativeButton(android.R.string.cancel, null)
}
val alertDialog = dialog.create()
@@ -98,6 +96,18 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
}
}
+ // By default, the ProgressDialog will immediately dismiss itself upon a button being pressed.
+ // Setting the OnClickListener again after the dialog is shown overrides this behavior.
+ override fun onResume() {
+ super.onResume()
+ val alertDialog = dialog as AlertDialog
+ val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE)
+ negativeButton.setOnClickListener {
+ alertDialog.setTitle(getString(R.string.cancelling))
+ taskViewModel.setCancelled(true)
+ }
+ }
+
companion object {
const val TAG = "IndeterminateProgressDialogFragment"
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 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.recyclerview.widget.GridLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.InstallableAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.Installable
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+
+class InstallableFragment : Fragment() {
+ private var _binding: FragmentInstallablesBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentInstallablesBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val mainActivity = requireActivity() as MainActivity
+
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarInstallables.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ val installables = listOf(
+ Installable(
+ R.string.user_data,
+ R.string.user_data_description,
+ install = { mainActivity.importUserData.launch(arrayOf("application/zip")) },
+ export = { mainActivity.exportUserData.launch("export.zip") }
+ ),
+ Installable(
+ R.string.install_game_content,
+ R.string.install_game_content_description,
+ install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) }
+ ),
+ Installable(
+ R.string.install_firmware,
+ R.string.install_firmware_description,
+ install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) }
+ ),
+ if (mainActivity.savesFolderRoot != "") {
+ Installable(
+ R.string.manage_save_data,
+ R.string.import_export_saves_description,
+ install = { mainActivity.importSaves.launch(arrayOf("application/zip")) },
+ export = { mainActivity.exportSave() }
+ )
+ } else {
+ Installable(
+ R.string.manage_save_data,
+ R.string.import_export_saves_description,
+ install = { mainActivity.importSaves.launch(arrayOf("application/zip")) }
+ )
+ },
+ Installable(
+ R.string.install_prod_keys,
+ R.string.install_prod_keys_description,
+ install = { mainActivity.getProdKey.launch(arrayOf("*/*")) }
+ ),
+ Installable(
+ R.string.install_amiibo_keys,
+ R.string.install_amiibo_keys_description,
+ install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }
+ )
+ )
+
+ binding.listInstallables.apply {
+ layoutManager = GridLayoutManager(
+ requireContext(),
+ resources.getInteger(R.integer.grid_columns)
+ )
+ adapter = InstallableAdapter(installables)
+ }
+
+ setInsets()
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.toolbarInstallables.layoutParams as ViewGroup.MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.toolbarInstallables.layoutParams = mlpAppBar
+
+ val mlpScrollAbout =
+ binding.listInstallables.layoutParams as ViewGroup.MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.listInstallables.layoutParams = mlpScrollAbout
+
+ binding.listInstallables.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
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 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.annotation.StringRes
+
+data class Installable(
+ @StringRes val titleId: Int,
+ @StringRes val descriptionId: Int,
+ val install: (() -> Unit)? = null,
+ val export: (() -> Unit)? = null
+)
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() {
}
}
}
+
+enum class TaskState {
+ Completed,
+ Failed,
+ Cancelled
+}
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
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.provider.DocumentsContract
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager
@@ -19,6 +20,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -29,6 +31,7 @@ import androidx.preference.PreferenceManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationBarView
+import kotlinx.coroutines.CoroutineScope
import java.io.File
import java.io.FilenameFilter
import java.io.IOException
@@ -41,20 +44,23 @@ import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.features.DocumentProvider
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
+import org.yuzu.yuzu_emu.getPublicFilesDir
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.TaskState
import org.yuzu.yuzu_emu.model.TaskViewModel
import org.yuzu.yuzu_emu.utils.*
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
-import java.io.FileInputStream
import java.io.FileOutputStream
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
-import java.util.zip.ZipOutputStream
class MainActivity : AppCompatActivity(), ThemeProvider {
private lateinit var binding: ActivityMainBinding
@@ -65,6 +71,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
override var themeId: Int = 0
+ private val savesFolder
+ get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
+
+ // Get first subfolder in saves folder (should be the user folder)
+ val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
+ private var lastZipCreated: File? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@@ -382,7 +395,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
val task: () -> Any = {
var messageToShow: Any
try {
- FileUtil.unzip(inputZip, cacheFirmwareDir)
+ FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheFirmwareDir)
val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1
val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
@@ -630,35 +643,17 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
R.string.exporting_user_data,
true
) {
- val zos = ZipOutputStream(
- BufferedOutputStream(contentResolver.openOutputStream(result))
+ val zipResult = FileUtil.zipFromInternalStorage(
+ File(DirectoryInitialization.userDirectory!!),
+ DirectoryInitialization.userDirectory!!,
+ BufferedOutputStream(contentResolver.openOutputStream(result)),
+ taskViewModel.cancelled
)
- zos.use { stream ->
- File(DirectoryInitialization.userDirectory!!).walkTopDown().forEach { file ->
- if (taskViewModel.cancelled.value) {
- return@newInstance R.string.user_data_export_cancelled
- }
-
- if (!file.isDirectory) {
- val newPath = file.path.substring(
- DirectoryInitialization.userDirectory!!.length,
- file.path.length
- )
- stream.putNextEntry(ZipEntry(newPath))
-
- val buffer = ByteArray(8096)
- var read: Int
- FileInputStream(file).use { fis ->
- while (fis.read(buffer).also { read = it } != -1) {
- stream.write(buffer, 0, read)
- }
- }
-
- stream.closeEntry()
- }
- }
+ return@newInstance when (zipResult) {
+ TaskState.Completed -> getString(R.string.user_data_export_success)
+ TaskState.Failed -> R.string.export_failed
+ TaskState.Cancelled -> R.string.user_data_export_cancelled
}
- return@newInstance getString(R.string.user_data_export_success)
}.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
}
@@ -686,43 +681,28 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
}
}
if (!isYuzuBackup) {
- return@newInstance getString(R.string.invalid_yuzu_backup)
+ return@newInstance MessageDialogFragment.newInstance(
+ this,
+ titleId = R.string.invalid_yuzu_backup,
+ descriptionId = R.string.user_data_import_failed_description
+ )
}
+ // Clear existing user data
File(DirectoryInitialization.userDirectory!!).deleteRecursively()
- val zis =
- ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result)))
- val userDirectory = File(DirectoryInitialization.userDirectory!!)
- val canonicalPath = userDirectory.canonicalPath + '/'
- zis.use { stream ->
- var ze: ZipEntry? = stream.nextEntry
- while (ze != null) {
- val newFile = File(userDirectory, ze!!.name)
- val destinationDirectory =
- if (ze!!.isDirectory) newFile else newFile.parentFile
-
- if (!newFile.canonicalPath.startsWith(canonicalPath)) {
- throw SecurityException(
- "Zip file attempted path traversal! ${ze!!.name}"
- )
- }
-
- if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) {
- throw IOException("Failed to create directory $destinationDirectory")
- }
-
- if (!ze!!.isDirectory) {
- val buffer = ByteArray(8096)
- var read: Int
- BufferedOutputStream(FileOutputStream(newFile)).use { bos ->
- while (zis.read(buffer).also { read = it } != -1) {
- bos.write(buffer, 0, read)
- }
- }
- }
- ze = stream.nextEntry
- }
+ // Copy archive to internal storage
+ try {
+ FileUtil.unzipToInternalStorage(
+ BufferedInputStream(contentResolver.openInputStream(result)),
+ File(DirectoryInitialization.userDirectory!!)
+ )
+ } catch (e: Exception) {
+ return@newInstance MessageDialogFragment.newInstance(
+ this,
+ titleId = R.string.import_failed,
+ descriptionId = R.string.user_data_import_failed_description
+ )
}
// Reinitialize relevant data
@@ -732,4 +712,146 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
return@newInstance getString(R.string.user_data_import_success)
}.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
}
+
+ /**
+ * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
+ * @return true if the zip file is successfully created, false otherwise.
+ */
+ private fun zipSave(): Boolean {
+ try {
+ val tempFolder = File(getPublicFilesDir().canonicalPath, "temp")
+ tempFolder.mkdirs()
+ val saveFolder = File(savesFolderRoot)
+ val outputZipFile = File(
+ tempFolder,
+ "yuzu saves - ${
+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
+ }.zip"
+ )
+ outputZipFile.createNewFile()
+ val result = FileUtil.zipFromInternalStorage(
+ saveFolder,
+ savesFolderRoot,
+ BufferedOutputStream(FileOutputStream(outputZipFile))
+ )
+ if (result == TaskState.Failed) {
+ return false
+ }
+ lastZipCreated = outputZipFile
+ } catch (e: Exception) {
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
+ */
+ fun exportSave() {
+ CoroutineScope(Dispatchers.IO).launch {
+ val wasZipCreated = zipSave()
+ val lastZipFile = lastZipCreated
+ if (!wasZipCreated || lastZipFile == null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.export_save_failed),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ return@launch
+ }
+
+ withContext(Dispatchers.Main) {
+ val file = DocumentFile.fromSingleUri(
+ this@MainActivity,
+ DocumentsContract.buildDocumentUri(
+ DocumentProvider.AUTHORITY,
+ "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
+ )
+ )!!
+ val intent = Intent(Intent.ACTION_SEND)
+ .setDataAndType(file.uri, "application/zip")
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(Intent.EXTRA_STREAM, file.uri)
+ startForResultExportSave.launch(
+ Intent.createChooser(
+ intent,
+ getString(R.string.share_save_file)
+ )
+ )
+ }
+ }
+ }
+
+ private val startForResultExportSave =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
+ File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
+ }
+
+ val importSaves =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ NativeLibrary.initializeEmptyUserDirectory()
+
+ val inputZip = contentResolver.openInputStream(result)
+ // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
+ var validZip = false
+ val savesFolder = File(savesFolderRoot)
+ val cacheSaveDir = File("${applicationContext.cacheDir.path}/saves/")
+ cacheSaveDir.mkdir()
+
+ if (inputZip == null) {
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ return@registerForActivityResult
+ }
+
+ val filterTitleId =
+ FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
+
+ try {
+ CoroutineScope(Dispatchers.IO).launch {
+ FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
+ cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
+ File(savesFolder, savePath).deleteRecursively()
+ File(cacheSaveDir, savePath).copyRecursively(
+ File(savesFolder, savePath),
+ true
+ )
+ validZip = true
+ }
+
+ withContext(Dispatchers.Main) {
+ if (!validZip) {
+ MessageDialogFragment.newInstance(
+ this@MainActivity,
+ titleId = R.string.save_file_invalid_zip_structure,
+ descriptionId = R.string.save_file_invalid_zip_structure_description
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ return@withContext
+ }
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.save_file_imported_success),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ cacheSaveDir.deleteRecursively()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
}
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
import android.net.Uri
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
+import kotlinx.coroutines.flow.StateFlow
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
@@ -18,6 +19,9 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.model.MinimalDocumentFile
+import org.yuzu.yuzu_emu.model.TaskState
+import java.io.BufferedOutputStream
+import java.util.zip.ZipOutputStream
object FileUtil {
const val PATH_TREE = "tree"
@@ -282,30 +286,65 @@ object FileUtil {
/**
* Extracts the given zip file into the given directory.
- * @exception IOException if the file was being created outside of the target directory
*/
@Throws(SecurityException::class)
- fun unzip(zipStream: InputStream, destDir: File): Boolean {
- ZipInputStream(BufferedInputStream(zipStream)).use { zis ->
+ fun unzipToInternalStorage(zipStream: BufferedInputStream, destDir: File) {
+ ZipInputStream(zipStream).use { zis ->
var entry: ZipEntry? = zis.nextEntry
while (entry != null) {
- val entryName = entry.name
- val entryFile = File(destDir, entryName)
- if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
- throw SecurityException("Entry is outside of the target dir: " + entryFile.name)
+ val newFile = File(destDir, entry.name)
+ val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile
+
+ if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
+ throw SecurityException("Zip file attempted path traversal! ${entry.name}")
+ }
+
+ if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) {
+ throw IOException("Failed to create directory $destinationDirectory")
}
- if (entry.isDirectory) {
- entryFile.mkdirs()
- } else {
- entryFile.parentFile?.mkdirs()
- entryFile.createNewFile()
- entryFile.outputStream().use { fos -> zis.copyTo(fos) }
+
+ if (!entry.isDirectory) {
+ newFile.outputStream().use { fos -> zis.copyTo(fos) }
}
entry = zis.nextEntry
}
}
+ }
- return true
+ /**
+ * Creates a zip file from a directory within internal storage
+ * @param inputFile File representation of the item that will be zipped
+ * @param rootDir Directory containing the inputFile
+ * @param outputStream Stream where the zip file will be output
+ */
+ fun zipFromInternalStorage(
+ inputFile: File,
+ rootDir: String,
+ outputStream: BufferedOutputStream,
+ cancelled: StateFlow<Boolean>? = null
+ ): TaskState {
+ try {
+ ZipOutputStream(outputStream).use { zos ->
+ inputFile.walkTopDown().forEach { file ->
+ if (cancelled?.value == true) {
+ return TaskState.Cancelled
+ }
+
+ if (!file.isDirectory) {
+ val entryName =
+ file.absolutePath.removePrefix(rootDir).removePrefix("/")
+ val entry = ZipEntry(entryName)
+ zos.putNextEntry(entry)
+ if (file.isFile) {
+ file.inputStream().use { fis -> fis.copyTo(zos) }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ return TaskState.Failed
+ }
+ return TaskState.Completed
}
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 @@
#include <android/api-level.h>
#include <android/native_window_jni.h>
+#include <common/fs/fs.h>
+#include <core/file_sys/savedata_factory.h>
#include <core/loader/nro.h>
#include <jni.h>
@@ -881,4 +883,24 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardInput(JNIEnv* env
EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code);
}
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmptyUserDirectory(JNIEnv* env,
+ jobject instance) {
+ const auto nand_dir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir);
+ auto vfs_nand_dir = EmulationSession::GetInstance().System().GetFilesystem()->OpenDirectory(
+ Common::FS::PathToUTF8String(nand_dir), FileSys::Mode::Read);
+
+ Service::Account::ProfileManager manager;
+ const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
+ ASSERT(user_id);
+
+ const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
+ EmulationSession::GetInstance().System(), vfs_nand_dir, FileSys::SaveDataSpaceId::NandUser,
+ FileSys::SaveDataType::SaveData, 1, user_id->AsU128(), 0);
+
+ const auto full_path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path);
+ if (!Common::FS::CreateParentDirs(full_path)) {
+ LOG_WARNING(Frontend, "Failed to create full path of the default user's save directory");
+ }
+}
+
} // 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="?attr/materialCardViewOutlinedStyle"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginVertical="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:orientation="horizontal"
+ android:layout_gravity="center">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="16dp"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/title"
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/user_data"
+ android:textAlignment="viewStart" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/description"
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="6dp"
+ android:text="@string/user_data_description"
+ android:textAlignment="viewStart" />
+
+ </LinearLayout>
+
+ <Button
+ android:id="@+id/button_export"
+ style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:contentDescription="@string/export"
+ android:tooltipText="@string/export"
+ android:visibility="gone"
+ app:icon="@drawable/ic_export"
+ tools:visibility="visible" />
+
+ <Button
+ android:id="@+id/button_install"
+ style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_marginStart="12dp"
+ android:contentDescription="@string/string_import"
+ android:tooltipText="@string/string_import"
+ android:visibility="gone"
+ app:icon="@drawable/ic_import"
+ tools:visibility="visible" />
+
+ </LinearLayout>
+
+</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 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal">
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingVertical="16dp"
- android:paddingHorizontal="16dp"
- android:orientation="vertical"
- android:layout_weight="1">
-
- <com.google.android.material.textview.MaterialTextView
- style="@style/TextAppearance.Material3.TitleMedium"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="24dp"
- android:textAlignment="viewStart"
- android:text="@string/user_data" />
-
- <com.google.android.material.textview.MaterialTextView
- style="@style/TextAppearance.Material3.BodyMedium"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="24dp"
- android:layout_marginTop="6dp"
- android:textAlignment="viewStart"
- android:text="@string/user_data_description" />
-
- </LinearLayout>
-
- <Button
- android:id="@+id/button_import"
- style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_gravity="center_vertical"
- android:contentDescription="@string/string_import"
- android:tooltipText="@string/string_import"
- app:icon="@drawable/ic_import" />
-
- <Button
- android:id="@+id/button_export"
- style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="12dp"
- android:layout_marginEnd="24dp"
- android:layout_gravity="center_vertical"
- android:contentDescription="@string/export"
- android:tooltipText="@string/export"
- app:icon="@drawable/ic_export" />
-
- </LinearLayout>
-
- <com.google.android.material.divider.MaterialDivider
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="20dp" />
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_horizontal"
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/coordinator_licenses"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appbar_installables"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar_installables"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:title="@string/manage_yuzu_data"
+ app:navigationIcon="@drawable/ic_back" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/list_installables"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+</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 @@
<action
android:id="@+id/action_homeSettingsFragment_to_earlyAccessFragment"
app:destination="@id/earlyAccessFragment" />
+ <action
+ android:id="@+id/action_homeSettingsFragment_to_installableFragment"
+ app:destination="@id/installableFragment" />
</fragment>
<fragment
@@ -88,5 +91,9 @@
<action
android:id="@+id/action_global_settingsActivity"
app:destination="@id/settingsActivity" />
+ <fragment
+ android:id="@+id/installableFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.InstallableFragment"
+ android:label="InstallableFragment" />
</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 @@
<string name="manage_save_data">Speicherdaten verwalten</string>
<string name="manage_save_data_description">Speicherdaten gefunden. Bitte wähle unten eine Option aus.</string>
<string name="import_export_saves_description">Speicherdaten importieren oder exportieren</string>
- <string name="import_export_saves_no_profile">Keine Speicherdaten gefunden. Bitte starte ein Spiel und versuche es erneut.</string>
<string name="save_file_imported_success">Erfolgreich importiert</string>
<string name="save_file_invalid_zip_structure">Ungültige Speicherverzeichnisstruktur</string>
<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 @@
<string name="manage_save_data">Administrar datos de guardado</string>
<string name="manage_save_data_description">Guardar los datos encontrados. Por favor, seleccione una opción de abajo.</string>
<string name="import_export_saves_description">Importar o exportar archivos de guardado</string>
- <string name="import_export_saves_no_profile">No se han encontrado datos de guardado. Por favor, ejecute un juego y vuelva a intentarlo.</string>
<string name="save_file_imported_success">Importado correctamente</string>
<string name="save_file_invalid_zip_structure">Estructura del directorio de guardado no válido</string>
<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 @@
<string name="manage_save_data">Gérer les données de sauvegarde</string>
<string name="manage_save_data_description">Données de sauvegarde trouvées. Veuillez sélectionner une option ci-dessous.</string>
<string name="import_export_saves_description">Importer ou exporter des fichiers de sauvegarde</string>
- <string name="import_export_saves_no_profile">Aucune données de sauvegarde trouvées. Veuillez lancer un jeu et réessayer.</string>
<string name="save_file_imported_success">Importé avec succès</string>
<string name="save_file_invalid_zip_structure">Structure de répertoire de sauvegarde non valide</string>
<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 @@
<string name="manage_save_data">Gestisci i salvataggi</string>
<string name="manage_save_data_description">Salvataggio non trovato. Seleziona un\'opzione di seguito.</string>
<string name="import_export_saves_description">Importa o esporta i salvataggi</string>
- <string name="import_export_saves_no_profile">Nessun salvataggio trovato. Avvia un gioco e riprova.</string>
<string name="save_file_imported_success">Importato con successo</string>
<string name="save_file_invalid_zip_structure">La struttura della cartella dei salvataggi è invalida</string>
<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 @@
<string name="manage_save_data">セーブデータを管理</string>
<string name="manage_save_data_description">セーブデータが見つかりました。以下のオプションから選択してください。</string>
<string name="import_export_saves_description">セーブファイルをインポート/エクスポート</string>
- <string name="import_export_saves_no_profile">セーブデータがありません。ゲームを起動してから再度お試しください。</string>
<string name="save_file_imported_success">インポートが完了しました</string>
<string name="save_file_invalid_zip_structure">セーブデータのディレクトリ構造が無効です</string>
<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 @@
<string name="manage_save_data">저장 데이터 관리</string>
<string name="manage_save_data_description">데이터를 저장했습니다. 아래에서 옵션을 선택하세요.</string>
<string name="import_export_saves_description">저장 파일 가져오기 또는 내보내기</string>
- <string name="import_export_saves_no_profile">저장 데이터를 찾을 수 없습니다. 게임을 실행한 후 다시 시도하세요.</string>
<string name="save_file_imported_success">가져오기 성공</string>
<string name="save_file_invalid_zip_structure">저장 디렉터리 구조가 잘못됨</string>
<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 @@
<string name="manage_save_data">Administrere lagringsdata</string>
<string name="manage_save_data_description">Lagringsdata funnet. Velg et alternativ nedenfor.</string>
<string name="import_export_saves_description">Importer eller eksporter lagringsfiler</string>
- <string name="import_export_saves_no_profile">Ingen lagringsdata funnet. Start et nytt spill og prøv på nytt.</string>
<string name="save_file_imported_success">Vellykket import</string>
<string name="save_file_invalid_zip_structure">Ugyldig struktur for lagringskatalog</string>
<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 @@
<string name="manage_save_data">Zarządzaj plikami zapisów gier</string>
<string name="manage_save_data_description">Znaleziono pliki zapisów gier. Wybierz opcję poniżej.</string>
<string name="import_export_saves_description">Importuj lub wyeksportuj pliki zapisów</string>
- <string name="import_export_saves_no_profile">Nie znaleziono plików zapisów. Uruchom grę i spróbuj ponownie.</string>
<string name="save_file_imported_success">Zaimportowano pomyślnie</string>
<string name="save_file_invalid_zip_structure">Niepoprawna struktura folderów</string>
<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 @@
<string name="manage_save_data">Gerir dados guardados</string>
<string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string>
<string name="import_export_saves_description">Importa ou exporta dados guardados</string>
- <string name="import_export_saves_no_profile">Dados não encontrados. Por favor lança o jogo e tenta novamente.</string>
<string name="save_file_imported_success">Importado com sucesso</string>
<string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string>
<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 @@
<string name="manage_save_data">Gerir dados guardados</string>
<string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string>
<string name="import_export_saves_description">Importa ou exporta dados guardados</string>
- <string name="import_export_saves_no_profile">Dados não encontrados. Por favor lança o jogo e tenta novamente.</string>
<string name="save_file_imported_success">Importado com sucesso</string>
<string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string>
<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 @@
<string name="manage_save_data">Управление данными сохранений</string>
<string name="manage_save_data_description">Найдено данные сохранений. Пожалуйста, выберите вариант ниже.</string>
<string name="import_export_saves_description">Импорт или экспорт файлов сохранения</string>
- <string name="import_export_saves_no_profile">Данные сохранений не найдены. Пожалуйста, запустите игру и повторите попытку.</string>
<string name="save_file_imported_success">Успешно импортировано</string>
<string name="save_file_invalid_zip_structure">Недопустимая структура папки сохранения</string>
<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 @@
<string name="manage_save_data">Керування даними збережень</string>
<string name="manage_save_data_description">Знайдено дані збережень. Будь ласка, виберіть варіант нижче.</string>
<string name="import_export_saves_description">Імпорт або експорт файлів збереження</string>
- <string name="import_export_saves_no_profile">Дані збережень не знайдено. Будь ласка, запустіть гру та повторіть спробу.</string>
<string name="save_file_imported_success">Успішно імпортовано</string>
<string name="save_file_invalid_zip_structure">Неприпустима структура папки збереження</string>
<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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <integer name="grid_columns">2</integer>
+
+</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 @@
<string name="manage_save_data">管理存档数据</string>
<string name="manage_save_data_description">已找到存档数据,请选择下方的选项。</string>
<string name="import_export_saves_description">导入或导出存档</string>
- <string name="import_export_saves_no_profile">找不到存档数据,请启动游戏并重试。</string>
<string name="save_file_imported_success">已成功导入存档</string>
<string name="save_file_invalid_zip_structure">无效的存档目录</string>
<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 @@
<string name="manage_save_data">管理儲存資料</string>
<string name="manage_save_data_description">已找到儲存資料,請選取下方的選項。</string>
<string name="import_export_saves_description">匯入或匯出儲存檔案</string>
- <string name="import_export_saves_no_profile">找不到儲存資料,請啟動遊戲並重試。</string>
<string name="save_file_imported_success">已成功匯入</string>
<string name="save_file_invalid_zip_structure">無效的儲存目錄結構</string>
<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 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <integer name="game_title_lines">2</integer>
+ <integer name="grid_columns">1</integer>
<!-- Default SWITCH landscape layout -->
<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 034761401..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 @@
<string name="manage_save_data">Manage save data</string>
<string name="manage_save_data_description">Save data found. Please select an option below.</string>
<string name="import_export_saves_description">Import or export save files</string>
- <string name="import_export_saves_no_profile">No save data found. Please launch a game and retry.</string>
<string name="save_file_imported_success">Imported successfully</string>
<string name="save_file_invalid_zip_structure">Invalid save directory structure</string>
<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 @@
<string name="firmware_installing">Installing firmware</string>
<string name="firmware_installed_success">Firmware installed successfully</string>
<string name="firmware_installed_failure">Firmware installation failed</string>
- <string name="firmware_installed_failure_description">Verify that the ZIP contains valid firmware and try again.</string>
+ <string name="firmware_installed_failure_description">Make sure the firmware nca files are at the root of the zip and try again.</string>
<string name="share_log">Share debug logs</string>
<string name="share_log_description">Share yuzu\'s log file to debug issues</string>
<string name="share_log_missing">No log file found</string>
@@ -119,6 +118,10 @@
<string name="install_game_content_help_link">https://yuzu-emu.org/help/quickstart/#dumping-installed-updates</string>
<string name="custom_driver_not_supported">Custom drivers not supported</string>
<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>
+ <string name="manage_yuzu_data">Manage yuzu data</string>
+ <string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string>
+ <string name="share_save_file">Share save file</string>
+ <string name="export_save_failed">Failed to export save</string>
<!-- About screen strings -->
<string name="gaia_is_not_real">Gaia isn\'t real</string>
@@ -138,6 +141,7 @@
<string name="user_data_export_success">User data exported successfully</string>
<string name="user_data_import_success">User data imported successfully</string>
<string name="user_data_export_cancelled">Export cancelled</string>
+ <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>
<string name="support_link">https://discord.gg/u77vRWY</string>
<string name="website_link">https://yuzu-emu.org/</string>
<string name="github_link">https://github.com/yuzu-emu</string>
@@ -227,6 +231,8 @@
<string name="string_null">Null</string>
<string name="string_import">Import</string>
<string name="export">Export</string>
+ <string name="export_failed">Export failed</string>
+ <string name="import_failed">Import failed</string>
<string name="cancelling">Cancelling</string>
<!-- GPU driver installation -->