forked from eden-emu/eden
		
	Merge pull request #12576 from t895/total-save-manager
android: Re-add global save manager
This commit is contained in:
		
						commit
						2d388e1391
					
				
					 6 changed files with 264 additions and 0 deletions
				
			
		|  | @ -547,6 +547,15 @@ object NativeLibrary { | ||||||
|      */ |      */ | ||||||
|     external fun getSavePath(programId: String): String |     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 |      * 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 |      * @param path Path to the file we're adding. Can be a string representation of a [Uri] or | ||||||
|  |  | ||||||
|  | @ -7,20 +7,39 @@ import android.os.Bundle | ||||||
| import android.view.LayoutInflater | import android.view.LayoutInflater | ||||||
| import android.view.View | import android.view.View | ||||||
| import android.view.ViewGroup | import android.view.ViewGroup | ||||||
|  | import android.widget.Toast | ||||||
|  | import androidx.activity.result.contract.ActivityResultContracts | ||||||
| import androidx.core.view.ViewCompat | import androidx.core.view.ViewCompat | ||||||
| import androidx.core.view.WindowInsetsCompat | import androidx.core.view.WindowInsetsCompat | ||||||
| import androidx.core.view.updatePadding | import androidx.core.view.updatePadding | ||||||
| import androidx.fragment.app.Fragment | import androidx.fragment.app.Fragment | ||||||
| import androidx.fragment.app.activityViewModels | import androidx.fragment.app.activityViewModels | ||||||
|  | import androidx.lifecycle.Lifecycle | ||||||
|  | import androidx.lifecycle.lifecycleScope | ||||||
|  | import androidx.lifecycle.repeatOnLifecycle | ||||||
| import androidx.navigation.findNavController | import androidx.navigation.findNavController | ||||||
| import androidx.recyclerview.widget.GridLayoutManager | import androidx.recyclerview.widget.GridLayoutManager | ||||||
| import com.google.android.material.transition.MaterialSharedAxis | 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.R | ||||||
|  | import org.yuzu.yuzu_emu.YuzuApplication | ||||||
| import org.yuzu.yuzu_emu.adapters.InstallableAdapter | import org.yuzu.yuzu_emu.adapters.InstallableAdapter | ||||||
| import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding | import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding | ||||||
| import org.yuzu.yuzu_emu.model.HomeViewModel | import org.yuzu.yuzu_emu.model.HomeViewModel | ||||||
| import org.yuzu.yuzu_emu.model.Installable | 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.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() { | class InstallableFragment : Fragment() { | ||||||
|     private var _binding: FragmentInstallablesBinding? = null |     private var _binding: FragmentInstallablesBinding? = null | ||||||
|  | @ -56,6 +75,17 @@ class InstallableFragment : Fragment() { | ||||||
|             binding.root.findNavController().popBackStack() |             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( |         val installables = listOf( | ||||||
|             Installable( |             Installable( | ||||||
|                 R.string.user_data, |                 R.string.user_data, | ||||||
|  | @ -63,6 +93,43 @@ class InstallableFragment : Fragment() { | ||||||
|                 install = { mainActivity.importUserData.launch(arrayOf("application/zip")) }, |                 install = { mainActivity.importUserData.launch(arrayOf("application/zip")) }, | ||||||
|                 export = { mainActivity.exportUserData.launch("export.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( |             Installable( | ||||||
|                 R.string.install_game_content, |                 R.string.install_game_content, | ||||||
|                 R.string.install_game_content_description, |                 R.string.install_game_content_description, | ||||||
|  | @ -121,4 +188,156 @@ class InstallableFragment : Fragment() { | ||||||
| 
 | 
 | ||||||
|             windowInsets |             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) | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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 Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj, | ||||||
|                                                           jstring jprogramId) { |                                                           jstring jprogramId) { | ||||||
|     auto program_id = EmulationSession::GetProgramId(env, jprogramId); |     auto program_id = EmulationSession::GetProgramId(env, jprogramId); | ||||||
|  |     if (program_id == 0) { | ||||||
|  |         return ToJString(env, ""); | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     auto& system = EmulationSession::GetInstance().System(); |     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); |     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, | void Java_org_yuzu_yuzu_1emu_NativeLibrary_addFileToFilesystemProvider(JNIEnv* env, jobject jobj, | ||||||
|                                                                        jstring jpath) { |                                                                        jstring jpath) { | ||||||
|     EmulationSession::GetInstance().ConfigureFilesystemProvider(GetJString(env, jpath)); |     EmulationSession::GetInstance().ConfigureFilesystemProvider(GetJString(env, jpath)); | ||||||
|  |  | ||||||
|  | @ -133,6 +133,15 @@ | ||||||
|     <string name="add_game_folder">Add game folder</string> |     <string name="add_game_folder">Add game folder</string> | ||||||
|     <string name="folder_already_added">This folder was already added!</string> |     <string name="folder_already_added">This folder was already added!</string> | ||||||
|     <string name="game_folder_properties">Game folder properties</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 --> |     <!-- Applet launcher strings --> | ||||||
|     <string name="applets">Applet launcher</string> |     <string name="applets">Applet launcher</string> | ||||||
|  | @ -276,6 +285,7 @@ | ||||||
|     <string name="global">Global</string> |     <string name="global">Global</string> | ||||||
|     <string name="custom">Custom</string> |     <string name="custom">Custom</string> | ||||||
|     <string name="notice">Notice</string> |     <string name="notice">Notice</string> | ||||||
|  |     <string name="import_complete">Import complete</string> | ||||||
| 
 | 
 | ||||||
|     <!-- GPU driver installation --> |     <!-- GPU driver installation --> | ||||||
|     <string name="select_gpu_driver">Select GPU driver</string> |     <string name="select_gpu_driver">Select GPU driver</string> | ||||||
|  |  | ||||||
|  | @ -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, | SaveDataSize SaveDataFactory::ReadSaveDataSize(SaveDataType type, u64 title_id, | ||||||
|                                                u128 user_id) const { |                                                u128 user_id) const { | ||||||
|     const auto path = |     const auto path = | ||||||
|  |  | ||||||
|  | @ -101,6 +101,7 @@ public: | ||||||
|     static std::string GetSaveDataSpaceIdPath(SaveDataSpaceId space); |     static std::string GetSaveDataSpaceIdPath(SaveDataSpaceId space); | ||||||
|     static std::string GetFullPath(Core::System& system, VirtualDir dir, SaveDataSpaceId space, |     static std::string GetFullPath(Core::System& system, VirtualDir dir, SaveDataSpaceId space, | ||||||
|                                    SaveDataType type, u64 title_id, u128 user_id, u64 save_id); |                                    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; |     SaveDataSize ReadSaveDataSize(SaveDataType type, u64 title_id, u128 user_id) const; | ||||||
|     void WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id, |     void WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id, | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 liamwhite
						liamwhite