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
This commit is contained in:
PabloG02 2023-04-28 00:03:24 +02:00 committed by Billy Laws
parent a6c2699395
commit 6e68786bd8
2 changed files with 106 additions and 68 deletions

View File

@ -10,6 +10,7 @@ import android.content.Intent
import android.content.pm.ShortcutInfo import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.view.KeyEvent import android.view.KeyEvent
@ -34,14 +35,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.FilenameFilter
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream 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 val savesFolderRoot by lazy { "${requireContext().getPublicFilesDir().canonicalPath}/switch/nand/user/save/0000000000000000/00000000000000000000000000000001/" }
private var lastZipCreated : File? = null private var lastZipCreated : File? = null
private val documentPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { private val documentPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) {
it?.let { uri -> it?.let { uri -> importSave(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()
}
}
} }
/** /**
@ -178,40 +150,7 @@ class AppDialog : BottomSheetDialogFragment() {
binding.exportSave.isEnabled = saveExists binding.exportSave.isEnabled = saveExists
binding.exportSave.setOnClickListener { binding.exportSave.setOnClickListener {
CoroutineScope(Dispatchers.IO).launch { exportSave(saveFolderPath)
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"))
}
}
} }
binding.gameTitleId.setOnLongClickListener { 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() 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()
}
}
} }

View File

@ -284,5 +284,5 @@
<string name="yes">Yes</string> <string name="yes">Yes</string>
<string name="no">No</string> <string name="no">No</string>
<string name="save_file_imported_ok">The save file was imported successfully</string> <string name="save_file_imported_ok">The save file was imported successfully</string>
<string name="save_file_invalid_zip_structure">Failed to unzip. First subfolder must be named after the TitleId of the game.</string> <string name="save_file_invalid_zip_structure">Invalid Zip directory structure: the first subfolder name must be the Title ID of the game.</string>
</resources> </resources>