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>
This commit is contained in:
		@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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));
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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 =
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user