From ab55aa0871322fba28b5f8f76105f405fc090c68 Mon Sep 17 00:00:00 2001 From: pineappleEA Date: Thu, 1 Jun 2023 16:56:06 +0200 Subject: [PATCH] early-access version 3628 --- CMakeModules/DownloadExternals.cmake | 3 +- README.md | 2 +- .../yuzu_emu/features/DocumentProvider.kt | 2 + .../fragments/HomeSettingsFragment.kt | 5 + .../fragments/ImportExportSavesFragment.kt | 234 ++++++++++++++++++ .../yuzu/yuzu_emu/utils/ForegroundService.kt | 5 +- .../app/src/main/res/drawable/ic_save.xml | 10 + .../app/src/main/res/values/strings.xml | 7 + src/audio_core/sink/sink_stream.cpp | 3 + src/input_common/drivers/virtual_amiibo.cpp | 8 +- 10 files changed, 271 insertions(+), 8 deletions(-) create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt create mode 100755 src/android/app/src/main/res/drawable/ic_save.xml diff --git a/CMakeModules/DownloadExternals.cmake b/CMakeModules/DownloadExternals.cmake index cfd1dab0f..3d4ace79e 100755 --- a/CMakeModules/DownloadExternals.cmake +++ b/CMakeModules/DownloadExternals.cmake @@ -17,8 +17,7 @@ elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(package_repo "ext-linux-bin/raw/main/") set(package_extension ".tar.xz") elseif (ANDROID) - set(package_base_url "https://gitlab.com/tertius42/") - set(package_repo "ext-android-bin/-/raw/main/") + set(package_repo "ext-android-bin/raw/main/") set(package_extension ".tar.xz") else() message(FATAL_ERROR "No package available for this platform") diff --git a/README.md b/README.md index eecf8c2f2..2890d10dd 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ yuzu emulator early access ============= -This is the source code for early-access 3627. +This is the source code for early-access 3628. ## Legal Notice diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt index e6e9a6fe8..4c3a9ca80 100755 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt @@ -13,6 +13,7 @@ import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.DocumentsProvider import android.webkit.MimeTypeMap +import org.yuzu.yuzu_emu.BuildConfig import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.getPublicFilesDir @@ -43,6 +44,7 @@ class DocumentProvider : DocumentsProvider() { DocumentsContract.Document.COLUMN_SIZE ) + const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user" const val ROOT_ID: String = "root" } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 3a334a74c..7cd2409df 100755 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -98,6 +98,11 @@ class HomeSettingsFragment : Fragment() { R.string.select_games_folder_description, R.drawable.ic_add ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, + HomeSetting( + R.string.import_export_saves, + R.string.import_export_saves_description, + R.drawable.ic_save + ) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) }, HomeSetting( R.string.install_prod_keys, R.string.install_prod_keys_description, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt new file mode 100755 index 000000000..6f5068bb5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.DocumentProvider +import org.yuzu.yuzu_emu.getPublicFilesDir +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.FilenameFilter +import java.io.IOException +import java.io.InputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +class ImportExportSavesFragment : DialogFragment() { + private val context = YuzuApplication.appContext + private val savesFolder = + "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000" + + // Get first subfolder in saves folder (should be the user folder) + private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: "" + private var lastZipCreated: File? = null + + private lateinit var startForResultExportSave: ActivityResultLauncher + private lateinit var documentPicker: ActivityResultLauncher> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val activityResultRegistry = requireActivity().activityResultRegistry + startForResultExportSave = activityResultRegistry.register( + "startForResultExportSaveKey", + ActivityResultContracts.StartActivityForResult() + ) { + File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively() + } + documentPicker = activityResultRegistry.register( + "documentPickerKey", + ActivityResultContracts.OpenDocument() + ) { + it?.let { uri -> importSave(uri) } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return if (savesFolderRoot == "") { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.import_export_saves) + .setMessage(R.string.import_export_saves_no_profile) + .setPositiveButton(android.R.string.ok, null) + .show() + } else { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.import_export_saves) + .setPositiveButton(R.string.export_saves) { _, _ -> + exportSave() + } + .setNeutralButton(R.string.import_saves) { _, _ -> + documentPicker.launch(arrayOf("application/zip")) + } + .show() + } + } + + /** + * Zips the save files located in the given folder path and creates a new zip file with the current date and time. + * @return true if the zip file is successfully created, false otherwise. + */ + private fun zipSave(): Boolean { + try { + val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp") + tempFolder.mkdirs() + val saveFolder = File(savesFolderRoot) + val outputZipFile = File( + tempFolder, "yuzu saves - ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}.zip" + ) + outputZipFile.createNewFile() + ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos -> + saveFolder.walkTopDown().forEach { file -> + val zipFileName = + file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/") + if (zipFileName == "") + return@forEach + val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}") + zos.putNextEntry(entry) + if (file.isFile) + file.inputStream().use { fis -> fis.copyTo(zos) } + } + } + lastZipCreated = outputZipFile + } catch (e: Exception) { + return false + } + return true + } + + /** + * Extracts the save files located in the given zip file and copies them to the saves folder. + * @exception IOException if the file was being created outside of the target directory + */ + private fun unzip(zipStream: InputStream, destDir: File): Boolean { + val zis = ZipInputStream(BufferedInputStream(zipStream)) + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + val entryName = entry.name + val entryFile = File(destDir, entryName) + if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { + zis.close() + throw IOException("Entry is outside of the target dir: " + entryFile.name) + } + if (entry.isDirectory) { + entryFile.mkdirs() + } else { + entryFile.parentFile?.mkdirs() + entryFile.createNewFile() + entryFile.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + } + zis.close() + return true + } + + /** + * Exports the save file located in the given folder path by creating a zip file and sharing it via intent. + */ + private fun exportSave() { + CoroutineScope(Dispatchers.IO).launch { + val wasZipCreated = zipSave() + val lastZipFile = lastZipCreated + if (!wasZipCreated || lastZipFile == null) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show() + } + return@launch + } + + withContext(Dispatchers.Main) { + val file = DocumentFile.fromSingleUri( + context, DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}" + ) + )!! + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(file.uri, "application/zip") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, file.uri) + startForResultExportSave.launch(Intent.createChooser(intent, "Share save file")) + } + } + } + + /** + * Imports the save files contained in the zip file, and replaces any existing ones with the new save file. + * @param zipUri The Uri of the zip file containing the save file(s) to import. + */ + private fun importSave(zipUri: Uri) { + val inputZip = context.contentResolver.openInputStream(zipUri) + // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid. + var validZip = false + val savesFolder = File(savesFolderRoot) + val cacheSaveDir = File("${context.cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + if (inputZip == null) { + Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) + .show() + return + } + + val filterTitleId = + FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) } + + try { + CoroutineScope(Dispatchers.IO).launch { + unzip(inputZip, cacheSaveDir) + cacheSaveDir.list(filterTitleId)?.forEach { savePath -> + File(savesFolder, savePath).deleteRecursively() + File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true) + validZip = true + } + + withContext(Dispatchers.Main) { + if (!validZip) { + Toast.makeText( + context, + context.getString(R.string.save_file_invalid_zip_structure), + Toast.LENGTH_LONG + ).show() + return@withContext + } + Toast.makeText( + context, + context.getString(R.string.save_file_imported_success), + Toast.LENGTH_LONG + ).show() + } + + cacheSaveDir.deleteRecursively() + } + } catch (e: Exception) { + Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) + .show() + } + } + + companion object { + const val TAG = "ImportExportSavesFragment" + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt index 626123966..dc9b7c744 100755 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt @@ -52,7 +52,10 @@ class ForegroundService : Service() { showRunningNotification() } - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + return START_NOT_STICKY; + } if (intent.action == ACTION_STOP) { NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION) stopForeground(STOP_FOREGROUND_REMOVE) diff --git a/src/android/app/src/main/res/drawable/ic_save.xml b/src/android/app/src/main/res/drawable/ic_save.xml new file mode 100755 index 000000000..a9af3d9cf --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index cffa3ff0b..d3340fe99 100755 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -80,6 +80,13 @@ No file manager found Could not open yuzu directory Please locate the user folder with the file manager\'s side panel manually. + Import/export saves + Import or export save files + No user profile found. Please launch a game first and retry. + Save files were imported successfully + Invalid save directory structure: The first subfolder name must be the title ID of the game. + Import + Export Gaia isn\'t real diff --git a/src/audio_core/sink/sink_stream.cpp b/src/audio_core/sink/sink_stream.cpp index 05c971387..da7b4c846 100755 --- a/src/audio_core/sink/sink_stream.cpp +++ b/src/audio_core/sink/sink_stream.cpp @@ -272,9 +272,12 @@ void SinkStream::WaitFreeSpace() { std::unique_lock lk{release_mutex}; release_cv.wait_for(lk, std::chrono::milliseconds(5), [this]() { return queued_buffers < max_queue_size; }); +#ifndef ANDROID + // This wait can cause a problematic shutdown hang on Android. if (queued_buffers > max_queue_size + 3) { release_cv.wait(lk, [this]() { return queued_buffers < max_queue_size; }); } +#endif } } // namespace AudioCore::Sink diff --git a/src/input_common/drivers/virtual_amiibo.cpp b/src/input_common/drivers/virtual_amiibo.cpp index 9d159f5fa..2135d69e5 100755 --- a/src/input_common/drivers/virtual_amiibo.cpp +++ b/src/input_common/drivers/virtual_amiibo.cpp @@ -82,14 +82,14 @@ VirtualAmiibo::Info VirtualAmiibo::LoadAmiibo(const std::string& filename) { switch (nfc_file.GetSize()) { case AmiiboSize: case AmiiboSizeWithoutPassword: - nfc_data.resize(AmiiboSize); - if (nfc_file.Read(nfc_data) < AmiiboSizeWithoutPassword) { + data.resize(AmiiboSize); + if (nfc_file.Read(data) < AmiiboSizeWithoutPassword) { return Info::NotAnAmiibo; } break; case MifareSize: - nfc_data.resize(MifareSize); - if (nfc_file.Read(nfc_data) < MifareSize) { + data.resize(MifareSize); + if (nfc_file.Read(data) < MifareSize) { return Info::NotAnAmiibo; } break;