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.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()
}
}
}

View File

@ -284,5 +284,5 @@
<string name="yes">Yes</string>
<string name="no">No</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>