From e975f3cde9d4dcb1d9e2bbce116f9a9ba99bf03f Mon Sep 17 00:00:00 2001 From: t895 Date: Sun, 10 Dec 2023 20:27:50 -0500 Subject: [PATCH] android: Add Game properties This commit has the UI for viewing a game's properties on long-press and some links to useful tools like - Game info - Shortcut to settings (global in this commit) - Addon manager with installer - Save data manager - Option to clear all save data - Option to clear shader cache --- .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 35 +- .../yuzu/yuzu_emu/adapters/AddonAdapter.kt | 52 +++ .../yuzu/yuzu_emu/adapters/AppletAdapter.kt | 6 +- .../org/yuzu/yuzu_emu/adapters/GameAdapter.kt | 16 +- .../adapters/GamePropertiesAdapter.kt | 133 ++++++ .../yuzu/yuzu_emu/fragments/AddonsFragment.kt | 214 +++++++++ .../ContentTypeSelectionDialogFragment.kt | 68 +++ .../yuzu_emu/fragments/GameInfoFragment.kt | 148 +++++++ .../fragments/GamePropertiesFragment.kt | 418 ++++++++++++++++++ .../IndeterminateProgressDialogFragment.kt | 2 +- .../fragments/LaunchGameDialogFragment.kt | 61 +++ .../fragments/MessageDialogFragment.kt | 37 +- .../yuzu/yuzu_emu/fragments/SearchFragment.kt | 4 +- .../java/org/yuzu/yuzu_emu/model/Addon.kt | 10 + .../org/yuzu/yuzu_emu/model/AddonViewModel.kt | 83 ++++ .../main/java/org/yuzu/yuzu_emu/model/Game.kt | 42 +- .../org/yuzu/yuzu_emu/model/GameProperties.kt | 34 ++ .../org/yuzu/yuzu_emu/model/HomeViewModel.kt | 15 + .../yuzu_emu/model/MessageDialogViewModel.kt | 4 +- .../org/yuzu/yuzu_emu/model/TaskViewModel.kt | 2 +- .../org/yuzu/yuzu_emu/ui/GamesFragment.kt | 11 +- .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 363 +++++++-------- .../java/org/yuzu/yuzu_emu/utils/AddonUtil.kt | 8 + .../java/org/yuzu/yuzu_emu/utils/FileUtil.kt | 32 ++ .../org/yuzu/yuzu_emu/utils/NativeConfig.kt | 19 + src/android/app/src/main/jni/id_cache.cpp | 40 ++ src/android/app/src/main/jni/id_cache.h | 6 + src/android/app/src/main/jni/native.cpp | 108 ++++- src/android/app/src/main/jni/native.h | 2 + .../app/src/main/jni/native_config.cpp | 26 ++ .../fragment_game_properties.xml | 99 +++++ .../src/main/res/layout/card_installable.xml | 3 +- ...et_option.xml => card_simple_outlined.xml} | 20 +- .../src/main/res/layout/fragment_addons.xml | 47 ++ .../main/res/layout/fragment_game_info.xml | 125 ++++++ .../res/layout/fragment_game_properties.xml | 86 ++++ .../src/main/res/layout/list_item_addon.xml | 57 +++ .../main/res/navigation/home_navigation.xml | 33 ++ .../app/src/main/res/values/dimens.xml | 2 +- .../app/src/main/res/values/strings.xml | 45 ++ 40 files changed, 2245 insertions(+), 271 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt create mode 100644 src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml rename src/android/app/src/main/res/layout/{card_applet_option.xml => card_simple_outlined.xml} (71%) create mode 100644 src/android/app/src/main/res/layout/fragment_addons.xml create mode 100644 src/android/app/src/main/res/layout/fragment_game_info.xml create mode 100644 src/android/app/src/main/res/layout/fragment_game_properties.xml create mode 100644 src/android/app/src/main/res/layout/list_item_addon.xml 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 e0f01127c..95b98798d 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 @@ -230,8 +230,6 @@ object NativeLibrary { */ external fun onTouchReleased(finger_id: Int) - external fun initGameIni(gameID: String?) - external fun setAppDirectory(directory: String) /** @@ -241,6 +239,8 @@ object NativeLibrary { */ external fun installFileToNand(filename: String, extension: String): Int + external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean + external fun initializeGpuDriver( hookLibDir: String?, customDriverDir: String?, @@ -252,18 +252,11 @@ object NativeLibrary { external fun initializeSystem(reload: Boolean) - external fun defaultCPUCore(): Int - /** * Begins emulation. */ external fun run(path: String?) - /** - * Begins emulation from the specified savestate. - */ - external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean) - // Surface Handling external fun surfaceChanged(surf: Surface?) @@ -304,10 +297,9 @@ object NativeLibrary { */ external fun getCpuBackend(): String - /** - * Notifies the core emulation that the orientation has changed. - */ - external fun notifyOrientationChange(layout_option: Int, rotation: Int) + external fun applySettings() + + external fun logSettings() enum class CoreError { ErrorSystemFiles, @@ -538,6 +530,23 @@ object NativeLibrary { */ external fun isFirmwareAvailable(): Boolean + /** + * Checks the PatchManager for any addons that are available + * + * @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 + */ + external fun getAddonsForFile(path: String, programId: String): Array>? + + /** + * Gets the save location for a specific game + * + * @param programId String representation of a game's program ID + * @return Save data path that may not exist yet + */ + external fun getSavePath(programId: String): String + /** * Button type for use in onTouchEvent */ 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 new file mode 100644 index 000000000..15c7ca3c9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding +import org.yuzu.yuzu_emu.model.Addon + +class AddonAdapter : ListAdapter( + AsyncDifferConfig.Builder(DiffCallback()).build() +) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder { + ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return AddonViewHolder(it) } + } + + override fun getItemCount(): Int = currentList.size + + override fun onBindViewHolder(holder: AddonViewHolder, position: Int) = + holder.bind(currentList[position]) + + inner class AddonViewHolder(val binding: ListItemAddonBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(addon: Addon) { + binding.root.setOnClickListener { + binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked + } + binding.title.text = addon.title + binding.version.text = addon.version + binding.addonSwitch.setOnCheckedChangeListener { _, checked -> + addon.enabled = checked + } + binding.addonSwitch.isChecked = addon.enabled + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Addon, newItem: Addon): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: Addon, newItem: Addon): Boolean { + return oldItem == newItem + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt index a21a705c1..4a05c5be9 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt @@ -15,7 +15,7 @@ import org.yuzu.yuzu_emu.HomeNavigationDirections import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.databinding.CardAppletOptionBinding +import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding import org.yuzu.yuzu_emu.model.Applet import org.yuzu.yuzu_emu.model.AppletInfo import org.yuzu.yuzu_emu.model.Game @@ -28,7 +28,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List) : parent: ViewGroup, viewType: Int ): AppletAdapter.AppletViewHolder { - CardAppletOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false) .apply { root.setOnClickListener(this@AppletAdapter) } .also { return AppletViewHolder(it) } } @@ -65,7 +65,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List) : view.findNavController().navigate(action) } - inner class AppletViewHolder(val binding: CardAppletOptionBinding) : + inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) : RecyclerView.ViewHolder(binding.root) { lateinit var applet: Applet diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt index 2ef638559..928bfe5a7 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -44,19 +44,20 @@ import org.yuzu.yuzu_emu.utils.GameIconUtils class GameAdapter(private val activity: AppCompatActivity) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()), - View.OnClickListener { + View.OnClickListener, + View.OnLongClickListener { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { // Create a new view. val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.cardGame.setOnClickListener(this) + binding.cardGame.setOnLongClickListener(this) // Use that view to create a ViewHolder. return GameViewHolder(binding) } - override fun onBindViewHolder(holder: GameViewHolder, position: Int) { + override fun onBindViewHolder(holder: GameViewHolder, position: Int) = holder.bind(currentList[position]) - } override fun getItemCount(): Int = currentList.size @@ -125,10 +126,17 @@ class GameAdapter(private val activity: AppCompatActivity) : } } - val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game, true) view.findNavController().navigate(action) } + override fun onLongClick(view: View): Boolean { + val holder = view.tag as GameViewHolder + val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(holder.game) + view.findNavController().navigate(action) + return true + } + inner class GameViewHolder(val binding: CardGameBinding) : RecyclerView.ViewHolder(binding.root) { lateinit var game: Game diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt new file mode 100644 index 000000000..ff6270fa8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.databinding.CardInstallableBinding +import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding +import org.yuzu.yuzu_emu.model.GameProperty +import org.yuzu.yuzu_emu.model.InstallableProperty +import org.yuzu.yuzu_emu.model.SubmenuProperty + +class GamePropertiesAdapter( + private val viewLifecycle: LifecycleOwner, + private var properties: List +) : + RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): GamePropertyViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + PropertyType.Submenu.ordinal -> { + SubmenuPropertyViewHolder( + CardSimpleOutlinedBinding.inflate( + inflater, + parent, + false + ) + ) + } + + else -> InstallablePropertyViewHolder( + CardInstallableBinding.inflate( + inflater, + parent, + false + ) + ) + } + } + + override fun getItemCount(): Int = properties.size + + override fun onBindViewHolder(holder: GamePropertyViewHolder, position: Int) = + holder.bind(properties[position]) + + override fun getItemViewType(position: Int): Int { + return when (properties[position]) { + is SubmenuProperty -> PropertyType.Submenu.ordinal + else -> PropertyType.Installable.ordinal + } + } + + sealed class GamePropertyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + abstract fun bind(property: GameProperty) + } + + inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) : + GamePropertyViewHolder(binding.root) { + override fun bind(property: GameProperty) { + val submenuProperty = property as SubmenuProperty + + binding.root.setOnClickListener { + submenuProperty.action.invoke() + } + + binding.title.setText(submenuProperty.titleId) + binding.description.setText(submenuProperty.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + submenuProperty.iconId, + binding.icon.context.theme + ) + ) + + binding.details.postDelayed({ + binding.details.isSelected = true + binding.details.ellipsize = TextUtils.TruncateAt.MARQUEE + }, 3000) + + if (submenuProperty.details != null) { + binding.details.visibility = View.VISIBLE + binding.details.text = submenuProperty.details.invoke() + } else if (submenuProperty.detailsFlow != null) { + binding.details.visibility = View.VISIBLE + viewLifecycle.lifecycleScope.launch { + viewLifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + submenuProperty.detailsFlow.collect { binding.details.text = it } + } + } + } else { + binding.details.visibility = View.GONE + } + } + } + + inner class InstallablePropertyViewHolder(val binding: CardInstallableBinding) : + GamePropertyViewHolder(binding.root) { + override fun bind(property: GameProperty) { + val installableProperty = property as InstallableProperty + + binding.title.setText(installableProperty.titleId) + binding.description.setText(installableProperty.descriptionId) + + if (installableProperty.install != null) { + binding.buttonInstall.visibility = View.VISIBLE + binding.buttonInstall.setOnClickListener { installableProperty.install.invoke() } + } + if (installableProperty.export != null) { + binding.buttonExport.visibility = View.VISIBLE + binding.buttonExport.setOnClickListener { installableProperty.export.invoke() } + } + } + } + + enum class PropertyType { + Submenu, + Installable + } +} 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 new file mode 100644 index 000000000..0dce8ad8d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile +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.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.AddonAdapter +import org.yuzu.yuzu_emu.databinding.FragmentAddonsBinding +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.AddonUtil +import org.yuzu.yuzu_emu.utils.FileUtil.copyFilesTo +import java.io.File + +class AddonsFragment : Fragment() { + private var _binding: FragmentAddonsBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val addonViewModel: AddonViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + addonViewModel.onOpenAddons(args.game) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAddonsBinding.inflate(inflater) + return binding.root + } + + // This is using the correct scope, lint is just acting up + @SuppressLint("UnsafeRepeatOnLifecycleDetector") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.toolbarAddons.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title) + + binding.listAddons.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = AddonAdapter() + } + + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + addonViewModel.addonList.collect { + (binding.listAddons.adapter as AddonAdapter).submitList(it) + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + addonViewModel.showModInstallPicker.collect { + if (it) { + installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + addonViewModel.showModInstallPicker(false) + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + addonViewModel.showModNoticeDialog.collect { + if (it) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.addon_notice, + descriptionId = R.string.addon_notice_description, + positiveAction = { addonViewModel.showModInstallPicker(true) } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + addonViewModel.showModNoticeDialog(false) + } + } + } + } + } + + binding.buttonInstall.setOnClickListener { + ContentTypeSelectionDialogFragment().show( + parentFragmentManager, + ContentTypeSelectionDialogFragment.TAG + ) + } + + setInsets() + } + + override fun onResume() { + super.onResume() + addonViewModel.refreshAddons() + } + + override fun onDestroy() { + super.onDestroy() + addonViewModel.onCloseAddons() + } + + val installAddon = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result) + if (externalAddonDirectory == null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@registerForActivityResult + } + + val isValid = externalAddonDirectory.listFiles() + .any { AddonUtil.validAddonDirectories.contains(it.name) } + val errorMessage = MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ) + if (isValid) { + IndeterminateProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_game_content, + false + ) { + val parentDirectoryName = externalAddonDirectory.name + val internalAddonDirectory = + File(args.game.addonDir + parentDirectoryName) + try { + externalAddonDirectory.copyFilesTo(internalAddonDirectory) + } catch (_: Exception) { + return@newInstance errorMessage + } + addonViewModel.refreshAddons() + return@newInstance getString(R.string.addon_installed_successfully) + }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) + } else { + errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val mlpToolbar = binding.toolbarAddons.layoutParams as ViewGroup.MarginLayoutParams + mlpToolbar.leftMargin = leftInsets + mlpToolbar.rightMargin = rightInsets + binding.toolbarAddons.layoutParams = mlpToolbar + + val mlpAddonsList = binding.listAddons.layoutParams as ViewGroup.MarginLayoutParams + mlpAddonsList.leftMargin = leftInsets + mlpAddonsList.rightMargin = rightInsets + binding.listAddons.layoutParams = mlpAddonsList + binding.listAddons.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + val mlpFab = + binding.buttonInstall.layoutParams as ViewGroup.MarginLayoutParams + mlpFab.leftMargin = leftInsets + fabSpacing + mlpFab.rightMargin = rightInsets + fabSpacing + mlpFab.bottomMargin = barInsets.bottom + fabSpacing + binding.buttonInstall.layoutParams = mlpFab + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt new file mode 100644 index 000000000..c1d8b9ea5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt @@ -0,0 +1,68 @@ +// 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.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity + +class ContentTypeSelectionDialogFragment : DialogFragment() { + private val addonViewModel: AddonViewModel by activityViewModels() + + private val preferences get() = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + private var selectedItem = 0 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val launchOptions = + arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats)) + + if (savedInstanceState != null) { + selectedItem = savedInstanceState.getInt(SELECTED_ITEM) + } + + val mainActivity = requireActivity() as MainActivity + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.select_content_type) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + when (selectedItem) { + 0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*")) + else -> { + if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) { + preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply() + addonViewModel.showModNoticeDialog(true) + return@setPositiveButton + } + addonViewModel.showModInstallPicker(true) + } + } + } + .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> + selectedItem = i + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_ITEM, selectedItem) + } + + companion object { + const val TAG = "ContentTypeSelectionDialogFragment" + + private const val SELECTED_ITEM = "SelectedItem" + private const val MOD_NOTICE_SEEN = "ModNoticeSeen" + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt new file mode 100644 index 000000000..fa2a4c9f9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +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.navigation.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentGameInfoBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.GameMetadata + +class GameInfoFragment : Fragment() { + private var _binding: FragmentGameInfoBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + + // Check for an up-to-date version string + args.game.version = GameMetadata.getVersion(args.game.path, true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGameInfoBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.apply { + toolbarInfo.title = args.game.title + toolbarInfo.setNavigationOnClickListener { + view.findNavController().popBackStack() + } + + val pathString = Uri.parse(args.game.path).path ?: "" + path.setHint(R.string.path) + pathField.setText(pathString) + pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) } + + programId.setHint(R.string.program_id) + programIdField.setText(args.game.programIdHex) + programIdField.setOnClickListener { + copyToClipboard(getString(R.string.program_id), args.game.programIdHex) + } + + if (args.game.developer.isNotEmpty()) { + developer.setHint(R.string.developer) + developerField.setText(args.game.developer) + developerField.setOnClickListener { + copyToClipboard(getString(R.string.developer), args.game.developer) + } + } else { + developer.visibility = View.GONE + } + + version.setHint(R.string.version) + versionField.setText(args.game.version) + versionField.setOnClickListener { + copyToClipboard(getString(R.string.version), args.game.version) + } + + buttonCopy.setOnClickListener { + val details = """ + ${args.game.title} + ${getString(R.string.path)} - $pathString + ${getString(R.string.program_id)} - ${args.game.programIdHex} + ${getString(R.string.developer)} - ${args.game.developer} + ${getString(R.string.version)} - ${args.game.version} + """.trimIndent() + copyToClipboard(args.game.title, details) + } + } + + setInsets() + } + + private fun copyToClipboard(label: String, body: String) { + val clipBoard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, body) + clipBoard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val mlpToolbar = binding.toolbarInfo.layoutParams as ViewGroup.MarginLayoutParams + mlpToolbar.leftMargin = leftInsets + mlpToolbar.rightMargin = rightInsets + binding.toolbarInfo.layoutParams = mlpToolbar + + val mlpScrollAbout = binding.scrollInfo.layoutParams as ViewGroup.MarginLayoutParams + mlpScrollAbout.leftMargin = leftInsets + mlpScrollAbout.rightMargin = rightInsets + binding.scrollInfo.layoutParams = mlpScrollAbout + + binding.contentInfo.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} 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 new file mode 100644 index 000000000..485989e2e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -0,0 +1,418 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.os.Bundle +import android.text.TextUtils +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.navigation.fragment.navArgs +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.HomeNavigationDirections +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter +import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.GameProperty +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.InstallableProperty +import org.yuzu.yuzu_emu.model.SubmenuProperty +import org.yuzu.yuzu_emu.model.TaskState +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +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 + +class GamePropertiesFragment : Fragment() { + private var _binding: FragmentGamePropertiesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamePropertiesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + + binding.buttonBack.setOnClickListener { + view.findNavController().popBackStack() + } + + GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen) + binding.title.text = args.game.title + binding.title.postDelayed( + { + binding.title.ellipsize = TextUtils.TruncateAt.MARQUEE + binding.title.isSelected = true + }, + 3000 + ) + + binding.buttonStart.setOnClickListener { + LaunchGameDialogFragment.newInstance(args.game) + .show(childFragmentManager, LaunchGameDialogFragment.TAG) + } + + reloadList() + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.openImportSaves.collect { + if (it) { + importSaves.launch(arrayOf("application/zip")) + homeViewModel.setOpenImportSaves(false) + } + } + } + } + + setInsets() + } + + override fun onDestroy() { + super.onDestroy() + gamesViewModel.reloadGames(true) + } + + private fun reloadList() { + _binding ?: return + + driverViewModel.updateDriverNameForGame(args.game) + val properties = mutableListOf().apply { + add( + SubmenuProperty( + R.string.info, + R.string.info_description, + R.drawable.ic_info_outline + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToGameInfoFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + add( + SubmenuProperty( + R.string.preferences_settings, + R.string.per_game_settings_description, + R.drawable.ic_settings + ) { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.root.findNavController().navigate(action) + } + ) + + if (!args.game.isHomebrew) { + add( + SubmenuProperty( + R.string.add_ons, + R.string.add_ons_description, + R.drawable.ic_edit + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToAddonsFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + add( + InstallableProperty( + R.string.save_data, + R.string.save_data_description, + { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.import_save_warning, + descriptionId = R.string.import_save_warning_description, + positiveAction = { homeViewModel.setOpenImportSaves(true) } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + }, + if (File(args.game.saveDir).exists()) { + { exportSaves.launch(args.game.saveZipName) } + } else { + null + } + ) + ) + + val saveDirFile = File(args.game.saveDir) + if (saveDirFile.exists()) { + add( + SubmenuProperty( + R.string.delete_save_data, + R.string.delete_save_data_description, + R.drawable.ic_delete, + action = { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.delete_save_data, + descriptionId = R.string.delete_save_data_warning_description, + positiveAction = { + File(args.game.saveDir).deleteRecursively() + Toast.makeText( + YuzuApplication.appContext, + R.string.save_data_deleted_successfully, + Toast.LENGTH_SHORT + ).show() + reloadList() + } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + ) + ) + } + + val shaderCacheDir = File( + DirectoryInitialization.userDirectory + + "/shader/" + args.game.settingsName.lowercase() + ) + if (shaderCacheDir.exists()) { + add( + SubmenuProperty( + R.string.clear_shader_cache, + R.string.clear_shader_cache_description, + R.drawable.ic_delete, + { + if (shaderCacheDir.exists()) { + val bytes = shaderCacheDir.walkTopDown().filter { it.isFile } + .map { it.length() }.sum() + MemoryUtil.bytesToSizeUnit(bytes.toFloat()) + } else { + MemoryUtil.bytesToSizeUnit(0f) + } + } + ) { + shaderCacheDir.deleteRecursively() + Toast.makeText( + YuzuApplication.appContext, + R.string.cleared_shaders_successfully, + Toast.LENGTH_SHORT + ).show() + reloadList() + } + ) + } + } + } + binding.listProperties.apply { + layoutManager = + GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) + adapter = GamePropertiesAdapter(viewLifecycleOwner, properties) + } + } + + override fun onResume() { + super.onResume() + driverViewModel.updateDriverNameForGame(args.game) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val smallLayout = resources.getBoolean(R.bool.small_layout) + if (smallLayout) { + val mlpListAll = + binding.listAll.layoutParams as ViewGroup.MarginLayoutParams + mlpListAll.leftMargin = leftInsets + mlpListAll.rightMargin = rightInsets + binding.listAll.layoutParams = mlpListAll + } else { + if (ViewCompat.getLayoutDirection(binding.root) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + val mlpListAll = + binding.listAll.layoutParams as ViewGroup.MarginLayoutParams + mlpListAll.rightMargin = rightInsets + binding.listAll.layoutParams = mlpListAll + + val mlpIconLayout = + binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams + mlpIconLayout.topMargin = barInsets.top + mlpIconLayout.leftMargin = leftInsets + binding.iconLayout!!.layoutParams = mlpIconLayout + } else { + val mlpListAll = + binding.listAll.layoutParams as ViewGroup.MarginLayoutParams + mlpListAll.leftMargin = leftInsets + binding.listAll.layoutParams = mlpListAll + + val mlpIconLayout = + binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams + mlpIconLayout.topMargin = barInsets.top + mlpIconLayout.rightMargin = rightInsets + binding.iconLayout!!.layoutParams = mlpIconLayout + } + } + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + val mlpFab = + binding.buttonStart.layoutParams as ViewGroup.MarginLayoutParams + mlpFab.leftMargin = leftInsets + fabSpacing + mlpFab.rightMargin = rightInsets + fabSpacing + mlpFab.bottomMargin = barInsets.bottom + fabSpacing + binding.buttonStart.layoutParams = mlpFab + + binding.layoutAll.updatePadding( + top = barInsets.top, + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } + + private val importSaves = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + 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( + requireActivity(), + R.string.save_files_importing, + false + ) { + try { + FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir) + val files = cacheSaveDir.listFiles() + var savesFolderFile: File? = null + if (files != null) { + val savesFolderName = args.game.programIdHex + for (file in files) { + if (file.isDirectory && file.name == savesFolderName) { + savesFolderFile = file + break + } + } + } + + if (savesFolderFile != null) { + savesFolder.deleteRecursively() + savesFolder.mkdir() + savesFolderFile.copyRecursively(savesFolder) + savesFolderFile.deleteRecursively() + } + + withContext(Dispatchers.Main) { + if (savesFolderFile == null) { + 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 + } + Toast.makeText( + YuzuApplication.appContext, + getString(R.string.save_file_imported_success), + Toast.LENGTH_LONG + ).show() + reloadList() + } + + cacheSaveDir.deleteRecursively() + } catch (e: Exception) { + Toast.makeText( + YuzuApplication.appContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + } + }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) + } + + /** + * Exports the save file located in the given folder path by creating a zip file and opening a + * file picker to save. + */ + private val exportSaves = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_exporting, + false + ) { + val saveLocation = args.game.saveDir + val zipResult = FileUtil.zipFromInternalStorage( + File(saveLocation), + saveLocation.replaceAfterLast("/", ""), + BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)) + ) + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.export_success) + TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) + } + }.show(parentFragmentManager, IndeterminateProgressDialogFragment.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/IndeterminateProgressDialogFragment.kt index 7e467814d..8847e5531 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/IndeterminateProgressDialogFragment.kt @@ -122,7 +122,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() { activity: FragmentActivity, titleId: Int, cancellable: Boolean = false, - task: () -> Any + task: suspend () -> Any ): IndeterminateProgressDialogFragment { val dialog = IndeterminateProgressDialogFragment() val args = Bundle() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt new file mode 100644 index 000000000..f653826a6 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt @@ -0,0 +1,61 @@ +// 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.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class LaunchGameDialogFragment : DialogFragment() { + private var selectedItem = 0 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val game = requireArguments().parcelable(GAME) + val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom)) + + if (savedInstanceState != null) { + selectedItem = savedInstanceState.getInt(SELECTED_ITEM) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.launch_options) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val action = HomeNavigationDirections + .actionGlobalEmulationActivity(game, selectedItem != 0) + requireParentFragment().findNavController().navigate(action) + } + .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> + selectedItem = i + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_ITEM, selectedItem) + } + + companion object { + const val TAG = "LaunchGameDialogFragment" + + const val GAME = "Game" + const val SELECTED_ITEM = "SelectedItem" + + fun newInstance(game: Game): LaunchGameDialogFragment { + val args = Bundle() + args.putParcelable(GAME, game) + val fragment = LaunchGameDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt index a6183d19e..32062b6fe 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt @@ -27,30 +27,31 @@ class MessageDialogFragment : DialogFragment() { val descriptionString = requireArguments().getString(DESCRIPTION_STRING)!! val helpLinkId = requireArguments().getInt(HELP_LINK) - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setPositiveButton(R.string.close, null) + val builder = MaterialAlertDialogBuilder(requireContext()) - if (titleId != 0) dialog.setTitle(titleId) - if (titleString.isNotEmpty()) dialog.setTitle(titleString) + if (messageDialogViewModel.positiveAction == null) { + builder.setPositiveButton(R.string.close, null) + } else { + builder.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + messageDialogViewModel.positiveAction?.invoke() + }.setNegativeButton(android.R.string.cancel, null) + } + + if (titleId != 0) builder.setTitle(titleId) + if (titleString.isNotEmpty()) builder.setTitle(titleString) if (descriptionId != 0) { - dialog.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY)) + builder.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY)) } - if (descriptionString.isNotEmpty()) dialog.setMessage(descriptionString) + if (descriptionString.isNotEmpty()) builder.setMessage(descriptionString) if (helpLinkId != 0) { - dialog.setNeutralButton(R.string.learn_more) { _, _ -> + builder.setNeutralButton(R.string.learn_more) { _, _ -> openLink(getString(helpLinkId)) } } - return dialog.show() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - messageDialogViewModel.dismissAction.invoke() - messageDialogViewModel.clear() + return builder.show() } private fun openLink(link: String) { @@ -74,7 +75,7 @@ class MessageDialogFragment : DialogFragment() { descriptionId: Int = 0, descriptionString: String = "", helpLinkId: Int = 0, - dismissAction: () -> Unit = {} + positiveAction: (() -> Unit)? = null ): MessageDialogFragment { val dialog = MessageDialogFragment() val bundle = Bundle() @@ -85,8 +86,10 @@ class MessageDialogFragment : DialogFragment() { putString(DESCRIPTION_STRING, descriptionString) putInt(HELP_LINK, helpLinkId) } - ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction = - dismissAction + ViewModelProvider(activity)[MessageDialogViewModel::class.java].apply { + clear() + this.positiveAction = positiveAction + } dialog.arguments = bundle return dialog } 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 2dbca76a5..3ac054d8f 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 @@ -60,7 +60,9 @@ class SearchFragment : Fragment() { // This is using the correct scope, lint is just acting up @SuppressLint("UnsafeRepeatOnLifecycleDetector") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - homeViewModel.setNavigationVisibility(visible = true, animated = false) + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) if (savedInstanceState != null) { 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 new file mode 100644 index 000000000..ed79a8b02 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt @@ -0,0 +1,10 @@ +// 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 new file mode 100644 index 000000000..075252f5b --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.utils.NativeConfig +import java.util.concurrent.atomic.AtomicBoolean + +class AddonViewModel : ViewModel() { + private val _addonList = MutableStateFlow(mutableListOf()) + val addonList get() = _addonList.asStateFlow() + + private val _showModInstallPicker = MutableStateFlow(false) + val showModInstallPicker get() = _showModInstallPicker.asStateFlow() + + private val _showModNoticeDialog = MutableStateFlow(false) + val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow() + + var game: Game? = null + + private val isRefreshing = AtomicBoolean(false) + + fun onOpenAddons(game: Game) { + this.game = game + refreshAddons() + } + + fun refreshAddons() { + if (isRefreshing.get() || game == null) { + return + } + isRefreshing.set(true) + viewModelScope.launch { + withContext(Dispatchers.IO) { + val addonList = mutableListOf() + 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 + isRefreshing.set(false) + } + } + } + + fun onCloseAddons() { + if (_addonList.value.isEmpty()) { + return + } + + NativeConfig.setDisabledAddons( + game!!.programId, + _addonList.value.mapNotNull { + if (it.enabled) { + null + } else { + it.title + } + }.toTypedArray() + ) + NativeConfig.saveGlobalConfig() + _addonList.value.clear() + game = null + } + + fun showModInstallPicker(install: Boolean) { + _showModInstallPicker.value = install + } + + fun showModNoticeDialog(show: Boolean) { + _showModNoticeDialog.value = show + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt index 2fa3ab31b..ac642c16e 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt @@ -3,10 +3,18 @@ package org.yuzu.yuzu_emu.model +import android.net.Uri import android.os.Parcelable import java.util.HashSet import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.FileUtil +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter @Parcelize @Serializable @@ -15,12 +23,44 @@ class Game( val path: String, val programId: String = "", val developer: String = "", - val version: String = "", + var version: String = "", val isHomebrew: Boolean = false ) : Parcelable { val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime" val keyLastPlayedTime get() = "${path}_LastPlayed" + val settingsName: String + get() { + val programIdLong = programId.toLong() + return if (programIdLong == 0L) { + FileUtil.getFilename(Uri.parse(path)) + } else { + "0" + programIdLong.toString(16).uppercase() + } + } + + val programIdHex: String + get() { + val programIdLong = programId.toLong() + return if (programIdLong == 0L) { + "0" + } else { + "0" + programIdLong.toString(16).uppercase() + } + } + + val saveZipName: String + get() = "$title ${YuzuApplication.appContext.getString(R.string.save_data).lowercase()} - ${ + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + }.zip" + + val saveDir: String + get() = DirectoryInitialization.userDirectory + "/nand" + + NativeLibrary.getSavePath(programId) + + val addonDir: String + get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/" + override fun equals(other: Any?): Boolean { if (other !is Game) { return false diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt new file mode 100644 index 000000000..bb3df5bd0 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import kotlinx.coroutines.flow.StateFlow + +interface GameProperty { + @get:StringRes + val titleId: Int + get() = -1 + + @get:StringRes + val descriptionId: Int + get() = -1 +} + +data class SubmenuProperty( + override val titleId: Int, + override val descriptionId: Int, + @DrawableRes val iconId: Int, + val details: (() -> String)? = null, + val detailsFlow: StateFlow? = null, + val action: () -> Unit +) : GameProperty + +data class InstallableProperty( + override val titleId: Int, + override val descriptionId: Int, + val install: (() -> Unit)? = null, + val export: (() -> Unit)? = null +) : GameProperty diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt index 07e65b028..d801db105 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt @@ -3,6 +3,7 @@ package org.yuzu.yuzu_emu.model +import android.net.Uri import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,6 +22,12 @@ class HomeViewModel : ViewModel() { private val _gamesDirSelected = MutableStateFlow(false) val gamesDirSelected get() = _gamesDirSelected.asStateFlow() + private val _openImportSaves = MutableStateFlow(false) + val openImportSaves get() = _openImportSaves.asStateFlow() + + private val _contentToInstall = MutableStateFlow?>(null) + val contentToInstall get() = _contentToInstall.asStateFlow() + var navigatedToSetup = false fun setNavigationVisibility(visible: Boolean, animated: Boolean) { @@ -44,4 +51,12 @@ class HomeViewModel : ViewModel() { fun setGamesDirSelected(selected: Boolean) { _gamesDirSelected.value = selected } + + fun setOpenImportSaves(import: Boolean) { + _openImportSaves.value = import + } + + fun setContentToInstall(documents: List?) { + _contentToInstall.value = documents + } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt index 36ffd08d2..641c5cb17 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt @@ -6,9 +6,9 @@ package org.yuzu.yuzu_emu.model import androidx.lifecycle.ViewModel class MessageDialogViewModel : ViewModel() { - var dismissAction: () -> Unit = {} + var positiveAction: (() -> Unit)? = null fun clear() { - dismissAction = {} + positiveAction = null } } 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 16a794dee..e59c95733 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 @@ -23,7 +23,7 @@ class TaskViewModel : ViewModel() { val cancelled: StateFlow get() = _cancelled private val _cancelled = MutableStateFlow(false) - lateinit var task: () -> Any + lateinit var task: suspend () -> Any fun clear() { _result.value = Any() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt index 805b89b31..d5acf8479 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt @@ -19,7 +19,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.color.MaterialColors -import com.google.android.material.transition.MaterialFadeThrough +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.adapters.GameAdapter @@ -35,11 +35,6 @@ class GamesFragment : Fragment() { private val gamesViewModel: GamesViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -52,7 +47,9 @@ class GamesFragment : Fragment() { // This is using the correct scope, lint is just acting up @SuppressLint("UnsafeRepeatOnLifecycleDetector") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - homeViewModel.setNavigationVisibility(visible = true, animated = false) + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) binding.gridGames.apply { layoutManager = AutofitGridLayoutManager( 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 16323a316..09ddd1bbd 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 @@ -43,7 +43,7 @@ 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.MessageDialogFragment -import org.yuzu.yuzu_emu.getPublicFilesDir +import org.yuzu.yuzu_emu.model.AddonViewModel import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.model.TaskState @@ -60,15 +60,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider { private val homeViewModel: HomeViewModel by viewModels() private val gamesViewModel: GamesViewModel by viewModels() private val taskViewModel: TaskViewModel by viewModels() + private val addonViewModel: AddonViewModel by viewModels() override var themeId: Int = 0 - private val savesFolder - get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000" - - // Get first subfolder in saves folder (should be the user folder) - val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: "" - override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } @@ -145,6 +140,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider { homeViewModel.statusBarShadeVisible.collect { showStatusBarShade(it) } } } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + homeViewModel.contentToInstall.collect { + if (it != null) { + installContent(it) + homeViewModel.setContentToInstall(null) + } + } + } + } } // Dismiss previous notifications (should not happen unless a crash occurred) @@ -468,110 +473,150 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val installGameUpdate = registerForActivityResult( ActivityResultContracts.OpenMultipleDocuments() ) { documents: List -> - if (documents.isNotEmpty()) { - IndeterminateProgressDialogFragment.newInstance( - this@MainActivity, - R.string.installing_game_content - ) { - var installSuccess = 0 - var installOverwrite = 0 - var errorBaseGame = 0 - var errorExtension = 0 - var errorOther = 0 - documents.forEach { - when ( - NativeLibrary.installFileToNand( - it.toString(), - FileUtil.getExtension(it) - ) - ) { - NativeLibrary.InstallFileToNandResult.Success -> { - installSuccess += 1 - } - - NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> { - installOverwrite += 1 - } - - NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> { - errorBaseGame += 1 - } - - NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> { - errorExtension += 1 - } - - else -> { - errorOther += 1 - } - } - } - - val separator = System.getProperty("line.separator") ?: "\n" - val installResult = StringBuilder() - if (installSuccess > 0) { - installResult.append( - getString( - R.string.install_game_content_success_install, - installSuccess - ) - ) - installResult.append(separator) - } - if (installOverwrite > 0) { - installResult.append( - getString( - R.string.install_game_content_success_overwrite, - installOverwrite - ) - ) - installResult.append(separator) - } - val errorTotal: Int = errorBaseGame + errorExtension + errorOther - if (errorTotal > 0) { - installResult.append(separator) - installResult.append( - getString( - R.string.install_game_content_failed_count, - errorTotal - ) - ) - installResult.append(separator) - if (errorBaseGame > 0) { - installResult.append(separator) - installResult.append( - getString(R.string.install_game_content_failure_base) - ) - 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) { - installResult.append( - getString(R.string.install_game_content_failure_description) - ) - installResult.append(separator) - } - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.install_game_content_failure, - descriptionString = installResult.toString().trim(), - helpLinkId = R.string.install_game_content_help_link - ) - } else { - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.install_game_content_success, - descriptionString = installResult.toString().trim() - ) - } - }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + if (documents.isEmpty()) { + return@registerForActivityResult } + + if (addonViewModel.game == null) { + installContent(documents) + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + this@MainActivity, + R.string.verifying_content, + false + ) { + var updatesMatchProgram = true + for (document in documents) { + val valid = NativeLibrary.doesUpdateMatchProgram( + addonViewModel.game!!.programId, + document.toString() + ) + if (!valid) { + updatesMatchProgram = false + break + } + } + + if (updatesMatchProgram) { + homeViewModel.setContentToInstall(documents) + } else { + MessageDialogFragment.newInstance( + this@MainActivity, + titleId = R.string.content_install_notice, + descriptionId = R.string.content_install_notice_description, + positiveAction = { homeViewModel.setContentToInstall(documents) } + ) + } + }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + } + + private fun installContent(documents: List) { + IndeterminateProgressDialogFragment.newInstance( + this@MainActivity, + R.string.installing_game_content + ) { + var installSuccess = 0 + var installOverwrite = 0 + var errorBaseGame = 0 + var errorExtension = 0 + var errorOther = 0 + documents.forEach { + when ( + NativeLibrary.installFileToNand( + it.toString(), + FileUtil.getExtension(it) + ) + ) { + NativeLibrary.InstallFileToNandResult.Success -> { + installSuccess += 1 + } + + NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> { + installOverwrite += 1 + } + + NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> { + errorBaseGame += 1 + } + + NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> { + errorExtension += 1 + } + + else -> { + errorOther += 1 + } + } + } + + addonViewModel.refreshAddons() + + val separator = System.getProperty("line.separator") ?: "\n" + val installResult = StringBuilder() + if (installSuccess > 0) { + installResult.append( + getString( + R.string.install_game_content_success_install, + installSuccess + ) + ) + installResult.append(separator) + } + if (installOverwrite > 0) { + installResult.append( + getString( + R.string.install_game_content_success_overwrite, + installOverwrite + ) + ) + installResult.append(separator) + } + val errorTotal: Int = errorBaseGame + errorExtension + errorOther + if (errorTotal > 0) { + installResult.append(separator) + installResult.append( + getString( + R.string.install_game_content_failed_count, + errorTotal + ) + ) + installResult.append(separator) + if (errorBaseGame > 0) { + installResult.append(separator) + installResult.append( + getString(R.string.install_game_content_failure_base) + ) + 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) { + installResult.append( + getString(R.string.install_game_content_failure_description) + ) + installResult.append(separator) + } + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_failure, + descriptionString = installResult.toString().trim(), + helpLinkId = R.string.install_game_content_help_link + ) + } else { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_success, + descriptionString = installResult.toString().trim() + ) + } + }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) } val exportUserData = registerForActivityResult( @@ -657,102 +702,4 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return@newInstance getString(R.string.user_data_import_success) }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) } - - /** - * Exports the save file located in the given folder path by creating a zip file and sharing it via intent. - */ - val exportSaves = registerForActivityResult( - ActivityResultContracts.CreateDocument("application/zip") - ) { result -> - if (result == null) { - return@registerForActivityResult - } - - IndeterminateProgressDialogFragment.newInstance( - this, - R.string.save_files_exporting, - false - ) { - val zipResult = FileUtil.zipFromInternalStorage( - File(savesFolderRoot), - savesFolderRoot, - BufferedOutputStream(contentResolver.openOutputStream(result)) - ) - return@newInstance when (zipResult) { - TaskState.Completed -> getString(R.string.export_success) - TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) - } - }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) - } - - private val startForResultExportSave = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> - File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively() - } - - val importSaves = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result == null) { - return@registerForActivityResult - } - - NativeLibrary.initializeEmptyUserDirectory() - - val inputZip = contentResolver.openInputStream(result) - // 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("${applicationContext.cacheDir.path}/saves/") - cacheSaveDir.mkdir() - - if (inputZip == null) { - Toast.makeText( - applicationContext, - getString(R.string.fatal_error), - Toast.LENGTH_LONG - ).show() - return@registerForActivityResult - } - - val filterTitleId = - FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) } - - try { - CoroutineScope(Dispatchers.IO).launch { - FileUtil.unzipToInternalStorage(BufferedInputStream(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) { - MessageDialogFragment.newInstance( - this@MainActivity, - titleId = R.string.save_file_invalid_zip_structure, - descriptionId = R.string.save_file_invalid_zip_structure_description - ).show(supportFragmentManager, MessageDialogFragment.TAG) - return@withContext - } - Toast.makeText( - applicationContext, - getString(R.string.save_file_imported_success), - Toast.LENGTH_LONG - ).show() - } - - cacheSaveDir.deleteRecursively() - } - } catch (e: Exception) { - Toast.makeText( - applicationContext, - getString(R.string.fatal_error), - Toast.LENGTH_LONG - ).show() - } - } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt new file mode 100644 index 000000000..8cc5ea71f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +object AddonUtil { + val validAddonDirectories = listOf("cheats", "exefs", "romfs") +} 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 bbe7bfa92..00c6bf90e 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 @@ -22,6 +22,7 @@ import java.io.BufferedOutputStream import java.lang.NullPointerException import java.nio.charset.StandardCharsets import java.util.zip.ZipOutputStream +import kotlin.IllegalStateException object FileUtil { const val PATH_TREE = "tree" @@ -342,6 +343,37 @@ object FileUtil { return TaskState.Completed } + /** + * Helper function that copies the contents of a DocumentFile folder into a [File] + * @param file [File] representation of the folder to copy into + * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa + */ + fun DocumentFile.copyFilesTo(file: File) { + file.mkdirs() + if (!this.isDirectory || !file.isDirectory) { + throw IllegalStateException( + "[FileUtil] Tried to copy a folder into a file or vice versa" + ) + } + + this.listFiles().forEach { + val newFile = File(file, it.name!!) + if (it.isDirectory) { + newFile.mkdirs() + DocumentFile.fromTreeUri(YuzuApplication.appContext, it.uri)?.copyFilesTo(newFile) + } else { + val inputStream = + YuzuApplication.appContext.contentResolver.openInputStream(it.uri) + BufferedInputStream(inputStream).use { bos -> + if (!newFile.exists()) { + newFile.createNewFile() + } + newFile.outputStream().use { os -> bos.copyTo(os) } + } + } + } + } + fun isRootTreeUri(uri: Uri): Boolean { val paths = uri.pathSegments return paths.size == 2 && PATH_TREE == paths[0] diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt index 4c7316ba3..7d629b7d5 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt @@ -105,4 +105,23 @@ object NativeConfig { */ @Synchronized external fun addGameDir(dir: GameDir) + + /** + * Gets an array of the addons that are disabled for a given game + * + * @param programId String representation of a game's program ID + * @return An array of disabled addons + */ + @Synchronized + external fun getDisabledAddons(programId: String): Array + + /** + * Clears the disabled addons array corresponding to [programId] and replaces them + * with [disabledAddons] + * + * @param programId String representation of a game's program ID + * @param disabledAddons Replacement array of disabled addons + */ + @Synchronized + external fun setDisabledAddons(programId: String, disabledAddons: Array) } diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index a56ed5662..df8935178 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -20,6 +20,12 @@ static jmethodID s_disk_cache_load_progress; static jmethodID s_on_emulation_started; static jmethodID s_on_emulation_stopped; +static jclass s_string_class; +static jclass s_pair_class; +static jmethodID s_pair_constructor; +static jfieldID s_pair_first_field; +static jfieldID s_pair_second_field; + static constexpr jint JNI_VERSION = JNI_VERSION_1_6; namespace IDCache { @@ -79,6 +85,26 @@ jmethodID GetOnEmulationStopped() { return s_on_emulation_stopped; } +jclass GetStringClass() { + return s_string_class; +} + +jclass GetPairClass() { + return s_pair_class; +} + +jmethodID GetPairConstructor() { + return s_pair_constructor; +} + +jfieldID GetPairFirstField() { + return s_pair_first_field; +} + +jfieldID GetPairSecondField() { + return s_pair_second_field; +} + } // namespace IDCache #ifdef __cplusplus @@ -115,6 +141,18 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { s_on_emulation_stopped = env->GetStaticMethodID(s_native_library_class, "onEmulationStopped", "(I)V"); + const jclass string_class = env->FindClass("java/lang/String"); + s_string_class = reinterpret_cast(env->NewGlobalRef(string_class)); + env->DeleteLocalRef(string_class); + + const jclass pair_class = env->FindClass("kotlin/Pair"); + s_pair_class = reinterpret_cast(env->NewGlobalRef(pair_class)); + s_pair_constructor = + env->GetMethodID(pair_class, "", "(Ljava/lang/Object;Ljava/lang/Object;)V"); + s_pair_first_field = env->GetFieldID(pair_class, "first", "Ljava/lang/Object;"); + s_pair_second_field = env->GetFieldID(pair_class, "second", "Ljava/lang/Object;"); + env->DeleteLocalRef(pair_class); + // Initialize Android Storage Common::FS::Android::RegisterCallbacks(env, s_native_library_class); @@ -136,6 +174,8 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) { env->DeleteGlobalRef(s_disk_cache_progress_class); env->DeleteGlobalRef(s_load_callback_stage_class); env->DeleteGlobalRef(s_game_dir_class); + env->DeleteGlobalRef(s_string_class); + env->DeleteGlobalRef(s_pair_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 855649efa..36233423e 100644 --- a/src/android/app/src/main/jni/id_cache.h +++ b/src/android/app/src/main/jni/id_cache.h @@ -20,4 +20,10 @@ jmethodID GetDiskCacheLoadProgress(); jmethodID GetOnEmulationStarted(); jmethodID GetOnEmulationStopped(); +jclass GetStringClass(); +jclass GetPairClass(); +jmethodID GetPairConstructor(); +jfieldID GetPairFirstField(); +jfieldID GetPairSecondField(); + } // namespace IDCache diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index e5d3158c8..ce570b811 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -79,6 +80,10 @@ Core::System& EmulationSession::System() { return m_system; } +FileSys::ManualContentProvider* EmulationSession::ContentProvider() { + return m_manual_provider.get(); +} + const EmuWindow_Android& EmulationSession::Window() const { return *m_window; } @@ -455,6 +460,15 @@ void EmulationSession::OnEmulationStopped(Core::SystemResultStatus result) { static_cast(result)); } +u64 EmulationSession::GetProgramId(JNIEnv* env, jstring jprogramId) { + auto program_id_string = GetJString(env, jprogramId); + try { + return std::stoull(program_id_string); + } catch (...) { + return 0; + } +} + static Core::SystemResultStatus RunEmulation(const std::string& filepath) { MicroProfileOnThreadCreate("EmuThread"); SCOPE_EXIT({ MicroProfileShutdown(); }); @@ -504,6 +518,27 @@ int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject GetJString(env, j_file_extension)); } +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_doesUpdateMatchProgram(JNIEnv* env, jobject jobj, + jstring jprogramId, + jstring jupdatePath) { + u64 program_id = EmulationSession::GetProgramId(env, jprogramId); + std::string updatePath = GetJString(env, jupdatePath); + std::shared_ptr nsp = std::make_shared( + EmulationSession::GetInstance().System().GetFilesystem()->OpenFile(updatePath, + FileSys::Mode::Read)); + for (const auto& item : nsp->GetNCAs()) { + for (const auto& nca_details : item.second) { + if (nca_details.second->GetName().ends_with(".cnmt.nca")) { + auto update_id = nca_details.second->GetTitleId() & ~0xFFFULL; + if (update_id == program_id) { + return true; + } + } + } + } + return false; +} + void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz, jstring hook_lib_dir, jstring custom_driver_dir, @@ -665,13 +700,6 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass EmulationSession::GetInstance().InitializeSystem(reload); } -jint Java_org_yuzu_yuzu_1emu_NativeLibrary_defaultCPUCore(JNIEnv* env, jclass clazz) { - return {}; -} - -void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2Ljava_lang_String_2Z( - JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate) {} - jdoubleArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPerfStats(JNIEnv* env, jclass clazz) { jdoubleArray j_stats = env->NewDoubleArray(4); @@ -696,9 +724,13 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getCpuBackend(JNIEnv* env, jclass return ToJString(env, "JIT"); } -void Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_setSysDirectory(JNIEnv* env, - jclass clazz, - jstring j_path) {} +void Java_org_yuzu_yuzu_1emu_NativeLibrary_applySettings(JNIEnv* env, jobject jobj) { + EmulationSession::GetInstance().System().ApplySettings(); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_logSettings(JNIEnv* env, jobject jobj) { + Settings::LogSettings(); +} void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2(JNIEnv* env, jclass clazz, jstring j_path) { @@ -792,4 +824,60 @@ 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) { + const auto path = GetJString(env, jpath); + const auto vFile = + Core::GetGameFileFromPath(EmulationSession::GetInstance().System().GetFilesystem(), path); + if (vFile == nullptr) { + return nullptr; + } + + auto& system = EmulationSession::GetInstance().System(); + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + const FileSys::PatchManager pm{program_id, system.GetFileSystemController(), + system.GetContentProvider()}; + const auto loader = Loader::GetLoader(system, vFile); + + 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); + 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); + ++i; + } + return jaddonsArray; +} + +jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj, + jstring jprogramId) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + + auto& system = EmulationSession::GetInstance().System(); + + 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(0)); + ASSERT(user_id); + + const auto nandDir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir); + auto vfsNandDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(nandDir), + FileSys::Mode::Read); + + const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath( + system, vfsNandDir, FileSys::SaveDataSpaceId::NandUser, FileSys::SaveDataType::SaveData, + program_id, user_id->AsU128(), 0); + return ToJString(env, user_save_data_path); +} + } // extern "C" diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h index f1457bd1f..96c22d52b 100644 --- a/src/android/app/src/main/jni/native.h +++ b/src/android/app/src/main/jni/native.h @@ -54,6 +54,8 @@ public: static void OnEmulationStarted(); + static u64 GetProgramId(JNIEnv* env, jstring jprogramId); + private: static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max); static void OnEmulationStopped(Core::SystemResultStatus result); diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp index 9439d11e1..7f2485720 100644 --- a/src/android/app/src/main/jni/native_config.cpp +++ b/src/android/app/src/main/jni/native_config.cpp @@ -283,4 +283,30 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject AndroidSettings::GameDir{uriString, static_cast(jdeepScanBoolean)}); } +jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getDisabledAddons(JNIEnv* env, jobject obj, + jstring jprogramId) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + auto& disabledAddons = Settings::values.disabled_addons[program_id]; + jobjectArray jdisabledAddonsArray = + env->NewObjectArray(disabledAddons.size(), IDCache::GetStringClass(), ToJString(env, "")); + for (size_t i = 0; i < disabledAddons.size(); ++i) { + env->SetObjectArrayElement(jdisabledAddonsArray, i, ToJString(env, disabledAddons[i])); + } + return jdisabledAddonsArray; +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setDisabledAddons(JNIEnv* env, jobject obj, + jstring jprogramId, + jobjectArray jdisabledAddons) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + Settings::values.disabled_addons[program_id].clear(); + std::vector disabled_addons; + const int size = env->GetArrayLength(jdisabledAddons); + for (int i = 0; i < size; ++i) { + auto jaddon = static_cast(env->GetObjectArrayElement(jdisabledAddons, i)); + disabled_addons.push_back(GetJString(env, jaddon)); + } + Settings::values.disabled_addons[program_id] = disabled_addons; +} + } // extern "C" diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml new file mode 100644 index 000000000..0b9633855 --- /dev/null +++ b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + +