early-access version 3628
This commit is contained in:
parent
8e874ed498
commit
ab55aa0871
@ -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")
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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<Intent>
|
||||
private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
10
src/android/app/src/main/res/drawable/ic_save.xml
Executable file
10
src/android/app/src/main/res/drawable/ic_save.xml
Executable file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L647,120Q663,120 677.5,126Q692,132 703,143L817,257Q828,268 834,282.5Q840,297 840,313L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM280,400L560,400Q577,400 588.5,388.5Q600,377 600,360L600,280Q600,263 588.5,251.5Q577,240 560,240L280,240Q263,240 251.5,251.5Q240,263 240,280L240,360Q240,377 251.5,388.5Q263,400 280,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z"/>
|
||||
</vector>
|
@ -80,6 +80,13 @@
|
||||
<string name="no_file_manager">No file manager found</string>
|
||||
<string name="notification_no_directory_link">Could not open yuzu directory</string>
|
||||
<string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string>
|
||||
<string name="import_export_saves">Import/export saves</string>
|
||||
<string name="import_export_saves_description">Import or export save files</string>
|
||||
<string name="import_export_saves_no_profile">No user profile found. Please launch a game first and retry.</string>
|
||||
<string name="save_file_imported_success">Save files were imported successfully</string>
|
||||
<string name="save_file_invalid_zip_structure">Invalid save directory structure: The first subfolder name must be the title ID of the game.</string>
|
||||
<string name="import_saves">Import</string>
|
||||
<string name="export_saves">Export</string>
|
||||
|
||||
<!-- About screen strings -->
|
||||
<string name="gaia_is_not_real">Gaia isn\'t real</string>
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user