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.