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