diff options
6 files changed, 264 insertions, 0 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 010c44951..b7556e353 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 @@ -548,6 +548,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       * a normal path 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 569727b90..5b4bf2c9f 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, @@ -64,6 +94,43 @@ class InstallableFragment : Fragment() {                  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,                  install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) } @@ -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 056920a4a..136c8dee6 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 83aa1b781..3bb92ad67 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 8d5d593e8..12b3bd797 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 e3a0f8cef..fd4887e99 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, | 
