mirror of
https://github.com/skyline-emu/skyline.git
synced 2024-11-25 16:46:53 +01:00
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:
parent
a6c2699395
commit
6e68786bd8
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user