summaryrefslogtreecommitdiffstats
path: root/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
diff options
context:
space:
mode:
Diffstat (limited to 'src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt210
1 files changed, 210 insertions, 0 deletions
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
new file mode 100644
index 000000000..36e63bb9e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
@@ -0,0 +1,210 @@
+// 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 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
+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
+
+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(
+ R.string.save_file_invalid_zip_structure,
+ 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"
+ }
+}