summaryrefslogtreecommitdiffstats
path: root/src/android/app/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/android/app/src/main/java')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt5
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt49
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt7
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt48
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt214
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt138
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt162
8 files changed, 374 insertions, 262 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index 21f67f32a..f474a3873 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -512,6 +512,11 @@ object NativeLibrary {
external fun submitInlineKeyboardInput(key_code: Int)
/**
+ * Creates a generic user directory if it doesn't exist already
+ */
+ external fun initializeEmptyUserDirectory()
+
+ /**
* Button type for use in onTouchEvent
*/
object ButtonType {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt
new file mode 100644
index 000000000..e960fbaab
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.databinding.CardInstallableBinding
+import org.yuzu.yuzu_emu.model.Installable
+
+class InstallableAdapter(private val installables: List<Installable>) :
+ RecyclerView.Adapter<InstallableAdapter.InstallableViewHolder>() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): InstallableAdapter.InstallableViewHolder {
+ val binding =
+ CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return InstallableViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = installables.size
+
+ override fun onBindViewHolder(holder: InstallableAdapter.InstallableViewHolder, position: Int) =
+ holder.bind(installables[position])
+
+ inner class InstallableViewHolder(val binding: CardInstallableBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var installable: Installable
+
+ fun bind(installable: Installable) {
+ this.installable = installable
+
+ binding.title.setText(installable.titleId)
+ binding.description.setText(installable.descriptionId)
+
+ if (installable.install != null) {
+ binding.buttonInstall.visibility = View.VISIBLE
+ binding.buttonInstall.setOnClickListener { installable.install.invoke() }
+ }
+ if (installable.export != null) {
+ binding.buttonExport.visibility = View.VISIBLE
+ binding.buttonExport.setOnClickListener { installable.export.invoke() }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
index 7b8f99872..2ff827c6b 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
@@ -26,7 +26,6 @@ import org.yuzu.yuzu_emu.BuildConfig
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
import org.yuzu.yuzu_emu.model.HomeViewModel
-import org.yuzu.yuzu_emu.ui.main.MainActivity
class AboutFragment : Fragment() {
private var _binding: FragmentAboutBinding? = null
@@ -93,12 +92,6 @@ class AboutFragment : Fragment() {
}
}
- val mainActivity = requireActivity() as MainActivity
- binding.buttonExport.setOnClickListener { mainActivity.exportUserData.launch("export.zip") }
- binding.buttonImport.setOnClickListener {
- mainActivity.importUserData.launch(arrayOf("application/zip"))
- }
-
binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
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 c119e69c9..8923c0ea2 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
@@ -118,18 +118,13 @@ class HomeSettingsFragment : Fragment() {
)
add(
HomeSetting(
- R.string.install_amiibo_keys,
- R.string.install_amiibo_keys_description,
- R.drawable.ic_nfc,
- { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }
- )
- )
- add(
- HomeSetting(
- R.string.install_game_content,
- R.string.install_game_content_description,
- R.drawable.ic_system_update_alt,
- { mainActivity.installGameUpdate.launch(arrayOf("*/*")) }
+ R.string.manage_yuzu_data,
+ R.string.manage_yuzu_data_description,
+ R.drawable.ic_install,
+ {
+ binding.root.findNavController()
+ .navigate(R.id.action_homeSettingsFragment_to_installableFragment)
+ }
)
)
add(
@@ -150,35 +145,6 @@ class HomeSettingsFragment : Fragment() {
)
add(
HomeSetting(
- R.string.manage_save_data,
- R.string.import_export_saves_description,
- R.drawable.ic_save,
- {
- ImportExportSavesFragment().show(
- parentFragmentManager,
- ImportExportSavesFragment.TAG
- )
- }
- )
- )
- add(
- HomeSetting(
- R.string.install_prod_keys,
- R.string.install_prod_keys_description,
- R.drawable.ic_unlock,
- { mainActivity.getProdKey.launch(arrayOf("*/*")) }
- )
- )
- add(
- HomeSetting(
- R.string.install_firmware,
- R.string.install_firmware_description,
- R.drawable.ic_firmware,
- { mainActivity.getFirmware.launch(arrayOf("application/zip")) }
- )
- )
- add(
- HomeSetting(
R.string.share_log,
R.string.share_log_description,
R.drawable.ic_log,
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
deleted file mode 100644
index ee2d44718..000000000
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
+++ /dev/null
@@ -1,214 +0,0 @@
-// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.yuzu.yuzu_emu.fragments
-
-import android.app.Dialog
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.provider.DocumentsContract
-import android.widget.Toast
-import androidx.activity.result.ActivityResultLauncher
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import androidx.documentfile.provider.DocumentFile
-import androidx.fragment.app.DialogFragment
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-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.ZipOutputStream
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.yuzu.yuzu_emu.R
-import org.yuzu.yuzu_emu.YuzuApplication
-import org.yuzu.yuzu_emu.features.DocumentProvider
-import org.yuzu.yuzu_emu.getPublicFilesDir
-import org.yuzu.yuzu_emu.utils.FileUtil
-
-class ImportExportSavesFragment : DialogFragment() {
- private val context = YuzuApplication.appContext
- private val savesFolder =
- "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
-
- // Get first subfolder in saves folder (should be the user folder)
- private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
- private var lastZipCreated: File? = null
-
- private lateinit var startForResultExportSave: ActivityResultLauncher<Intent>
- private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val activity = requireActivity() as AppCompatActivity
-
- val activityResultRegistry = requireActivity().activityResultRegistry
- startForResultExportSave = activityResultRegistry.register(
- "startForResultExportSaveKey",
- ActivityResultContracts.StartActivityForResult()
- ) {
- File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
- }
- documentPicker = activityResultRegistry.register(
- "documentPickerKey",
- ActivityResultContracts.OpenDocument()
- ) {
- it?.let { uri -> importSave(uri, activity) }
- }
- }
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return if (savesFolderRoot == "") {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.manage_save_data)
- .setMessage(R.string.import_export_saves_no_profile)
- .setPositiveButton(android.R.string.ok, null)
- .show()
- } else {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.manage_save_data)
- .setMessage(R.string.manage_save_data_description)
- .setNegativeButton(R.string.export_saves) { _, _ ->
- exportSave()
- }
- .setPositiveButton(R.string.import_saves) { _, _ ->
- documentPicker.launch(arrayOf("application/zip"))
- }
- .setNeutralButton(android.R.string.cancel, null)
- .show()
- }
- }
-
- /**
- * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
- * @return true if the zip file is successfully created, false otherwise.
- */
- private fun zipSave(): Boolean {
- try {
- val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
- tempFolder.mkdirs()
- val saveFolder = File(savesFolderRoot)
- val outputZipFile = File(
- tempFolder,
- "yuzu saves - ${
- LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
- }.zip"
- )
- 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
- }
-
- /**
- * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
- */
- private fun exportSave() {
- CoroutineScope(Dispatchers.IO).launch {
- val wasZipCreated = zipSave()
- val lastZipFile = lastZipCreated
- if (!wasZipCreated || lastZipFile == null) {
- withContext(Dispatchers.Main) {
- Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
- }
- return@launch
- }
-
- withContext(Dispatchers.Main) {
- val file = DocumentFile.fromSingleUri(
- context,
- DocumentsContract.buildDocumentUri(
- DocumentProvider.AUTHORITY,
- "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.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, activity: AppCompatActivity) {
- val inputZip = context.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("${context.cacheDir.path}/saves/")
- cacheSaveDir.mkdir()
-
- if (inputZip == null) {
- Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
- .show()
- return
- }
-
- val filterTitleId =
- FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
-
- try {
- CoroutineScope(Dispatchers.IO).launch {
- FileUtil.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) {
- MessageDialogFragment.newInstance(
- requireActivity(),
- titleId = R.string.save_file_invalid_zip_structure,
- descriptionId = R.string.save_file_invalid_zip_structure_description
- ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
- return@withContext
- }
- Toast.makeText(
- context,
- context.getString(R.string.save_file_imported_success),
- Toast.LENGTH_LONG
- ).show()
- }
-
- cacheSaveDir.deleteRecursively()
- }
- } catch (e: Exception) {
- Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
- .show()
- }
- }
-
- companion object {
- const val TAG = "ImportExportSavesFragment"
- }
-}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
new file mode 100644
index 000000000..ec116ab62
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
@@ -0,0 +1,138 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.recyclerview.widget.GridLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.InstallableAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.Installable
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+
+class InstallableFragment : Fragment() {
+ private var _binding: FragmentInstallablesBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentInstallablesBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val mainActivity = requireActivity() as MainActivity
+
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarInstallables.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ val installables = listOf(
+ Installable(
+ R.string.user_data,
+ R.string.user_data_description,
+ install = { mainActivity.importUserData.launch(arrayOf("application/zip")) },
+ export = { mainActivity.exportUserData.launch("export.zip") }
+ ),
+ Installable(
+ R.string.install_game_content,
+ R.string.install_game_content_description,
+ install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) }
+ ),
+ Installable(
+ R.string.install_firmware,
+ R.string.install_firmware_description,
+ install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) }
+ ),
+ if (mainActivity.savesFolderRoot != "") {
+ Installable(
+ R.string.manage_save_data,
+ R.string.import_export_saves_description,
+ install = { mainActivity.importSaves.launch(arrayOf("application/zip")) },
+ export = { mainActivity.exportSave() }
+ )
+ } else {
+ Installable(
+ R.string.manage_save_data,
+ R.string.import_export_saves_description,
+ install = { mainActivity.importSaves.launch(arrayOf("application/zip")) }
+ )
+ },
+ Installable(
+ R.string.install_prod_keys,
+ R.string.install_prod_keys_description,
+ install = { mainActivity.getProdKey.launch(arrayOf("*/*")) }
+ ),
+ Installable(
+ R.string.install_amiibo_keys,
+ R.string.install_amiibo_keys_description,
+ install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }
+ )
+ )
+
+ binding.listInstallables.apply {
+ layoutManager = GridLayoutManager(
+ requireContext(),
+ resources.getInteger(R.integer.grid_columns)
+ )
+ adapter = InstallableAdapter(installables)
+ }
+
+ setInsets()
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.toolbarInstallables.layoutParams as ViewGroup.MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.toolbarInstallables.layoutParams = mlpAppBar
+
+ val mlpScrollAbout =
+ binding.listInstallables.layoutParams as ViewGroup.MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.listInstallables.layoutParams = mlpScrollAbout
+
+ binding.listInstallables.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt
new file mode 100644
index 000000000..36a7c97b8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.annotation.StringRes
+
+data class Installable(
+ @StringRes val titleId: Int,
+ @StringRes val descriptionId: Int,
+ val install: (() -> Unit)? = null,
+ val export: (() -> Unit)? = null
+)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index 6fa847631..1164dfe94 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.ui.main
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.provider.DocumentsContract
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager
@@ -19,6 +20,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -29,6 +31,7 @@ import androidx.preference.PreferenceManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationBarView
+import kotlinx.coroutines.CoroutineScope
import java.io.File
import java.io.FilenameFilter
import java.io.IOException
@@ -41,9 +44,11 @@ import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.features.DocumentProvider
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
+import org.yuzu.yuzu_emu.getPublicFilesDir
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.model.TaskViewModel
@@ -52,6 +57,8 @@ import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@@ -65,6 +72,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
override var themeId: Int = 0
+ private val savesFolder
+ get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
+
+ // Get first subfolder in saves folder (should be the user folder)
+ val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
+ private var lastZipCreated: File? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@@ -727,4 +741,152 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
return@newInstance getString(R.string.user_data_import_success)
}.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
}
+
+ /**
+ * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
+ * @return true if the zip file is successfully created, false otherwise.
+ */
+ private fun zipSave(): Boolean {
+ try {
+ val tempFolder = File(getPublicFilesDir().canonicalPath, "temp")
+ tempFolder.mkdirs()
+ val saveFolder = File(savesFolderRoot)
+ val outputZipFile = File(
+ tempFolder,
+ "yuzu saves - ${
+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
+ }.zip"
+ )
+ 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
+ }
+
+ /**
+ * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
+ */
+ fun exportSave() {
+ CoroutineScope(Dispatchers.IO).launch {
+ val wasZipCreated = zipSave()
+ val lastZipFile = lastZipCreated
+ if (!wasZipCreated || lastZipFile == null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.export_save_failed),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ return@launch
+ }
+
+ withContext(Dispatchers.Main) {
+ val file = DocumentFile.fromSingleUri(
+ this@MainActivity,
+ DocumentsContract.buildDocumentUri(
+ DocumentProvider.AUTHORITY,
+ "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.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,
+ getString(R.string.share_save_file)
+ )
+ )
+ }
+ }
+ }
+
+ private val startForResultExportSave =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
+ File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
+ }
+
+ val importSaves =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ NativeLibrary.initializeEmptyUserDirectory()
+
+ val inputZip = applicationContext.contentResolver.openInputStream(result)
+ // 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("${applicationContext.cacheDir.path}/saves/")
+ cacheSaveDir.mkdir()
+
+ if (inputZip == null) {
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ return@registerForActivityResult
+ }
+
+ val filterTitleId =
+ FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
+
+ try {
+ CoroutineScope(Dispatchers.IO).launch {
+ FileUtil.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) {
+ MessageDialogFragment.newInstance(
+ this@MainActivity,
+ titleId = R.string.save_file_invalid_zip_structure,
+ descriptionId = R.string.save_file_invalid_zip_structure_description
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ return@withContext
+ }
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.save_file_imported_success),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ cacheSaveDir.deleteRecursively()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
}