diff options
40 files changed, 700 insertions, 460 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 21f67f32a..6e39e542b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -247,7 +247,12 @@ object NativeLibrary { external fun setAppDirectory(directory: String) - external fun installFileToNand(filename: String): Int + /** + * Installs a nsp or xci file to nand + * @param filename String representation of file uri + * @param extension Lowercase string representation of file extension without "." + */ + external fun installFileToNand(filename: String, extension: String): Int external fun initializeGpuDriver( hookLibDir: String?, @@ -512,6 +517,11 @@ object NativeLibrary { external fun submitInlineKeyboardInput(key_code: Int) /** + * Creates a generic user directory if it doesn't exist already + */ + external fun initializeEmptyUserDirectory() + + /** * Button type for use in onTouchEvent */ object ButtonType { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt new file mode 100644 index 000000000..e960fbaab --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.databinding.CardInstallableBinding +import org.yuzu.yuzu_emu.model.Installable + +class InstallableAdapter(private val installables: List<Installable>) : + RecyclerView.Adapter<InstallableAdapter.InstallableViewHolder>() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): InstallableAdapter.InstallableViewHolder { + val binding = + CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return InstallableViewHolder(binding) + } + + override fun getItemCount(): Int = installables.size + + override fun onBindViewHolder(holder: InstallableAdapter.InstallableViewHolder, position: Int) = + holder.bind(installables[position]) + + inner class InstallableViewHolder(val binding: CardInstallableBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var installable: Installable + + fun bind(installable: Installable) { + this.installable = installable + + binding.title.setText(installable.titleId) + binding.description.setText(installable.descriptionId) + + if (installable.install != null) { + binding.buttonInstall.visibility = View.VISIBLE + binding.buttonInstall.setOnClickListener { installable.install.invoke() } + } + if (installable.export != null) { + binding.buttonExport.visibility = View.VISIBLE + binding.buttonExport.setOnClickListener { installable.export.invoke() } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt index 4d2f2f604..c73edd50e 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt @@ -21,6 +21,7 @@ import androidx.navigation.navArgs import com.google.android.material.color.MaterialColors import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.NativeLibrary import java.io.IOException import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding @@ -168,7 +169,7 @@ class SettingsActivity : AppCompatActivity() { if (!settingsFile.delete()) { throw IOException("Failed to delete $settingsFile") } - Settings.settingsList.forEach { it.reset() } + NativeLibrary.reloadSettings() Toast.makeText( applicationContext, @@ -181,12 +182,14 @@ class SettingsActivity : AppCompatActivity() { private fun setInsets() { ViewCompat.setOnApplyWindowInsetsListener( binding.navigationBarShade - ) { view: View, windowInsets: WindowInsetsCompat -> + ) { _: View, windowInsets: WindowInsetsCompat -> val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val mlpShade = view.layoutParams as MarginLayoutParams - mlpShade.height = barInsets.bottom - view.layoutParams = mlpShade + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade windowInsets } 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/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 750638bc9..e6ad2aa77 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -17,6 +17,7 @@ import android.os.Handler import android.os.Looper import android.view.* import android.widget.TextView +import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.widget.PopupMenu import androidx.core.content.res.ResourcesCompat @@ -53,6 +54,7 @@ import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.EmulationViewModel import org.yuzu.yuzu_emu.overlay.InputOverlay import org.yuzu.yuzu_emu.utils.* +import java.lang.NullPointerException class EmulationFragment : Fragment(), SurfaceHolder.Callback { private lateinit var preferences: SharedPreferences @@ -104,10 +106,21 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { null } } - game = if (args.game != null) { - args.game!! - } else { - intentGame ?: error("[EmulationFragment] No bootable game present!") + + try { + game = if (args.game != null) { + args.game!! + } else { + intentGame!! + } + } catch (e: NullPointerException) { + Toast.makeText( + requireContext(), + R.string.no_game_present, + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return } // So this fragment doesn't restart on configuration changes; i.e. rotation. @@ -131,6 +144,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // This is using the correct scope, lint is just acting up @SuppressLint("UnsafeRepeatOnLifecycleDetector") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (requireActivity().isFinishing) { + return + } + binding.surfaceEmulation.holder.addCallback(this) binding.showFpsText.setTextColor(Color.YELLOW) binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } @@ -286,25 +304,23 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) + if (_binding == null) { + return + } + updateScreenLayout() if (emulationActivity?.isInPictureInPictureMode == true) { if (binding.drawerLayout.isOpen) { binding.drawerLayout.close() } if (EmulationMenuSettings.showOverlay) { - binding.surfaceInputOverlay.post { - binding.surfaceInputOverlay.visibility = View.INVISIBLE - } + binding.surfaceInputOverlay.visibility = View.INVISIBLE } } else { if (EmulationMenuSettings.showOverlay && emulationViewModel.emulationStarted.value) { - binding.surfaceInputOverlay.post { - binding.surfaceInputOverlay.visibility = View.VISIBLE - } + binding.surfaceInputOverlay.visibility = View.VISIBLE } else { - binding.surfaceInputOverlay.post { - binding.surfaceInputOverlay.visibility = View.INVISIBLE - } + binding.surfaceInputOverlay.visibility = View.INVISIBLE } if (!isInFoldableLayout) { if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index c119e69c9..8923c0ea2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -118,18 +118,13 @@ class HomeSettingsFragment : Fragment() { ) 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/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt index fbb2f6e18..c66bb635a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt @@ -295,8 +295,10 @@ class SetupFragment : Fragment() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) - outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) + if (_binding != null) { + outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) + outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) + } outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) } @@ -353,11 +355,15 @@ class SetupFragment : Fragment() { } fun pageForward() { - binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1 + if (_binding != null) { + binding.viewPager2.currentItem += 1 + } } fun pageBackward() { - binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1 + if (_binding != null) { + binding.viewPager2.currentItem -= 1 + } } fun setPageWarned(page: Int) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt new file mode 100644 index 000000000..36a7c97b8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt @@ -0,0 +1,13 @@ +// 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/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt index c055c2e35..a13faf3c7 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt @@ -352,7 +352,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : } private fun addOverlayControls(layout: String) { - val windowSize = getSafeScreenSize(context) + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) if (preferences.getBoolean(Settings.PREF_BUTTON_A, true)) { overlayButtons.add( initializeOverlayButton( @@ -593,7 +593,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : } private fun saveControlPosition(prefId: String, x: Int, y: Int, layout: String) { - val windowSize = getSafeScreenSize(context) + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) val min = windowSize.first val max = windowSize.second PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() @@ -968,14 +968,17 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : * @return A pair of points, the first being the top left corner of the safe area, * the second being the bottom right corner of the safe area */ - private fun getSafeScreenSize(context: Context): Pair<Point, Point> { + private fun getSafeScreenSize( + context: Context, + screenSize: Pair<Int, Int> + ): Pair<Point, Point> { // Get screen size val windowMetrics = WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(context as Activity) - var maxY = windowMetrics.bounds.height().toFloat() - var maxX = windowMetrics.bounds.width().toFloat() - var minY = 0 + var maxX = screenSize.first.toFloat() + var maxY = screenSize.second.toFloat() var minX = 0 + var minY = 0 // If we have API access, calculate the safe area to draw the overlay var cutoutLeft = 0 diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 6fa847631..0fa5df5e5 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.ui.main 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) { @@ -515,7 +528,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (documents.isNotEmpty()) { IndeterminateProgressDialogFragment.newInstance( this@MainActivity, - R.string.install_game_content + R.string.installing_game_content ) { var installSuccess = 0 var installOverwrite = 0 @@ -523,7 +536,12 @@ class MainActivity : AppCompatActivity(), ThemeProvider { var errorExtension = 0 var errorOther = 0 documents.forEach { - when (NativeLibrary.installFileToNand(it.toString())) { + when ( + NativeLibrary.installFileToNand( + it.toString(), + FileUtil.getExtension(it) + ) + ) { NativeLibrary.InstallFileToNandResult.Success -> { installSuccess += 1 } @@ -625,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) } @@ -681,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 @@ -727,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 f31fe054b..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> @@ -102,7 +104,7 @@ public: m_native_window = native_window; } - int InstallFileToNand(std::string filename) { + int InstallFileToNand(std::string filename, std::string file_extension) { jconst copy_func = [](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest, std::size_t block_size) { if (src == nullptr || dest == nullptr) { @@ -134,12 +136,12 @@ public: m_system.GetFileSystemController().CreateFactories(*m_vfs); [[maybe_unused]] std::shared_ptr<FileSys::NSP> nsp; - if (filename.ends_with("nsp")) { + if (file_extension == "nsp") { nsp = std::make_shared<FileSys::NSP>(m_vfs->OpenFile(filename, FileSys::Mode::Read)); if (nsp->IsExtractedType()) { return InstallError; } - } else if (filename.ends_with("xci")) { + } else if (file_extension == "xci") { jconst xci = std::make_shared<FileSys::XCI>(m_vfs->OpenFile(filename, FileSys::Mode::Read)); nsp = xci->GetSecurePartitionNSP(); @@ -607,8 +609,10 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject } int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject instance, - [[maybe_unused]] jstring j_file) { - return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file)); + jstring j_file, + jstring j_file_extension) { + return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file), + GetJString(env, j_file_extension)); } void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz, @@ -879,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/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml index 8a026a30a..a187665f2 100644 --- a/src/android/app/src/main/res/layout/activity_settings.xml +++ b/src/android/app/src/main/res/layout/activity_settings.xml @@ -22,7 +22,7 @@ <View android:id="@+id/navigation_bar_shade" - android:layout_width="match_parent" + android:layout_width="0dp" android:layout_height="1px" android:background="@android:color/transparent" android:clickable="false" diff --git a/src/android/app/src/main/res/layout/card_installable.xml b/src/android/app/src/main/res/layout/card_installable.xml new file mode 100644 index 000000000..f5b0e3741 --- /dev/null +++ b/src/android/app/src/main/res/layout/card_installable.xml @@ -0,0 +1,71 @@ +<?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_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml index da97d85c1..750ce094a 100644 --- a/src/android/app/src/main/res/layout/fragment_emulation.xml +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -32,7 +32,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:focusable="false"> + android:focusable="false" + android:clickable="false"> <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/loading_layout" @@ -155,7 +156,7 @@ android:id="@+id/in_game_menu" android:layout_width="wrap_content" android:layout_height="match_parent" - android:layout_gravity="start|bottom" + android:layout_gravity="start" app:headerLayout="@layout/header_in_game" app:menu="@menu/menu_in_game" tools:visibility="gone" /> diff --git a/src/android/app/src/main/res/layout/fragment_installables.xml b/src/android/app/src/main/res/layout/fragment_installables.xml new file mode 100644 index 000000000..3a4df81a6 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_installables.xml @@ -0,0 +1,31 @@ +<?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 0730143bd..e51edf872 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -90,7 +90,6 @@ <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,12 +100,13 @@ <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> <string name="install_game_content">Install game content</string> <string name="install_game_content_description">Install game updates or DLC</string> + <string name="installing_game_content">Installing content…</string> <string name="install_game_content_failure">Error installing file(s) to NAND</string> <string name="install_game_content_failure_description">Please ensure content(s) are valid and that the prod.keys file is installed.</string> <string name="install_game_content_failure_base">Installation of base games isn\'t permitted in order to avoid possible conflicts.</string> @@ -118,6 +118,10 @@ <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> @@ -137,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> @@ -226,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 --> @@ -293,6 +300,7 @@ <string name="performance_warning">Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled.</string> <string name="device_memory_inadequate">Device RAM: %1$s\nRecommended: %2$s</string> <string name="memory_formatted">%1$s %2$s</string> + <string name="no_game_present">No bootable game present!</string> <!-- Region Names --> <string name="region_japan">Japan</string> diff --git a/src/common/settings_setting.h b/src/common/settings_setting.h index 7be6f26f7..3175ab07d 100644 --- a/src/common/settings_setting.h +++ b/src/common/settings_setting.h @@ -187,6 +187,8 @@ public: this->SetValue(input == "true"); } else if constexpr (std::is_same_v<Type, float>) { this->SetValue(std::stof(input)); + } else if constexpr (std::is_same_v<Type, AudioEngine>) { + this->SetValue(ToEnum<AudioEngine>(input)); } else { this->SetValue(static_cast<Type>(std::stoll(input))); } |