Răsfoiți Sursa

Merge pull request #11543 from t895/import-export-user-data

android: Add import/export buttons for user data
Charles Lombardo 2 ani în urmă
părinte
comite
1fae4a01a8

+ 1 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt

@@ -49,6 +49,7 @@ class HomeSettingAdapter(
             holder.option.onClick.invoke()
             holder.option.onClick.invoke()
         } else {
         } else {
             MessageDialogFragment.newInstance(
             MessageDialogFragment.newInstance(
+                activity,
                 titleId = holder.option.disabledTitleId,
                 titleId = holder.option.disabledTitleId,
                 descriptionId = holder.option.disabledMessageId
                 descriptionId = holder.option.disabledMessageId
             ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
             ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)

+ 7 - 0
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.R
 import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
 import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
 import org.yuzu.yuzu_emu.model.HomeViewModel
 import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.ui.main.MainActivity
 
 
 class AboutFragment : Fragment() {
 class AboutFragment : Fragment() {
     private var _binding: FragmentAboutBinding? = null
     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.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
         binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
         binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
         binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
         binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }

+ 1 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt

@@ -187,6 +187,7 @@ class ImportExportSavesFragment : DialogFragment() {
                 withContext(Dispatchers.Main) {
                 withContext(Dispatchers.Main) {
                     if (!validZip) {
                     if (!validZip) {
                         MessageDialogFragment.newInstance(
                         MessageDialogFragment.newInstance(
+                            requireActivity(),
                             titleId = R.string.save_file_invalid_zip_structure,
                             titleId = R.string.save_file_invalid_zip_structure,
                             descriptionId = R.string.save_file_invalid_zip_structure_description
                             descriptionId = R.string.save_file_invalid_zip_structure_description
                         ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
                         ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)

+ 43 - 16
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt

@@ -4,6 +4,7 @@
 package org.yuzu.yuzu_emu.fragments
 package org.yuzu.yuzu_emu.fragments
 
 
 import android.app.Dialog
 import android.app.Dialog
+import android.content.DialogInterface
 import android.os.Bundle
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.LayoutInflater
 import android.view.View
 import android.view.View
@@ -18,6 +19,7 @@ import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
 import androidx.lifecycle.repeatOnLifecycle
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
 import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
 import org.yuzu.yuzu_emu.model.TaskViewModel
 import org.yuzu.yuzu_emu.model.TaskViewModel
 
 
@@ -28,19 +30,27 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
 
 
     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
         val titleId = requireArguments().getInt(TITLE)
         val titleId = requireArguments().getInt(TITLE)
+        val cancellable = requireArguments().getBoolean(CANCELLABLE)
 
 
         binding = DialogProgressBarBinding.inflate(layoutInflater)
         binding = DialogProgressBarBinding.inflate(layoutInflater)
         binding.progressBar.isIndeterminate = true
         binding.progressBar.isIndeterminate = true
         val dialog = MaterialAlertDialogBuilder(requireContext())
         val dialog = MaterialAlertDialogBuilder(requireContext())
             .setTitle(titleId)
             .setTitle(titleId)
             .setView(binding.root)
             .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) {
         if (!taskViewModel.isRunning.value) {
             taskViewModel.runTask()
             taskViewModel.runTask()
         }
         }
-        return dialog
+        return alertDialog
     }
     }
 
 
     override fun onCreateView(
     override fun onCreateView(
@@ -53,21 +63,35 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
 
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
         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"
         const val TAG = "IndeterminateProgressDialogFragment"
 
 
         private const val TITLE = "Title"
         private const val TITLE = "Title"
+        private const val CANCELLABLE = "Cancellable"
 
 
         fun newInstance(
         fun newInstance(
             activity: AppCompatActivity,
             activity: AppCompatActivity,
             titleId: Int,
             titleId: Int,
+            cancellable: Boolean = false,
             task: () -> Any
             task: () -> Any
         ): IndeterminateProgressDialogFragment {
         ): IndeterminateProgressDialogFragment {
             val dialog = IndeterminateProgressDialogFragment()
             val dialog = IndeterminateProgressDialogFragment()
             val args = Bundle()
             val args = Bundle()
             ViewModelProvider(activity)[TaskViewModel::class.java].task = task
             ViewModelProvider(activity)[TaskViewModel::class.java].task = task
             args.putInt(TITLE, titleId)
             args.putInt(TITLE, titleId)
+            args.putBoolean(CANCELLABLE, cancellable)
             dialog.arguments = args
             dialog.arguments = args
             return dialog
             return dialog
         }
         }

+ 18 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt

@@ -4,14 +4,21 @@
 package org.yuzu.yuzu_emu.fragments
 package org.yuzu.yuzu_emu.fragments
 
 
 import android.app.Dialog
 import android.app.Dialog
+import android.content.DialogInterface
 import android.content.Intent
 import android.content.Intent
 import android.net.Uri
 import android.net.Uri
 import android.os.Bundle
 import android.os.Bundle
 import androidx.fragment.app.DialogFragment
 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.model.MessageDialogViewModel
 
 
 class MessageDialogFragment : DialogFragment() {
 class MessageDialogFragment : DialogFragment() {
+    private val messageDialogViewModel: MessageDialogViewModel by activityViewModels()
+
     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
         val titleId = requireArguments().getInt(TITLE_ID)
         val titleId = requireArguments().getInt(TITLE_ID)
         val titleString = requireArguments().getString(TITLE_STRING)!!
         val titleString = requireArguments().getString(TITLE_STRING)!!
@@ -37,6 +44,12 @@ class MessageDialogFragment : DialogFragment() {
         return dialog.show()
         return dialog.show()
     }
     }
 
 
+    override fun onDismiss(dialog: DialogInterface) {
+        super.onDismiss(dialog)
+        messageDialogViewModel.dismissAction.invoke()
+        messageDialogViewModel.clear()
+    }
+
     private fun openLink(link: String) {
     private fun openLink(link: String) {
         val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
         val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
         startActivity(intent)
         startActivity(intent)
@@ -52,11 +65,13 @@ class MessageDialogFragment : DialogFragment() {
         private const val HELP_LINK = "Link"
         private const val HELP_LINK = "Link"
 
 
         fun newInstance(
         fun newInstance(
+            activity: FragmentActivity,
             titleId: Int = 0,
             titleId: Int = 0,
             titleString: String = "",
             titleString: String = "",
             descriptionId: Int = 0,
             descriptionId: Int = 0,
             descriptionString: String = "",
             descriptionString: String = "",
-            helpLinkId: Int = 0
+            helpLinkId: Int = 0,
+            dismissAction: () -> Unit = {}
         ): MessageDialogFragment {
         ): MessageDialogFragment {
             val dialog = MessageDialogFragment()
             val dialog = MessageDialogFragment()
             val bundle = Bundle()
             val bundle = Bundle()
@@ -67,6 +82,8 @@ class MessageDialogFragment : DialogFragment() {
                 putString(DESCRIPTION_STRING, descriptionString)
                 putString(DESCRIPTION_STRING, descriptionString)
                 putInt(HELP_LINK, helpLinkId)
                 putInt(HELP_LINK, helpLinkId)
             }
             }
+            ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction =
+                dismissAction
             dialog.arguments = bundle
             dialog.arguments = bundle
             return dialog
             return dialog
         }
         }

+ 14 - 0
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 = {}
+    }
+}

+ 8 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt

@@ -20,12 +20,20 @@ class TaskViewModel : ViewModel() {
     val isRunning: StateFlow<Boolean> get() = _isRunning
     val isRunning: StateFlow<Boolean> get() = _isRunning
     private val _isRunning = MutableStateFlow(false)
     private val _isRunning = MutableStateFlow(false)
 
 
+    val cancelled: StateFlow<Boolean> get() = _cancelled
+    private val _cancelled = MutableStateFlow(false)
+
     lateinit var task: () -> Any
     lateinit var task: () -> Any
 
 
     fun clear() {
     fun clear() {
         _result.value = Any()
         _result.value = Any()
         _isComplete.value = false
         _isComplete.value = false
         _isRunning.value = false
         _isRunning.value = false
+        _cancelled.value = false
+    }
+
+    fun setCancelled(value: Boolean) {
+        _cancelled.value = value
     }
     }
 
 
     fun runTask() {
     fun runTask() {

+ 122 - 1
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.fragments.MessageDialogFragment
 import org.yuzu.yuzu_emu.model.GamesViewModel
 import org.yuzu.yuzu_emu.model.GamesViewModel
 import org.yuzu.yuzu_emu.model.HomeViewModel
 import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.TaskViewModel
 import org.yuzu.yuzu_emu.utils.*
 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 {
 class MainActivity : AppCompatActivity(), ThemeProvider {
     private lateinit var binding: ActivityMainBinding
     private lateinit var binding: ActivityMainBinding
 
 
     private val homeViewModel: HomeViewModel by viewModels()
     private val homeViewModel: HomeViewModel by viewModels()
     private val gamesViewModel: GamesViewModel by viewModels()
     private val gamesViewModel: GamesViewModel by viewModels()
+    private val taskViewModel: TaskViewModel by viewModels()
 
 
     override var themeId: Int = 0
     override var themeId: Int = 0
 
 
@@ -307,6 +315,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
     fun processKey(result: Uri): Boolean {
     fun processKey(result: Uri): Boolean {
         if (FileUtil.getExtension(result) != "keys") {
         if (FileUtil.getExtension(result) != "keys") {
             MessageDialogFragment.newInstance(
             MessageDialogFragment.newInstance(
+                this,
                 titleId = R.string.reading_keys_failure,
                 titleId = R.string.reading_keys_failure,
                 descriptionId = R.string.install_prod_keys_failure_extension_description
                 descriptionId = R.string.install_prod_keys_failure_extension_description
             ).show(supportFragmentManager, MessageDialogFragment.TAG)
             ).show(supportFragmentManager, MessageDialogFragment.TAG)
@@ -336,6 +345,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 return true
                 return true
             } else {
             } else {
                 MessageDialogFragment.newInstance(
                 MessageDialogFragment.newInstance(
+                    this,
                     titleId = R.string.invalid_keys_error,
                     titleId = R.string.invalid_keys_error,
                     descriptionId = R.string.install_keys_failure_description,
                     descriptionId = R.string.install_keys_failure_description,
                     helpLinkId = R.string.dumping_keys_quickstart_link
                     helpLinkId = R.string.dumping_keys_quickstart_link
@@ -376,6 +386,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                     val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
                     val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
                     messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
                     messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
                         MessageDialogFragment.newInstance(
                         MessageDialogFragment.newInstance(
+                            this,
                             titleId = R.string.firmware_installed_failure,
                             titleId = R.string.firmware_installed_failure,
                             descriptionId = R.string.firmware_installed_failure_description
                             descriptionId = R.string.firmware_installed_failure_description
                         )
                         )
@@ -395,7 +406,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
             IndeterminateProgressDialogFragment.newInstance(
             IndeterminateProgressDialogFragment.newInstance(
                 this,
                 this,
                 R.string.firmware_installing,
                 R.string.firmware_installing,
-                task
+                task = task
             ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
             ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
         }
         }
 
 
@@ -407,6 +418,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
 
 
             if (FileUtil.getExtension(result) != "bin") {
             if (FileUtil.getExtension(result) != "bin") {
                 MessageDialogFragment.newInstance(
                 MessageDialogFragment.newInstance(
+                    this,
                     titleId = R.string.reading_keys_failure,
                     titleId = R.string.reading_keys_failure,
                     descriptionId = R.string.install_amiibo_keys_failure_extension_description
                     descriptionId = R.string.install_amiibo_keys_failure_extension_description
                 ).show(supportFragmentManager, MessageDialogFragment.TAG)
                 ).show(supportFragmentManager, MessageDialogFragment.TAG)
@@ -434,6 +446,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                     ).show()
                     ).show()
                 } else {
                 } else {
                     MessageDialogFragment.newInstance(
                     MessageDialogFragment.newInstance(
+                        this,
                         titleId = R.string.invalid_keys_error,
                         titleId = R.string.invalid_keys_error,
                         descriptionId = R.string.install_keys_failure_description,
                         descriptionId = R.string.install_keys_failure_description,
                         helpLinkId = R.string.dumping_keys_quickstart_link
                         helpLinkId = R.string.dumping_keys_quickstart_link
@@ -583,12 +596,14 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                         installResult.append(separator)
                         installResult.append(separator)
                     }
                     }
                     return@newInstance MessageDialogFragment.newInstance(
                     return@newInstance MessageDialogFragment.newInstance(
+                        this,
                         titleId = R.string.install_game_content_failure,
                         titleId = R.string.install_game_content_failure,
                         descriptionString = installResult.toString().trim(),
                         descriptionString = installResult.toString().trim(),
                         helpLinkId = R.string.install_game_content_help_link
                         helpLinkId = R.string.install_game_content_help_link
                     )
                     )
                 } else {
                 } else {
                     return@newInstance MessageDialogFragment.newInstance(
                     return@newInstance MessageDialogFragment.newInstance(
+                        this,
                         titleId = R.string.install_game_content_success,
                         titleId = R.string.install_game_content_success,
                         descriptionString = installResult.toString().trim()
                         descriptionString = installResult.toString().trim()
                     )
                     )
@@ -596,4 +611,110 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
             }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
             }.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)
+        }
 }
 }

+ 9 - 0
src/android/app/src/main/res/drawable/ic_export.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="?attr/colorControlNormal"
+        android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2L5,20z" />
+</vector>

+ 9 - 0
src/android/app/src/main/res/drawable/ic_import.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="?attr/colorControlNormal"
+        android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" />
+</vector>

+ 6 - 22
src/android/app/src/main/res/layout/dialog_progress_bar.xml

@@ -1,24 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
+<com.google.android.material.progressindicator.LinearProgressIndicator xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:orientation="vertical">
-
-    <com.google.android.material.progressindicator.LinearProgressIndicator
-        android:id="@+id/progress_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_margin="24dp"
-        app:trackCornerRadius="4dp" />
-
-    <TextView
-        android:id="@+id/progress_text"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginLeft="24dp"
-        android:layout_marginRight="24dp"
-        android:layout_marginBottom="24dp"
-        android:gravity="end" />
-
-</LinearLayout>
+    android:id="@+id/progress_bar"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="24dp"
+    app:trackCornerRadius="4dp" />

+ 61 - 0
src/android/app/src/main/res/layout/fragment_about.xml

@@ -176,6 +176,67 @@
 
 
             </LinearLayout>
             </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">
+
+                <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
             <com.google.android.material.divider.MaterialDivider
                 android:layout_width="match_parent"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_height="wrap_content"

+ 12 - 0
src/android/app/src/main/res/values/strings.xml

@@ -128,6 +128,15 @@
     <string name="contributors_link">https://github.com/yuzu-emu/yuzu/graphs/contributors</string>
     <string name="contributors_link">https://github.com/yuzu-emu/yuzu/graphs/contributors</string>
     <string name="licenses_description">Projects that make yuzu for Android possible</string>
     <string name="licenses_description">Projects that make yuzu for Android possible</string>
     <string name="build">Build</string>
     <string name="build">Build</string>
+    <string name="user_data">User data</string>
+    <string name="user_data_description">Import/export all app data.\n\nWhen importing user data, all existing user data will be deleted!</string>
+    <string name="exporting_user_data">Exporting user data…</string>
+    <string name="importing_user_data">Importing user data…</string>
+    <string name="import_user_data">Import user data</string>
+    <string name="invalid_yuzu_backup">Invalid yuzu backup</string>
+    <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="support_link">https://discord.gg/u77vRWY</string>
     <string name="support_link">https://discord.gg/u77vRWY</string>
     <string name="website_link">https://yuzu-emu.org/</string>
     <string name="website_link">https://yuzu-emu.org/</string>
     <string name="github_link">https://github.com/yuzu-emu</string>
     <string name="github_link">https://github.com/yuzu-emu</string>
@@ -215,6 +224,9 @@
     <string name="auto">Auto</string>
     <string name="auto">Auto</string>
     <string name="submit">Submit</string>
     <string name="submit">Submit</string>
     <string name="string_null">Null</string>
     <string name="string_null">Null</string>
+    <string name="string_import">Import</string>
+    <string name="export">Export</string>
+    <string name="cancelling">Cancelling</string>
 
 
     <!-- GPU driver installation -->
     <!-- GPU driver installation -->
     <string name="select_gpu_driver">Select GPU driver</string>
     <string name="select_gpu_driver">Select GPU driver</string>