From 33d36ded28a709b72524fd8e532e5cc1df9f04bc Mon Sep 17 00:00:00 2001 From: PabloG02 Date: Wed, 31 May 2023 23:24:33 +0200 Subject: [PATCH] Add save import/export in UI --- .../yuzu_emu/features/DocumentProvider.kt | 2 + .../fragments/HomeSettingsFragment.kt | 5 + .../fragments/ImportExportSavesFragment.kt | 226 ++++++++++++++++++ .../app/src/main/res/drawable/ic_save.xml | 10 + .../app/src/main/res/values/strings.xml | 4 + 5 files changed, 247 insertions(+) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt create mode 100644 src/android/app/src/main/res/drawable/ic_save.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt index e6e9a6fe89..4c3a9ca806 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt @@ -13,6 +13,7 @@ import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.DocumentsProvider import android.webkit.MimeTypeMap +import org.yuzu.yuzu_emu.BuildConfig import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.getPublicFilesDir @@ -43,6 +44,7 @@ class DocumentProvider : DocumentsProvider() { DocumentsContract.Document.COLUMN_SIZE ) + const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user" const val ROOT_ID: String = "root" } 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 3a334a74c3..7cd2409df7 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 @@ -98,6 +98,11 @@ class HomeSettingsFragment : Fragment() { R.string.select_games_folder_description, R.drawable.ic_add ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, + HomeSetting( + R.string.import_export_saves, + R.string.import_export_saves_description, + R.drawable.ic_save + ) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) }, HomeSetting( R.string.install_prod_keys, R.string.install_prod_keys_description, 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 0000000000..20c1b6be5c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt @@ -0,0 +1,226 @@ +// 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.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 java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.FilenameFilter +import java.io.IOException +import java.io.InputStream +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 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 + private lateinit var documentPicker: ActivityResultLauncher> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + 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) } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setTitle("Import/Export Saves") + .setPositiveButton("Export") { _, _ -> + exportSave() + } + .setNeutralButton("Import") { _, _ -> + documentPicker.launch(arrayOf("application/zip")) + } + .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 + } + + /** + * Extracts the save files located in the given zip file and copies them to the saves folder. + * @exception IOException if the file was being created outside of the target directory + */ + private fun unzip(zipStream: InputStream, destDir: File): Boolean { + val zis = ZipInputStream(BufferedInputStream(zipStream)) + 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)) { + zis.close() + throw IOException("Entry is outside of the target dir: " + entryFile.name) + } + if (entry.isDirectory) { + entryFile.mkdirs() + } else { + entryFile.parentFile?.mkdirs() + entryFile.createNewFile() + entryFile.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + } + zis.close() + 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/${lastZipCreated?.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) { + 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 { + 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) { + Toast.makeText( + context, + context.getString(R.string.save_file_invalid_zip_structure), + Toast.LENGTH_LONG + ).show() + 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/res/drawable/ic_save.xml b/src/android/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000000..a9af3d9cf2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index cffa3ff0bd..9754dccd7e 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -80,6 +80,10 @@ No file manager found Could not open yuzu directory Please locate the user folder with the file manager\'s side panel manually. + Import/Export Saves + Import or export save files + The save files were imported successfully + Invalid Zip directory structure: the first subfolder name must be the Title ID of the game. Gaia isn\'t real