From 1e740df9b841c8e8f0bdb4bec268e44d5bf5b024 Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Tue, 19 Sep 2023 11:33:54 -0400 Subject: android: Add import/export buttons for user data --- .../yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt | 1 + .../org/yuzu/yuzu_emu/fragments/AboutFragment.kt | 7 ++ .../fragments/ImportExportSavesFragment.kt | 1 + .../IndeterminateProgressDialogFragment.kt | 59 +++++++--- .../yuzu_emu/fragments/MessageDialogFragment.kt | 19 +++- .../yuzu/yuzu_emu/model/MessageDialogViewModel.kt | 14 +++ .../java/org/yuzu/yuzu_emu/model/TaskViewModel.kt | 8 ++ .../java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 123 ++++++++++++++++++++- .../app/src/main/res/drawable/ic_export.xml | 9 ++ .../app/src/main/res/drawable/ic_import.xml | 9 ++ .../src/main/res/layout/dialog_progress_bar.xml | 28 +---- .../app/src/main/res/layout/fragment_about.xml | 61 ++++++++++ src/android/app/src/main/res/values/strings.xml | 12 ++ 13 files changed, 311 insertions(+), 40 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt create mode 100644 src/android/app/src/main/res/drawable/ic_export.xml create mode 100644 src/android/app/src/main/res/drawable/ic_import.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt index 1675627a1..58ce343f4 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt @@ -49,6 +49,7 @@ class HomeSettingAdapter( holder.option.onClick.invoke() } else { MessageDialogFragment.newInstance( + activity, titleId = holder.option.disabledTitleId, descriptionId = holder.option.disabledMessageId ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) 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 2ff827c6b..7b8f99872 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,6 +26,7 @@ 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 @@ -92,6 +93,12 @@ 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/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt index f38aeea53..ee2d44718 100644 --- 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 @@ -187,6 +187,7 @@ class ImportExportSavesFragment : DialogFragment() { 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) 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 18bc34b9f..0d16a7d37 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,6 +4,7 @@ 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 @@ -18,6 +19,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding import org.yuzu.yuzu_emu.model.TaskViewModel @@ -28,19 +30,27 @@ class IndeterminateProgressDialogFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val titleId = requireArguments().getInt(TITLE) + val cancellable = requireArguments().getBoolean(CANCELLABLE) binding = DialogProgressBarBinding.inflate(layoutInflater) binding.progressBar.isIndeterminate = true val dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle(titleId) .setView(binding.root) - .create() - dialog.setCanceledOnTouchOutside(false) + + if (cancellable) { + dialog.setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> + taskViewModel.setCancelled(true) + } + } + + val alertDialog = dialog.create() + alertDialog.setCanceledOnTouchOutside(false) if (!taskViewModel.isRunning.value) { taskViewModel.runTask() } - return dialog + return alertDialog } override fun onCreateView( @@ -53,21 +63,35 @@ class IndeterminateProgressDialogFragment : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - taskViewModel.isComplete.collect { - if (it) { - dismiss() - when (val result = taskViewModel.result.value) { - is String -> Toast.makeText(requireContext(), result, Toast.LENGTH_LONG) - .show() + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.isComplete.collect { + if (it) { + dismiss() + when (val result = taskViewModel.result.value) { + is String -> Toast.makeText( + requireContext(), + result, + Toast.LENGTH_LONG + ).show() - is MessageDialogFragment -> result.show( - requireActivity().supportFragmentManager, - MessageDialogFragment.TAG - ) + is MessageDialogFragment -> result.show( + requireActivity().supportFragmentManager, + MessageDialogFragment.TAG + ) + } + taskViewModel.clear() + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.cancelled.collect { + if (it) { + dialog?.setTitle(R.string.cancelling) } - taskViewModel.clear() } } } @@ -78,16 +102,19 @@ class IndeterminateProgressDialogFragment : DialogFragment() { const val TAG = "IndeterminateProgressDialogFragment" private const val TITLE = "Title" + private const val CANCELLABLE = "Cancellable" fun newInstance( activity: AppCompatActivity, titleId: Int, + cancellable: Boolean = false, task: () -> Any ): IndeterminateProgressDialogFragment { val dialog = IndeterminateProgressDialogFragment() val args = Bundle() ViewModelProvider(activity)[TaskViewModel::class.java].task = task args.putInt(TITLE, titleId) + args.putBoolean(CANCELLABLE, cancellable) dialog.arguments = args return dialog } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt index 7d1c2c8dd..541b22f47 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt @@ -4,14 +4,21 @@ package org.yuzu.yuzu_emu.fragments import android.app.Dialog +import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.model.MessageDialogViewModel class MessageDialogFragment : DialogFragment() { + private val messageDialogViewModel: MessageDialogViewModel by activityViewModels() + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val titleId = requireArguments().getInt(TITLE_ID) val titleString = requireArguments().getString(TITLE_STRING)!! @@ -37,6 +44,12 @@ class MessageDialogFragment : DialogFragment() { return dialog.show() } + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + messageDialogViewModel.dismissAction.invoke() + messageDialogViewModel.clear() + } + private fun openLink(link: String) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) startActivity(intent) @@ -52,11 +65,13 @@ class MessageDialogFragment : DialogFragment() { private const val HELP_LINK = "Link" fun newInstance( + activity: FragmentActivity, titleId: Int = 0, titleString: String = "", descriptionId: Int = 0, descriptionString: String = "", - helpLinkId: Int = 0 + helpLinkId: Int = 0, + dismissAction: () -> Unit = {} ): MessageDialogFragment { val dialog = MessageDialogFragment() val bundle = Bundle() @@ -67,6 +82,8 @@ class MessageDialogFragment : DialogFragment() { putString(DESCRIPTION_STRING, descriptionString) putInt(HELP_LINK, helpLinkId) } + ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction = + dismissAction dialog.arguments = bundle return dialog } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt new file mode 100644 index 000000000..36ffd08d2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel + +class MessageDialogViewModel : ViewModel() { + var dismissAction: () -> Unit = {} + + fun clear() { + dismissAction = {} + } +} 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 531c2aaf0..d6418a666 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 @@ -20,12 +20,20 @@ class TaskViewModel : ViewModel() { val isRunning: StateFlow get() = _isRunning private val _isRunning = MutableStateFlow(false) + val cancelled: StateFlow get() = _cancelled + private val _cancelled = MutableStateFlow(false) + lateinit var task: () -> Any fun clear() { _result.value = Any() _isComplete.value = false _isRunning.value = false + _cancelled.value = false + } + + fun setCancelled(value: Boolean) { + _cancelled.value = value } fun runTask() { 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 b6b6c6c17..74941f934 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 @@ -46,13 +46,21 @@ import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment import org.yuzu.yuzu_emu.fragments.MessageDialogFragment import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.TaskViewModel import org.yuzu.yuzu_emu.utils.* +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream class MainActivity : AppCompatActivity(), ThemeProvider { private lateinit var binding: ActivityMainBinding private val homeViewModel: HomeViewModel by viewModels() private val gamesViewModel: GamesViewModel by viewModels() + private val taskViewModel: TaskViewModel by viewModels() override var themeId: Int = 0 @@ -307,6 +315,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { fun processKey(result: Uri): Boolean { if (FileUtil.getExtension(result) != "keys") { MessageDialogFragment.newInstance( + this, titleId = R.string.reading_keys_failure, descriptionId = R.string.install_prod_keys_failure_extension_description ).show(supportFragmentManager, MessageDialogFragment.TAG) @@ -336,6 +345,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return true } else { MessageDialogFragment.newInstance( + this, titleId = R.string.invalid_keys_error, descriptionId = R.string.install_keys_failure_description, helpLinkId = R.string.dumping_keys_quickstart_link @@ -376,6 +386,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { MessageDialogFragment.newInstance( + this, titleId = R.string.firmware_installed_failure, descriptionId = R.string.firmware_installed_failure_description ) @@ -395,7 +406,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { IndeterminateProgressDialogFragment.newInstance( this, R.string.firmware_installing, - task + task = task ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) } @@ -407,6 +418,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (FileUtil.getExtension(result) != "bin") { MessageDialogFragment.newInstance( + this, titleId = R.string.reading_keys_failure, descriptionId = R.string.install_amiibo_keys_failure_extension_description ).show(supportFragmentManager, MessageDialogFragment.TAG) @@ -434,6 +446,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { ).show() } else { MessageDialogFragment.newInstance( + this, titleId = R.string.invalid_keys_error, descriptionId = R.string.install_keys_failure_description, helpLinkId = R.string.dumping_keys_quickstart_link @@ -583,12 +596,14 @@ class MainActivity : AppCompatActivity(), ThemeProvider { installResult.append(separator) } return@newInstance MessageDialogFragment.newInstance( + this, titleId = R.string.install_game_content_failure, descriptionString = installResult.toString().trim(), helpLinkId = R.string.install_game_content_help_link ) } else { return@newInstance MessageDialogFragment.newInstance( + this, titleId = R.string.install_game_content_success, descriptionString = installResult.toString().trim() ) @@ -596,4 +611,110 @@ class MainActivity : AppCompatActivity(), ThemeProvider { }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) } } + + val exportUserData = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + this, + R.string.exporting_user_data, + true + ) { + val zos = ZipOutputStream( + BufferedOutputStream(contentResolver.openOutputStream(result)) + ) + 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)) + stream.write(file.readBytes()) + stream.closeEntry() + } + } + } + return@newInstance getString(R.string.user_data_export_success) + }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + } + + val importUserData = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + this, + R.string.importing_user_data + ) { + val checkStream = + ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) + var isYuzuBackup = false + checkStream.use { stream -> + var ze: ZipEntry? = null + while (stream.nextEntry?.also { ze = it } != null) { + if (ze!!.name.trim() == "/config/config.ini") { + isYuzuBackup = true + return@use + } + } + } + if (!isYuzuBackup) { + return@newInstance getString(R.string.invalid_yuzu_backup) + } + + 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 + } + } + + // Reinitialize relevant data + NativeLibrary.initializeEmulation() + gamesViewModel.reloadGames(false) + + return@newInstance getString(R.string.user_data_import_success) + }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + } } diff --git a/src/android/app/src/main/res/drawable/ic_export.xml b/src/android/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 000000000..463d2f41c --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_import.xml b/src/android/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 000000000..3a99dd5e6 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_import.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml index d17711a65..0209ea082 100644 --- a/src/android/app/src/main/res/layout/dialog_progress_bar.xml +++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml @@ -1,24 +1,8 @@ - - - - - - - + android:id="@+id/progress_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="24dp" + app:trackCornerRadius="4dp" /> 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 3e1d98451..36b350338 100644 --- a/src/android/app/src/main/res/layout/fragment_about.xml +++ b/src/android/app/src/main/res/layout/fragment_about.xml @@ -176,6 +176,67 @@ + + + + + + + + + + + + +