summaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorGravatar PabloG022023-05-31 23:24:33 +0200
committerGravatar bunnei2023-06-03 00:06:06 -0700
commit33d36ded28a709b72524fd8e532e5cc1df9f04bc (patch)
treeaf5232b09a5bb176cde13f9582b3ff2372fc944e /src/android
parentandroid: Use ext-android-bin for external binaries. (diff)
downloadyuzu-33d36ded28a709b72524fd8e532e5cc1df9f04bc.tar.gz
yuzu-33d36ded28a709b72524fd8e532e5cc1df9f04bc.tar.xz
yuzu-33d36ded28a709b72524fd8e532e5cc1df9f04bc.zip
Add save import/export in UI
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt5
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt226
-rw-r--r--src/android/app/src/main/res/drawable/ic_save.xml10
-rw-r--r--src/android/app/src/main/res/values/strings.xml4
5 files changed, 247 insertions, 0 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
index e6e9a6fe8..4c3a9ca80 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
@@ -13,6 +13,7 @@ import android.os.ParcelFileDescriptor
13import android.provider.DocumentsContract 13import android.provider.DocumentsContract
14import android.provider.DocumentsProvider 14import android.provider.DocumentsProvider
15import android.webkit.MimeTypeMap 15import android.webkit.MimeTypeMap
16import org.yuzu.yuzu_emu.BuildConfig
16import org.yuzu.yuzu_emu.R 17import org.yuzu.yuzu_emu.R
17import org.yuzu.yuzu_emu.YuzuApplication 18import org.yuzu.yuzu_emu.YuzuApplication
18import org.yuzu.yuzu_emu.getPublicFilesDir 19import org.yuzu.yuzu_emu.getPublicFilesDir
@@ -43,6 +44,7 @@ class DocumentProvider : DocumentsProvider() {
43 DocumentsContract.Document.COLUMN_SIZE 44 DocumentsContract.Document.COLUMN_SIZE
44 ) 45 )
45 46
47 const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user"
46 const val ROOT_ID: String = "root" 48 const val ROOT_ID: String = "root"
47 } 49 }
48 50
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
index 3a334a74c..7cd2409df 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
@@ -99,6 +99,11 @@ class HomeSettingsFragment : Fragment() {
99 R.drawable.ic_add 99 R.drawable.ic_add
100 ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, 100 ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
101 HomeSetting( 101 HomeSetting(
102 R.string.import_export_saves,
103 R.string.import_export_saves_description,
104 R.drawable.ic_save
105 ) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) },
106 HomeSetting(
102 R.string.install_prod_keys, 107 R.string.install_prod_keys,
103 R.string.install_prod_keys_description, 108 R.string.install_prod_keys_description,
104 R.drawable.ic_unlock 109 R.drawable.ic_unlock
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
new file mode 100644
index 000000000..20c1b6be5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
@@ -0,0 +1,226 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.app.Dialog
7import android.content.Intent
8import android.net.Uri
9import android.os.Bundle
10import android.provider.DocumentsContract
11import android.widget.Toast
12import androidx.activity.result.ActivityResultLauncher
13import androidx.activity.result.contract.ActivityResultContracts
14import androidx.documentfile.provider.DocumentFile
15import androidx.fragment.app.DialogFragment
16import com.google.android.material.dialog.MaterialAlertDialogBuilder
17import kotlinx.coroutines.CoroutineScope
18import kotlinx.coroutines.Dispatchers
19import kotlinx.coroutines.launch
20import kotlinx.coroutines.withContext
21import org.yuzu.yuzu_emu.R
22import org.yuzu.yuzu_emu.YuzuApplication
23import org.yuzu.yuzu_emu.features.DocumentProvider
24import org.yuzu.yuzu_emu.getPublicFilesDir
25import java.io.BufferedInputStream
26import java.io.BufferedOutputStream
27import java.io.File
28import java.io.FileOutputStream
29import java.io.FilenameFilter
30import java.io.IOException
31import java.io.InputStream
32import java.time.LocalDateTime
33import java.time.format.DateTimeFormatter
34import java.util.zip.ZipEntry
35import java.util.zip.ZipInputStream
36import java.util.zip.ZipOutputStream
37
38class ImportExportSavesFragment : DialogFragment() {
39 private val context = YuzuApplication.appContext
40 private val savesFolder =
41 "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
42
43 // Get first subfolder in saves folder (should be the user folder)
44 private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
45 private var lastZipCreated: File? = null
46
47 private lateinit var startForResultExportSave: ActivityResultLauncher<Intent>
48 private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
49
50 override fun onCreate(savedInstanceState: Bundle?) {
51 super.onCreate(savedInstanceState)
52
53 val activityResultRegistry = requireActivity().activityResultRegistry
54 startForResultExportSave = activityResultRegistry.register(
55 "startForResultExportSaveKey",
56 ActivityResultContracts.StartActivityForResult()
57 ) {
58 File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
59 }
60 documentPicker = activityResultRegistry.register(
61 "documentPickerKey",
62 ActivityResultContracts.OpenDocument()
63 ) {
64 it?.let { uri -> importSave(uri) }
65 }
66 }
67
68 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
69 return MaterialAlertDialogBuilder(requireContext())
70 .setTitle("Import/Export Saves")
71 .setPositiveButton("Export") { _, _ ->
72 exportSave()
73 }
74 .setNeutralButton("Import") { _, _ ->
75 documentPicker.launch(arrayOf("application/zip"))
76 }
77 .show()
78 }
79
80 /**
81 * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
82 * @return true if the zip file is successfully created, false otherwise.
83 */
84 private fun zipSave(): Boolean {
85 try {
86 val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
87 tempFolder.mkdirs()
88 val saveFolder = File(savesFolderRoot)
89 val outputZipFile = File(
90 tempFolder, "Yuzu saves - ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}.zip"
91 )
92 outputZipFile.createNewFile()
93 ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
94 saveFolder.walkTopDown().forEach { file ->
95 val zipFileName =
96 file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/")
97 if (zipFileName == "")
98 return@forEach
99 val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
100 zos.putNextEntry(entry)
101 if (file.isFile)
102 file.inputStream().use { fis -> fis.copyTo(zos) }
103 }
104 }
105 lastZipCreated = outputZipFile
106 } catch (e: Exception) {
107 return false
108 }
109 return true
110 }
111
112 /**
113 * Extracts the save files located in the given zip file and copies them to the saves folder.
114 * @exception IOException if the file was being created outside of the target directory
115 */
116 private fun unzip(zipStream: InputStream, destDir: File): Boolean {
117 val zis = ZipInputStream(BufferedInputStream(zipStream))
118 var entry: ZipEntry? = zis.nextEntry
119 while (entry != null) {
120 val entryName = entry.name
121 val entryFile = File(destDir, entryName)
122 if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
123 zis.close()
124 throw IOException("Entry is outside of the target dir: " + entryFile.name)
125 }
126 if (entry.isDirectory) {
127 entryFile.mkdirs()
128 } else {
129 entryFile.parentFile?.mkdirs()
130 entryFile.createNewFile()
131 entryFile.outputStream().use { fos -> zis.copyTo(fos) }
132 }
133 entry = zis.nextEntry
134 }
135 zis.close()
136 return true
137 }
138
139 /**
140 * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
141 */
142 private fun exportSave() {
143 CoroutineScope(Dispatchers.IO).launch {
144 val wasZipCreated = zipSave()
145 val lastZipFile = lastZipCreated
146 if (!wasZipCreated || lastZipFile == null) {
147 withContext(Dispatchers.Main) {
148 Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
149 }
150 return@launch
151 }
152
153 withContext(Dispatchers.Main) {
154 val file = DocumentFile.fromSingleUri(
155 context, DocumentsContract.buildDocumentUri(
156 DocumentProvider.AUTHORITY,
157 "${DocumentProvider.ROOT_ID}/temp/${lastZipCreated?.name}"
158 )
159 )!!
160 val intent = Intent(Intent.ACTION_SEND)
161 .setDataAndType(file.uri, "application/zip")
162 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
163 .putExtra(Intent.EXTRA_STREAM, file.uri)
164 startForResultExportSave.launch(Intent.createChooser(intent, "Share save file"))
165 }
166 }
167 }
168
169 /**
170 * Imports the save files contained in the zip file, and replaces any existing ones with the new save file.
171 * @param zipUri The Uri of the zip file containing the save file(s) to import.
172 */
173 private fun importSave(zipUri: Uri) {
174 val inputZip = context.contentResolver.openInputStream(zipUri)
175 // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
176 var validZip = false
177 val savesFolder = File(savesFolderRoot)
178 val cacheSaveDir = File("${context.cacheDir.path}/saves/")
179 cacheSaveDir.mkdir()
180
181 if (inputZip == null) {
182 Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
183 .show()
184 return
185 }
186
187 val filterTitleId =
188 FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
189
190 try {
191 CoroutineScope(Dispatchers.IO).launch {
192 unzip(inputZip, cacheSaveDir)
193 cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
194 File(savesFolder, savePath).deleteRecursively()
195 File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true)
196 validZip = true
197 }
198
199 withContext(Dispatchers.Main) {
200 if (!validZip) {
201 Toast.makeText(
202 context,
203 context.getString(R.string.save_file_invalid_zip_structure),
204 Toast.LENGTH_LONG
205 ).show()
206 return@withContext
207 }
208 Toast.makeText(
209 context,
210 context.getString(R.string.save_file_imported_success),
211 Toast.LENGTH_LONG
212 ).show()
213 }
214
215 cacheSaveDir.deleteRecursively()
216 }
217 } catch (e: Exception) {
218 Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
219 .show()
220 }
221 }
222
223 companion object {
224 const val TAG = "ImportExportSavesFragment"
225 }
226}
diff --git a/src/android/app/src/main/res/drawable/ic_save.xml b/src/android/app/src/main/res/drawable/ic_save.xml
new file mode 100644
index 000000000..a9af3d9cf
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_save.xml
@@ -0,0 +1,10 @@
1<vector xmlns:android="http://schemas.android.com/apk/res/android"
2 android:width="24dp"
3 android:height="24dp"
4 android:viewportWidth="960"
5 android:viewportHeight="960"
6 android:tint="?attr/colorControlNormal">
7 <path
8 android:fillColor="@android:color/white"
9 android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L647,120Q663,120 677.5,126Q692,132 703,143L817,257Q828,268 834,282.5Q840,297 840,313L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM280,400L560,400Q577,400 588.5,388.5Q600,377 600,360L600,280Q600,263 588.5,251.5Q577,240 560,240L280,240Q263,240 251.5,251.5Q240,263 240,280L240,360Q240,377 251.5,388.5Q263,400 280,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z"/>
10</vector>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index cffa3ff0b..9754dccd7 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -80,6 +80,10 @@
80 <string name="no_file_manager">No file manager found</string> 80 <string name="no_file_manager">No file manager found</string>
81 <string name="notification_no_directory_link">Could not open yuzu directory</string> 81 <string name="notification_no_directory_link">Could not open yuzu directory</string>
82 <string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string> 82 <string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string>
83 <string name="import_export_saves">Import/Export Saves</string>
84 <string name="import_export_saves_description">Import or export save files</string>
85 <string name="save_file_imported_success">The save files were imported successfully</string>
86 <string name="save_file_invalid_zip_structure">Invalid Zip directory structure: the first subfolder name must be the Title ID of the game.</string>
83 87
84 <!-- About screen strings --> 88 <!-- About screen strings -->
85 <string name="gaia_is_not_real">Gaia isn\'t real</string> 89 <string name="gaia_is_not_real">Gaia isn\'t real</string>