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 @@ -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/ or /user/save/000...000/ + * + * @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 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, @@ -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 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(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 @@ Add game folder This folder was already added! Game folder properties + + Failed to import %d save + Failed to import %d saves + + + Successfully imported %d save + Successfully imported %d saves + + No save data found Applet launcher @@ -276,6 +285,7 @@ Global Custom Notice + Import complete Select GPU driver 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,