diff options
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 | |||
| 4 | package org.yuzu.yuzu_emu.adapters | ||
| 5 | |||
| 6 | import android.view.LayoutInflater | ||
| 7 | import android.view.View | ||
| 8 | import android.view.ViewGroup | ||
| 9 | import androidx.recyclerview.widget.RecyclerView | ||
| 10 | import org.yuzu.yuzu_emu.databinding.CardInstallableBinding | ||
| 11 | import org.yuzu.yuzu_emu.model.Installable | ||
| 12 | |||
| 13 | class 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 | |||
| 26 | import org.yuzu.yuzu_emu.R | 26 | import org.yuzu.yuzu_emu.R |
| 27 | import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding | 27 | import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding |
| 28 | import org.yuzu.yuzu_emu.model.HomeViewModel | 28 | import org.yuzu.yuzu_emu.model.HomeViewModel |
| 29 | import org.yuzu.yuzu_emu.ui.main.MainActivity | ||
| 30 | 29 | ||
| 31 | class AboutFragment : Fragment() { | 30 | class 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 | |||
| 4 | package org.yuzu.yuzu_emu.fragments | ||
| 5 | |||
| 6 | import android.app.Dialog | ||
| 7 | import android.content.Intent | ||
| 8 | import android.net.Uri | ||
| 9 | import android.os.Bundle | ||
| 10 | import android.provider.DocumentsContract | ||
| 11 | import android.widget.Toast | ||
| 12 | import androidx.activity.result.ActivityResultLauncher | ||
| 13 | import androidx.activity.result.contract.ActivityResultContracts | ||
| 14 | import androidx.appcompat.app.AppCompatActivity | ||
| 15 | import androidx.documentfile.provider.DocumentFile | ||
| 16 | import androidx.fragment.app.DialogFragment | ||
| 17 | import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||
| 18 | import java.io.BufferedOutputStream | ||
| 19 | import java.io.File | ||
| 20 | import java.io.FileOutputStream | ||
| 21 | import java.io.FilenameFilter | ||
| 22 | import java.time.LocalDateTime | ||
| 23 | import java.time.format.DateTimeFormatter | ||
| 24 | import java.util.zip.ZipEntry | ||
| 25 | import java.util.zip.ZipOutputStream | ||
| 26 | import kotlinx.coroutines.CoroutineScope | ||
| 27 | import kotlinx.coroutines.Dispatchers | ||
| 28 | import kotlinx.coroutines.launch | ||
| 29 | import kotlinx.coroutines.withContext | ||
| 30 | import org.yuzu.yuzu_emu.R | ||
| 31 | import org.yuzu.yuzu_emu.YuzuApplication | ||
| 32 | import org.yuzu.yuzu_emu.features.DocumentProvider | ||
| 33 | import org.yuzu.yuzu_emu.getPublicFilesDir | ||
| 34 | import org.yuzu.yuzu_emu.utils.FileUtil | ||
| 35 | |||
| 36 | class 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 @@ | |||
| 4 | package org.yuzu.yuzu_emu.fragments | 4 | package org.yuzu.yuzu_emu.fragments |
| 5 | 5 | ||
| 6 | import android.app.Dialog | 6 | import android.app.Dialog |
| 7 | import android.content.DialogInterface | ||
| 8 | import android.os.Bundle | 7 | import android.os.Bundle |
| 9 | import android.view.LayoutInflater | 8 | import android.view.LayoutInflater |
| 10 | import android.view.View | 9 | import android.view.View |
| 11 | import android.view.ViewGroup | 10 | import android.view.ViewGroup |
| 12 | import android.widget.Toast | 11 | import android.widget.Toast |
| 12 | import androidx.appcompat.app.AlertDialog | ||
| 13 | import androidx.appcompat.app.AppCompatActivity | 13 | import androidx.appcompat.app.AppCompatActivity |
| 14 | import androidx.fragment.app.DialogFragment | 14 | import androidx.fragment.app.DialogFragment |
| 15 | import androidx.fragment.app.activityViewModels | 15 | import 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 | |||
| 4 | package org.yuzu.yuzu_emu.fragments | ||
| 5 | |||
| 6 | import android.os.Bundle | ||
| 7 | import android.view.LayoutInflater | ||
| 8 | import android.view.View | ||
| 9 | import android.view.ViewGroup | ||
| 10 | import androidx.core.view.ViewCompat | ||
| 11 | import androidx.core.view.WindowInsetsCompat | ||
| 12 | import androidx.core.view.updatePadding | ||
| 13 | import androidx.fragment.app.Fragment | ||
| 14 | import androidx.fragment.app.activityViewModels | ||
| 15 | import androidx.navigation.findNavController | ||
| 16 | import androidx.recyclerview.widget.GridLayoutManager | ||
| 17 | import com.google.android.material.transition.MaterialSharedAxis | ||
| 18 | import org.yuzu.yuzu_emu.R | ||
| 19 | import org.yuzu.yuzu_emu.adapters.InstallableAdapter | ||
| 20 | import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding | ||
| 21 | import org.yuzu.yuzu_emu.model.HomeViewModel | ||
| 22 | import org.yuzu.yuzu_emu.model.Installable | ||
| 23 | import org.yuzu.yuzu_emu.ui.main.MainActivity | ||
| 24 | |||
| 25 | class 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 | |||
| 4 | package org.yuzu.yuzu_emu.model | ||
| 5 | |||
| 6 | import androidx.annotation.StringRes | ||
| 7 | |||
| 8 | data 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 | |||
| 54 | enum 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 | |||
| 6 | import android.content.Intent | 6 | import android.content.Intent |
| 7 | import android.net.Uri | 7 | import android.net.Uri |
| 8 | import android.os.Bundle | 8 | import android.os.Bundle |
| 9 | import android.provider.DocumentsContract | ||
| 9 | import android.view.View | 10 | import android.view.View |
| 10 | import android.view.ViewGroup.MarginLayoutParams | 11 | import android.view.ViewGroup.MarginLayoutParams |
| 11 | import android.view.WindowManager | 12 | import android.view.WindowManager |
| @@ -19,6 +20,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen | |||
| 19 | import androidx.core.view.ViewCompat | 20 | import androidx.core.view.ViewCompat |
| 20 | import androidx.core.view.WindowCompat | 21 | import androidx.core.view.WindowCompat |
| 21 | import androidx.core.view.WindowInsetsCompat | 22 | import androidx.core.view.WindowInsetsCompat |
| 23 | import androidx.documentfile.provider.DocumentFile | ||
| 22 | import androidx.lifecycle.Lifecycle | 24 | import androidx.lifecycle.Lifecycle |
| 23 | import androidx.lifecycle.lifecycleScope | 25 | import androidx.lifecycle.lifecycleScope |
| 24 | import androidx.lifecycle.repeatOnLifecycle | 26 | import androidx.lifecycle.repeatOnLifecycle |
| @@ -29,6 +31,7 @@ import androidx.preference.PreferenceManager | |||
| 29 | import com.google.android.material.color.MaterialColors | 31 | import com.google.android.material.color.MaterialColors |
| 30 | import com.google.android.material.dialog.MaterialAlertDialogBuilder | 32 | import com.google.android.material.dialog.MaterialAlertDialogBuilder |
| 31 | import com.google.android.material.navigation.NavigationBarView | 33 | import com.google.android.material.navigation.NavigationBarView |
| 34 | import kotlinx.coroutines.CoroutineScope | ||
| 32 | import java.io.File | 35 | import java.io.File |
| 33 | import java.io.FilenameFilter | 36 | import java.io.FilenameFilter |
| 34 | import java.io.IOException | 37 | import java.io.IOException |
| @@ -41,20 +44,23 @@ import org.yuzu.yuzu_emu.R | |||
| 41 | import org.yuzu.yuzu_emu.activities.EmulationActivity | 44 | import org.yuzu.yuzu_emu.activities.EmulationActivity |
| 42 | import org.yuzu.yuzu_emu.databinding.ActivityMainBinding | 45 | import org.yuzu.yuzu_emu.databinding.ActivityMainBinding |
| 43 | import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding | 46 | import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding |
| 47 | import org.yuzu.yuzu_emu.features.DocumentProvider | ||
| 44 | import org.yuzu.yuzu_emu.features.settings.model.Settings | 48 | import org.yuzu.yuzu_emu.features.settings.model.Settings |
| 45 | import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment | 49 | import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment |
| 46 | import org.yuzu.yuzu_emu.fragments.MessageDialogFragment | 50 | import org.yuzu.yuzu_emu.fragments.MessageDialogFragment |
| 51 | import org.yuzu.yuzu_emu.getPublicFilesDir | ||
| 47 | import org.yuzu.yuzu_emu.model.GamesViewModel | 52 | import org.yuzu.yuzu_emu.model.GamesViewModel |
| 48 | import org.yuzu.yuzu_emu.model.HomeViewModel | 53 | import org.yuzu.yuzu_emu.model.HomeViewModel |
| 54 | import org.yuzu.yuzu_emu.model.TaskState | ||
| 49 | import org.yuzu.yuzu_emu.model.TaskViewModel | 55 | import org.yuzu.yuzu_emu.model.TaskViewModel |
| 50 | import org.yuzu.yuzu_emu.utils.* | 56 | import org.yuzu.yuzu_emu.utils.* |
| 51 | import java.io.BufferedInputStream | 57 | import java.io.BufferedInputStream |
| 52 | import java.io.BufferedOutputStream | 58 | import java.io.BufferedOutputStream |
| 53 | import java.io.FileInputStream | ||
| 54 | import java.io.FileOutputStream | 59 | import java.io.FileOutputStream |
| 60 | import java.time.LocalDateTime | ||
| 61 | import java.time.format.DateTimeFormatter | ||
| 55 | import java.util.zip.ZipEntry | 62 | import java.util.zip.ZipEntry |
| 56 | import java.util.zip.ZipInputStream | 63 | import java.util.zip.ZipInputStream |
| 57 | import java.util.zip.ZipOutputStream | ||
| 58 | 64 | ||
| 59 | class MainActivity : AppCompatActivity(), ThemeProvider { | 65 | class 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 | |||
| 8 | import android.net.Uri | 8 | import android.net.Uri |
| 9 | import android.provider.DocumentsContract | 9 | import android.provider.DocumentsContract |
| 10 | import androidx.documentfile.provider.DocumentFile | 10 | import androidx.documentfile.provider.DocumentFile |
| 11 | import kotlinx.coroutines.flow.StateFlow | ||
| 11 | import java.io.BufferedInputStream | 12 | import java.io.BufferedInputStream |
| 12 | import java.io.File | 13 | import java.io.File |
| 13 | import java.io.FileOutputStream | 14 | import java.io.FileOutputStream |
| @@ -18,6 +19,9 @@ import java.util.zip.ZipEntry | |||
| 18 | import java.util.zip.ZipInputStream | 19 | import java.util.zip.ZipInputStream |
| 19 | import org.yuzu.yuzu_emu.YuzuApplication | 20 | import org.yuzu.yuzu_emu.YuzuApplication |
| 20 | import org.yuzu.yuzu_emu.model.MinimalDocumentFile | 21 | import org.yuzu.yuzu_emu.model.MinimalDocumentFile |
| 22 | import org.yuzu.yuzu_emu.model.TaskState | ||
| 23 | import java.io.BufferedOutputStream | ||
| 24 | import java.util.zip.ZipOutputStream | ||
| 21 | 25 | ||
| 22 | object FileUtil { | 26 | object 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 | ||
| 886 | void 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 --> |