From 53d4dbacf0e10cfcc5e43f0db3e811f326ba281d Mon Sep 17 00:00:00 2001
From: t895 <clombardo169@gmail.com>
Date: Tue, 2 Jan 2024 21:51:28 -0500
Subject: [PATCH] android: Re-add global save manager

Reworked to correctly collect and import/export saves that could exist in either /nand/user/save/000...000/<user id> or /nand/user/save/account/<user id raw string>
---
 .../java/org/yuzu/yuzu_emu/NativeLibrary.kt   |   9 +
 .../yuzu_emu/fragments/InstallableFragment.kt | 219 ++++++++++++++++++
 src/android/app/src/main/jni/native.cpp       |  16 ++
 .../app/src/main/res/values/strings.xml       |  10 +
 src/core/file_sys/savedata_factory.cpp        |   9 +
 src/core/file_sys/savedata_factory.h          |   1 +
 6 files changed, 264 insertions(+)

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 010c449514..b7556e3530 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
@@ -547,6 +547,15 @@ object NativeLibrary {
      */
     external fun getSavePath(programId: String): String
 
+    /**
+     * Gets the root save directory for the default profile as either
+     * /user/save/account/<user id raw string> or /user/save/000...000/<user id>
+     *
+     * @param future If true, returns the /user/save/account/... directory
+     * @return Save data path that may not exist yet
+     */
+    external fun getDefaultProfileSaveDataRoot(future: Boolean): String
+
     /**
      * Adds a file to the manual filesystem provider in our EmulationSession instance
      * @param path Path to the file we're adding. Can be a string representation of a [Uri] or
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
index 569727b901..5b4bf2c9ff 100644
--- 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
@@ -7,20 +7,39 @@ import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
 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.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.navigation.findNavController
 import androidx.recyclerview.widget.GridLayoutManager
 import com.google.android.material.transition.MaterialSharedAxis
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
 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.model.TaskState
 import org.yuzu.yuzu_emu.ui.main.MainActivity
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.FileUtil
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.File
+import java.math.BigInteger
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
 
 class InstallableFragment : Fragment() {
     private var _binding: FragmentInstallablesBinding? = null
@@ -56,6 +75,17 @@ class InstallableFragment : Fragment() {
             binding.root.findNavController().popBackStack()
         }
 
+        viewLifecycleOwner.lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.CREATED) {
+                homeViewModel.openImportSaves.collect {
+                    if (it) {
+                        importSaves.launch(arrayOf("application/zip"))
+                        homeViewModel.setOpenImportSaves(false)
+                    }
+                }
+            }
+        }
+
         val installables = listOf(
             Installable(
                 R.string.user_data,
@@ -63,6 +93,43 @@ class InstallableFragment : Fragment() {
                 install = { mainActivity.importUserData.launch(arrayOf("application/zip")) },
                 export = { mainActivity.exportUserData.launch("export.zip") }
             ),
+            Installable(
+                R.string.manage_save_data,
+                R.string.manage_save_data_description,
+                install = {
+                    MessageDialogFragment.newInstance(
+                        requireActivity(),
+                        titleId = R.string.import_save_warning,
+                        descriptionId = R.string.import_save_warning_description,
+                        positiveAction = { homeViewModel.setOpenImportSaves(true) }
+                    ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                },
+                export = {
+                    val oldSaveDataFolder = File(
+                        "${DirectoryInitialization.userDirectory}/nand" +
+                            NativeLibrary.getDefaultProfileSaveDataRoot(false)
+                    )
+                    val futureSaveDataFolder = File(
+                        "${DirectoryInitialization.userDirectory}/nand" +
+                            NativeLibrary.getDefaultProfileSaveDataRoot(true)
+                    )
+                    if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) {
+                        Toast.makeText(
+                            YuzuApplication.appContext,
+                            R.string.no_save_data_found,
+                            Toast.LENGTH_SHORT
+                        ).show()
+                        return@Installable
+                    } else {
+                        exportSaves.launch(
+                            "${getString(R.string.save_data)} " +
+                                LocalDateTime.now().format(
+                                    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
+                                )
+                        )
+                    }
+                }
+            ),
             Installable(
                 R.string.install_game_content,
                 R.string.install_game_content_description,
@@ -121,4 +188,156 @@ class InstallableFragment : Fragment() {
 
             windowInsets
         }
+
+    private val importSaves =
+        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+            if (result == null) {
+                return@registerForActivityResult
+            }
+
+            val inputZip = requireContext().contentResolver.openInputStream(result)
+            val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
+            cacheSaveDir.mkdir()
+
+            if (inputZip == null) {
+                Toast.makeText(
+                    YuzuApplication.appContext,
+                    getString(R.string.fatal_error),
+                    Toast.LENGTH_LONG
+                ).show()
+                return@registerForActivityResult
+            }
+
+            IndeterminateProgressDialogFragment.newInstance(
+                requireActivity(),
+                R.string.save_files_importing,
+                false
+            ) {
+                try {
+                    FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
+                    val files = cacheSaveDir.listFiles()
+                    var successfulImports = 0
+                    var failedImports = 0
+                    if (files != null) {
+                        for (file in files) {
+                            if (file.isDirectory) {
+                                val baseSaveDir =
+                                    NativeLibrary.getSavePath(BigInteger(file.name, 16).toString())
+                                if (baseSaveDir.isEmpty()) {
+                                    failedImports++
+                                    continue
+                                }
+
+                                val internalSaveFolder = File(
+                                    "${DirectoryInitialization.userDirectory}/nand$baseSaveDir"
+                                )
+                                internalSaveFolder.deleteRecursively()
+                                internalSaveFolder.mkdir()
+                                file.copyRecursively(target = internalSaveFolder, overwrite = true)
+                                successfulImports++
+                            }
+                        }
+                    }
+
+                    withContext(Dispatchers.Main) {
+                        if (successfulImports == 0) {
+                            MessageDialogFragment.newInstance(
+                                requireActivity(),
+                                titleId = R.string.save_file_invalid_zip_structure,
+                                descriptionId = R.string.save_file_invalid_zip_structure_description
+                            ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                            return@withContext
+                        }
+                        val successString = if (failedImports > 0) {
+                            """
+                            ${
+                            requireContext().resources.getQuantityString(
+                                R.plurals.saves_import_success,
+                                successfulImports,
+                                successfulImports
+                            )
+                            }
+                            ${
+                            requireContext().resources.getQuantityString(
+                                R.plurals.saves_import_failed,
+                                failedImports,
+                                failedImports
+                            )
+                            }
+                            """
+                        } else {
+                            requireContext().resources.getQuantityString(
+                                R.plurals.saves_import_success,
+                                successfulImports,
+                                successfulImports
+                            )
+                        }
+                        MessageDialogFragment.newInstance(
+                            requireActivity(),
+                            titleId = R.string.import_complete,
+                            descriptionString = successString
+                        ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                    }
+
+                    cacheSaveDir.deleteRecursively()
+                } catch (e: Exception) {
+                    Toast.makeText(
+                        YuzuApplication.appContext,
+                        getString(R.string.fatal_error),
+                        Toast.LENGTH_LONG
+                    ).show()
+                }
+            }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+        }
+
+    private val exportSaves = registerForActivityResult(
+        ActivityResultContracts.CreateDocument("application/zip")
+    ) { result ->
+        if (result == null) {
+            return@registerForActivityResult
+        }
+
+        IndeterminateProgressDialogFragment.newInstance(
+            requireActivity(),
+            R.string.save_files_exporting,
+            false
+        ) {
+            val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
+            cacheSaveDir.mkdir()
+
+            val oldSaveDataFolder = File(
+                "${DirectoryInitialization.userDirectory}/nand" +
+                    NativeLibrary.getDefaultProfileSaveDataRoot(false)
+            )
+            if (oldSaveDataFolder.exists()) {
+                oldSaveDataFolder.copyRecursively(cacheSaveDir)
+            }
+
+            val futureSaveDataFolder = File(
+                "${DirectoryInitialization.userDirectory}/nand" +
+                    NativeLibrary.getDefaultProfileSaveDataRoot(true)
+            )
+            if (futureSaveDataFolder.exists()) {
+                futureSaveDataFolder.copyRecursively(cacheSaveDir)
+            }
+
+            val saveFilesTotal = cacheSaveDir.listFiles()?.size ?: 0
+            if (saveFilesTotal == 0) {
+                cacheSaveDir.deleteRecursively()
+                return@newInstance getString(R.string.no_save_data_found)
+            }
+
+            val zipResult = FileUtil.zipFromInternalStorage(
+                cacheSaveDir,
+                cacheSaveDir.path,
+                BufferedOutputStream(requireContext().contentResolver.openOutputStream(result))
+            )
+            cacheSaveDir.deleteRecursively()
+
+            return@newInstance when (zipResult) {
+                TaskState.Completed -> getString(R.string.export_success)
+                TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
+            }
+        }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+    }
 }
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index 0c1db7d464..b0c00c1ea3 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -862,6 +862,9 @@ jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env,
 jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj,
                                                           jstring jprogramId) {
     auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    if (program_id == 0) {
+        return ToJString(env, "");
+    }
 
     auto& system = EmulationSession::GetInstance().System();
 
@@ -880,6 +883,19 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject j
     return ToJString(env, user_save_data_path);
 }
 
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getDefaultProfileSaveDataRoot(JNIEnv* env,
+                                                                            jobject jobj,
+                                                                            jboolean jfuture) {
+    Service::Account::ProfileManager manager;
+    // TODO: Pass in a selected user once we get the relevant UI working
+    const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
+    ASSERT(user_id);
+
+    const auto user_save_data_root =
+        FileSys::SaveDataFactory::GetUserGameSaveDataRoot(user_id->AsU128(), jfuture);
+    return ToJString(env, user_save_data_root);
+}
+
 void Java_org_yuzu_yuzu_1emu_NativeLibrary_addFileToFilesystemProvider(JNIEnv* env, jobject jobj,
                                                                        jstring jpath) {
     EmulationSession::GetInstance().ConfigureFilesystemProvider(GetJString(env, jpath));
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 1bedcb1ef7..fd18067a2d 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -133,6 +133,15 @@
     <string name="add_game_folder">Add game folder</string>
     <string name="folder_already_added">This folder was already added!</string>
     <string name="game_folder_properties">Game folder properties</string>
+    <plurals name="saves_import_failed">
+        <item quantity="one">Failed to import %d save</item>
+        <item quantity="other">Failed to import %d saves</item>
+    </plurals>
+    <plurals name="saves_import_success">
+        <item quantity="one">Successfully imported %d save</item>
+        <item quantity="other">Successfully imported %d saves</item>
+    </plurals>
+    <string name="no_save_data_found">No save data found</string>
 
     <!-- Applet launcher strings -->
     <string name="applets">Applet launcher</string>
@@ -276,6 +285,7 @@
     <string name="global">Global</string>
     <string name="custom">Custom</string>
     <string name="notice">Notice</string>
+    <string name="import_complete">Import complete</string>
 
     <!-- GPU driver installation -->
     <string name="select_gpu_driver">Select GPU driver</string>
diff --git a/src/core/file_sys/savedata_factory.cpp b/src/core/file_sys/savedata_factory.cpp
index 8d5d593e8a..12b3bd7973 100644
--- a/src/core/file_sys/savedata_factory.cpp
+++ b/src/core/file_sys/savedata_factory.cpp
@@ -189,6 +189,15 @@ std::string SaveDataFactory::GetFullPath(Core::System& system, VirtualDir dir,
     }
 }
 
+std::string SaveDataFactory::GetUserGameSaveDataRoot(u128 user_id, bool future) {
+    if (future) {
+        Common::UUID uuid;
+        std::memcpy(uuid.uuid.data(), user_id.data(), sizeof(Common::UUID));
+        return fmt::format("/user/save/account/{}", uuid.RawString());
+    }
+    return fmt::format("/user/save/{:016X}/{:016X}{:016X}", 0, user_id[1], user_id[0]);
+}
+
 SaveDataSize SaveDataFactory::ReadSaveDataSize(SaveDataType type, u64 title_id,
                                                u128 user_id) const {
     const auto path =
diff --git a/src/core/file_sys/savedata_factory.h b/src/core/file_sys/savedata_factory.h
index e3a0f8cef3..fd4887e997 100644
--- a/src/core/file_sys/savedata_factory.h
+++ b/src/core/file_sys/savedata_factory.h
@@ -101,6 +101,7 @@ public:
     static std::string GetSaveDataSpaceIdPath(SaveDataSpaceId space);
     static std::string GetFullPath(Core::System& system, VirtualDir dir, SaveDataSpaceId space,
                                    SaveDataType type, u64 title_id, u128 user_id, u64 save_id);
+    static std::string GetUserGameSaveDataRoot(u128 user_id, bool future);
 
     SaveDataSize ReadSaveDataSize(SaveDataType type, u64 title_id, u128 user_id) const;
     void WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id,