From 6e68786bd8696712be86487487486202c674852c Mon Sep 17 00:00:00 2001 From: PabloG02 Date: Fri, 28 Apr 2023 00:03:24 +0200 Subject: [PATCH] Address some feedback Replace 0-9 with \d Change zip import to use cache dir Move export and import logic to their own functions Delete zip after being shared --- app/src/main/java/emu/skyline/AppDialog.kt | 172 +++++++++++++-------- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 106 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/emu/skyline/AppDialog.kt b/app/src/main/java/emu/skyline/AppDialog.kt index 85c2709b..61f25776 100644 --- a/app/src/main/java/emu/skyline/AppDialog.kt +++ b/app/src/main/java/emu/skyline/AppDialog.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager import android.graphics.drawable.Icon +import android.net.Uri import android.os.Bundle import android.provider.DocumentsContract import android.view.KeyEvent @@ -34,14 +35,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.BufferedInputStream 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.ZipInputStream import java.util.zip.ZipOutputStream /** @@ -69,35 +69,7 @@ class AppDialog : BottomSheetDialogFragment() { private val savesFolderRoot by lazy { "${requireContext().getPublicFilesDir().canonicalPath}/switch/nand/user/save/0000000000000000/00000000000000000000000000000001/" } private var lastZipCreated : File? = null private val documentPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { - it?.let { uri -> - try { - val savesFolder = File(savesFolderRoot) - var inputZip = requireContext().contentResolver.openInputStream(uri) - var validZip = false - // A TitleID must be the first folder name inside the zip in order to be considered valid. - if (inputZip != null) { - ZipInputStream(BufferedInputStream(inputZip)).use { zis -> - validZip = Regex("^0100[0-9A-Fa-f]{12}/").containsMatchIn(zis.nextEntry.name) - } - } - inputZip = requireContext().contentResolver.openInputStream(uri) - if (inputZip != null && validZip){ - CoroutineScope(Dispatchers.IO).launch { - ZipUtils.unzip(inputZip, savesFolder) - withContext(Dispatchers.Main){ - val isSaveFileOfThisGame = File("$savesFolderRoot${item.titleId}").exists() - binding.deleteSave.isEnabled = isSaveFileOfThisGame - binding.exportSave.isEnabled = isSaveFileOfThisGame - Snackbar.make(binding.root, R.string.save_file_imported_ok, Snackbar.LENGTH_LONG).show() - } - } - } else { - Snackbar.make(binding.root, getString(R.string.save_file_invalid_zip_structure), Snackbar.LENGTH_LONG).show() - } - } catch (e: Exception) { - Snackbar.make(binding.root, getString(R.string.error), Snackbar.LENGTH_LONG).show() - } - } + it?.let { uri -> importSave(uri) } } /** @@ -178,40 +150,7 @@ class AppDialog : BottomSheetDialogFragment() { binding.exportSave.isEnabled = saveExists binding.exportSave.setOnClickListener { - CoroutineScope(Dispatchers.IO).launch { - try { - val saveFolder = File(saveFolderPath) - val outputZipFile = File("$savesFolderRoot${item.title} (v${binding.gameVersion.text}) [${item.titleId}] - ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}.zip") - lastZipCreated?.delete() - 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) { - withContext(Dispatchers.Main){ - Snackbar.make(binding.root, e.message as CharSequence, Snackbar.LENGTH_LONG).show() - } - return@launch - } - - withContext(Dispatchers.Main) { - val file = DocumentFile.fromSingleUri(requireContext(), DocumentsContract.buildDocumentUri(DocumentsProvider.AUTHORITY, "${DocumentsProvider.ROOT_ID}/switch/nand/user/save/0000000000000000/00000000000000000000000000000001/${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) - startActivity(Intent.createChooser(intent, "Share save file")) - } - } + exportSave(saveFolderPath) } binding.gameTitleId.setOnLongClickListener { @@ -231,8 +170,107 @@ class AppDialog : BottomSheetDialogFragment() { } } - override fun onDestroyView(){ - super.onDestroyView() + /** + * Zips the save file located in the given folder path and creates a new zip file with the name and version of the game, and the current date and time. + * @param saveFolderPath The path to the folder containing the save file to zip. + * @return true if the zip file is successfully created, false otherwise. + */ + private fun zipSave(saveFolderPath : String) : Boolean { + try { + val saveFolder = File(saveFolderPath) + val outputZipFile = File("$savesFolderRoot${item.title} (v${binding.gameVersion.text}) [${item.titleId}] - ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}.zip") + lastZipCreated?.delete() + 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 + } + + private val startForResultExportSave = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> lastZipCreated?.delete() } + + /** + * Exports the save file located in the given folder path by creating a zip file and sharing it via intent. + * @param saveFolderPath The path to the folder containing the save file(s) to export. + */ + private fun exportSave(saveFolderPath : String) { + CoroutineScope(Dispatchers.IO).launch { + if (!zipSave(saveFolderPath)) { + withContext(Dispatchers.Main) { + Snackbar.make(binding.root, R.string.error, Snackbar.LENGTH_LONG).show() + } + return@launch + } + + withContext(Dispatchers.Main) { + val file = DocumentFile.fromSingleUri(requireContext(), DocumentsContract.buildDocumentUri(DocumentsProvider.AUTHORITY, "${DocumentsProvider.ROOT_ID}/switch/nand/user/save/0000000000000000/00000000000000000000000000000001/${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 = requireContext().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("${requireContext().cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + if (inputZip == null) { + Snackbar.make(binding.root, getString(R.string.error), Snackbar.LENGTH_LONG).show() + return + } + + val filterTitleId = FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) } + + try { + CoroutineScope(Dispatchers.IO).launch { + ZipUtils.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) { + val isSaveFileOfThisGame = File("$savesFolderRoot${item.titleId}").exists() + binding.deleteSave.isEnabled = isSaveFileOfThisGame + binding.exportSave.isEnabled = isSaveFileOfThisGame + Snackbar.make(binding.root, R.string.save_file_imported_ok, Snackbar.LENGTH_LONG).show() + } else { + Snackbar.make(binding.root, getString(R.string.save_file_invalid_zip_structure), Snackbar.LENGTH_LONG).show() + } + } + + cacheSaveDir.deleteRecursively() + } + } catch (e : Exception) { + Snackbar.make(binding.root, getString(R.string.error), Snackbar.LENGTH_LONG).show() + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a490b01..e5ec230a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -284,5 +284,5 @@ Yes No The save file was imported successfully - Failed to unzip. First subfolder must be named after the TitleId of the game. + Invalid Zip directory structure: the first subfolder name must be the Title ID of the game.