diff options
Diffstat (limited to 'src/android/app')
26 files changed, 585 insertions, 261 deletions
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 b7556e353..1c9fb0675 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 @@ -21,6 +21,8 @@ import org.yuzu.yuzu_emu.utils.DocumentsTree import org.yuzu.yuzu_emu.utils.FileUtil import org.yuzu.yuzu_emu.utils.Log import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable +import org.yuzu.yuzu_emu.model.InstallResult +import org.yuzu.yuzu_emu.model.Patch /** * Class which contains methods that interact @@ -235,9 +237,12 @@ object NativeLibrary { /** * Installs a nsp or xci file to nand * @param filename String representation of file uri - * @param extension Lowercase string representation of file extension without "." + * @return int representation of [InstallResult] */ - external fun installFileToNand(filename: String, extension: String): Int + external fun installFileToNand( + filename: String, + callback: (max: Long, progress: Long) -> Boolean + ): Int external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean @@ -535,9 +540,29 @@ object NativeLibrary { * * @param path Path to game file. Can be a [Uri]. * @param programId String representation of a game's program ID - * @return Array of pairs where the first value is the name of an addon and the second is the version + * @return Array of available patches */ - external fun getAddonsForFile(path: String, programId: String): Array<Pair<String, String>>? + external fun getPatchesForFile(path: String, programId: String): Array<Patch>? + + /** + * Removes an update for a given [programId] + * @param programId String representation of a game's program ID + */ + external fun removeUpdate(programId: String) + + /** + * Removes all DLC for a [programId] + * @param programId String representation of a game's program ID + */ + external fun removeDLC(programId: String) + + /** + * Removes a mod installed for a given [programId] + * @param programId String representation of a game's program ID + * @param name The name of a mod as given by [getPatchesForFile]. This corresponds with the name + * of the mod's directory in a game's load folder. + */ + external fun removeMod(programId: String, name: String) /** * Gets the save location for a specific game @@ -609,15 +634,4 @@ object NativeLibrary { const val RELEASED = 0 const val PRESSED = 1 } - - /** - * Result from installFileToNand - */ - object InstallFileToNandResult { - const val Success = 0 - const val SuccessFileOverwritten = 1 - const val Error = 2 - const val ErrorBaseGame = 3 - const val ErrorFilenameExtension = 4 - } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt index 94c151325..ff254d9b7 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt @@ -6,27 +6,32 @@ package org.yuzu.yuzu_emu.adapters import android.view.LayoutInflater import android.view.ViewGroup import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding -import org.yuzu.yuzu_emu.model.Addon +import org.yuzu.yuzu_emu.model.Patch +import org.yuzu.yuzu_emu.model.AddonViewModel import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder -class AddonAdapter : AbstractDiffAdapter<Addon, AddonAdapter.AddonViewHolder>() { +class AddonAdapter(val addonViewModel: AddonViewModel) : + AbstractDiffAdapter<Patch, AddonAdapter.AddonViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder { ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false) .also { return AddonViewHolder(it) } } inner class AddonViewHolder(val binding: ListItemAddonBinding) : - AbstractViewHolder<Addon>(binding) { - override fun bind(model: Addon) { + AbstractViewHolder<Patch>(binding) { + override fun bind(model: Patch) { binding.root.setOnClickListener { - binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked + binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked } - binding.title.text = model.title + binding.title.text = model.name binding.version.text = model.version - binding.addonSwitch.setOnCheckedChangeListener { _, checked -> + binding.addonCheckbox.setOnCheckedChangeListener { _, checked -> model.enabled = checked } - binding.addonSwitch.isChecked = model.enabled + binding.addonCheckbox.isChecked = model.enabled + binding.buttonDelete.setOnClickListener { + addonViewModel.setAddonToDelete(model) + } } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt index 816336820..adb65812c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt @@ -74,7 +74,7 @@ class AddonsFragment : Fragment() { binding.listAddons.apply { layoutManager = LinearLayoutManager(requireContext()) - adapter = AddonAdapter() + adapter = AddonAdapter(addonViewModel) } viewLifecycleOwner.lifecycleScope.apply { @@ -110,6 +110,21 @@ class AddonsFragment : Fragment() { } } } + launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + addonViewModel.addonToDelete.collect { + if (it != null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.confirm_uninstall, + descriptionId = R.string.confirm_uninstall_description, + positiveAction = { addonViewModel.onDeleteAddon(it) } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + addonViewModel.setAddonToDelete(null) + } + } + } + } } binding.buttonInstall.setOnClickListener { @@ -156,22 +171,22 @@ class AddonsFragment : Fragment() { descriptionId = R.string.invalid_directory_description ) if (isValid) { - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( requireActivity(), R.string.installing_game_content, false - ) { + ) { progressCallback, _ -> val parentDirectoryName = externalAddonDirectory.name val internalAddonDirectory = File(args.game.addonDir + parentDirectoryName) try { - externalAddonDirectory.copyFilesTo(internalAddonDirectory) + externalAddonDirectory.copyFilesTo(internalAddonDirectory, progressCallback) } catch (_: Exception) { return@newInstance errorMessage } addonViewModel.refreshAddons() return@newInstance getString(R.string.addon_installed_successfully) - }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) } else { errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt index 9dabb9c41..6c758d80b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt @@ -173,11 +173,11 @@ class DriverManagerFragment : Fragment() { return@registerForActivityResult } - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( requireActivity(), R.string.installing_driver, false - ) { + ) { _, _ -> val driverPath = "${GpuDriverHelper.driverStoragePath}${FileUtil.getFilename(result)}" val driverFile = File(driverPath) @@ -213,6 +213,6 @@ class DriverManagerFragment : Fragment() { } } return@newInstance Any() - }.show(childFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(childFragmentManager, ProgressDialogFragment.TAG) } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt index b04d1208f..83a845434 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -44,7 +44,6 @@ import org.yuzu.yuzu_emu.utils.FileUtil import org.yuzu.yuzu_emu.utils.GameIconUtils import org.yuzu.yuzu_emu.utils.GpuDriverHelper import org.yuzu.yuzu_emu.utils.MemoryUtil -import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File @@ -357,27 +356,17 @@ class GamePropertiesFragment : Fragment() { return@registerForActivityResult } - val inputZip = requireContext().contentResolver.openInputStream(result) val savesFolder = File(args.game.saveDir) 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( + ProgressDialogFragment.newInstance( requireActivity(), R.string.save_files_importing, false - ) { + ) { _, _ -> try { - FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir) + FileUtil.unzipToInternalStorage(result.toString(), cacheSaveDir) val files = cacheSaveDir.listFiles() var savesFolderFile: File? = null if (files != null) { @@ -422,7 +411,7 @@ class GamePropertiesFragment : Fragment() { Toast.LENGTH_LONG ).show() } - }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) } /** @@ -436,11 +425,11 @@ class GamePropertiesFragment : Fragment() { return@registerForActivityResult } - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( requireActivity(), R.string.save_files_exporting, false - ) { + ) { _, _ -> val saveLocation = args.game.saveDir val zipResult = FileUtil.zipFromInternalStorage( File(saveLocation), @@ -452,6 +441,6 @@ class GamePropertiesFragment : Fragment() { TaskState.Completed -> getString(R.string.export_success) TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) } - }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) } } 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 5b4bf2c9f..7df8e6bf4 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 @@ -34,7 +34,6 @@ 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 @@ -195,26 +194,20 @@ class InstallableFragment : Fragment() { 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( + ProgressDialogFragment.newInstance( requireActivity(), R.string.save_files_importing, false - ) { + ) { progressCallback, _ -> try { - FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir) + FileUtil.unzipToInternalStorage( + result.toString(), + cacheSaveDir, + progressCallback + ) val files = cacheSaveDir.listFiles() var successfulImports = 0 var failedImports = 0 @@ -287,7 +280,7 @@ class InstallableFragment : Fragment() { Toast.LENGTH_LONG ).show() } - }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) } private val exportSaves = registerForActivityResult( @@ -297,11 +290,11 @@ class InstallableFragment : Fragment() { return@registerForActivityResult } - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( requireActivity(), R.string.save_files_exporting, false - ) { + ) { _, _ -> val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") cacheSaveDir.mkdir() @@ -338,6 +331,6 @@ class InstallableFragment : Fragment() { TaskState.Completed -> getString(R.string.export_success) TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) } - }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt index 8847e5531..d201cb80c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt @@ -23,11 +23,13 @@ import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding import org.yuzu.yuzu_emu.model.TaskViewModel -class IndeterminateProgressDialogFragment : DialogFragment() { +class ProgressDialogFragment : DialogFragment() { private val taskViewModel: TaskViewModel by activityViewModels() private lateinit var binding: DialogProgressBarBinding + private val PROGRESS_BAR_RESOLUTION = 1000 + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val titleId = requireArguments().getInt(TITLE) val cancellable = requireArguments().getBoolean(CANCELLABLE) @@ -61,6 +63,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.message.isSelected = true viewLifecycleOwner.lifecycleScope.apply { launch { repeatOnLifecycle(Lifecycle.State.CREATED) { @@ -97,6 +100,35 @@ class IndeterminateProgressDialogFragment : DialogFragment() { } } } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.progress.collect { + if (it != 0.0) { + binding.progressBar.apply { + isIndeterminate = false + progress = ( + (it / taskViewModel.maxProgress.value) * + PROGRESS_BAR_RESOLUTION + ).toInt() + min = 0 + max = PROGRESS_BAR_RESOLUTION + } + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.message.collect { + if (it.isEmpty()) { + binding.message.visibility = View.GONE + } else { + binding.message.visibility = View.VISIBLE + binding.message.text = it + } + } + } + } } } @@ -108,6 +140,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() { val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE) negativeButton.setOnClickListener { alertDialog.setTitle(getString(R.string.cancelling)) + binding.progressBar.isIndeterminate = true taskViewModel.setCancelled(true) } } @@ -122,9 +155,12 @@ class IndeterminateProgressDialogFragment : DialogFragment() { activity: FragmentActivity, titleId: Int, cancellable: Boolean = false, - task: suspend () -> Any - ): IndeterminateProgressDialogFragment { - val dialog = IndeterminateProgressDialogFragment() + task: suspend ( + progressCallback: (max: Long, progress: Long) -> Boolean, + messageCallback: (message: String) -> Unit + ) -> Any + ): ProgressDialogFragment { + val dialog = ProgressDialogFragment() val args = Bundle() ViewModelProvider(activity)[TaskViewModel::class.java].task = task args.putInt(TITLE, titleId) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt index 64b295fbd..20b10b1a0 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt @@ -136,14 +136,14 @@ class SearchFragment : Fragment() { baseList.filter { val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - } + }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } } R.id.chip_recently_added -> { baseList.filter { val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - } + }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } } R.id.chip_homebrew -> baseList.filter { it.isHomebrew } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt deleted file mode 100644 index ed79a8b02..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -data class Addon( - var enabled: Boolean, - val title: String, - val version: String -) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt index 075252f5b..b9c8e49ca 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt @@ -15,8 +15,8 @@ import org.yuzu.yuzu_emu.utils.NativeConfig import java.util.concurrent.atomic.AtomicBoolean class AddonViewModel : ViewModel() { - private val _addonList = MutableStateFlow(mutableListOf<Addon>()) - val addonList get() = _addonList.asStateFlow() + private val _patchList = MutableStateFlow(mutableListOf<Patch>()) + val addonList get() = _patchList.asStateFlow() private val _showModInstallPicker = MutableStateFlow(false) val showModInstallPicker get() = _showModInstallPicker.asStateFlow() @@ -24,6 +24,9 @@ class AddonViewModel : ViewModel() { private val _showModNoticeDialog = MutableStateFlow(false) val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow() + private val _addonToDelete = MutableStateFlow<Patch?>(null) + val addonToDelete = _addonToDelete.asStateFlow() + var game: Game? = null private val isRefreshing = AtomicBoolean(false) @@ -40,36 +43,47 @@ class AddonViewModel : ViewModel() { isRefreshing.set(true) viewModelScope.launch { withContext(Dispatchers.IO) { - val addonList = mutableListOf<Addon>() - val disabledAddons = NativeConfig.getDisabledAddons(game!!.programId) - NativeLibrary.getAddonsForFile(game!!.path, game!!.programId)?.forEach { - val name = it.first.replace("[D] ", "") - addonList.add(Addon(!disabledAddons.contains(name), name, it.second)) - } - addonList.sortBy { it.title } - _addonList.value = addonList + val patchList = ( + NativeLibrary.getPatchesForFile(game!!.path, game!!.programId) + ?: emptyArray() + ).toMutableList() + patchList.sortBy { it.name } + _patchList.value = patchList isRefreshing.set(false) } } } + fun setAddonToDelete(patch: Patch?) { + _addonToDelete.value = patch + } + + fun onDeleteAddon(patch: Patch) { + when (PatchType.from(patch.type)) { + PatchType.Update -> NativeLibrary.removeUpdate(patch.programId) + PatchType.DLC -> NativeLibrary.removeDLC(patch.programId) + PatchType.Mod -> NativeLibrary.removeMod(patch.programId, patch.name) + } + refreshAddons() + } + fun onCloseAddons() { - if (_addonList.value.isEmpty()) { + if (_patchList.value.isEmpty()) { return } NativeConfig.setDisabledAddons( game!!.programId, - _addonList.value.mapNotNull { + _patchList.value.mapNotNull { if (it.enabled) { null } else { - it.title + it.name } }.toTypedArray() ) NativeConfig.saveGlobalConfig() - _addonList.value.clear() + _patchList.value.clear() game = null } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt new file mode 100644 index 000000000..0c3cd0521 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +enum class InstallResult(val int: Int) { + Success(0), + Overwrite(1), + Failure(2), + BaseInstallAttempted(3); + + companion object { + fun from(int: Int): InstallResult = entries.firstOrNull { it.int == int } ?: Success + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt new file mode 100644 index 000000000..25cb9e365 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.annotation.Keep + +@Keep +data class Patch( + var enabled: Boolean, + val name: String, + val version: String, + val type: Int, + val programId: String, + val titleId: String +) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt new file mode 100644 index 000000000..e9a54162b --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +enum class PatchType(val int: Int) { + Update(0), + DLC(1), + Mod(2); + + companion object { + fun from(int: Int): PatchType = entries.firstOrNull { it.int == int } ?: Update + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt index e59c95733..4361eb972 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class TaskViewModel : ViewModel() { @@ -23,13 +24,28 @@ class TaskViewModel : ViewModel() { val cancelled: StateFlow<Boolean> get() = _cancelled private val _cancelled = MutableStateFlow(false) - lateinit var task: suspend () -> Any + private val _progress = MutableStateFlow(0.0) + val progress = _progress.asStateFlow() + + private val _maxProgress = MutableStateFlow(0.0) + val maxProgress = _maxProgress.asStateFlow() + + private val _message = MutableStateFlow("") + val message = _message.asStateFlow() + + lateinit var task: suspend ( + progressCallback: (max: Long, progress: Long) -> Boolean, + messageCallback: (message: String) -> Unit + ) -> Any fun clear() { _result.value = Any() _isComplete.value = false _isRunning.value = false _cancelled.value = false + _progress.value = 0.0 + _maxProgress.value = 0.0 + _message.value = "" } fun setCancelled(value: Boolean) { @@ -43,7 +59,16 @@ class TaskViewModel : ViewModel() { _isRunning.value = true viewModelScope.launch(Dispatchers.IO) { - val res = task() + val res = task( + { max, progress -> + _maxProgress.value = max.toDouble() + _progress.value = progress.toDouble() + return@task cancelled.value + }, + { message -> + _message.value = message + } + ) _result.value = res _isComplete.value = true _isRunning.value = false diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 644289e25..c2cc29961 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -38,12 +38,13 @@ import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.databinding.ActivityMainBinding import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment -import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment +import org.yuzu.yuzu_emu.fragments.ProgressDialogFragment import org.yuzu.yuzu_emu.fragments.MessageDialogFragment import org.yuzu.yuzu_emu.model.AddonViewModel import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.InstallResult import org.yuzu.yuzu_emu.model.TaskState import org.yuzu.yuzu_emu.model.TaskViewModel import org.yuzu.yuzu_emu.utils.* @@ -369,26 +370,23 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return@registerForActivityResult } - val inputZip = contentResolver.openInputStream(result) - if (inputZip == null) { - Toast.makeText( - applicationContext, - getString(R.string.fatal_error), - Toast.LENGTH_LONG - ).show() - return@registerForActivityResult - } - val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } val firmwarePath = File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") val cacheFirmwareDir = File("${cacheDir.path}/registered/") - val task: () -> Any = { + ProgressDialogFragment.newInstance( + this, + R.string.firmware_installing + ) { progressCallback, _ -> var messageToShow: Any try { - FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheFirmwareDir) + FileUtil.unzipToInternalStorage( + result.toString(), + cacheFirmwareDir, + progressCallback + ) val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { @@ -404,18 +402,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider { getString(R.string.save_file_imported_success) } } catch (e: Exception) { + Log.error("[MainActivity] Firmware install failed - ${e.message}") messageToShow = getString(R.string.fatal_error) } finally { cacheFirmwareDir.deleteRecursively() } messageToShow - } - - IndeterminateProgressDialogFragment.newInstance( - this, - R.string.firmware_installing, - task = task - ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) } val getAmiiboKey = @@ -474,11 +467,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return@registerForActivityResult } - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( this@MainActivity, R.string.verifying_content, false - ) { + ) { _, _ -> var updatesMatchProgram = true for (document in documents) { val valid = NativeLibrary.doesUpdateMatchProgram( @@ -501,44 +494,42 @@ class MainActivity : AppCompatActivity(), ThemeProvider { positiveAction = { homeViewModel.setContentToInstall(documents) } ) } - }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) } private fun installContent(documents: List<Uri>) { - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( this@MainActivity, R.string.installing_game_content - ) { + ) { progressCallback, messageCallback -> var installSuccess = 0 var installOverwrite = 0 var errorBaseGame = 0 - var errorExtension = 0 - var errorOther = 0 + var error = 0 documents.forEach { + messageCallback.invoke(FileUtil.getFilename(it)) when ( - NativeLibrary.installFileToNand( - it.toString(), - FileUtil.getExtension(it) + InstallResult.from( + NativeLibrary.installFileToNand( + it.toString(), + progressCallback + ) ) ) { - NativeLibrary.InstallFileToNandResult.Success -> { + InstallResult.Success -> { installSuccess += 1 } - NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> { + InstallResult.Overwrite -> { installOverwrite += 1 } - NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> { + InstallResult.BaseInstallAttempted -> { errorBaseGame += 1 } - NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> { - errorExtension += 1 - } - - else -> { - errorOther += 1 + InstallResult.Failure -> { + error += 1 } } } @@ -565,7 +556,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { ) installResult.append(separator) } - val errorTotal: Int = errorBaseGame + errorExtension + errorOther + val errorTotal: Int = errorBaseGame + error if (errorTotal > 0) { installResult.append(separator) installResult.append( @@ -582,14 +573,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { ) installResult.append(separator) } - if (errorExtension > 0) { - installResult.append(separator) - installResult.append( - getString(R.string.install_game_content_failure_file_extension) - ) - installResult.append(separator) - } - if (errorOther > 0) { + if (error > 0) { installResult.append( getString(R.string.install_game_content_failure_description) ) @@ -608,7 +592,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { descriptionString = installResult.toString().trim() ) } - }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) } val exportUserData = registerForActivityResult( @@ -618,16 +602,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return@registerForActivityResult } - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( this, R.string.exporting_user_data, true - ) { + ) { progressCallback, _ -> val zipResult = FileUtil.zipFromInternalStorage( File(DirectoryInitialization.userDirectory!!), DirectoryInitialization.userDirectory!!, BufferedOutputStream(contentResolver.openOutputStream(result)), - taskViewModel.cancelled, + progressCallback, compression = false ) return@newInstance when (zipResult) { @@ -635,7 +619,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { TaskState.Failed -> R.string.export_failed TaskState.Cancelled -> R.string.user_data_export_cancelled } - }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) } val importUserData = @@ -644,10 +628,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return@registerForActivityResult } - IndeterminateProgressDialogFragment.newInstance( + ProgressDialogFragment.newInstance( this, R.string.importing_user_data - ) { + ) { progressCallback, _ -> val checkStream = ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) var isYuzuBackup = false @@ -676,8 +660,9 @@ class MainActivity : AppCompatActivity(), ThemeProvider { // Copy archive to internal storage try { FileUtil.unzipToInternalStorage( - BufferedInputStream(contentResolver.openInputStream(result)), - File(DirectoryInitialization.userDirectory!!) + result.toString(), + File(DirectoryInitialization.userDirectory!!), + progressCallback ) } catch (e: Exception) { return@newInstance MessageDialogFragment.newInstance( @@ -694,6 +679,6 @@ class MainActivity : AppCompatActivity(), ThemeProvider { driverViewModel.reloadDriverData() return@newInstance getString(R.string.user_data_import_success) - }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt index b54a19c65..fc2339f5a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -7,7 +7,6 @@ import android.database.Cursor import android.net.Uri import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile -import kotlinx.coroutines.flow.StateFlow import java.io.BufferedInputStream import java.io.File import java.io.IOException @@ -19,6 +18,7 @@ import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.model.MinimalDocumentFile import org.yuzu.yuzu_emu.model.TaskState import java.io.BufferedOutputStream +import java.io.OutputStream import java.lang.NullPointerException import java.nio.charset.StandardCharsets import java.util.zip.Deflater @@ -283,12 +283,34 @@ object FileUtil { /** * Extracts the given zip file into the given directory. + * @param path String representation of a [Uri] or a typical path delimited by '/' + * @param destDir Location to unzip the contents of [path] into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. */ @Throws(SecurityException::class) - fun unzipToInternalStorage(zipStream: BufferedInputStream, destDir: File) { - ZipInputStream(zipStream).use { zis -> + fun unzipToInternalStorage( + path: String, + destDir: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { + var totalEntries = 0L + ZipInputStream(getInputStream(path)).use { zis -> + var tempEntry = zis.nextEntry + while (tempEntry != null) { + tempEntry = zis.nextEntry + totalEntries++ + } + } + + var progress = 0L + ZipInputStream(getInputStream(path)).use { zis -> var entry: ZipEntry? = zis.nextEntry while (entry != null) { + if (progressCallback.invoke(totalEntries, progress)) { + return@use + } + val newFile = File(destDir, entry.name) val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile @@ -304,6 +326,7 @@ object FileUtil { newFile.outputStream().use { fos -> zis.copyTo(fos) } } entry = zis.nextEntry + progress++ } } } @@ -313,14 +336,15 @@ object FileUtil { * @param inputFile File representation of the item that will be zipped * @param rootDir Directory containing the inputFile * @param outputStream Stream where the zip file will be output - * @param cancelled [StateFlow] that reports whether this process has been cancelled + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. * @param compression Disables compression if true */ fun zipFromInternalStorage( inputFile: File, rootDir: String, outputStream: BufferedOutputStream, - cancelled: StateFlow<Boolean>? = null, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }, compression: Boolean = true ): TaskState { try { @@ -330,8 +354,10 @@ object FileUtil { zos.setLevel(Deflater.NO_COMPRESSION) } + var count = 0L + val totalFiles = inputFile.walkTopDown().count().toLong() inputFile.walkTopDown().forEach { file -> - if (cancelled?.value == true) { + if (progressCallback.invoke(totalFiles, count)) { return TaskState.Cancelled } @@ -343,6 +369,7 @@ object FileUtil { if (file.isFile) { file.inputStream().use { fis -> fis.copyTo(zos) } } + count++ } } } @@ -356,9 +383,14 @@ object FileUtil { /** * Helper function that copies the contents of a DocumentFile folder into a [File] * @param file [File] representation of the folder to copy into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa */ - fun DocumentFile.copyFilesTo(file: File) { + fun DocumentFile.copyFilesTo( + file: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { file.mkdirs() if (!this.isDirectory || !file.isDirectory) { throw IllegalStateException( @@ -366,7 +398,13 @@ object FileUtil { ) } + var count = 0L + val totalFiles = this.listFiles().size.toLong() this.listFiles().forEach { + if (progressCallback.invoke(totalFiles, count)) { + return + } + val newFile = File(file, it.name!!) if (it.isDirectory) { newFile.mkdirs() @@ -381,6 +419,7 @@ object FileUtil { newFile.outputStream().use { os -> bos.copyTo(os) } } } + count++ } } @@ -427,6 +466,18 @@ object FileUtil { } } + fun getInputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).inputStream() + } else { + File(path).inputStream() + } + + fun getOutputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).outputStream() + } else { + File(path).outputStream() + } + @Throws(IOException::class) fun getStringFromFile(file: File): String = String(file.readBytes(), StandardCharsets.UTF_8) @@ -434,4 +485,19 @@ object FileUtil { @Throws(IOException::class) fun getStringFromInputStream(stream: InputStream): String = String(stream.readBytes(), StandardCharsets.UTF_8) + + fun DocumentFile.inputStream(): InputStream = + YuzuApplication.appContext.contentResolver.openInputStream(uri)!! + + fun DocumentFile.outputStream(): OutputStream = + YuzuApplication.appContext.contentResolver.openOutputStream(uri)!! + + fun Uri.inputStream(): InputStream = + YuzuApplication.appContext.contentResolver.openInputStream(this)!! + + fun Uri.outputStream(): OutputStream = + YuzuApplication.appContext.contentResolver.openOutputStream(this)!! + + fun Uri.asDocumentFile(): DocumentFile? = + DocumentFile.fromSingleUri(YuzuApplication.appContext, this) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt index a8f9dcc34..81212cbee 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt @@ -5,7 +5,6 @@ package org.yuzu.yuzu_emu.utils import android.net.Uri import android.os.Build -import java.io.BufferedInputStream import java.io.File import java.io.IOException import org.yuzu.yuzu_emu.NativeLibrary @@ -123,7 +122,7 @@ object GpuDriverHelper { // Unzip the driver. try { FileUtil.unzipToInternalStorage( - BufferedInputStream(copiedFile.inputStream()), + copiedFile.path, File(driverInstallationPath!!) ) } catch (e: SecurityException) { @@ -156,7 +155,7 @@ object GpuDriverHelper { // Unzip the driver to the private installation directory try { FileUtil.unzipToInternalStorage( - BufferedInputStream(driver.inputStream()), + driver.path, File(driverInstallationPath!!) ) } catch (e: SecurityException) { diff --git a/src/android/app/src/main/jni/android_common/android_common.cpp b/src/android/app/src/main/jni/android_common/android_common.cpp index 1e884ffdd..7018a52af 100644 --- a/src/android/app/src/main/jni/android_common/android_common.cpp +++ b/src/android/app/src/main/jni/android_common/android_common.cpp @@ -42,3 +42,19 @@ double GetJDouble(JNIEnv* env, jobject jdouble) { jobject ToJDouble(JNIEnv* env, double value) { return env->NewObject(IDCache::GetDoubleClass(), IDCache::GetDoubleConstructor(), value); } + +s32 GetJInteger(JNIEnv* env, jobject jinteger) { + return env->GetIntField(jinteger, IDCache::GetIntegerValueField()); +} + +jobject ToJInteger(JNIEnv* env, s32 value) { + return env->NewObject(IDCache::GetIntegerClass(), IDCache::GetIntegerConstructor(), value); +} + +bool GetJBoolean(JNIEnv* env, jobject jboolean) { + return env->GetBooleanField(jboolean, IDCache::GetBooleanValueField()); +} + +jobject ToJBoolean(JNIEnv* env, bool value) { + return env->NewObject(IDCache::GetBooleanClass(), IDCache::GetBooleanConstructor(), value); +} diff --git a/src/android/app/src/main/jni/android_common/android_common.h b/src/android/app/src/main/jni/android_common/android_common.h index 8eb803e1b..29a338c0a 100644 --- a/src/android/app/src/main/jni/android_common/android_common.h +++ b/src/android/app/src/main/jni/android_common/android_common.h @@ -6,6 +6,7 @@ #include <string> #include <jni.h> +#include "common/common_types.h" std::string GetJString(JNIEnv* env, jstring jstr); jstring ToJString(JNIEnv* env, std::string_view str); @@ -13,3 +14,9 @@ jstring ToJString(JNIEnv* env, std::u16string_view str); double GetJDouble(JNIEnv* env, jobject jdouble); jobject ToJDouble(JNIEnv* env, double value); + +s32 GetJInteger(JNIEnv* env, jobject jinteger); +jobject ToJInteger(JNIEnv* env, s32 value); + +bool GetJBoolean(JNIEnv* env, jobject jboolean); +jobject ToJBoolean(JNIEnv* env, bool value); diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index c79ad7d76..96f2ad3d4 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -43,10 +43,27 @@ static jfieldID s_overlay_control_data_landscape_position_field; static jfieldID s_overlay_control_data_portrait_position_field; static jfieldID s_overlay_control_data_foldable_position_field; +static jclass s_patch_class; +static jmethodID s_patch_constructor; +static jfieldID s_patch_enabled_field; +static jfieldID s_patch_name_field; +static jfieldID s_patch_version_field; +static jfieldID s_patch_type_field; +static jfieldID s_patch_program_id_field; +static jfieldID s_patch_title_id_field; + static jclass s_double_class; static jmethodID s_double_constructor; static jfieldID s_double_value_field; +static jclass s_integer_class; +static jmethodID s_integer_constructor; +static jfieldID s_integer_value_field; + +static jclass s_boolean_class; +static jmethodID s_boolean_constructor; +static jfieldID s_boolean_value_field; + static constexpr jint JNI_VERSION = JNI_VERSION_1_6; namespace IDCache { @@ -186,6 +203,38 @@ jfieldID GetOverlayControlDataFoldablePositionField() { return s_overlay_control_data_foldable_position_field; } +jclass GetPatchClass() { + return s_patch_class; +} + +jmethodID GetPatchConstructor() { + return s_patch_constructor; +} + +jfieldID GetPatchEnabledField() { + return s_patch_enabled_field; +} + +jfieldID GetPatchNameField() { + return s_patch_name_field; +} + +jfieldID GetPatchVersionField() { + return s_patch_version_field; +} + +jfieldID GetPatchTypeField() { + return s_patch_type_field; +} + +jfieldID GetPatchProgramIdField() { + return s_patch_program_id_field; +} + +jfieldID GetPatchTitleIdField() { + return s_patch_title_id_field; +} + jclass GetDoubleClass() { return s_double_class; } @@ -198,6 +247,30 @@ jfieldID GetDoubleValueField() { return s_double_value_field; } +jclass GetIntegerClass() { + return s_integer_class; +} + +jmethodID GetIntegerConstructor() { + return s_integer_constructor; +} + +jfieldID GetIntegerValueField() { + return s_integer_value_field; +} + +jclass GetBooleanClass() { + return s_boolean_class; +} + +jmethodID GetBooleanConstructor() { + return s_boolean_constructor; +} + +jfieldID GetBooleanValueField() { + return s_boolean_value_field; +} + } // namespace IDCache #ifdef __cplusplus @@ -278,12 +351,37 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { env->GetFieldID(overlay_control_data_class, "foldablePosition", "Lkotlin/Pair;"); env->DeleteLocalRef(overlay_control_data_class); + const jclass patch_class = env->FindClass("org/yuzu/yuzu_emu/model/Patch"); + s_patch_class = reinterpret_cast<jclass>(env->NewGlobalRef(patch_class)); + s_patch_constructor = env->GetMethodID( + patch_class, "<init>", + "(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V"); + s_patch_enabled_field = env->GetFieldID(patch_class, "enabled", "Z"); + s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;"); + s_patch_version_field = env->GetFieldID(patch_class, "version", "Ljava/lang/String;"); + s_patch_type_field = env->GetFieldID(patch_class, "type", "I"); + s_patch_program_id_field = env->GetFieldID(patch_class, "programId", "Ljava/lang/String;"); + s_patch_title_id_field = env->GetFieldID(patch_class, "titleId", "Ljava/lang/String;"); + env->DeleteLocalRef(patch_class); + const jclass double_class = env->FindClass("java/lang/Double"); s_double_class = reinterpret_cast<jclass>(env->NewGlobalRef(double_class)); s_double_constructor = env->GetMethodID(double_class, "<init>", "(D)V"); s_double_value_field = env->GetFieldID(double_class, "value", "D"); env->DeleteLocalRef(double_class); + const jclass int_class = env->FindClass("java/lang/Integer"); + s_integer_class = reinterpret_cast<jclass>(env->NewGlobalRef(int_class)); + s_integer_constructor = env->GetMethodID(int_class, "<init>", "(I)V"); + s_integer_value_field = env->GetFieldID(int_class, "value", "I"); + env->DeleteLocalRef(int_class); + + const jclass boolean_class = env->FindClass("java/lang/Boolean"); + s_boolean_class = reinterpret_cast<jclass>(env->NewGlobalRef(boolean_class)); + s_boolean_constructor = env->GetMethodID(boolean_class, "<init>", "(Z)V"); + s_boolean_value_field = env->GetFieldID(boolean_class, "value", "Z"); + env->DeleteLocalRef(boolean_class); + // Initialize Android Storage Common::FS::Android::RegisterCallbacks(env, s_native_library_class); @@ -309,7 +407,10 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) { env->DeleteGlobalRef(s_string_class); env->DeleteGlobalRef(s_pair_class); env->DeleteGlobalRef(s_overlay_control_data_class); + env->DeleteGlobalRef(s_patch_class); env->DeleteGlobalRef(s_double_class); + env->DeleteGlobalRef(s_integer_class); + env->DeleteGlobalRef(s_boolean_class); // UnInitialize applets SoftwareKeyboard::CleanupJNI(env); diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h index 784d1412f..a002e705d 100644 --- a/src/android/app/src/main/jni/id_cache.h +++ b/src/android/app/src/main/jni/id_cache.h @@ -43,8 +43,25 @@ jfieldID GetOverlayControlDataLandscapePositionField(); jfieldID GetOverlayControlDataPortraitPositionField(); jfieldID GetOverlayControlDataFoldablePositionField(); +jclass GetPatchClass(); +jmethodID GetPatchConstructor(); +jfieldID GetPatchEnabledField(); +jfieldID GetPatchNameField(); +jfieldID GetPatchVersionField(); +jfieldID GetPatchTypeField(); +jfieldID GetPatchProgramIdField(); +jfieldID GetPatchTitleIdField(); + jclass GetDoubleClass(); jmethodID GetDoubleConstructor(); jfieldID GetDoubleValueField(); +jclass GetIntegerClass(); +jmethodID GetIntegerConstructor(); +jfieldID GetIntegerValueField(); + +jclass GetBooleanClass(); +jmethodID GetBooleanConstructor(); +jfieldID GetBooleanValueField(); + } // namespace IDCache diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index ed3b1353a..be0a723b1 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -17,6 +17,7 @@ #include <core/file_sys/patch_manager.h> #include <core/file_sys/savedata_factory.h> #include <core/loader/nro.h> +#include <frontend_common/content_manager.h> #include <jni.h> #include "common/detached_tasks.h" @@ -100,67 +101,6 @@ void EmulationSession::SetNativeWindow(ANativeWindow* native_window) { m_native_window = native_window; } -int EmulationSession::InstallFileToNand(std::string filename, std::string file_extension) { - jconst copy_func = [](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest, - std::size_t block_size) { - if (src == nullptr || dest == nullptr) { - return false; - } - if (!dest->Resize(src->GetSize())) { - return false; - } - - using namespace Common::Literals; - [[maybe_unused]] std::vector<u8> buffer(1_MiB); - - for (std::size_t i = 0; i < src->GetSize(); i += buffer.size()) { - jconst read = src->Read(buffer.data(), buffer.size(), i); - dest->Write(buffer.data(), read, i); - } - return true; - }; - - enum InstallResult { - Success = 0, - SuccessFileOverwritten = 1, - InstallError = 2, - ErrorBaseGame = 3, - ErrorFilenameExtension = 4, - }; - - [[maybe_unused]] std::shared_ptr<FileSys::NSP> nsp; - if (file_extension == "nsp") { - nsp = std::make_shared<FileSys::NSP>(m_vfs->OpenFile(filename, FileSys::Mode::Read)); - if (nsp->IsExtractedType()) { - return InstallError; - } - } else { - return ErrorFilenameExtension; - } - - if (!nsp) { - return InstallError; - } - - if (nsp->GetStatus() != Loader::ResultStatus::Success) { - return InstallError; - } - - jconst res = m_system.GetFileSystemController().GetUserNANDContents()->InstallEntry(*nsp, true, - copy_func); - - switch (res) { - case FileSys::InstallResult::Success: - return Success; - case FileSys::InstallResult::OverwriteExisting: - return SuccessFileOverwritten; - case FileSys::InstallResult::ErrorBaseInstall: - return ErrorBaseGame; - default: - return InstallError; - } -} - void EmulationSession::InitializeGpuDriver(const std::string& hook_lib_dir, const std::string& custom_driver_dir, const std::string& custom_driver_name, @@ -512,10 +452,20 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject } int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject instance, - jstring j_file, - jstring j_file_extension) { - return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file), - GetJString(env, j_file_extension)); + jstring j_file, jobject jcallback) { + auto jlambdaClass = env->GetObjectClass(jcallback); + auto jlambdaInvokeMethod = env->GetMethodID( + jlambdaClass, "invoke", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + const auto callback = [env, jcallback, jlambdaInvokeMethod](size_t max, size_t progress) { + auto jwasCancelled = env->CallObjectMethod(jcallback, jlambdaInvokeMethod, + ToJDouble(env, max), ToJDouble(env, progress)); + return GetJBoolean(env, jwasCancelled); + }; + + return static_cast<int>( + ContentManager::InstallNSP(&EmulationSession::GetInstance().System(), + EmulationSession::GetInstance().System().GetFilesystem().get(), + GetJString(env, j_file), callback)); } jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_doesUpdateMatchProgram(JNIEnv* env, jobject jobj, @@ -824,9 +774,9 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isFirmwareAvailable(JNIEnv* env, return true; } -jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env, jobject jobj, - jstring jpath, - jstring jprogramId) { +jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPatchesForFile(JNIEnv* env, jobject jobj, + jstring jpath, + jstring jprogramId) { const auto path = GetJString(env, jpath); const auto vFile = Core::GetGameFileFromPath(EmulationSession::GetInstance().System().GetFilesystem(), path); @@ -843,20 +793,40 @@ jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env, FileSys::VirtualFile update_raw; loader->ReadUpdateRaw(update_raw); - auto addons = pm.GetPatchVersionNames(update_raw); - auto jemptyString = ToJString(env, ""); - auto jemptyStringPair = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(), - jemptyString, jemptyString); - jobjectArray jaddonsArray = - env->NewObjectArray(addons.size(), IDCache::GetPairClass(), jemptyStringPair); + auto patches = pm.GetPatches(update_raw); + jobjectArray jpatchArray = + env->NewObjectArray(patches.size(), IDCache::GetPatchClass(), nullptr); int i = 0; - for (const auto& addon : addons) { - jobject jaddon = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(), - ToJString(env, addon.first), ToJString(env, addon.second)); - env->SetObjectArrayElement(jaddonsArray, i, jaddon); + for (const auto& patch : patches) { + jobject jpatch = env->NewObject( + IDCache::GetPatchClass(), IDCache::GetPatchConstructor(), patch.enabled, + ToJString(env, patch.name), ToJString(env, patch.version), + static_cast<jint>(patch.type), ToJString(env, std::to_string(patch.program_id)), + ToJString(env, std::to_string(patch.title_id))); + env->SetObjectArrayElement(jpatchArray, i, jpatch); ++i; } - return jaddonsArray; + return jpatchArray; +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_removeUpdate(JNIEnv* env, jobject jobj, + jstring jprogramId) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + ContentManager::RemoveUpdate(EmulationSession::GetInstance().System().GetFileSystemController(), + program_id); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_removeDLC(JNIEnv* env, jobject jobj, + jstring jprogramId) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + ContentManager::RemoveAllDLC(&EmulationSession::GetInstance().System(), program_id); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_removeMod(JNIEnv* env, jobject jobj, jstring jprogramId, + jstring jname) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + ContentManager::RemoveMod(EmulationSession::GetInstance().System().GetFileSystemController(), + program_id, GetJString(env, jname)); } jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj, diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h index 4a8049578..dadb138ad 100644 --- a/src/android/app/src/main/jni/native.h +++ b/src/android/app/src/main/jni/native.h @@ -7,6 +7,7 @@ #include "core/file_sys/registered_cache.h" #include "core/hle/service/acc/profile_manager.h" #include "core/perf_stats.h" +#include "frontend_common/content_manager.h" #include "jni/applets/software_keyboard.h" #include "jni/emu_window/emu_window.h" #include "video_core/rasterizer_interface.h" @@ -29,7 +30,6 @@ public: void SetNativeWindow(ANativeWindow* native_window); void SurfaceChanged(); - int InstallFileToNand(std::string filename, std::string file_extension); void InitializeGpuDriver(const std::string& hook_lib_dir, const std::string& custom_driver_dir, const std::string& custom_driver_name, const std::string& file_redirect_dir); diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml index 0209ea082..e61aa5294 100644 --- a/src/android/app/src/main/res/layout/dialog_progress_bar.xml +++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml @@ -1,8 +1,30 @@ <?xml version="1.0" encoding="utf-8"?> -<com.google.android.material.progressindicator.LinearProgressIndicator xmlns:android="http://schemas.android.com/apk/res/android" +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - android:id="@+id/progress_bar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:padding="24dp" - app:trackCornerRadius="4dp" /> + android:orientation="vertical"> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/message" + style="@style/TextAppearance.Material3.BodyMedium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="12dp" + android:layout_marginBottom="6dp" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:requiresFadingEdge="horizontal" + android:singleLine="true" + android:textAlignment="viewStart" + android:visibility="gone" /> + + <com.google.android.material.progressindicator.LinearProgressIndicator + android:id="@+id/progress_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="24dp" + app:trackCornerRadius="4dp" /> + +</LinearLayout> diff --git a/src/android/app/src/main/res/layout/list_item_addon.xml b/src/android/app/src/main/res/layout/list_item_addon.xml index 74ca04ef1..3a1382fe2 100644 --- a/src/android/app/src/main/res/layout/list_item_addon.xml +++ b/src/android/app/src/main/res/layout/list_item_addon.xml @@ -14,12 +14,11 @@ android:id="@+id/text_container" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="16dp" android:orientation="vertical" - app:layout_constraintBottom_toBottomOf="@+id/addon_switch" - app:layout_constraintEnd_toStartOf="@+id/addon_switch" + android:layout_marginEnd="16dp" + app:layout_constraintEnd_toStartOf="@+id/addon_checkbox" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@+id/addon_switch"> + app:layout_constraintTop_toTopOf="parent"> <com.google.android.material.textview.MaterialTextView android:id="@+id/title" @@ -42,16 +41,29 @@ </LinearLayout> - <com.google.android.material.materialswitch.MaterialSwitch - android:id="@+id/addon_switch" + <com.google.android.material.checkbox.MaterialCheckBox + android:id="@+id/addon_checkbox" android:layout_width="wrap_content" android:layout_height="wrap_content" android:focusable="true" android:gravity="center" - android:nextFocusLeft="@id/addon_container" - app:layout_constraintBottom_toBottomOf="parent" + android:layout_marginEnd="8dp" + app:layout_constraintTop_toTopOf="@+id/text_container" + app:layout_constraintBottom_toBottomOf="@+id/text_container" + app:layout_constraintEnd_toStartOf="@+id/button_delete" /> + + <Button + android:id="@+id/button_delete" + style="@style/Widget.Material3.Button.IconButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:contentDescription="@string/delete" + android:tooltipText="@string/delete" + app:icon="@drawable/ic_delete" + app:iconTint="?attr/colorControlNormal" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/text_container" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="@+id/addon_checkbox" + app:layout_constraintBottom_toBottomOf="@+id/addon_checkbox" /> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 547752bda..db5b27d38 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -286,6 +286,7 @@ <string name="custom">Custom</string> <string name="notice">Notice</string> <string name="import_complete">Import complete</string> + <string name="more_options">More options</string> <!-- GPU driver installation --> <string name="select_gpu_driver">Select GPU driver</string> @@ -348,6 +349,8 @@ <string name="verifying_content">Verifying content…</string> <string name="content_install_notice">Content install notice</string> <string name="content_install_notice_description">The content that you selected does not match this game.\nInstall anyway?</string> + <string name="confirm_uninstall">Confirm uninstall</string> + <string name="confirm_uninstall_description">Are you sure you want to uninstall this addon?</string> <!-- ROM loading errors --> <string name="loader_error_encrypted">Your ROM is encrypted</string> |